修复 Codex App MCP 跨线程假就绪
This commit is contained in:
Binary file not shown.
@@ -1155,6 +1155,15 @@ function handleRequest(message) {
|
||||
threadId: 'mock-reload-unrelated-thread',
|
||||
},
|
||||
});
|
||||
// 全局通知可能不带线程;服务端只有在单个重载窗口内才允许安全归属。
|
||||
send({
|
||||
method: 'mcpServer/startupStatus/updated',
|
||||
params: {
|
||||
name: 'ccweb',
|
||||
status: 'ready',
|
||||
message: 'ccweb MCP ready without thread id CC_WEB_MCP_TOKEN=mock-secret-token',
|
||||
},
|
||||
});
|
||||
}
|
||||
send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } });
|
||||
return;
|
||||
@@ -1165,6 +1174,20 @@ function handleRequest(message) {
|
||||
id,
|
||||
result: {
|
||||
data: [
|
||||
{
|
||||
name: 'ccweb',
|
||||
authStatus: 'unsupported',
|
||||
resources: [],
|
||||
resourceTemplates: [],
|
||||
serverInfo: { name: 'ccweb', version: '1.0.0' },
|
||||
tools: {
|
||||
ccweb_list_conversations: {
|
||||
name: 'ccweb_list_conversations',
|
||||
description: 'Regression ccweb MCP tool.',
|
||||
inputSchema: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'reg-app-project',
|
||||
authStatus: 'unsupported',
|
||||
@@ -1206,6 +1229,16 @@ function handleRequest(message) {
|
||||
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;
|
||||
}
|
||||
if (method === 'thread/fork') {
|
||||
const source = ensureThread(params.threadId, params);
|
||||
const thread = ensureThread(null, params);
|
||||
thread.forkedFromId = source.id;
|
||||
thread.lastForkTurnId = params.lastTurnId || params.beforeTurnId || null;
|
||||
thread.lastThreadConfigMethod = 'thread/fork';
|
||||
thread.lastForkConfig = params.config || {};
|
||||
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;
|
||||
}
|
||||
if (method === 'thread/resume') {
|
||||
if (params.threadId && resumeMismatchThreads.delete(params.threadId)) {
|
||||
const thread = ensureThread(null, params);
|
||||
|
||||
@@ -296,6 +296,19 @@ async function postAuthedJson(port, token, pathname, body = {}) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function postAuthedJsonAllowFailure(port, token, pathname, body = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const payload = await response.json();
|
||||
return { status: response.status, payload };
|
||||
}
|
||||
|
||||
async function callInternalMcp(port, token, payload) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
|
||||
method: 'POST',
|
||||
@@ -1617,16 +1630,21 @@ 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');
|
||||
const startTurnSource = extractFunctionSource(serverSource, 'startCodexAppTurn');
|
||||
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'
|
||||
&& serverSource.includes('queryCodexAppMcpInventory')
|
||||
&& serverSource.includes('inventory_thread_mismatch')
|
||||
&& startTurnSource.includes('ensureCodexAppMcpReadyForThread')
|
||||
&& !serverSource.includes('allowThreadMismatch: true'),
|
||||
'ccweb MCP recovery should expose configurable windows, strict thread routing, and inventory verification'
|
||||
);
|
||||
assert(
|
||||
serverSource.includes('codex_app_mcp_reload_timeout')
|
||||
&& serverSource.includes('codex_app_mcp_reload_inventory_failed')
|
||||
&& serverSource.includes("creationStatus: 'created_but_initial_message_failed'")
|
||||
&& serverSource.includes('mcpStatus: buildCodexAppMcpStatusSummary'),
|
||||
'ccweb MCP recovery should persist timeout failure and expose creation/MCP status'
|
||||
@@ -1636,7 +1654,13 @@ function assertCcwebMcpRecoveryContract() {
|
||||
&& 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');
|
||||
assert(
|
||||
mockSource.includes('mock-reload-unrelated-thread')
|
||||
&& mockSource.includes('without thread id')
|
||||
&& mockSource.includes("name: 'ccweb'")
|
||||
&& mockSource.includes('ccweb_list_conversations'),
|
||||
'MCP reload regression fixture should cover unrelated notifications and current-thread tool inventory'
|
||||
);
|
||||
}
|
||||
|
||||
function assertFrontendSubagentCardMetadataContract() {
|
||||
@@ -2603,6 +2627,23 @@ function assertCodexAppTransientReconnectContract() {
|
||||
assert(sent.filter((message) => message.type === 'system_message').length === 7, 'Reconnect progress and terminal errors should each be forwarded as system messages');
|
||||
}
|
||||
|
||||
function assertCodexAppBranchForkContract() {
|
||||
const source = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const branchSource = extractFunctionSource(source, 'resolveBranchSource');
|
||||
const turnSource = extractFunctionSource(source, 'startCodexAppTurn');
|
||||
assert(branchSource.includes('sourceThreadId'), 'Codex App branch should persist the source thread id');
|
||||
assert(branchSource.includes('sourceTurnId'), 'Codex App branch should persist the selected source turn id');
|
||||
assert(branchSource.includes("selectedMessage?.role !== 'assistant'"), 'Codex App branch should require the selected assistant message');
|
||||
assert(branchSource.includes("selectedMessage?.codexAppTurnId"), 'Codex App branch should use the selected assistant turn id only');
|
||||
assert(!branchSource.includes('slice(0, sourceMessageIndex + 1)].reverse'), 'Codex App branch should not guess an earlier turn id');
|
||||
assert(turnSource.includes("client.request('thread/fork'"), 'Codex App branch should call native thread/fork');
|
||||
assert(turnSource.includes('lastTurnId: sourceTurnId'), 'Codex App fork should stop at the selected source turn');
|
||||
assert(turnSource.includes('缺少来源线程或回合信息'), 'Codex App branch should refuse to start an empty thread when fork metadata is missing');
|
||||
const goalSource = extractFunctionSource(source, 'ensureCodexAppGoalThread');
|
||||
assert(goalSource.includes("client.request('thread/fork'"), 'Codex App goal should call native thread/fork for branches');
|
||||
assert(goalSource.includes('lastTurnId: sourceTurnId'), 'Codex App goal fork should stop at the selected source turn');
|
||||
}
|
||||
|
||||
function assertGoalModeTitleContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
@@ -7000,6 +7041,11 @@ async function main() {
|
||||
console.log('Codex App retry runtime regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'codexapp-branch-fork') {
|
||||
assertCodexAppBranchForkContract();
|
||||
console.log('Codex App branch fork regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'goal-mode-title') {
|
||||
assertGoalModeTitleContract();
|
||||
console.log('Goal mode/title regression checks passed.');
|
||||
@@ -7026,6 +7072,7 @@ async function main() {
|
||||
assertFrontendSubagentCardMetadataContract();
|
||||
assertCodexAppRuntimeSubAgentActivityContract();
|
||||
assertCodexAppTransientReconnectContract();
|
||||
assertCodexAppBranchForkContract();
|
||||
assertGoalModeTitleContract();
|
||||
assertFrontendPrimaryCodexAppUiContract();
|
||||
assertSetTitleMcpContract();
|
||||
@@ -8594,7 +8641,10 @@ async function main() {
|
||||
|
||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp task schema refresh', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||
const reloadAfterTracking = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
|
||||
const reloadAfterTrackingResponse = await postAuthedJsonAllowFailure(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
|
||||
const reloadAfterTracking = reloadAfterTrackingResponse.payload;
|
||||
assert(reloadAfterTrackingResponse.status === 503, 'MCP reload without a final startup notification should return HTTP 503');
|
||||
assert(reloadAfterTracking.ok === false, 'MCP reload without a final startup notification should report ok=false');
|
||||
assert(
|
||||
Number(reloadAfterTracking.result?.reloadCount || 0) === baselineMcpReloadCount + 1,
|
||||
'Task tracking changes must not trigger an implicit MCP reload; only the explicit reload request should count'
|
||||
|
||||
349
server.js
349
server.js
@@ -3455,6 +3455,124 @@ function normalizeCodexAppMcpInventory(result) {
|
||||
return servers;
|
||||
}
|
||||
|
||||
function mergeCodexAppMcpInventories(inventories) {
|
||||
const byName = new Map();
|
||||
for (const inventory of inventories) {
|
||||
for (const server of Array.isArray(inventory) ? inventory : []) {
|
||||
const current = byName.get(server.server);
|
||||
if (!current) {
|
||||
byName.set(server.server, { ...server, tools: [...(server.tools || [])] });
|
||||
continue;
|
||||
}
|
||||
const tools = new Map(current.tools.map((tool) => [tool.name, tool]));
|
||||
for (const tool of server.tools || []) tools.set(tool.name, tool);
|
||||
current.tools = Array.from(tools.values());
|
||||
}
|
||||
}
|
||||
return Array.from(byName.values());
|
||||
}
|
||||
|
||||
function summarizeCodexAppMcpInventory(servers) {
|
||||
const normalized = Array.isArray(servers) ? servers : [];
|
||||
const ccweb = normalized.find((server) => codexAppMcpStatusKey(server.server) === CODEX_APP_MCP_DEFAULT_SERVER);
|
||||
const toolCount = normalized.reduce((total, server) => total + (Array.isArray(server.tools) ? server.tools.length : 0), 0);
|
||||
return {
|
||||
serverCount: normalized.length,
|
||||
toolCount,
|
||||
ccwebServerCount: ccweb ? 1 : 0,
|
||||
ccwebToolCount: ccweb?.tools?.length || 0,
|
||||
ccwebReady: !!ccweb && ccweb.tools.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function recordCodexAppMcpInventoryState(session, inventoryResult) {
|
||||
if (!session || !isCodexAppSession(session)) return;
|
||||
const state = ensureCodexAppMcpStartupState(session);
|
||||
if (!state) return;
|
||||
const summary = summarizeCodexAppMcpInventory(inventoryResult?.servers || []);
|
||||
state.inventory = {
|
||||
status: inventoryResult?.ok ? 'ready' : 'failed',
|
||||
threadId: inventoryResult?.threadId || getRuntimeSessionId(session) || null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...summary,
|
||||
error: inventoryResult?.ok ? '' : safeMcpStatusString(inventoryResult?.error || 'MCP 工具清单查询失败', 500),
|
||||
};
|
||||
saveSession(session);
|
||||
}
|
||||
|
||||
async function queryCodexAppMcpInventory(session, options = {}) {
|
||||
if (!isCodexAppSession(session)) return { ok: false, code: 'not_codexapp', servers: [], error: '当前会话不是 Codex App。' };
|
||||
const threadId = normalizeCodexAppThreadId(options.threadId || getRuntimeSessionId(session));
|
||||
if (!threadId) return { ok: false, code: 'missing_thread_id', servers: [], error: 'Codex App 当前没有可验证的线程。' };
|
||||
if (!codexAppClient?.isRunning()) return { ok: false, code: 'client_unavailable', threadId, servers: [], error: 'Codex App app-server 尚未运行。' };
|
||||
|
||||
const allServers = [];
|
||||
let cursor = null;
|
||||
try {
|
||||
for (let page = 0; page < CODEX_APP_MCP_INVENTORY_MAX_PAGES; page += 1) {
|
||||
const response = await codexAppClient.request('mcpServerStatus/list', {
|
||||
threadId,
|
||||
detail: 'full',
|
||||
limit: 200,
|
||||
cursor,
|
||||
}, options.timeoutMs || 5000);
|
||||
const responseThreadId = normalizeCodexAppThreadId(response?.threadId || response?.thread?.id);
|
||||
if (responseThreadId && responseThreadId !== threadId) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'inventory_thread_mismatch',
|
||||
threadId,
|
||||
responseThreadId,
|
||||
servers: [],
|
||||
error: `MCP 工具清单属于其他线程(期望 ${threadId.slice(0, 24)},实际 ${responseThreadId.slice(0, 24)})。`,
|
||||
};
|
||||
}
|
||||
allServers.push(normalizeCodexAppMcpInventory(response));
|
||||
const nextCursor = String(response?.nextCursor || '').trim();
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'inventory_request_failed',
|
||||
threadId,
|
||||
servers: [],
|
||||
error: `MCP 工具清单查询失败: ${err?.message || String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const servers = mergeCodexAppMcpInventories(allServers);
|
||||
const summary = summarizeCodexAppMcpInventory(servers);
|
||||
if (options.requireCcweb && !summary.ccwebReady) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'ccweb_tools_unavailable',
|
||||
threadId,
|
||||
servers,
|
||||
...summary,
|
||||
error: '当前线程的 ccweb MCP 未返回可调用工具,不能判定 MCP 已就绪。',
|
||||
};
|
||||
}
|
||||
return { ok: true, code: 'ok', threadId, servers, ...summary, error: '' };
|
||||
}
|
||||
|
||||
async function waitForCodexAppMcpInventory(session, options = {}) {
|
||||
const maxWaitMs = Math.max(0, Number(options.maxWaitMs ?? CODEX_APP_MCP_STARTUP_TIMEOUT_SEC * 1000));
|
||||
const deadline = Date.now() + maxWaitMs;
|
||||
let lastResult = null;
|
||||
while (true) {
|
||||
const remaining = Math.max(1, deadline - Date.now());
|
||||
lastResult = await queryCodexAppMcpInventory(session, {
|
||||
...options,
|
||||
timeoutMs: Math.min(Number(options.timeoutMs || 5000), remaining),
|
||||
});
|
||||
if (lastResult.ok || !['ccweb_tools_unavailable', 'inventory_request_failed'].includes(lastResult.code)) return lastResult;
|
||||
if (Date.now() >= deadline) return lastResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, deadline - Date.now()))));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCodexAppMcpInventory(session) {
|
||||
if (!isCodexAppSession(session)) return [];
|
||||
const threadId = normalizeCodexAppThreadId(getRuntimeSessionId(session));
|
||||
@@ -3466,49 +3584,55 @@ async function loadCodexAppMcpInventory(session) {
|
||||
const existing = codexAppMcpInventoryPending.get(threadId);
|
||||
if (existing) return existing;
|
||||
const pending = (async () => {
|
||||
const allServers = [];
|
||||
let cursor = null;
|
||||
for (let page = 0; page < CODEX_APP_MCP_INVENTORY_MAX_PAGES; page += 1) {
|
||||
const response = await codexAppClient.request('mcpServerStatus/list', {
|
||||
threadId,
|
||||
detail: 'full',
|
||||
limit: 200,
|
||||
cursor,
|
||||
}, 5000);
|
||||
allServers.push(...normalizeCodexAppMcpInventory(response));
|
||||
const nextCursor = String(response?.nextCursor || '').trim();
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
const byName = new Map();
|
||||
for (const server of allServers) {
|
||||
const current = byName.get(server.server);
|
||||
if (!current) {
|
||||
byName.set(server.server, server);
|
||||
continue;
|
||||
}
|
||||
const tools = new Map(current.tools.map((tool) => [tool.name, tool]));
|
||||
for (const tool of server.tools) tools.set(tool.name, tool);
|
||||
current.tools = Array.from(tools.values());
|
||||
}
|
||||
const servers = Array.from(byName.values());
|
||||
codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers });
|
||||
return servers;
|
||||
})()
|
||||
.catch((err) => {
|
||||
const result = await queryCodexAppMcpInventory(session, { threadId });
|
||||
if (!result.ok) {
|
||||
plog('WARN', 'codex_app_mcp_inventory_failed', {
|
||||
threadId: threadId.slice(0, 16),
|
||||
error: err?.message || String(err),
|
||||
code: result.code,
|
||||
error: result.error,
|
||||
});
|
||||
// 对旧版 app-server 做短暂负缓存,避免每次输入都重复等待不支持的方法。
|
||||
codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: [] });
|
||||
return [];
|
||||
})
|
||||
const error = new Error(result.error || 'MCP 工具清单查询失败。');
|
||||
error.code = result.code || 'codexapp_mcp_inventory_failed';
|
||||
throw error;
|
||||
}
|
||||
codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: result.servers });
|
||||
return result.servers;
|
||||
})()
|
||||
.finally(() => codexAppMcpInventoryPending.delete(threadId));
|
||||
codexAppMcpInventoryPending.set(threadId, pending);
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function ensureCodexAppMcpReadyForThread(session, threadId) {
|
||||
const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session));
|
||||
if (!normalizedThreadId) {
|
||||
throw new Error('Codex App MCP 校验失败:当前线程 ID 不存在。');
|
||||
}
|
||||
const cached = codexAppMcpInventoryByThread.get(normalizedThreadId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CODEX_APP_MCP_INVENTORY_TTL_MS) {
|
||||
const cachedSummary = summarizeCodexAppMcpInventory(cached.servers);
|
||||
if (cachedSummary.ccwebReady) return cached.servers;
|
||||
}
|
||||
const result = await waitForCodexAppMcpInventory(session, {
|
||||
threadId: normalizedThreadId,
|
||||
requireCcweb: true,
|
||||
timeoutMs: 5000,
|
||||
maxWaitMs: CODEX_APP_MCP_STARTUP_TIMEOUT_SEC * 1000,
|
||||
});
|
||||
recordCodexAppMcpInventoryState(session, result);
|
||||
if (!result.ok) {
|
||||
plog('WARN', 'codex_app_mcp_preflight_failed', {
|
||||
sessionId: session?.id ? session.id.slice(0, 8) : null,
|
||||
threadId: normalizedThreadId.slice(0, 16),
|
||||
code: result.code,
|
||||
error: result.error,
|
||||
});
|
||||
throw new Error(result.error || '当前线程 MCP 工具不可用,已停止发送本轮消息。');
|
||||
}
|
||||
codexAppMcpInventoryByThread.set(normalizedThreadId, { fetchedAt: Date.now(), servers: result.servers });
|
||||
return result.servers;
|
||||
}
|
||||
|
||||
function summarizeSkillDependencies(skill) {
|
||||
const tools = Array.isArray(skill?.dependencies?.tools) ? skill.dependencies.tools : [];
|
||||
return tools
|
||||
@@ -4573,6 +4697,7 @@ function buildCodexAppMcpStatusSummary(session, options = {}) {
|
||||
const state = mcpStatusObject(session?.codexAppMcpStartupStatus) || {};
|
||||
const serversObject = mcpStatusObject(state.servers) || {};
|
||||
const servers = Object.values(serversObject).map((record) => publicCodexAppMcpStatusRecord(record));
|
||||
const inventory = mcpStatusObject(state.inventory) || null;
|
||||
let current = findCodexAppMcpStatusRecord(state, options.serverName || CODEX_APP_MCP_DEFAULT_SERVER);
|
||||
const reloadRequestedAt = safeMcpStatusString(options.reloadRequestedAt || state.reloadRequestedAt || '', 80) || null;
|
||||
if (!current) {
|
||||
@@ -4593,6 +4718,13 @@ function buildCodexAppMcpStatusSummary(session, options = {}) {
|
||||
reloadRequestId: safeMcpStatusString(state.reloadRequestId || '', 80) || null,
|
||||
hasStartupStatus: current.source === 'notification',
|
||||
servers,
|
||||
inventoryStatus: safeMcpStatusString(inventory?.status || 'unknown', 40) || 'unknown',
|
||||
inventoryThreadId: safeMcpStatusString(inventory?.threadId || '', 160) || null,
|
||||
inventoryUpdatedAt: safeMcpStatusString(inventory?.updatedAt || '', 80) || null,
|
||||
inventoryServerCount: Number.isFinite(Number(inventory?.serverCount)) ? Number(inventory.serverCount) : 0,
|
||||
inventoryToolCount: Number.isFinite(Number(inventory?.toolCount)) ? Number(inventory.toolCount) : 0,
|
||||
ccwebToolCount: Number.isFinite(Number(inventory?.ccwebToolCount)) ? Number(inventory.ccwebToolCount) : 0,
|
||||
inventoryError: safeMcpStatusString(inventory?.error || '', 500) || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4606,6 +4738,17 @@ function markCodexAppMcpReloadPending(session, sessionId) {
|
||||
state.reloadRequestId = reloadRequestId;
|
||||
state.updatedAt = requestedAt;
|
||||
state.threadId = threadId;
|
||||
state.inventory = {
|
||||
status: 'pending',
|
||||
threadId,
|
||||
updatedAt: requestedAt,
|
||||
serverCount: 0,
|
||||
toolCount: 0,
|
||||
ccwebServerCount: 0,
|
||||
ccwebToolCount: 0,
|
||||
ccwebReady: false,
|
||||
error: '已请求重载,等待当前线程 MCP 工具清单',
|
||||
};
|
||||
state.servers[codexAppMcpStatusKey(CODEX_APP_MCP_DEFAULT_SERVER)] = publicCodexAppMcpStatusRecord({
|
||||
name: CODEX_APP_MCP_DEFAULT_SERVER,
|
||||
status: 'pending',
|
||||
@@ -4620,9 +4763,6 @@ 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 {
|
||||
@@ -4721,6 +4861,22 @@ function updateCodexAppSessionMcpStatus(sessionId, statusRecord) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
function updateCodexAppSessionMcpInventory(sessionId, inventoryResult) {
|
||||
const normalizedId = sanitizeId(sessionId || '');
|
||||
if (!normalizedId) return null;
|
||||
const session = loadSession(normalizedId);
|
||||
if (!session || !isCodexAppSession(session)) return null;
|
||||
recordCodexAppMcpInventoryState(session, inventoryResult);
|
||||
const summary = buildCodexAppMcpStatusSummary(session);
|
||||
sendSessionEventToViewers(normalizedId, {
|
||||
type: 'mcp_startup_status',
|
||||
sessionId: normalizedId,
|
||||
status: summary,
|
||||
mcpStatus: summary,
|
||||
});
|
||||
return summary;
|
||||
}
|
||||
|
||||
function markCodexAppMcpReloadFailed(sessionId, message) {
|
||||
return updateCodexAppSessionMcpStatus(sessionId, {
|
||||
name: CODEX_APP_MCP_DEFAULT_SERVER,
|
||||
@@ -4735,14 +4891,22 @@ function markCodexAppMcpReloadFailed(sessionId, message) {
|
||||
function codexAppMcpStatusTargetSessionIds(statusRecord, routed) {
|
||||
cleanupExpiredCodexAppMcpReloads();
|
||||
const targetSessionIds = new Set();
|
||||
if (routed?.sessionId) targetSessionIds.add(routed.sessionId);
|
||||
const statusThreadId = normalizeCodexAppThreadId(statusRecord?.threadId);
|
||||
if (routed?.sessionId) {
|
||||
const routedSession = loadSession(routed.sessionId);
|
||||
const routedThreadId = normalizeCodexAppThreadId(getRuntimeSessionId(routedSession));
|
||||
if (!statusThreadId || !routedThreadId || statusThreadId === routedThreadId) targetSessionIds.add(routed.sessionId);
|
||||
}
|
||||
for (const [sessionId, pending] of pendingCodexAppMcpReloads.entries()) {
|
||||
if (
|
||||
statusRecord.threadId
|
||||
&& pending.threadId
|
||||
&& statusRecord.threadId !== pending.threadId
|
||||
&& pending.allowThreadMismatch !== true
|
||||
) continue;
|
||||
const pendingThreadId = normalizeCodexAppThreadId(pending.threadId);
|
||||
if (statusThreadId && pendingThreadId && statusThreadId === pendingThreadId) {
|
||||
targetSessionIds.add(sessionId);
|
||||
}
|
||||
}
|
||||
// app-server 的全局通知有时不带 threadId。只有恰好一个重载请求在窗口内时,
|
||||
// 才能安全归属;多个并发会话时必须放弃关联,不能广播到所有会话。
|
||||
if (!statusThreadId && targetSessionIds.size === 0 && pendingCodexAppMcpReloads.size === 1) {
|
||||
const [sessionId] = pendingCodexAppMcpReloads.keys();
|
||||
targetSessionIds.add(sessionId);
|
||||
}
|
||||
return targetSessionIds;
|
||||
@@ -4828,6 +4992,31 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||
});
|
||||
}
|
||||
|
||||
let inventoryResult = null;
|
||||
if (mcpStatus?.status === 'ready') {
|
||||
const targetSession = loadSession(sessionId);
|
||||
inventoryResult = await waitForCodexAppMcpInventory(targetSession, {
|
||||
threadId: currentThreadId,
|
||||
requireCcweb: true,
|
||||
timeoutMs: 5000,
|
||||
maxWaitMs: 5000,
|
||||
});
|
||||
updateCodexAppSessionMcpInventory(sessionId, inventoryResult);
|
||||
if (!inventoryResult.ok) {
|
||||
const inventoryMessage = inventoryResult.error || '当前线程 MCP 工具清单不可用,不能判定 MCP 已就绪。';
|
||||
mcpStatus = markCodexAppMcpReloadFailed(sessionId, inventoryMessage)
|
||||
|| buildCodexAppMcpStatusSummary(loadSession(sessionId), { reloadRequestedAt });
|
||||
plog('WARN', 'codex_app_mcp_reload_inventory_failed', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
threadId: currentThreadId || null,
|
||||
code: inventoryResult.code,
|
||||
error: inventoryMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const reloadReady = mcpStatus?.status === 'ready' && inventoryResult?.ok === true;
|
||||
|
||||
plog('INFO', 'codex_app_mcp_reload_requested', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
threadId: getRuntimeSessionId(session) || null,
|
||||
@@ -4835,8 +5024,10 @@ async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||
reloadTimedOut,
|
||||
});
|
||||
|
||||
return jsonResponse(res, 200, {
|
||||
ok: true,
|
||||
return jsonResponse(res, reloadReady ? 200 : 503, {
|
||||
ok: reloadReady,
|
||||
code: reloadReady ? undefined : 'codexapp_mcp_not_ready',
|
||||
message: reloadReady ? undefined : (mcpStatus?.message || 'MCP 重载未完成:当前线程工具不可用。'),
|
||||
sessionId,
|
||||
threadId: getRuntimeSessionId(session) || null,
|
||||
result: result || {},
|
||||
@@ -9220,6 +9411,9 @@ wss.on('connection', (ws, req) => {
|
||||
trigger: ['/', '$', '@'].includes(msg.trigger) ? msg.trigger : '',
|
||||
query: String(msg.query || ''),
|
||||
items: [],
|
||||
ok: false,
|
||||
code: err?.code || 'composer_mcp_inventory_failed',
|
||||
error: err?.message || 'MCP 工具清单查询失败,请稍后重试。',
|
||||
});
|
||||
});
|
||||
break;
|
||||
@@ -9818,8 +10012,24 @@ async function ensureCodexAppGoalThread(session) {
|
||||
const resumed = await client.request('thread/resume', { ...threadParams, threadId }, 60000);
|
||||
threadId = resumed?.thread?.id || threadId;
|
||||
} else {
|
||||
const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
|
||||
const branch = session.createdFrom?.kind === 'branch' ? session.createdFrom : null;
|
||||
const sourceThreadId = normalizeCodexAppThreadId(branch?.sourceThreadId || '');
|
||||
const sourceTurnId = String(branch?.sourceTurnId || '').trim();
|
||||
if (branch && (!sourceThreadId || !sourceTurnId)) {
|
||||
throw new Error('Codex App 分支缺少来源线程或回合信息,已停止启动空线程;请从原会话重新创建分支。');
|
||||
}
|
||||
const started = sourceThreadId && sourceTurnId
|
||||
? await client.request('thread/fork', { ...threadParams, threadId: sourceThreadId, lastTurnId: sourceTurnId }, 60000)
|
||||
: await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
|
||||
threadId = started?.thread?.id || null;
|
||||
if (branch) {
|
||||
plog('INFO', 'codex_app_goal_thread_forked', {
|
||||
sessionId: session.id.slice(0, 8),
|
||||
sourceThreadId: sourceThreadId.slice(0, 24),
|
||||
sourceTurnId: sourceTurnId.slice(0, 24),
|
||||
threadId: threadId ? threadId.slice(0, 24) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!threadId) throw new Error('Codex app-server 未返回 threadId。');
|
||||
|
||||
@@ -10543,6 +10753,15 @@ function resolveBranchSource(args = {}) {
|
||||
const sourceMessageIndex = Number.isFinite(parsedIndex)
|
||||
? Math.max(0, Math.min(sourceMessages.length - 1, parsedIndex))
|
||||
: sourceMessages.length - 1;
|
||||
const selectedMessage = sourceMessages[sourceMessageIndex] || null;
|
||||
if (isCodexAppSession(sourceSession)
|
||||
&& (selectedMessage?.role !== 'assistant' || !selectedMessage?.codexAppTurnId)) {
|
||||
return mcpToolError('branch_source_turn_not_found', '选中的 Codex App 消息没有可分叉的原生回合,请选择已完成的助手回复。', { sourceSessionId, sourceMessageIndex });
|
||||
}
|
||||
const sourceThreadId = isCodexAppSession(sourceSession)
|
||||
? normalizeCodexAppThreadId(getRuntimeSessionId(sourceSession))
|
||||
: null;
|
||||
const sourceTurnId = selectedMessage?.codexAppTurnId || null;
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
return {
|
||||
@@ -10554,6 +10773,8 @@ function resolveBranchSource(args = {}) {
|
||||
sourceSessionId: sourceSession.id,
|
||||
sourceTitle: sourceSession.title || 'Untitled',
|
||||
sourceMessageIndex,
|
||||
sourceThreadId,
|
||||
sourceTurnId,
|
||||
createdAt,
|
||||
},
|
||||
defaultTitle: buildBranchSessionTitle(sourceSession),
|
||||
@@ -13574,9 +13795,31 @@ async function startCodexAppTurn(sessionId, input) {
|
||||
throw new Error(`${prefix}恢复到不同线程,已停止以避免上下文丢失(期望 ${expectedShort},实际 ${actualShort})。`);
|
||||
}
|
||||
threadId = resumedThreadId;
|
||||
} else {
|
||||
const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
|
||||
threadId = started?.thread?.id || null;
|
||||
} else {
|
||||
const branch = session.createdFrom?.kind === 'branch' ? session.createdFrom : null;
|
||||
const sourceThreadId = normalizeCodexAppThreadId(branch?.sourceThreadId || '');
|
||||
const sourceTurnId = String(branch?.sourceTurnId || '').trim();
|
||||
if (branch && (!sourceThreadId || !sourceTurnId)) {
|
||||
throw new Error('Codex App 分支缺少来源线程或回合信息,已停止启动空线程;请从原会话重新创建分支。');
|
||||
}
|
||||
if (sourceThreadId && sourceTurnId) {
|
||||
const forked = await client.request('thread/fork', {
|
||||
...threadParams,
|
||||
threadId: sourceThreadId,
|
||||
lastTurnId: sourceTurnId,
|
||||
}, 60000);
|
||||
threadId = forked?.thread?.id || null;
|
||||
if (!threadId) throw new Error('Codex App thread/fork 未返回新 threadId。');
|
||||
plog('INFO', 'codex_app_thread_forked', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
sourceThreadId: sourceThreadId.slice(0, 24),
|
||||
sourceTurnId: sourceTurnId.slice(0, 24),
|
||||
threadId: threadId.slice(0, 24),
|
||||
});
|
||||
} else {
|
||||
const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
|
||||
threadId = started?.thread?.id || null;
|
||||
}
|
||||
}
|
||||
if (!threadId) throw new Error('Codex app-server 未返回 threadId。');
|
||||
|
||||
@@ -13586,6 +13829,10 @@ async function startCodexAppTurn(sessionId, input) {
|
||||
saveSession(session);
|
||||
persistCodexAppTurnState(sessionId, entry, { immediate: true });
|
||||
|
||||
// 首轮消息必须先确认当前线程真的拿到了 ccweb 工具;仅收到 startupStatus=ready
|
||||
// 不足以证明模型可调用 MCP,失败时保留会话并给出可重试错误。
|
||||
await ensureCodexAppMcpReadyForThread(session, threadId);
|
||||
|
||||
const turn = await client.request('turn/start', codexAppTurnParams(session, input, threadId, entry.clientUserMessageId), 60000);
|
||||
if (turn?.turn?.id) entry.turnId = turn.turn.id;
|
||||
if (entry.giteaWorkflow?.handleTurnStarted && entry.giteaWorkflowTaskId && entry.turnId) {
|
||||
|
||||
Reference in New Issue
Block a user