修复会话切换气泡丢失并重新打包
This commit is contained in:
96
server.js
96
server.js
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user