修复 ccweb MCP 启动与重载并重新打包

This commit is contained in:
shiyue
2026-09-12 21:13:30 +08:00
parent 965aefe9a4
commit d9db66ca8f
10 changed files with 217 additions and 28 deletions

View File

@@ -10,6 +10,12 @@ CLAUDE_PATH=claude
# Codex CLI 路径(默认在 PATH 中查找 codex
CODEX_PATH=codex
# Codex App / ccweb MCP 启动与调用超时(可选)
# CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC=30
# CC_WEB_CODEX_MCP_TOOL_TIMEOUT_SEC=60
# CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS=35000
# CC_WEB_CODEX_APP_MCP_RELOAD_TRACK_MS=60000
# PushPlus Token可选首次启动会自动迁移到 config/notify.json
PUSHPLUS_TOKEN=

View File

@@ -0,0 +1,39 @@
# 调查发现
## 用户现象
- ccweb MCP 创建对话有时失败。
- 创建失败后重载 MCP 也挂载不上,导致当前对话不可继续使用。
- 截图中的 `$log-ccweb-title` 提示显示:当前会话没有提供 `ccweb_list_conversations``ccweb_set_title``wiznote_mcp_wiz_*` 工具,因此无法获取对话 ID无法安全写入 `/coding` 日志。
## 代码与运行证据
### 1. MCP 冷启动窗口固定为 10 秒
- `server.js:3528-3572``buildCcwebMcpRuntimeConfig()` 在 streamable HTTP 和 stdio 两条路径都固定写入 `startup_timeout_sec: 10`,工具调用窗口为 60 秒。
- 历史会话 `723ffd3f-71fc-42ee-87b6-768836316099``4c0f6be3-b46c-4ec0-b6f5-03c31188b7d8``5ed15712-312c-4d1b-b629-3d3a3c0d06a7` 均持久化了:`MCP client for ccweb timed out after 10 seconds`
- 同一批历史记录随后出现 `ccweb_list_conversations` 60 秒工具调用超时,说明客户端失败后仍可能继续尝试调用失效连接。
### 2. 创建对话成功不等于目标 MCP 已就绪
- `createMcpConversation()` 先通过 `createPersistentConversationSession()` 写入会话文件,再调用 `sendCrossConversationMessage()` 投递首条消息。
- `sendCrossConversationMessage()` 调用 `handleMessage()`Codex App 分支的 `handleCodexAppMessage()` 只登记 active turn 并异步执行 `startCodexAppTurn(...).catch(...)`,立即返回 `{ ok: true }`
- 因此创建接口返回的 `ok/status=running` 只表示“会话已落盘、首轮已开始”,不保证首轮完成,更不保证 ccweb MCP 已 ready。MCP 启动失败会在返回之后发生。
### 3. 重载状态关联存在 threadId 严格匹配竞态
- `handleReloadMcpApi()``markCodexAppMcpReloadPending()` 后调用无 thread 参数的 `config/mcpServer/reload`,并只等待 `CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200` 毫秒。
- `codexAppMcpStatusTargetSessionIds()` 对 pending 会话要求 `statusRecord.threadId === pending.threadId`;不一致时直接跳过。
- `logs/process.old.log:6713-6721` 中,重载请求针对 `019ff161-...`,随后上报的是 `019fef64-...``019fef0f-...`,且全部 `targetSessions=0`。对应会话 `b73e4b07-4aaa-43d5-906b-413747a09f4b` 最终仍持久化为 `ccweb.status=starting/rawStatus=pending`
- `cleanupExpiredCodexAppMcpReloads()` 只删除内存 pending 映射,不会把持久化状态从 `starting/pending` 改成失败或可重试,因此会话在 UI 上长期像“挂载中”。
### 4. 当前环境可复现“并发启动时序差异”
- 当前调查会话的 `ccweb` 最终为 `ready`,但同一线程的 `playwright` 在 20 秒后失败,说明多个 MCP 并发启动时不同服务的 ready/fail 到达时间并不一致;固定 10 秒窗口和 1.2 秒重载等待会放大这个时序问题。
## 已实施修复
- `server.js``lib/agent-runtime.js`ccweb MCP 默认启动超时提高到 30 秒,工具超时和 reload 等待窗口支持环境变量覆盖reload 默认等待 35 秒。
- `server.js`reload pending 记录允许同一 app-server 的全局 reload 通知跨 threadId 关联;等待窗口结束仍未收到 ready/failed/cancelled 时,将持久化状态收敛为 `failed` 并标记可重试。
- `server.js`Codex App 创建对话结果附带当前 `mcpStatus`;首条消息投递失败时保留会话 ID、失败阶段和可重试标记避免“已创建的会话”被误认为完全不存在。
- `scripts/mock-codex-app-server.js``scripts/regression.js`:新增无关 threadId 和无最终状态通知的回归场景。

