修复会话切换气泡丢失并重新打包

This commit is contained in:
shiyue
2026-09-14 22:03:23 +08:00
parent b1cd820819
commit a1e66c08f1
6 changed files with 558 additions and 26 deletions

View File

@@ -1467,6 +1467,7 @@ const CODEX_APP_MCP_RELOAD_TRACK_MS = readPositiveIntEnv(
{ min: 5000, max: 300000 },
);
const CODEX_APP_MCP_INVENTORY_TTL_MS = 3000;
const CODEX_APP_MCP_INVENTORY_FAILURE_TTL_MS = 30000;
const CODEX_APP_MCP_INVENTORY_MAX_PAGES = 10;
const codexAppMcpStartupStatusByServer = new Map();
// threadId -> { fetchedAt, servers }
@@ -3573,12 +3574,56 @@ async function waitForCodexAppMcpInventory(session, options = {}) {
}
}
function codexAppMcpStatusForThread(session, threadId, serverName = CODEX_APP_MCP_DEFAULT_SERVER) {
const normalizedThreadId = normalizeCodexAppThreadId(threadId || '');
if (!normalizedThreadId) return null;
const summary = buildCodexAppMcpStatusSummary(session, { serverName });
const statusThreadId = normalizeCodexAppThreadId(summary?.threadId || '');
if (statusThreadId !== normalizedThreadId) return null;
return summary;
}
async function waitForCodexAppMcpReadyStatus(session, threadId, options = {}) {
const normalizedThreadId = normalizeCodexAppThreadId(threadId || getRuntimeSessionId(session));
const maxWaitMs = Math.max(0, Number(options.maxWaitMs ?? CODEX_APP_MCP_STARTUP_TIMEOUT_SEC * 1000));
const deadline = Date.now() + maxWaitMs;
let lastSummary = null;
while (true) {
lastSummary = codexAppMcpStatusForThread(session, normalizedThreadId);
if (lastSummary?.status === 'ready') {
return { ok: true, code: 'ok', threadId: normalizedThreadId, status: lastSummary };
}
if (lastSummary?.status === 'failed' || lastSummary?.status === 'cancelled') {
return {
ok: false,
code: 'ccweb_startup_failed',
threadId: normalizedThreadId,
status: lastSummary,
error: lastSummary.message || `当前线程 ccweb MCP 启动${lastSummary.status === 'failed' ? '失败' : '已取消'}。`,
};
}
if (Date.now() >= deadline) {
return {
ok: false,
code: 'ccweb_startup_timeout',
threadId: normalizedThreadId,
status: lastSummary,
error: `当前线程 ccweb MCP 在 ${Math.ceil(maxWaitMs / 1000)} 秒内未完成启动。`,
};
}
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));
if (!threadId || !codexAppClient?.isRunning()) return [];
const cached = codexAppMcpInventoryByThread.get(threadId);
if (cached && Date.now() - cached.fetchedAt < CODEX_APP_MCP_INVENTORY_TTL_MS) {
const cacheTtl = cached?.failed
? CODEX_APP_MCP_INVENTORY_FAILURE_TTL_MS
: CODEX_APP_MCP_INVENTORY_TTL_MS;
if (cached && Date.now() - cached.fetchedAt < cacheTtl) {
return cached.servers;
}
const existing = codexAppMcpInventoryPending.get(threadId);
@@ -3591,9 +3636,9 @@ async function loadCodexAppMcpInventory(session) {
code: result.code,
error: result.error,
});
const error = new Error(result.error || 'MCP 工具清单查询失败。');
error.code = result.code || 'codexapp_mcp_inventory_failed';
throw error;
// 清单仅用于补充 composer 候选;慢或暂不可用时保留本地配置候选,不能阻断输入框和首轮消息。
codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: [], failed: true });
return [];
}
codexAppMcpInventoryByThread.set(threadId, { fetchedAt: Date.now(), servers: result.servers });
return result.servers;
@@ -3613,13 +3658,10 @@ async function ensureCodexAppMcpReadyForThread(session, threadId) {
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);
// MCP 启动状态和完整 inventory 都是运行能力探测,不能阻断首轮 turn/start。
// app-server 会在工具真正调用时继续管理 MCP 生命周期;慢的全局 MCP(例如
// 远程 playwright)不应让当前线程连普通消息也发不出去。
const result = await waitForCodexAppMcpReadyStatus(session, normalizedThreadId, { maxWaitMs: 0 });
if (!result.ok) {
plog('WARN', 'codex_app_mcp_preflight_failed', {
sessionId: session?.id ? session.id.slice(0, 8) : null,
@@ -3627,10 +3669,9 @@ async function ensureCodexAppMcpReadyForThread(session, threadId) {
code: result.code,
error: result.error,
});
throw new Error(result.error || '当前线程 MCP 工具不可用,已停止发送本轮消息。');
return [];
}
codexAppMcpInventoryByThread.set(normalizedThreadId, { fetchedAt: Date.now(), servers: result.servers });
return result.servers;
return codexAppMcpInventoryByThread.get(normalizedThreadId)?.servers || [];
}
function summarizeSkillDependencies(skill) {
@@ -6670,8 +6711,11 @@ function sendSessionList(ws) {
}
sessions.sort(compareSessionsForList);
wsSend(ws, { type: 'session_list', sessions });
} catch {
wsSend(ws, { type: 'session_list', sessions: [] });
} catch (error) {
// 会话目录短暂不可读时不要广播空列表;空列表会让前端误判当前会话已被删除并清空气泡。
plog('WARN', 'session_list_load_failed', {
error: error?.message || String(error || ''),
});
}
}
@@ -11045,13 +11089,29 @@ function handleResumeSession(ws, msg = {}) {
detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, sessionId);
const attached = attachActiveRuntimeToWs(ws, sessionId, msg);
// 重连只发送 resume_generating 会让前端无法补回断线期间丢掉的历史气泡;
// 同时带上最近历史,前端可按消息索引幂等补齐而不会重绘整个会话。
const history = typeof resolveSessionHistory === 'function'
? resolveSessionHistory(session)
: { messages: session.messages || [], source: 'snapshot', available: true };
const historyMessages = sanitizeMessagesForTransport(history.messages || []);
const recentMessages = historyMessages.slice(-INITIAL_HISTORY_COUNT);
const hasActiveRuntime = activeProcesses.has(sessionId)
|| activeCodexAppTurns.has(sessionId)
|| activeCodexAppGoalCommands.has(sessionId);
wsSend(ws, attachClientRequestId({
type: 'resume_session_result',
sessionId,
isRunning: isSessionRunning(sessionId),
attached,
attached: hasActiveRuntime,
messages: recentMessages,
historyTotal: historyMessages.length,
historyBaseIndex: Math.max(0, historyMessages.length - recentMessages.length),
historyAvailable: history.available !== false,
historySource: history.source || 'snapshot',
}, msg));
// 先让前端补齐已持久化气泡,再追加流式气泡,避免重连瞬间只看到助手输出。
if (hasActiveRuntime) attachActiveRuntimeToWs(ws, sessionId, msg);
}
function handleLoadSession(ws, msg) {