修复 Codex App MCP 跨线程假就绪

This commit is contained in:
shiyue
2026-09-14 17:42:05 +08:00
parent e785e3c5dd
commit b1cd820819
4 changed files with 385 additions and 55 deletions

View File

@@ -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);

View File

@@ -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
View File

@@ -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) {