修复 Codex App MCP 重载与线程库存恢复
This commit is contained in:
Binary file not shown.
@@ -1170,6 +1170,9 @@ function handleRequest(message) {
|
|||||||
}
|
}
|
||||||
if (method === 'mcpServerStatus/list') {
|
if (method === 'mcpServerStatus/list') {
|
||||||
const thread = ensureThread(params.threadId, params);
|
const thread = ensureThread(params.threadId, params);
|
||||||
|
const projectMcpConfigured = Boolean(thread.config?.['mcp_servers.reg-app-project'])
|
||||||
|
|| thread.staleProjectMcpInventoryWarmup === true;
|
||||||
|
if (thread.staleProjectMcpInventoryWarmup === true) thread.staleProjectMcpInventoryWarmup = false;
|
||||||
send({
|
send({
|
||||||
id,
|
id,
|
||||||
result: {
|
result: {
|
||||||
@@ -1188,7 +1191,7 @@ function handleRequest(message) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
...(projectMcpConfigured ? [{
|
||||||
name: 'reg-app-project',
|
name: 'reg-app-project',
|
||||||
authStatus: 'unsupported',
|
authStatus: 'unsupported',
|
||||||
resources: [],
|
resources: [],
|
||||||
@@ -1201,7 +1204,7 @@ function handleRequest(message) {
|
|||||||
inputSchema: { type: 'object' },
|
inputSchema: { type: 'object' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}] : []),
|
||||||
{
|
{
|
||||||
name: 'reg-runtime-only',
|
name: 'reg-runtime-only',
|
||||||
authStatus: 'unsupported',
|
authStatus: 'unsupported',
|
||||||
@@ -1218,13 +1221,19 @@ function handleRequest(message) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
threadId: thread.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (method === 'thread/start') {
|
if (method === 'thread/start') {
|
||||||
const thread = ensureThread(null, params);
|
const thread = ensureThread(null, params);
|
||||||
|
if (process.env.MOCK_CODEX_APP_STALE_PROJECT_MCP === '1'
|
||||||
|
&& String(params.cwd || '').includes('codexapp-stale-mcp')
|
||||||
|
&& params.config?.['mcp_servers.reg-app-project']) {
|
||||||
|
thread.staleProjectMcpConfig = params.config['mcp_servers.reg-app-project'];
|
||||||
|
thread.staleProjectMcpInventoryWarmup = true;
|
||||||
|
delete thread.config['mcp_servers.reg-app-project'];
|
||||||
|
}
|
||||||
thread.lastThreadConfigMethod = 'thread/start';
|
thread.lastThreadConfigMethod = 'thread/start';
|
||||||
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
|
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1635,6 +1635,9 @@ function assertCcwebMcpRecoveryContract() {
|
|||||||
const readinessSource = extractFunctionSource(serverSource, 'waitForCodexAppMcpReadyStatus');
|
const readinessSource = extractFunctionSource(serverSource, 'waitForCodexAppMcpReadyStatus');
|
||||||
const preflightSource = extractFunctionSource(serverSource, 'ensureCodexAppMcpReadyForThread');
|
const preflightSource = extractFunctionSource(serverSource, 'ensureCodexAppMcpReadyForThread');
|
||||||
const inventorySource = extractFunctionSource(serverSource, 'loadCodexAppMcpInventory');
|
const inventorySource = extractFunctionSource(serverSource, 'loadCodexAppMcpInventory');
|
||||||
|
const reloadStart = serverSource.indexOf('async function handleReloadMcpApi');
|
||||||
|
const reloadEnd = serverSource.indexOf('\nfunction setRuntimeSessionId', reloadStart);
|
||||||
|
const reloadSource = reloadStart >= 0 && reloadEnd > reloadStart ? serverSource.slice(reloadStart, reloadEnd) : '';
|
||||||
assert(
|
assert(
|
||||||
serverSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC')
|
serverSource.includes('CC_WEB_CODEX_APP_MCP_STARTUP_TIMEOUT_SEC')
|
||||||
&& serverSource.includes('CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,')
|
&& serverSource.includes('CODEX_APP_MCP_STARTUP_TIMEOUT_SEC,')
|
||||||
@@ -1645,11 +1648,12 @@ function assertCcwebMcpRecoveryContract() {
|
|||||||
&& startTurnSource.includes('ensureCodexAppMcpReadyForThread')
|
&& startTurnSource.includes('ensureCodexAppMcpReadyForThread')
|
||||||
&& readinessSource.includes("status === 'ready'")
|
&& readinessSource.includes("status === 'ready'")
|
||||||
&& statusSource.includes('statusThreadId !== normalizedThreadId')
|
&& statusSource.includes('statusThreadId !== normalizedThreadId')
|
||||||
&& preflightSource.includes('waitForCodexAppMcpReadyStatus')
|
&& preflightSource.includes('waitForCodexAppMcpInventory')
|
||||||
&& !preflightSource.includes('waitForCodexAppMcpInventory')
|
&& preflightSource.includes('requireServers')
|
||||||
&& inventorySource.includes('return []')
|
&& inventorySource.includes('return []')
|
||||||
|
&& reloadSource.includes('refreshCodexAppThreadMcpConfig')
|
||||||
&& !serverSource.includes('allowThreadMismatch: true'),
|
&& !serverSource.includes('allowThreadMismatch: true'),
|
||||||
'ccweb MCP recovery should gate turns on current-thread startup status and degrade inventory lookup safely'
|
'ccweb MCP recovery should gate turns on current-thread inventory and rebind the original thread before reload'
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
serverSource.includes('codex_app_mcp_reload_timeout')
|
serverSource.includes('codex_app_mcp_reload_timeout')
|
||||||
@@ -1667,7 +1671,8 @@ function assertCcwebMcpRecoveryContract() {
|
|||||||
mockSource.includes('mock-reload-unrelated-thread')
|
mockSource.includes('mock-reload-unrelated-thread')
|
||||||
&& mockSource.includes('without thread id')
|
&& mockSource.includes('without thread id')
|
||||||
&& mockSource.includes("name: 'ccweb'")
|
&& mockSource.includes("name: 'ccweb'")
|
||||||
&& mockSource.includes('ccweb_list_conversations'),
|
&& mockSource.includes('ccweb_list_conversations')
|
||||||
|
&& mockSource.includes('projectMcpConfigured'),
|
||||||
'MCP reload regression fixture should cover unrelated notifications and current-thread tool inventory'
|
'MCP reload regression fixture should cover unrelated notifications and current-thread tool inventory'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1678,9 +1683,11 @@ function assertCodexAppMcpReadinessContract() {
|
|||||||
const statusSource = extractFunctionSource(source, 'codexAppMcpStatusForThread');
|
const statusSource = extractFunctionSource(source, 'codexAppMcpStatusForThread');
|
||||||
const readinessSource = extractFunctionSource(source, 'waitForCodexAppMcpReadyStatus');
|
const readinessSource = extractFunctionSource(source, 'waitForCodexAppMcpReadyStatus');
|
||||||
const inventorySource = extractFunctionSource(source, 'loadCodexAppMcpInventory');
|
const inventorySource = extractFunctionSource(source, 'loadCodexAppMcpInventory');
|
||||||
assert(readinessSource.includes("status === 'ready'"), 'Codex App first turn should wait for current-thread ccweb readiness');
|
const preflightSource = extractFunctionSource(source, 'ensureCodexAppMcpReadyForThread');
|
||||||
|
assert(readinessSource.includes("status === 'ready'"), 'Codex App readiness should retain current-thread startup status handling');
|
||||||
assert(readinessSource.includes("status === 'failed'") && readinessSource.includes("status === 'cancelled'"), 'Codex App readiness should surface terminal MCP startup failures');
|
assert(readinessSource.includes("status === 'failed'") && readinessSource.includes("status === 'cancelled'"), 'Codex App readiness should surface terminal MCP startup failures');
|
||||||
assert(statusSource.includes('statusThreadId !== normalizedThreadId'), 'Codex App readiness must reject stale status from another thread');
|
assert(statusSource.includes('statusThreadId !== normalizedThreadId'), 'Codex App readiness must reject stale status from another thread');
|
||||||
|
assert(preflightSource.includes('waitForCodexAppMcpInventory') && preflightSource.includes('requireServers'), 'Codex App first turn should verify the configured server inventory before sending a turn');
|
||||||
assert(inventorySource.includes('return []'), 'Composer MCP inventory failure should degrade to local suggestions');
|
assert(inventorySource.includes('return []'), 'Composer MCP inventory failure should degrade to local suggestions');
|
||||||
assert(workerClientSource.includes("sendWorker('start', {}, 120000)"), 'Codex App worker start should cover initialize and best-effort capability probes');
|
assert(workerClientSource.includes("sendWorker('start', {}, 120000)"), 'Codex App worker start should cover initialize and best-effort capability probes');
|
||||||
}
|
}
|
||||||
@@ -7374,6 +7381,7 @@ async function main() {
|
|||||||
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',
|
CC_WEB_CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS: '1000',
|
||||||
MOCK_CODEX_APP_MCP_RELOAD_TIMEOUT_ON_SECOND: '1',
|
MOCK_CODEX_APP_MCP_RELOAD_TIMEOUT_ON_SECOND: '1',
|
||||||
|
MOCK_CODEX_APP_STALE_PROJECT_MCP: '1',
|
||||||
}, async () => {
|
}, async () => {
|
||||||
await assertWsUpgradeRejected(port, '/not-ws');
|
await assertWsUpgradeRejected(port, '/not-ws');
|
||||||
|
|
||||||
@@ -8320,6 +8328,34 @@ async function main() {
|
|||||||
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
|
const codexAppSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === codexAppCwd);
|
||||||
assert(codexAppSession.model === 'gpt-5.5(max)', 'Codex App new_session should preserve the max default Codex model');
|
assert(codexAppSession.model === 'gpt-5.5(max)', 'Codex App new_session should preserve the max default Codex model');
|
||||||
|
|
||||||
|
const staleMcpCwd = path.join(tempRoot, 'codexapp-stale-mcp');
|
||||||
|
mkdirp(path.join(staleMcpCwd, '.codex'));
|
||||||
|
fs.writeFileSync(path.join(staleMcpCwd, '.codex', 'config.toml'), [
|
||||||
|
'[mcp_servers.reg-app-project]',
|
||||||
|
'type = "stdio"',
|
||||||
|
`command = ${JSON.stringify(process.execPath)}`,
|
||||||
|
'args = ["regression-app-mcp.js"]',
|
||||||
|
'enabled = true',
|
||||||
|
].join('\n'));
|
||||||
|
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: staleMcpCwd, mode: 'yolo' }));
|
||||||
|
const staleMcpSession = await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === staleMcpCwd
|
||||||
|
));
|
||||||
|
ws.send(JSON.stringify({ type: 'message', text: 'stale mcp dynamic inventory probe', sessionId: staleMcpSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||||
|
const staleMcpTool = await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'tool_end' && msg.sessionId === staleMcpSession.sessionId && msg.toolUseId === 'mcp-ccweb-list'
|
||||||
|
));
|
||||||
|
assert(/"hasProjectMcpConfig": false/.test(staleMcpTool.result || ''), 'Stale MCP fixture should reproduce an original thread missing its project MCP config');
|
||||||
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === staleMcpSession.sessionId);
|
||||||
|
const staleMcpReload = await postAuthedJson(port, token, `/api/sessions/${staleMcpSession.sessionId}/reload-mcp`);
|
||||||
|
assert(staleMcpReload.ok === true, `MCP reload should restore the stale original thread: ${JSON.stringify(staleMcpReload)}`);
|
||||||
|
assert(staleMcpReload.mcpStatus?.inventoryStatus === 'ready', `MCP reload should verify the restored thread inventory: ${JSON.stringify(staleMcpReload)}`);
|
||||||
|
assert(staleMcpReload.mcpStatus?.inventoryServers?.some((server) => (
|
||||||
|
server.server === 'reg-app-project'
|
||||||
|
&& Array.isArray(server.tools)
|
||||||
|
&& server.tools.some((tool) => tool.name === 'reg_app_inspect')
|
||||||
|
)), `MCP reload inventory should contain the project MCP restored by thread resume: ${JSON.stringify(staleMcpReload.mcpStatus)}`);
|
||||||
|
|
||||||
const codexAppGoalTitleCwd = path.join(tempRoot, 'codexapp-goal-title');
|
const codexAppGoalTitleCwd = path.join(tempRoot, 'codexapp-goal-title');
|
||||||
mkdirp(codexAppGoalTitleCwd);
|
mkdirp(codexAppGoalTitleCwd);
|
||||||
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppGoalTitleCwd, mode: 'yolo' }));
|
ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: codexAppGoalTitleCwd, mode: 'yolo' }));
|
||||||
|
|||||||
174
server.js
174
server.js
@@ -3486,14 +3486,45 @@ function summarizeCodexAppMcpInventory(servers) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expectedCodexAppMcpServers(session, options = {}) {
|
||||||
|
return listRuntimeMcpServerConfigs({
|
||||||
|
...options,
|
||||||
|
session,
|
||||||
|
agent: 'codexapp',
|
||||||
|
})
|
||||||
|
.map((item) => normalizeMcpServerName(item?.server || item?.name))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingCodexAppMcpServers(servers, requiredServers = []) {
|
||||||
|
const available = new Map(
|
||||||
|
(Array.isArray(servers) ? servers : []).map((server) => [
|
||||||
|
codexAppMcpStatusKey(server?.server),
|
||||||
|
Array.isArray(server?.tools) ? server.tools.length : 0,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return Array.from(new Set(requiredServers
|
||||||
|
.map((server) => normalizeMcpServerName(server))
|
||||||
|
.filter(Boolean)))
|
||||||
|
.filter((server) => !available.has(codexAppMcpStatusKey(server))
|
||||||
|
|| available.get(codexAppMcpStatusKey(server)) <= 0);
|
||||||
|
}
|
||||||
|
|
||||||
function recordCodexAppMcpInventoryState(session, inventoryResult) {
|
function recordCodexAppMcpInventoryState(session, inventoryResult) {
|
||||||
if (!session || !isCodexAppSession(session)) return;
|
if (!session || !isCodexAppSession(session)) return;
|
||||||
const state = ensureCodexAppMcpStartupState(session);
|
const state = ensureCodexAppMcpStartupState(session);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
const summary = summarizeCodexAppMcpInventory(inventoryResult?.servers || []);
|
const summary = summarizeCodexAppMcpInventory(inventoryResult?.servers || []);
|
||||||
|
const inventoryThreadId = inventoryResult?.threadId || getRuntimeSessionId(session) || null;
|
||||||
|
if (inventoryResult?.ok && inventoryThreadId) {
|
||||||
|
codexAppMcpInventoryByThread.set(inventoryThreadId, {
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
servers: inventoryResult.servers || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
state.inventory = {
|
state.inventory = {
|
||||||
status: inventoryResult?.ok ? 'ready' : 'failed',
|
status: inventoryResult?.ok ? 'ready' : 'failed',
|
||||||
threadId: inventoryResult?.threadId || getRuntimeSessionId(session) || null,
|
threadId: inventoryThreadId,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
...summary,
|
...summary,
|
||||||
error: inventoryResult?.ok ? '' : safeMcpStatusString(inventoryResult?.error || 'MCP 工具清单查询失败', 500),
|
error: inventoryResult?.ok ? '' : safeMcpStatusString(inventoryResult?.error || 'MCP 工具清单查询失败', 500),
|
||||||
@@ -3545,6 +3576,8 @@ async function queryCodexAppMcpInventory(session, options = {}) {
|
|||||||
|
|
||||||
const servers = mergeCodexAppMcpInventories(allServers);
|
const servers = mergeCodexAppMcpInventories(allServers);
|
||||||
const summary = summarizeCodexAppMcpInventory(servers);
|
const summary = summarizeCodexAppMcpInventory(servers);
|
||||||
|
const requiredServers = Array.isArray(options.requireServers) ? options.requireServers : [];
|
||||||
|
const missingServers = missingCodexAppMcpServers(servers, requiredServers);
|
||||||
if (options.requireCcweb && !summary.ccwebReady) {
|
if (options.requireCcweb && !summary.ccwebReady) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -3552,9 +3585,21 @@ async function queryCodexAppMcpInventory(session, options = {}) {
|
|||||||
threadId,
|
threadId,
|
||||||
servers,
|
servers,
|
||||||
...summary,
|
...summary,
|
||||||
|
missingServers: missingServers.length > 0 ? missingServers : [CODEX_APP_MCP_DEFAULT_SERVER],
|
||||||
error: '当前线程的 ccweb MCP 未返回可调用工具,不能判定 MCP 已就绪。',
|
error: '当前线程的 ccweb MCP 未返回可调用工具,不能判定 MCP 已就绪。',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (missingServers.length > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: 'configured_mcp_tools_unavailable',
|
||||||
|
threadId,
|
||||||
|
servers,
|
||||||
|
...summary,
|
||||||
|
missingServers,
|
||||||
|
error: `当前线程缺少可调用 MCP 工具:${missingServers.join('、')}。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
return { ok: true, code: 'ok', threadId, servers, ...summary, error: '' };
|
return { ok: true, code: 'ok', threadId, servers, ...summary, error: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3568,7 +3613,7 @@ async function waitForCodexAppMcpInventory(session, options = {}) {
|
|||||||
...options,
|
...options,
|
||||||
timeoutMs: Math.min(Number(options.timeoutMs || 5000), remaining),
|
timeoutMs: Math.min(Number(options.timeoutMs || 5000), remaining),
|
||||||
});
|
});
|
||||||
if (lastResult.ok || !['ccweb_tools_unavailable', 'inventory_request_failed'].includes(lastResult.code)) return lastResult;
|
if (lastResult.ok || !['ccweb_tools_unavailable', 'configured_mcp_tools_unavailable', 'inventory_request_failed'].includes(lastResult.code)) return lastResult;
|
||||||
if (Date.now() >= deadline) return lastResult;
|
if (Date.now() >= deadline) return lastResult;
|
||||||
await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, deadline - Date.now()))));
|
await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, deadline - Date.now()))));
|
||||||
}
|
}
|
||||||
@@ -3648,20 +3693,71 @@ async function loadCodexAppMcpInventory(session) {
|
|||||||
return pending;
|
return pending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetCodexAppMcpStateForThread(session, threadId, reason = '') {
|
||||||
|
if (!session || !isCodexAppSession(session)) return null;
|
||||||
|
const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session));
|
||||||
|
if (!normalizedThreadId) return null;
|
||||||
|
const state = ensureCodexAppMcpStartupState(session);
|
||||||
|
if (!state) return null;
|
||||||
|
const previousThreadId = normalizeCodexAppThreadId(state.threadId || '');
|
||||||
|
if (reason === 'reload' || (previousThreadId && previousThreadId !== normalizedThreadId)) {
|
||||||
|
state.servers = {};
|
||||||
|
state.inventory = null;
|
||||||
|
}
|
||||||
|
state.threadId = normalizedThreadId;
|
||||||
|
state.updatedAt = new Date().toISOString();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshCodexAppThreadMcpConfig(client, session, threadId, options = {}) {
|
||||||
|
const normalizedThreadId = normalizeCodexAppThreadId(threadId);
|
||||||
|
if (!normalizedThreadId) {
|
||||||
|
const error = new Error('重载 MCP 前无法恢复原对话:当前会话没有 Codex App 线程 ID。');
|
||||||
|
error.code = 'codexapp_thread_rebind_missing';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const threadParams = codexAppThreadParams(session, options);
|
||||||
|
let resumed;
|
||||||
|
try {
|
||||||
|
resumed = await client.request('thread/resume', {
|
||||||
|
...threadParams,
|
||||||
|
threadId: normalizedThreadId,
|
||||||
|
}, options.timeoutMs || 60000);
|
||||||
|
} catch (err) {
|
||||||
|
const error = new Error(`重载 MCP 前恢复原对话线程失败:${err?.message || String(err)}`);
|
||||||
|
error.code = 'codexapp_thread_rebind_failed';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const resumedThreadId = normalizeCodexAppThreadId(resumed?.thread?.id || normalizedThreadId);
|
||||||
|
if (resumedThreadId !== normalizedThreadId) {
|
||||||
|
const error = new Error(`重载 MCP 恢复到了不同线程(期望 ${normalizedThreadId.slice(0, 24)},实际 ${resumedThreadId.slice(0, 24)})。`);
|
||||||
|
error.code = 'codexapp_thread_rebind_mismatch';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return resumedThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureCodexAppMcpReadyForThread(session, threadId) {
|
async function ensureCodexAppMcpReadyForThread(session, threadId) {
|
||||||
const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session));
|
const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session));
|
||||||
if (!normalizedThreadId) {
|
if (!normalizedThreadId) {
|
||||||
throw new Error('Codex App MCP 校验失败:当前线程 ID 不存在。');
|
throw new Error('Codex App MCP 校验失败:当前线程 ID 不存在。');
|
||||||
}
|
}
|
||||||
|
const requiredServers = expectedCodexAppMcpServers(session);
|
||||||
const cached = codexAppMcpInventoryByThread.get(normalizedThreadId);
|
const cached = codexAppMcpInventoryByThread.get(normalizedThreadId);
|
||||||
if (cached && Date.now() - cached.fetchedAt < CODEX_APP_MCP_INVENTORY_TTL_MS) {
|
if (cached && Date.now() - cached.fetchedAt < CODEX_APP_MCP_INVENTORY_TTL_MS) {
|
||||||
const cachedSummary = summarizeCodexAppMcpInventory(cached.servers);
|
const cachedSummary = summarizeCodexAppMcpInventory(cached.servers);
|
||||||
if (cachedSummary.ccwebReady) return cached.servers;
|
const cachedMissing = missingCodexAppMcpServers(cached.servers, requiredServers);
|
||||||
|
if (cachedSummary.ccwebReady && cachedMissing.length === 0) return cached.servers;
|
||||||
}
|
}
|
||||||
// MCP 启动状态和完整 inventory 都是运行能力探测,不能阻断首轮 turn/start。
|
const result = await waitForCodexAppMcpInventory(session, {
|
||||||
// app-server 会在工具真正调用时继续管理 MCP 生命周期;慢的全局 MCP(例如
|
threadId: normalizedThreadId,
|
||||||
// 远程 playwright)不应让当前线程连普通消息也发不出去。
|
requireCcweb: true,
|
||||||
const result = await waitForCodexAppMcpReadyStatus(session, normalizedThreadId, { maxWaitMs: 0 });
|
requireServers: requiredServers,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
// 工具清单探测是 best-effort:全局慢 MCP 不能把普通对话阻塞到启动超时。
|
||||||
|
// 重载接口仍会用更长窗口并明确返回失败;普通消息只在确认配置服务器缺失时阻断。
|
||||||
|
maxWaitMs: 5000,
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
plog('WARN', 'codex_app_mcp_preflight_failed', {
|
plog('WARN', 'codex_app_mcp_preflight_failed', {
|
||||||
sessionId: session?.id ? session.id.slice(0, 8) : null,
|
sessionId: session?.id ? session.id.slice(0, 8) : null,
|
||||||
@@ -3669,9 +3765,25 @@ async function ensureCodexAppMcpReadyForThread(session, threadId) {
|
|||||||
code: result.code,
|
code: result.code,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
});
|
});
|
||||||
return [];
|
if (['inventory_request_failed', 'client_unavailable', 'ccweb_tools_unavailable'].includes(result.code)) {
|
||||||
|
plog('WARN', 'codex_app_mcp_preflight_degraded', {
|
||||||
|
sessionId: session?.id ? session.id.slice(0, 8) : null,
|
||||||
|
threadId: normalizedThreadId.slice(0, 16),
|
||||||
|
code: result.code,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
return codexAppMcpInventoryByThread.get(normalizedThreadId)?.servers || [];
|
||||||
|
}
|
||||||
|
const missing = Array.isArray(result.missingServers) && result.missingServers.length > 0
|
||||||
|
? `(缺少:${result.missingServers.join('、')})`
|
||||||
|
: '';
|
||||||
|
throw new Error(`当前线程的 MCP 工具清单不可用${missing}:${result.error || '请点击“重载 MCP”后重试。'}`);
|
||||||
}
|
}
|
||||||
return codexAppMcpInventoryByThread.get(normalizedThreadId)?.servers || [];
|
codexAppMcpInventoryByThread.set(normalizedThreadId, {
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
servers: result.servers,
|
||||||
|
});
|
||||||
|
return result.servers;
|
||||||
}
|
}
|
||||||
|
|
||||||
function summarizeSkillDependencies(skill) {
|
function summarizeSkillDependencies(skill) {
|
||||||
@@ -4739,6 +4851,9 @@ function buildCodexAppMcpStatusSummary(session, options = {}) {
|
|||||||
const serversObject = mcpStatusObject(state.servers) || {};
|
const serversObject = mcpStatusObject(state.servers) || {};
|
||||||
const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record));
|
const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record));
|
||||||
const inventory = mcpStatusObject(state.inventory) || null;
|
const inventory = mcpStatusObject(state.inventory) || null;
|
||||||
|
const inventoryRuntime = inventory?.threadId
|
||||||
|
? codexAppMcpInventoryByThread.get(normalizeCodexAppThreadId(inventory.threadId))?.servers
|
||||||
|
: null;
|
||||||
let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER);
|
let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER);
|
||||||
const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null;
|
const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null;
|
||||||
if (!current) {
|
if (!current) {
|
||||||
@@ -4765,6 +4880,7 @@ function buildCodexAppMcpStatusSummary(session, options = {}) {
|
|||||||
inventoryServerCount: Number.isFinite(Number(inventory?.serverCount)) ? Number(inventory.serverCount) : 0,
|
inventoryServerCount: Number.isFinite(Number(inventory?.serverCount)) ? Number(inventory.serverCount) : 0,
|
||||||
inventoryToolCount: Number.isFinite(Number(inventory?.toolCount)) ? Number(inventory.toolCount) : 0,
|
inventoryToolCount: Number.isFinite(Number(inventory?.toolCount)) ? Number(inventory.toolCount) : 0,
|
||||||
ccwebToolCount: Number.isFinite(Number(inventory?.ccwebToolCount)) ? Number(inventory.ccwebToolCount) : 0,
|
ccwebToolCount: Number.isFinite(Number(inventory?.ccwebToolCount)) ? Number(inventory.ccwebToolCount) : 0,
|
||||||
|
inventoryServers: Array.isArray(inventoryRuntime) ? inventoryRuntime : [],
|
||||||
inventoryError: safeMcpStatusString(inventory?.error || '', 500) || null,
|
inventoryError: safeMcpStatusString(inventory?.error || '', 500) || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -4775,6 +4891,7 @@ function markCodexAppMcpReloadPending(session, sessionId) {
|
|||||||
const reloadRequestId = crypto.randomUUID();
|
const reloadRequestId = crypto.randomUUID();
|
||||||
const state = ensureCodexAppMcpStartupState(session);
|
const state = ensureCodexAppMcpStartupState(session);
|
||||||
if (!state) return { requestedAt, summary: null };
|
if (!state) return { requestedAt, summary: null };
|
||||||
|
resetCodexAppMcpStateForThread(session, threadId, 'reload');
|
||||||
state.reloadRequestedAt = requestedAt;
|
state.reloadRequestedAt = requestedAt;
|
||||||
state.reloadRequestId = reloadRequestId;
|
state.reloadRequestId = reloadRequestId;
|
||||||
state.updatedAt = requestedAt;
|
state.updatedAt = requestedAt;
|
||||||
@@ -4876,20 +4993,28 @@ function updateCodexAppSessionMcpStatus(sessionId, statusRecord) {
|
|||||||
if (!normalizedId) return null;
|
if (!normalizedId) return null;
|
||||||
const session = loadSession(normalizedId);
|
const session = loadSession(normalizedId);
|
||||||
if (!session || !isCodexAppSession(session)) return null;
|
if (!session || !isCodexAppSession(session)) return null;
|
||||||
|
const sessionThreadId = normalizeCodexAppThreadId(getRuntimeSessionId(session) || '');
|
||||||
|
const statusThreadId = normalizeCodexAppThreadId(statusRecord?.threadId || '');
|
||||||
|
if (sessionThreadId && statusThreadId && sessionThreadId !== statusThreadId) {
|
||||||
|
plog('WARN', 'codex_app_mcp_status_thread_mismatch', {
|
||||||
|
sessionId: normalizedId.slice(0, 8),
|
||||||
|
expectedThreadId: sessionThreadId.slice(0, 24),
|
||||||
|
actualThreadId: statusThreadId.slice(0, 24),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
resetCodexAppMcpStateForThread(session, sessionThreadId || statusThreadId, 'status');
|
||||||
const state = ensureCodexAppMcpStartupState(session);
|
const state = ensureCodexAppMcpStartupState(session);
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
const record = publicCodexAppMcpStatusRecord({
|
const record = publicCodexAppMcpStatusRecord({
|
||||||
...statusRecord,
|
...statusRecord,
|
||||||
threadId: statusRecord.threadId || state.threadId || getRuntimeSessionId(session) || null,
|
threadId: statusThreadId || state.threadId || sessionThreadId || null,
|
||||||
source: 'notification',
|
source: 'notification',
|
||||||
});
|
});
|
||||||
const key = codexAppMcpStatusKey(record.name);
|
const key = codexAppMcpStatusKey(record.name);
|
||||||
state.servers[key] = record;
|
state.servers[key] = record;
|
||||||
state.updatedAt = record.updatedAt;
|
state.updatedAt = record.updatedAt;
|
||||||
state.threadId = record.threadId || state.threadId || null;
|
state.threadId = record.threadId || state.threadId || null;
|
||||||
if (key === codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER) && isFinalCodexAppMcpStatus(record.status)) {
|
|
||||||
pendingCodexAppMcpReloads.delete(normalizedId);
|
|
||||||
}
|
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
const summary = buildCodexAppMcpStatusSummary(session);
|
const summary = buildCodexAppMcpStatusSummary(session);
|
||||||
resolveCodexAppMcpStatusWaiters(normalizedId, summary);
|
resolveCodexAppMcpStatusWaiters(normalizedId, summary);
|
||||||
@@ -5012,7 +5137,19 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
|||||||
const client = clientResult.client;
|
const client = clientResult.client;
|
||||||
await client.start();
|
await client.start();
|
||||||
const currentThreadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
|
const currentThreadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
|
||||||
if (currentThreadId) codexAppMcpInventoryByThread.delete(currentThreadId);
|
if (!currentThreadId) {
|
||||||
|
const error = new Error('重载 MCP 失败:当前会话没有可恢复的原始 Codex App 线程,请先发送一条消息建立线程。');
|
||||||
|
error.code = 'codexapp_thread_missing';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
codexAppMcpInventoryByThread.delete(currentThreadId);
|
||||||
|
codexAppMcpInventoryPending.delete(currentThreadId);
|
||||||
|
resetCodexAppMcpStateForThread(session, currentThreadId, 'reload');
|
||||||
|
saveSession(session);
|
||||||
|
await refreshCodexAppThreadMcpConfig(client, session, currentThreadId, {
|
||||||
|
mcpContext: {},
|
||||||
|
timeoutMs: 60000,
|
||||||
|
});
|
||||||
const pendingMcp = markCodexAppMcpReloadPending(session, sessionId);
|
const pendingMcp = markCodexAppMcpReloadPending(session, sessionId);
|
||||||
reloadRequestedAt = pendingMcp.requestedAt;
|
reloadRequestedAt = pendingMcp.requestedAt;
|
||||||
const result = typeof client.reloadMcpServers === 'function'
|
const result = typeof client.reloadMcpServers === 'function'
|
||||||
@@ -5039,10 +5176,12 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
|||||||
inventoryResult = await waitForCodexAppMcpInventory(targetSession, {
|
inventoryResult = await waitForCodexAppMcpInventory(targetSession, {
|
||||||
threadId: currentThreadId,
|
threadId: currentThreadId,
|
||||||
requireCcweb: true,
|
requireCcweb: true,
|
||||||
|
requireServers: expectedCodexAppMcpServers(targetSession),
|
||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
maxWaitMs: 5000,
|
maxWaitMs: 5000,
|
||||||
});
|
});
|
||||||
updateCodexAppSessionMcpInventory(sessionId, inventoryResult);
|
updateCodexAppSessionMcpInventory(sessionId, inventoryResult);
|
||||||
|
mcpStatus = buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt });
|
||||||
if (!inventoryResult.ok) {
|
if (!inventoryResult.ok) {
|
||||||
const inventoryMessage = inventoryResult.error || '当前线程 MCP 工具清单不可用,不能判定 MCP 已就绪。';
|
const inventoryMessage = inventoryResult.error || '当前线程 MCP 工具清单不可用,不能判定 MCP 已就绪。';
|
||||||
mcpStatus = markCodexAppMcpReloadFailed(sessionId, inventoryMessage)
|
mcpStatus = markCodexAppMcpReloadFailed(sessionId, inventoryMessage)
|
||||||
@@ -5057,6 +5196,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reloadReady = mcpStatus?.status === 'ready' && inventoryResult?.ok === true;
|
const reloadReady = mcpStatus?.status === 'ready' && inventoryResult?.ok === true;
|
||||||
|
pendingCodexAppMcpReloads.delete(sessionId);
|
||||||
|
|
||||||
plog('INFO', 'codex_app_mcp_reload_requested', {
|
plog('INFO', 'codex_app_mcp_reload_requested', {
|
||||||
sessionId: sessionId.slice(0, 8),
|
sessionId: sessionId.slice(0, 8),
|
||||||
@@ -5083,6 +5223,7 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
|||||||
const mcpStatus = reloadRequestedAt
|
const mcpStatus = reloadRequestedAt
|
||||||
? markCodexAppMcpReloadFailed(sessionId, message)
|
? markCodexAppMcpReloadFailed(sessionId, message)
|
||||||
: buildCodexAppMcpStatusSummary(session);
|
: buildCodexAppMcpStatusSummary(session);
|
||||||
|
pendingCodexAppMcpReloads.delete(sessionId);
|
||||||
return jsonResponse(res, unsupported ? 501 : 500, {
|
return jsonResponse(res, unsupported ? 501 : 500, {
|
||||||
ok: false,
|
ok: false,
|
||||||
code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed',
|
code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed',
|
||||||
@@ -5105,6 +5246,7 @@ function setRuntimeSessionId(session, runtimeId) {
|
|||||||
codexAppMcpInventoryByThread.delete(previousThreadId);
|
codexAppMcpInventoryByThread.delete(previousThreadId);
|
||||||
codexAppMcpInventoryPending.delete(previousThreadId);
|
codexAppMcpInventoryPending.delete(previousThreadId);
|
||||||
}
|
}
|
||||||
|
if (nextThreadId) resetCodexAppMcpStateForThread(session, nextThreadId, 'thread_changed');
|
||||||
if (previousThreadId && previousThreadId !== nextThreadId) {
|
if (previousThreadId && previousThreadId !== nextThreadId) {
|
||||||
session.codexAppGoal = null;
|
session.codexAppGoal = null;
|
||||||
if (session.id) codexAppGoalStates.delete(session.id);
|
if (session.id) codexAppGoalStates.delete(session.id);
|
||||||
@@ -13889,8 +14031,8 @@ async function startCodexAppTurn(sessionId, input) {
|
|||||||
saveSession(session);
|
saveSession(session);
|
||||||
persistCodexAppTurnState(sessionId, entry, { immediate: true });
|
persistCodexAppTurnState(sessionId, entry, { immediate: true });
|
||||||
|
|
||||||
// 首轮消息必须先确认当前线程真的拿到了 ccweb 工具;仅收到 startupStatus=ready
|
// 每轮消息都先确认当前线程的配置服务器和工具库存;仅收到 startupStatus=ready
|
||||||
// 不足以证明模型可调用 MCP,失败时保留会话并给出可重试错误。
|
// 不足以证明模型可调用具体 MCP,失败时保留会话并给出可重试错误。
|
||||||
await ensureCodexAppMcpReadyForThread(session, threadId);
|
await ensureCodexAppMcpReadyForThread(session, threadId);
|
||||||
|
|
||||||
const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000);
|
const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000);
|
||||||
|
|||||||
Reference in New Issue
Block a user