修复 ccweb MCP 启动与重载并重新打包
This commit is contained in:
@@ -10,6 +10,12 @@ CLAUDE_PATH=claude
|
|||||||
# Codex CLI 路径(默认在 PATH 中查找 codex)
|
# Codex CLI 路径(默认在 PATH 中查找 codex)
|
||||||
CODEX_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(可选,首次启动会自动迁移到 config/notify.json)
|
||||||
PUSHPLUS_TOKEN=
|
PUSHPLUS_TOKEN=
|
||||||
|
|
||||||
|
|||||||
39
.planning/2026-09-12-ccweb-mcp-create-failure/findings.md
Normal file
39
.planning/2026-09-12-ccweb-mcp-create-failure/findings.md
Normal 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 和无最终状态通知的回归场景。
|
||||||
13
.planning/2026-09-12-ccweb-mcp-create-failure/progress.md
Normal file
13
.planning/2026-09-12-ccweb-mcp-create-failure/progress.md
Normal 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-12:mock + 完整 regression 已通过;未重启服务,待收尾清理临时清单。
|
||||||
25
.planning/2026-09-12-ccweb-mcp-create-failure/task_plan.md
Normal file
25
.planning/2026-09-12-ccweb-mcp-create-failure/task_plan.md
Normal 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 只做行号与文本补充校验。
|
||||||
|
- 不覆盖用户已有未提交改动。
|
||||||
|
- 仅在确认根因且改动属于本问题范围时修改业务代码。
|
||||||
|
|
||||||
|
## 错误记录
|
||||||
|
|
||||||
|
| 错误 | 尝试 | 处理 |
|
||||||
|
|---|---:|---|
|
||||||
@@ -262,6 +262,10 @@ node server.js
|
|||||||
| `CC_WEB_LOGS_DIR` | `./logs` | 日志目录覆写,常用于测试隔离 |
|
| `CC_WEB_LOGS_DIR` | `./logs` | 日志目录覆写,常用于测试隔离 |
|
||||||
| `CC_WEB_CODEX_APP_WORKER` | 开启 | 设为 `0` / `false` / `off` 可关闭 Codex App worker |
|
| `CC_WEB_CODEX_APP_WORKER` | 开启 | 设为 `0` / `false` / `off` 可关闭 Codex App worker |
|
||||||
| `CC_WEB_PROCESS_CLEAN_PATH` | 自动探测 | 清理旧 Codex app-server 进程时使用的匹配路径覆写 |
|
| `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 状态落盘的高级限制参数,例如:
|
还有若干面向大历史、长输出和 Codex App 状态落盘的高级限制参数,例如:
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -37,6 +37,16 @@ function createAgentRuntime(deps) {
|
|||||||
256 * 1024,
|
256 * 1024,
|
||||||
{ min: 4096 },
|
{ 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';
|
const RUNTIME_TRUNCATED_HEAD = '[cc-web: 前文过长,已保留尾部以保护服务稳定性]\n';
|
||||||
|
|
||||||
function keepTail(value, maxLen) {
|
function keepTail(value, maxLen) {
|
||||||
@@ -99,8 +109,8 @@ function createAgentRuntime(deps) {
|
|||||||
'-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`,
|
'-c', `mcp_servers.ccweb.command=${tomlString(nodePath || 'node')}`,
|
||||||
'-c', `mcp_servers.ccweb.args=${tomlStringArray(serverArgs)}`,
|
'-c', `mcp_servers.ccweb.args=${tomlStringArray(serverArgs)}`,
|
||||||
'-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`,
|
'-c', `mcp_servers.ccweb.env_vars=${tomlStringArray(envVars)}`,
|
||||||
'-c', 'mcp_servers.ccweb.startup_timeout_sec=10',
|
'-c', `mcp_servers.ccweb.startup_timeout_sec=${CCWEB_MCP_STARTUP_TIMEOUT_SEC}`,
|
||||||
'-c', 'mcp_servers.ccweb.tool_timeout_sec=60'
|
'-c', `mcp_servers.ccweb.tool_timeout_sec=${CCWEB_MCP_TOOL_TIMEOUT_SEC}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1133,24 +1133,29 @@ function handleRequest(message) {
|
|||||||
}
|
}
|
||||||
if (method === 'config/mcpServer/reload') {
|
if (method === 'config/mcpServer/reload') {
|
||||||
mcpReloadCount += 1;
|
mcpReloadCount += 1;
|
||||||
send({
|
const suppressReloadStatus = process.env.MOCK_CODEX_APP_MCP_RELOAD_TIMEOUT_ON_SECOND === '1'
|
||||||
method: 'mcpServer/startupStatus/updated',
|
&& mcpReloadCount >= 2;
|
||||||
params: {
|
if (!suppressReloadStatus) {
|
||||||
server: 'ccweb',
|
send({
|
||||||
state: 'starting',
|
method: 'mcpServer/startupStatus/updated',
|
||||||
message: 'ccweb MCP starting',
|
params: {
|
||||||
threadId: null,
|
server: 'ccweb',
|
||||||
},
|
state: 'starting',
|
||||||
});
|
message: 'ccweb MCP starting',
|
||||||
send({
|
// 模拟 app-server 的全局 reload 通知可能携带其他线程 ID。
|
||||||
method: 'mcpServer/startupStatus/updated',
|
threadId: 'mock-reload-unrelated-thread',
|
||||||
params: {
|
},
|
||||||
name: 'ccweb',
|
});
|
||||||
status: 'ready',
|
send({
|
||||||
message: 'ccweb MCP ready CC_WEB_MCP_TOKEN=mock-secret-token',
|
method: 'mcpServer/startupStatus/updated',
|
||||||
threadId: null,
|
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 } });
|
send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1613,6 +1613,32 @@ function assertFrontendMcpReloadContract() {
|
|||||||
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
|
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() {
|
function assertFrontendSubagentCardMetadataContract() {
|
||||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||||
@@ -6637,6 +6663,7 @@ async function main() {
|
|||||||
assertFrontendMarkdownLinkContract();
|
assertFrontendMarkdownLinkContract();
|
||||||
assertMockCodexAppPromptUserNotTextTriggered();
|
assertMockCodexAppPromptUserNotTextTriggered();
|
||||||
assertFrontendMcpReloadContract();
|
assertFrontendMcpReloadContract();
|
||||||
|
assertCcwebMcpRecoveryContract();
|
||||||
assertPlanListProgressContract();
|
assertPlanListProgressContract();
|
||||||
assertFrontendSubagentCardMetadataContract();
|
assertFrontendSubagentCardMetadataContract();
|
||||||
assertCodexAppRuntimeSubAgentActivityContract();
|
assertCodexAppRuntimeSubAgentActivityContract();
|
||||||
@@ -6793,6 +6820,8 @@ async function main() {
|
|||||||
CLAUDE_PATH: MOCK_CLAUDE,
|
CLAUDE_PATH: MOCK_CLAUDE,
|
||||||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||||||
CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS: '100',
|
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 () => {
|
}, async () => {
|
||||||
await assertWsUpgradeRejected(port, '/not-ws');
|
await assertWsUpgradeRejected(port, '/not-ws');
|
||||||
|
|
||||||
@@ -8212,6 +8241,11 @@ async function main() {
|
|||||||
Number(reloadAfterTracking.result?.reloadCount || 0) === baselineMcpReloadCount + 1,
|
Number(reloadAfterTracking.result?.reloadCount || 0) === baselineMcpReloadCount + 1,
|
||||||
'Task tracking changes must not trigger an implicit MCP reload; only the explicit reload request should count'
|
'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' }));
|
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');
|
const codexAppPromptUserMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-codexapp-prompt-user-mcp');
|
||||||
|
|||||||
69
server.js
69
server.js
@@ -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_UNROUTED_NOTIFICATION_LOG_MAX = 1000;
|
||||||
const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated';
|
const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated';
|
||||||
const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb';
|
const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb';
|
||||||
const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200;
|
// Codex App 会并发启动多个 MCP;10 秒和 1.2 秒都不足以覆盖冷启动抖动。
|
||||||
const CODEX_APP_MCP_RELOAD_TRACK_MS = 15000;
|
// 保留环境变量覆盖,便于低配机器或 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_TTL_MS = 3000;
|
||||||
const CODEX_APP_MCP_INVENTORY_MAX_PAGES = 10;
|
const CODEX_APP_MCP_INVENTORY_MAX_PAGES = 10;
|
||||||
const codexAppMcpStartupStatusByServer = new Map();
|
const codexAppMcpStartupStatusByServer = new Map();
|
||||||
@@ -3547,8 +3567,8 @@ function buildCcwebMcpRuntimeConfig(session, options = {}) {
|
|||||||
type: 'streamable_http',
|
type: 'streamable_http',
|
||||||
url: `http://127.0.0.1:${PORT}/api/internal/mcp/stream?${params.toString()}`,
|
url: `http://127.0.0.1:${PORT}/api/internal/mcp/stream?${params.toString()}`,
|
||||||
bearer_token_env_var: CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV,
|
bearer_token_env_var: CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV,
|
||||||
startup_timeout_sec: 10,
|
startup_timeout_sec: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,
|
||||||
tool_timeout_sec: 60,
|
tool_timeout_sec: CODEX_APP_MCP_TOOL_TIMEOUT_SEC,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3565,8 +3585,8 @@ function buildCcwebMcpRuntimeConfig(session, options = {}) {
|
|||||||
command: commandSpec.command,
|
command: commandSpec.command,
|
||||||
args: commandSpec.args,
|
args: commandSpec.args,
|
||||||
env,
|
env,
|
||||||
startup_timeout_sec: 10,
|
startup_timeout_sec: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,
|
||||||
tool_timeout_sec: 60,
|
tool_timeout_sec: CODEX_APP_MCP_TOOL_TIMEOUT_SEC,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4600,6 +4620,9 @@ function markCodexAppMcpReloadPending(session, sessionId) {
|
|||||||
requestedAt,
|
requestedAt,
|
||||||
expiresAt: Date.now() + CODEX_APP_MCP_RELOAD_TRACK_MS,
|
expiresAt: Date.now() + CODEX_APP_MCP_RELOAD_TRACK_MS,
|
||||||
reloadRequestId,
|
reloadRequestId,
|
||||||
|
// config/mcpServer/reload 作用于整个 app-server,不保证通知携带当前线程。
|
||||||
|
// 因此 reload 窗口内允许用同一 app-server 的其他 threadId 状态完成关联。
|
||||||
|
allowThreadMismatch: true,
|
||||||
});
|
});
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
return {
|
return {
|
||||||
@@ -4714,7 +4737,12 @@ function codexAppMcpStatusTargetSessionIds(statusRecord, routed) {
|
|||||||
const targetSessionIds = new Set();
|
const targetSessionIds = new Set();
|
||||||
if (routed?.sessionId) targetSessionIds.add(routed.sessionId);
|
if (routed?.sessionId) targetSessionIds.add(routed.sessionId);
|
||||||
for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) {
|
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);
|
targetSessionIds.add(sessionId);
|
||||||
}
|
}
|
||||||
return targetSessionIds;
|
return targetSessionIds;
|
||||||
@@ -4785,12 +4813,26 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
|||||||
const result = typeof client.reloadMcpServers === 'function'
|
const result = typeof client.reloadMcpServers === 'function'
|
||||||
? await client.reloadMcpServers()
|
? await client.reloadMcpServers()
|
||||||
: await client.request('config/mcpServer/reload', {}, 30000);
|
: 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', {
|
plog('INFO', 'codex_app_mcp_reload_requested', {
|
||||||
sessionId: sessionId.slice(0, 8),
|
sessionId: sessionId.slice(0, 8),
|
||||||
threadId: getRuntimeSessionId(session) || null,
|
threadId: getRuntimeSessionId(session) || null,
|
||||||
status: mcpStatus?.status || pendingMcp.summary?.status || 'pending',
|
status: mcpStatus?.status || pendingMcp.summary?.status || 'pending',
|
||||||
|
reloadTimedOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonResponse(res, 200, {
|
return jsonResponse(res, 200, {
|
||||||
@@ -4798,6 +4840,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
|||||||
sessionId,
|
sessionId,
|
||||||
threadId: getRuntimeSessionId(session) || null,
|
threadId: getRuntimeSessionId(session) || null,
|
||||||
result: result || {},
|
result: result || {},
|
||||||
|
reloadTimedOut,
|
||||||
mcpStatus: mcpStatus || pendingMcp.summary || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }),
|
mcpStatus: mcpStatus || pendingMcp.summary || buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt }),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -7061,6 +7104,7 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount =
|
|||||||
content: initialMessage,
|
content: initialMessage,
|
||||||
}, sourceId, normalizedHopCount, { expectReply: requestReply });
|
}, sourceId, normalizedHopCount, { expectReply: requestReply });
|
||||||
if (!delivery?.ok) {
|
if (!delivery?.ok) {
|
||||||
|
const persistedSession = loadSession(session.id) || session;
|
||||||
return mcpToolError(delivery?.code || 'initial_message_failed', delivery?.message || '创建对话后发送首条消息失败。', {
|
return mcpToolError(delivery?.code || 'initial_message_failed', delivery?.message || '创建对话后发送首条消息失败。', {
|
||||||
conversationId: session.id,
|
conversationId: session.id,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
@@ -7068,10 +7112,16 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount =
|
|||||||
cwd: session.cwd || '',
|
cwd: session.cwd || '',
|
||||||
mode: session.permissionMode || 'yolo',
|
mode: session.permissionMode || 'yolo',
|
||||||
status: isSessionRunning(session.id) ? 'running' : 'idle',
|
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();
|
broadcastSessionList();
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -7082,6 +7132,9 @@ function createMcpConversation(args = {}, sourceSessionId = '', sourceHopCount =
|
|||||||
mode: session.permissionMode || 'yolo',
|
mode: session.permissionMode || 'yolo',
|
||||||
status: isSessionRunning(session.id) ? 'running' : 'idle',
|
status: isSessionRunning(session.id) ? 'running' : 'idle',
|
||||||
sourceConversationId: sourceId || null,
|
sourceConversationId: sourceId || null,
|
||||||
|
...(isCodexAppSession(persistedSession)
|
||||||
|
? { mcpStatus: buildCodexAppMcpStatusSummary(persistedSession) }
|
||||||
|
: {}),
|
||||||
...(delivery ? {
|
...(delivery ? {
|
||||||
messageId: delivery.messageId || null,
|
messageId: delivery.messageId || null,
|
||||||
deliveryStatus: delivery.deliveryStatus || 'delivered',
|
deliveryStatus: delivery.deliveryStatus || 'delivered',
|
||||||
|
|||||||
Reference in New Issue
Block a user