View File

@@ -0,0 +1,13 @@
# 调查进度
## 记录
- 2026-09-12建立本轮 scoped 排查计划;确认 codebase-memory 项目 `home-cc-web` 索引状态为 ready。
- 2026-09-12截图现象先记录为“当前会话 MCP 工具未注入/不可见”,待与创建和重载链路对照。
- 2026-09-12确认 `buildCcwebMcpRuntimeConfig()` 两条传输路径均固定 `startup_timeout_sec=10`;历史会话持久化了 ccweb 10 秒超时。
- 2026-09-12确认 `createMcpConversation()` 的首条消息通过 `handleCodexAppMessage()` 异步启动,接口返回不等待目标线程/MCP ready。
- 2026-09-12确认 reload 仅等待 1200ms状态通知按 threadId 严格关联;历史日志出现请求线程与上报线程不一致、`targetSessions=0`,导致会话持久化为 `starting/pending`
- 2026-09-12用户确认进入修复实施已将 ccweb MCP 启动超时、工具超时、reload 等待窗口改为可配置,默认分别为 30 秒、60 秒、35 秒。
- 2026-09-12已修正 reload 的跨 threadId 全局状态关联,并在等待窗口超时后持久化 `failed` 状态和可重试提示。
- 2026-09-12创建对话结果补充 Codex App MCP 状态;首条消息投递失败保留会话 ID、creationStatus 和 retryable 元数据。
- 2026-09-12mock + 完整 regression 已通过;未重启服务,待收尾清理临时清单。

View File

@@ -0,0 +1,25 @@
# ccweb MCP 创建对话失败与重载失败排查
## 目标
定位 ccweb MCP 创建新对话偶发失败、失败后重载 MCP 无法挂载的共同根因,判断是否需要修复代码并完成最小回归验证。
## 阶段
- [completed] 梳理现有 MCP 配置与重载回归入口
- [completed] 提高并配置 ccweb MCP 启动与重载等待窗口
- [completed] 修正重载状态的 threadId 关联与超时收敛
- [completed] 补充创建对话的 MCP 状态可见性与失败信息
- [completed] 补充回归断言并运行相关验证
- [completed] 清理临时清单并汇总交付风险
## 约束
- 使用 codebase-memory-mcp 做代码定位rg 只做行号与文本补充校验。
- 不覆盖用户已有未提交改动。
- 仅在确认根因且改动属于本问题范围时修改业务代码。
## 错误记录
| 错误 | 尝试 | 处理 |
|---|---:|---|

View File

@@ -262,6 +262,10 @@ node server.js
| `CC_WEB_LOGS_DIR` | `./logs` | 日志目录覆写,常用于测试隔离 |
| `CC_WEB_CODEX_APP_WORKER` | 开启 | 设为 `0` / `false` / `off` 可关闭 Codex App worker |
| `CC_WEB_PROCESS_CLEAN_PATH` | 自动探测 | 清理旧 Codex app-server 进程时使用的匹配路径覆写 |
| `CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC` | `30` | Codex App MCP 启动等待秒数,范围 10-300 |
| `CC_WEB_CODEX_MCP_TOOL_TIMEOUT_SEC` | `60` | ccweb MCP 工具调用超时秒数,范围 10-600 |
| `CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS` | `35000` | 重载 MCP 等待最终状态的毫秒数,范围 1000-180000 |
| `CC_WEB_CODEX_APP_MCP_RELOAD_TRACK_MS` | `60000` 或等待窗口+10秒的较大值 | 重载状态关联保留时间,范围 5000-300000 |
还有若干面向大历史、长输出和 Codex App 状态落盘的高级限制参数,例如:

