diff --git a/.planning/.active_plan b/.planning/.active_plan new file mode 100644 index 0000000..1beeae0 --- /dev/null +++ b/.planning/.active_plan @@ -0,0 +1 @@ +codex-app-worker-timeout diff --git a/.planning/codex-app-worker-timeout/findings.md b/.planning/codex-app-worker-timeout/findings.md new file mode 100644 index 0000000..7437826 --- /dev/null +++ b/.planning/codex-app-worker-timeout/findings.md @@ -0,0 +1,7 @@ +# Codex App worker start 超时排查发现 + +## Findings + +- 用户报告错误:`Codex 任务失败:Codex App worker 请求超时: start`。 +- 初始判断:问题集中在 `codexapp` worker 的 `start` 请求链路,需确认 + worker 进程是否启动、`start` 响应是否丢失、超时时间是否过短或未清理。 diff --git a/.planning/codex-app-worker-timeout/progress.md b/.planning/codex-app-worker-timeout/progress.md new file mode 100644 index 0000000..14a36ab --- /dev/null +++ b/.planning/codex-app-worker-timeout/progress.md @@ -0,0 +1,5 @@ +# Codex App worker start 超时排查进度 + +## Log + +- 2026-07-07T09:51:08+08:00 创建 scoped 计划,避免覆盖根目录旧 hooks 排查记录。 diff --git a/.planning/codex-app-worker-timeout/task_plan.md b/.planning/codex-app-worker-timeout/task_plan.md new file mode 100644 index 0000000..d8fc2ab --- /dev/null +++ b/.planning/codex-app-worker-timeout/task_plan.md @@ -0,0 +1,27 @@ +# Codex App worker start 超时排查计划 + +## Goal + +定位并修复 `Codex App worker 请求超时: start`,优先确认 `codexapp` +worker 启动链路中的超时点、请求/响应匹配和错误传播。 + +## Status + +- [ ] 确认当前索引与项目结构入口 +- [ ] 定位 Codex App worker start 超时链路 +- [ ] 复现或从日志提取超时证据 +- [ ] 实现最小修复并补回归覆盖 +- [ ] 运行针对性验证并总结风险 + +## Constraints + +- 简体中文沟通。 +- 代码理解优先使用 `codebase-memory-mcp`,再用 `rg` / `nl` 校验。 +- 不覆盖用户已有未提交改动。 +- 修改服务后如需重启,必须先确认除当前对话外没有其他 running 会话。 +- 当前在 WSL 环境;若发现 dotnet 项目,禁止在 WSL 编译。 + +## Errors Encountered + +| Error | Attempt | Resolution | +|-------|---------|------------| diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json index 210cacd..c2936d7 100644 --- a/config/cross-conversation-replies.json +++ b/config/cross-conversation-replies.json @@ -1,5 +1,5 @@ { "version": 1, - "updatedAt": "2026-07-02T06:02:53.126Z", + "updatedAt": "2026-07-06T09:51:06.234Z", "replies": [] } \ No newline at end of file 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 0dffcb7..890f03e 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 75b958e..45603d3 100644 --- a/public/app.js +++ b/public/app.js @@ -8614,7 +8614,10 @@ msgInput.focus(); }); } - abortBtn.addEventListener('click', () => send({ type: 'abort' })); + abortBtn.addEventListener('click', () => { + if (!currentSessionId) return; + send({ type: 'abort', sessionId: currentSessionId }); + }); if (attachBtn && imageUploadInput) { attachBtn.addEventListener('click', () => imageUploadInput.click()); imageUploadInput.addEventListener('change', () => { diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 000ded0..7dab4a2 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -364,6 +364,62 @@ function completeTurn(thread, turnId, text, status = 'completed') { thread.steers = []; } +function completeGoalBackgroundTurn(thread, objective) { + const turnId = `goal-turn-${crypto.randomUUID()}`; + const text = `Goal background output: ${objective}`; + thread.activeTurnId = turnId; + send({ + method: 'turn/started', + params: { + threadId: thread.id, + turn: { id: turnId, status: 'running', items: [] }, + }, + }); + send({ + method: 'item/agentMessage/delta', + params: { + threadId: thread.id, + turnId, + itemId: 'goal-agent-msg', + delta: text, + }, + }); + send({ + method: 'item/completed', + params: { + threadId: thread.id, + turnId, + completedAtMs: Date.now(), + item: { + id: 'goal-agent-msg', + type: 'agentMessage', + text, + status: 'completed', + }, + }, + }); + send({ + method: 'thread/tokenUsage/updated', + params: { + threadId: thread.id, + turnId, + tokenUsage: tokenUsage(text), + }, + }); + send({ + method: 'turn/completed', + params: { + threadId: thread.id, + turn: { + id: turnId, + status: 'completed', + items: [], + }, + }, + }); + thread.activeTurnId = null; +} + function requestClient(method, params, callback) { const id = `mock-server-request-${nextServerRequestId++}`; pendingServerRequests.set(id, callback); @@ -874,6 +930,9 @@ function handleRequest(message) { }); setTimeout(() => { send({ id, result: { goal: thread.goal } }); + if (params.objective) { + setTimeout(() => completeGoalBackgroundTurn(thread, objective), 50); + } }, 250); return; } diff --git a/scripts/regression.js b/scripts/regression.js index 641d663..3f721a1 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -738,6 +738,10 @@ function assertSessionSwitchResilienceContract() { /const flushedSessionResume = flushedSessionSwitch \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource), 'Frontend should resume the current running session after auth without forcing load_session' ); + assert( + frontendSource.includes("send({ type: 'abort', sessionId: currentSessionId })"), + 'Frontend abort button should explicitly target the current session' + ); assert( /case 'background_done':[\s\S]*?if \(isNearBottom\(\)\)[\s\S]*?openSession\(msg\.sessionId,\s*\{ forceSync: true, blocking: false \}\)[\s\S]*?send\(\{ type: 'list_sessions' \}\)/.test(frontendSource), 'Frontend should not auto-rerender the current session on background_done while the user is reading history' @@ -775,6 +779,12 @@ function assertSessionSwitchResilienceContract() { assert(serverSource.includes("case 'resume_session':"), 'Server should accept lightweight resume_session requests'); assert(serverSource.includes('function handleResumeSession'), 'Server should implement lightweight running-session resume'); assert(serverSource.includes('function attachActiveRuntimeToWs'), 'Server should share runtime re-attach logic without sending session_info first'); + assert(/case 'abort':\s*handleAbort\(ws, msg\);/.test(serverSource), 'Server should pass abort request metadata to handleAbort'); + assert(/function handleAbort\(ws, msg = \{\}\)/.test(serverSource), 'Server handleAbort should accept the abort request payload'); + assert( + /function bindAbortSessionToWs\(sessionId, ws\)[\s\S]*?entry\.ws = ws/.test(serverSource), + 'Server abort should rebind the target running session to the current WebSocket before stopping' + ); assert(serverSource.includes('WS_HEARTBEAT_MAX_MISSES'), 'Server should tolerate missed WebSocket pongs before terminating'); assert(serverSource.includes('function markWsActivity'), 'Server should mark WebSocket activity on send/message/pong'); assert( @@ -1796,6 +1806,13 @@ async function main() { assert(codexAppGoalRunningList.sessions.some((session) => session.id === codexAppSession.sessionId && session.isRunning), 'Codex App /goal RPC should mark the session running while waiting for app-server'); const codexAppGoalSet = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.sessionId === codexAppSession.sessionId && /Goal active/.test(msg.message || '') && /improve benchmark coverage/.test(msg.message || '')); assert(/Goal active/.test(codexAppGoalSet.message || ''), 'Codex App /goal should set an active goal'); + const codexAppGoalBackgroundDelta = await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && + msg.sessionId === codexAppSession.sessionId && + /Goal background output: improve benchmark coverage/.test(msg.text || '') + ), 5000); + assert(/Goal background output/.test(codexAppGoalBackgroundDelta.text || ''), 'Codex App /goal background turn should stream through the active session'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId, 5000); const codexAppGoalIdleList = await nextMessage(messages, ws, (msg) => ( msg.type === 'session_list' && Array.isArray(msg.sessions) && @@ -1819,6 +1836,10 @@ async function main() { assert(/\/goal <目标描述>/.test(codexAppGoalEmpty.message || ''), 'Codex App /goal should show usage when no goal exists'); const storedCodexAppAfterGoal = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8')); assert(!storedCodexAppAfterGoal.messages.some((message) => message.role === 'user' && /^\/goal/.test(String(message.content || ''))), 'Codex App /goal slash commands should not be persisted as normal user messages'); + assert(storedCodexAppAfterGoal.messages.some((message) => ( + message.role === 'assistant' && + /Goal background output: improve benchmark coverage/.test(String(message.content || '')) + )), 'Codex App /goal background turn should be routed and persisted as an assistant message'); ws.send(JSON.stringify({ type: 'message', text: 'codexapp runtime warning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' })); const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => ( @@ -2174,11 +2195,13 @@ async function main() { targetConversationId: codexAppSession.sessionId, content: 'running codexapp target should reject this', }, - }); - assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets'); - await sleep(150); - ws.send(JSON.stringify({ type: 'abort' })); - await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); + }); + assert(codexAppRunningMcp.status === 400 && codexAppRunningMcp.body?.code === 'target_running', 'MCP cross send should reject running Codex App targets'); + await sleep(150); + ws.send(JSON.stringify({ type: 'detach_view' })); + await sleep(50); + ws.send(JSON.stringify({ type: 'abort', sessionId: codexAppSession.sessionId })); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId); const claudeAttachment = await uploadAttachment(port, token, { filename: 'claude-test.png', diff --git a/server.js b/server.js index e2e4ee9..2f2ed69 100644 --- a/server.js +++ b/server.js @@ -6309,7 +6309,7 @@ wss.on('connection', (ws, req) => { handleComposerSuggestions(ws, msg); break; case 'abort': - handleAbort(ws); + handleAbort(ws, msg); break; case 'new_session': handleNewSession(ws, msg); @@ -7831,9 +7831,26 @@ function handleDetachView(ws) { wsSessionMap.delete(ws); } -function handleAbort(ws) { - const sessionId = wsSessionMap.get(ws); +function bindAbortSessionToWs(sessionId, ws) { + if (!sessionId || !ws) return; + const entries = [ + activeProcesses.get(sessionId), + activeCodexAppTurns.get(sessionId), + activeCodexAppGoalCommands.get(sessionId), + ].filter(Boolean); + if (entries.length > 0) detachWsFromActiveRuntimes(ws); + wsSessionMap.set(ws, sessionId); + for (const entry of entries) { + entry.ws = ws; + entry.wsDisconnectTime = null; + } +} + +function handleAbort(ws, msg = {}) { + const requestedSessionId = sanitizeId(msg?.sessionId || ''); + const sessionId = requestedSessionId || wsSessionMap.get(ws); if (!sessionId) return; + bindAbortSessionToWs(sessionId, ws); if (handleCodexAppAbortSession(sessionId, ws)) return; if (cancelCodexAppGoalCommand(sessionId, ws)) return; const entry = activeProcesses.get(sessionId); @@ -8300,9 +8317,17 @@ function detachWsFromActiveRuntimes(ws, options = {}) { } } +function codexAppRuntimeThreadId(params = {}) { + return params.threadId || params.thread?.id || params.item?.threadId || null; +} + +function codexAppRuntimeTurnId(params = {}) { + return params.turnId || params.turn?.id || params.item?.turnId || null; +} + function findCodexAppEntryByRuntime(params = {}) { - const threadId = params.threadId || params.thread?.id || null; - const turnId = params.turnId || params.turn?.id || null; + const threadId = codexAppRuntimeThreadId(params); + const turnId = codexAppRuntimeTurnId(params); if (threadId) { for (const [sessionId, entry] of activeCodexAppTurns) { if (entry.threadId === threadId) return { sessionId, entry }; @@ -8316,6 +8341,95 @@ function findCodexAppEntryByRuntime(params = {}) { return null; } +function findCodexAppSessionByThreadId(threadId) { + const targetThreadId = String(threadId || '').trim(); + if (!targetThreadId) return null; + try { + for (const file of fs.readdirSync(SESSIONS_DIR)) { + if (!file.endsWith('.json')) continue; + const sessionId = sanitizeId(file.slice(0, -5)); + if (!sessionId) continue; + const session = loadSession(sessionId); + if (!session || !isCodexAppSession(session)) continue; + if (getRuntimeSessionId(session) === targetThreadId) return { sessionId: session.id, session }; + } + } catch (err) { + plog('WARN', 'codex_app_thread_session_lookup_failed', { + threadId: targetThreadId, + error: err?.message || String(err || ''), + }); + } + return null; +} + +function isCodexAppAdoptableRuntimeMethod(method) { + return method === 'turn/started' + || method === 'item/started' + || method === 'item/agentMessage/delta' + || method === 'item/commandExecution/outputDelta' + || method === 'item/fileChange/patchUpdated' + || method === 'item/mcpToolCall/progress' + || method === 'plan/updated' + || method === 'turn/plan/updated' + || method === 'item/plan/updated' + || method === 'item/todoList/updated' + || method === 'item/reasoning/summaryTextDelta' + || method === 'item/reasoning/textDelta' + || method === 'item/completed' + || method === 'item/commandExecution/requestApproval' + || method === 'item/fileChange/requestApproval' + || method === 'item/permissions/requestApproval' + || method === 'item/tool/requestApproval' + || method === 'item/tool/requestUserInput' + || method === 'item/tool/call'; +} + +function adoptCodexAppUnroutedTurn(params = {}, method = '') { + if (!isCodexAppAdoptableRuntimeMethod(method)) return null; + const threadId = codexAppRuntimeThreadId(params); + const turnId = codexAppRuntimeTurnId(params); + if (!threadId || !turnId) return null; + + const matched = findCodexAppSessionByThreadId(threadId); + if (!matched?.session) return null; + const existing = activeCodexAppTurns.get(matched.sessionId); + if (existing) return { sessionId: matched.sessionId, entry: existing }; + + const entry = { + ws: findViewingSessionWs(matched.sessionId), + agent: 'codexapp', + cwd: matched.session.cwd || getDefaultSessionCwd(), + threadId, + expectedThreadId: threadId, + turnId, + fullText: '', + toolCalls: [], + toolOutputDeltas: new Map(), + agentMessageItems: new Map(), + mcpContext: {}, + codexRetry: null, + lastUsage: null, + lastError: null, + errorSent: false, + crossConversationReplyRequestId: null, + retryRequest: null, + clientUserMessageId: crypto.randomUUID(), + startedAt: new Date().toISOString(), + recoveredFromNotification: true, + }; + activeCodexAppTurns.set(matched.sessionId, entry); + persistCodexAppTurnState(matched.sessionId, entry, { immediate: true }); + broadcastSessionList(); + plog('INFO', 'codex_app_unrouted_turn_adopted', { + sessionId: matched.sessionId.slice(0, 8), + threadId, + turnId, + method, + hasViewer: !!entry.ws, + }); + return { sessionId: matched.sessionId, entry }; +} + function parseMaybeJsonObject(value) { if (value && typeof value === 'object' && !Array.isArray(value)) return value; if (typeof value !== 'string') return null; @@ -8638,10 +8752,12 @@ function processCcwebMcpChildNotification(child, notification) { return { changed: false, done: false }; } -function findCodexAppRouteByRuntime(params = {}) { +function findCodexAppRouteByRuntime(params = {}, method = '') { const parent = findCodexAppEntryByRuntime(params); if (parent) return { ...parent, role: 'parent' }; - const threadId = params.threadId || params.thread?.id || null; + const recoveredParent = adoptCodexAppUnroutedTurn(params, method); + if (recoveredParent) return { ...recoveredParent, role: 'parent' }; + const threadId = codexAppRuntimeThreadId(params); if (threadId && ccwebMcpChildThreads.has(threadId)) { const child = ccwebMcpChildThreads.get(threadId); return { @@ -8655,7 +8771,7 @@ function findCodexAppRouteByRuntime(params = {}) { } function handleCodexAppNotification(notification) { - const routed = findCodexAppRouteByRuntime(notification?.params || {}); + const routed = findCodexAppRouteByRuntime(notification?.params || {}, notification?.method || ''); if (handleCodexAppMcpStartupStatusNotification(notification, routed)) return; if (!routed) { plog('INFO', 'codex_app_notification_unrouted', { @@ -9250,7 +9366,7 @@ function resolvePendingCodexAppApprovalsForSession(sessionId) { function handleCodexAppServerRequest(request) { const method = request?.method || ''; const params = request?.params || {}; - const routed = findCodexAppEntryByRuntime(params); + const routed = findCodexAppRouteByRuntime(params, method); const dynamicToolResponse = method === 'item/tool/call' ? handleCodexAppDynamicToolCall(routed, params) : null;