View File

@@ -37,6 +37,16 @@ function createAgentRuntime(deps) {
256 * 1024,
{ min: 4096 },
);
const CCWEB_MCP_STARTUP_TIMEOUT_SEC = readRuntimePositiveIntEnv(
'CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC',
30,
{ min: 10, max: 300 },
);
const CCWEB_MCP_TOOL_TIMEOUT_SEC = readRuntimePositiveIntEnv(
'CC_WEB_CODEX_MCP_TOOL_TIMEOUT_SEC',
60,
{ min: 10, max: 600 },
);
const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n';
function keepTail(value, maxLen) {
@@ -99,8 +109,8 @@ function createAgentRuntime(deps) {
'-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`,
'-c', `mcp_servers.ccweb.args=${tomlStringArray(serverArgs)}`,
'-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`,
'-c', 'mcp_servers.ccweb.startup_timeout_sec=10',
'-c', 'mcp_servers.ccweb.tool_timeout_sec=60'
'-c', `mcp_servers.ccweb.startup_timeout_sec=${CCWEB_MCP_STARTUP_TIMEOUT_SEC}`,
'-c', `mcp_servers.ccweb.tool_timeout_sec=${CCWEB_MCP_TOOL_TIMEOUT_SEC}`
);
}

View File

@@ -1133,24 +1133,29 @@ function handleRequest(message) {
}
if (method === 'config/mcpServer/reload') {
mcpReloadCount += 1;
send({
method: 'mcpServer/startupStatus/updated',
params: {
server: 'ccweb',
state: 'starting',
message: 'ccweb MCP starting',
threadId: null,
},
});
send({
method: 'mcpServer/startupStatus/updated',
params: {
name: 'ccweb',
status: 'ready',
message: 'ccweb MCP ready CC_WEB_MCP_TOKEN=mock-secret-token',
threadId: null,
},
});
const suppressReloadStatus = process.env.MOCK_CODEX_APP_MCP_RELOAD_TIMEOUT_ON_SECOND === '1'
&& mcpReloadCount >= 2;
if (!suppressReloadStatus) {
send({
method: 'mcpServer/startupStatus/updated',
params: {
server: 'ccweb',
state: 'starting',
message: 'ccweb MCP starting',
// 模拟 app-server 的全局 reload 通知可能携带其他线程 ID。
threadId: 'mock-reload-unrelated-thread',
},
});
send({
method: 'mcpServer/startupStatus/updated',
params: {
name: 'ccweb',
status: 'ready',
message: 'ccweb MCP ready CC_WEB_MCP_TOKEN=mock-secret-token',
threadId: 'mock-reload-unrelated-thread',
},
});
}
send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } });
return;
}

View File

@@ -1613,6 +1613,32 @@ function assertFrontendMcpReloadContract() {
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
}
function assertCcwebMcpRecoveryContract() {
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const runtimeSource = fs.readFileSync(path.join(REPO_DIR, 'lib', 'agent-runtime.js'), 'utf8');
const mockSource = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
assert(
serverSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC')
&& serverSource.includes('CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,')
&& serverSource.includes('CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS')
&& serverSource.includes('CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS,')
&& serverSource.includes('allowThreadMismatch: true'),
'ccweb MCP recovery should expose configurable startup/reload windows and tolerate global reload thread IDs'
);
assert(
serverSource.includes('codex_app_mcp_reload_timeout')
&& serverSource.includes("creationStatus: 'created_but_initial_message_failed'")
&& serverSource.includes('mcpStatus: buildCodexAppMcpStatusSummary'),
'ccweb MCP recovery should persist timeout failure and expose creation/MCP status'
);
assert(
runtimeSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC')
&& runtimeSource.includes('mcp_servers.ccweb.startup_timeout_sec=${CCWEB_MCP_STARTUP_TIMEOUT_SEC}'),
'Legacy Codex runtime should use the same configurable ccweb MCP startup timeout'
);
assert(mockSource.includes('mock-reload-unrelated-thread'), 'MCP reload regression fixture should cover unrelated notification thread ids');
}
function assertFrontendSubagentCardMetadataContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
@@ -6637,6 +6663,7 @@ async function main() {
assertFrontendMarkdownLinkContract();
assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract();
assertCcwebMcpRecoveryContract();
assertPlanListProgressContract();
assertFrontendSubagentCardMetadataContract();
assertCodexAppRuntimeSubAgentActivityContract();
@@ -6793,6 +6820,8 @@ async function main() {
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS: '100',
CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS: '1000',
MOCK_CODEX_APP_MCP_RELOAD_TIMEOUT_ON_SECOND: '1',
}, async () => {
await assertWsUpgradeRejected(port, '/not-ws');
@@ -8212,6 +8241,11 @@ async function main() {
Number(reloadAfterTracking.result?.reloadCount || 0) === baselineMcpReloadCount + 1,
'Task tracking changes must not trigger an implicit MCP reload; only the explicit reload request should count'
);
assert(reloadAfterTracking.reloadTimedOut === true, 'MCP reload without a final startup notification should report a timeout');
assert(reloadAfterTracking.mcpStatus?.status === 'failed', 'MCP reload timeout should converge to failed instead of pending');
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
assert(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.status === 'failed', 'MCP reload timeout should persist failed status for retry');
assert(/可稍后再次重试/.test(storedCodexApp.codexAppMcpStartupStatus?.servers?.ccweb?.message || ''), 'MCP reload timeout should explain that retry is available');
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-codexapp-prompt-user-mcp', trigger: '/', query: 'prompt_user', sessionId: codexAppSession.sessionId, agent: 'codexapp' }));
const codexAppPromptUserMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-prompt-user-mcp');

View File

@@ -1444,8 +1444,28 @@ const CODEX_APP_UNROUTED_NOTIFICATION_LOG_THROTTLE_MS = 30 * 1000;
const CODEX_APP_UNROUTED_NOTIFICATION_LOG_MAX = 1000;
const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated';
const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb';
const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200;
const CODEX_APP_MCP_RELOAD_TRACK_MS = 15000;
// Codex App 会并发启动多个 MCP10 秒和 1.2 秒都不足以覆盖冷启动抖动。
// 保留环境变量覆盖,便于低配机器或 CI 按需调整,而不必重新发布代码。
const CODEX_APP_MCP_STARTUP_TIMEOUT_SEC = readPositiveIntEnv(
'CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC',
30,
{ min: 10, max: 300 },
);
const CODEX_APP_MCP_TOOL_TIMEOUT_SEC = readPositiveIntEnv(
'CC_WEB_CODEX_MCP_TOOL_TIMEOUT_SEC',
60,
{ min: 10, max: 600 },
);
const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = readPositiveIntEnv(
'CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS',
35000,
{ min: 1000, max: 180000 },
);
const CODEX_APP_MCP_RELOAD_TRACK_MS = readPositiveIntEnv(
'CC_WEB_CODEX_APP_MCP_RELOAD_TRACK_MS',
Math.max(60000, CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS + 10000),
{ min: 5000, max: 300000 },
);
const CODEX_APP_MCP_INVENTORY_TTL_MS = 3000;
const CODEX_APP_MCP_INVENTORY_MAX_PAGES = 10;
const codexAppMcpStartupStatusByServer = new Map();
@@ -3547,8 +3567,8 @@ function buildCcwebMcpRuntimeConfig(session, options = {}) {
type: 'streamable_http',
url: `http://127.0.0.1:${PORT}/api/internal/mcp/stream?${params.toString()}`,
bearer_token_env_var: CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV,
startup_timeout_sec: 10,
tool_timeout_sec: 60,
startup_timeout_sec: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,
tool_timeout_sec: CODEX_APP_MCP_TOOL_TIMEOUT_SEC,
},
};
}
@@ -3565,8 +3585,8 @@ function buildCcwebMcpRuntimeConfig(session, options = {}) {
command: commandSpec.command,
args: commandSpec.args,
env,
startup_timeout_sec: 10,
tool_timeout_sec: 60,
startup_timeout_sec: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,
tool_timeout_sec: CODEX_APP_MCP_TOOL_TIMEOUT_SEC,
},
};
}
@@ -4600,6 +4620,9 @@ function markCodexAppMcpReloadPending(session, sessionId) {
requestedAt,
expiresAt: Date.now() + CODEX_APP_MCP_RELOAD_TRACK_MS,
reloadRequestId,
// config/mcpServer/reload 作用于整个 app-server不保证通知携带当前线程。
// 因此 reload 窗口内允许用同一 app-server 的其他 threadId 状态完成关联。
allowThreadMismatch: true,
});
saveSession(session);
return {
@@ -4714,7 +4737,12 @@ function codexAppMcpStatusTargetSessionIds(statusRecord, routed) {
const targetSessionIds = new Set();
if (routed?.sessionId) targetSessionIds.add(routed.sessionId);
for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) {
if (statusRecord.threadId && pending.threadId && statusRecord.threadId !== pending.threadId) continue;
if (
statusRecord.threadId
&& pending.threadId
&& statusRecord.threadId !== pending.threadId
&& pending.allowThreadMismatch !== true
) continue;
targetSessionIds.add(sessionId);
}
return targetSessionIds;
@@ -4785,12 +4813,26 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
const result = typeof client.reloadMcpServers === 'function'
? await client.reloadMcpServers()
: await client.request('config/mcpServer/reload', {}, 30000);
const mcpStatus = await waitForCodexAppMcpStatusAfterReload(sessionId, reloadRequestedAt);
let mcpStatus = await waitForCodexAppMcpStatusAfterReload(sessionId, reloadRequestedAt);
let reloadTimedOut = false;
if (!isFinalCodexAppMcpStatus(mcpStatus?.status)) {
reloadTimedOut = true;
const timeoutMessage = `重载 MCP 在 ${Math.ceil(CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS / 1000)} 秒内未收到最终启动状态,可稍后再次重试。`;
mcpStatus = markCodexAppMcpReloadFailed(sessionId, timeoutMessage)
|| mcpStatus
|| pendingMcp.summary;
plog('WARN', 'codex_app_mcp_reload_timeout', {
sessionId: sessionId.slice(0, 8),
threadId: getRuntimeSessionId(session) || null,
waitMs: CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS,
});
}
plog('INFO', 'codex_app_mcp_reload_requested', {
sessionId: sessionId.slice(0, 8),
threadId: getRuntimeSessionId(session) || null,
status: mcpStatus?.status || pendingMcp.summary?.status || 'pending',
reloadTimedOut,
});
return jsonResponse(res, 200, {
@@ -4798,6 +4840,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
sessionId,
threadId: getRuntimeSessionId(session) || null,
result: result || {},
reloadTimedOut,
mcpStatus: mcpStatus || pendingMcp.summary || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }),
});
} catch (err) {
@@ -7061,6 +7104,7 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount =
content: initialMessage,
}, sourceId, normalizedHopCount, { expectReply: requestReply });
if (!delivery?.ok) {
const persistedSession = loadSession(session.id) || session;
return mcpToolError(delivery?.code || 'initial_message_failed', delivery?.message || '创建对话后发送首条消息失败。', {
conversationId: session.id,
title: session.title,
@@ -7068,10 +7112,16 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount =
cwd: session.cwd || '',
mode: session.permissionMode || 'yolo',
status: isSessionRunning(session.id) ? 'running' : 'idle',
creationStatus: 'created_but_initial_message_failed',
retryable: true,
...(isCodexAppSession(persistedSession)
? { mcpStatus: buildCodexAppMcpStatusSummary(persistedSession) }
: {}),
});
}
}
const persistedSession = loadSession(session.id) || session;
broadcastSessionList();
return {
ok: true,
@@ -7082,6 +7132,9 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount =
mode: session.permissionMode || 'yolo',
status: isSessionRunning(session.id) ? 'running' : 'idle',
sourceConversationId: sourceId || null,
...(isCodexAppSession(persistedSession)
? { mcpStatus: buildCodexAppMcpStatusSummary(persistedSession) }
: {}),
...(delivery ? {
messageId: delivery.messageId || null,
deliveryStatus: delivery.deliveryStatus || 'delivered',