fix: stabilize session list and Codex imports

This commit is contained in:
shiyue
2026-07-30 18:06:39 +08:00
parent 21b10e3eb4
commit cc600bdf31
13 changed files with 1247 additions and 42 deletions

115
server.js
View File

@@ -698,6 +698,10 @@ let codexAppClient = null;
let codexAppClientSignature = '';
const CODEX_APP_STATE_FILE = 'codexapp-state.json';
const CODEX_APP_STATE_FLUSH_DELAY_MS = 250;
const CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS = CODEX_APP_STATE_FLUSH_DELAY_MS;
// 同一父会话的 child 增量共用一个短周期待保存快照,避免每条增量都读写整份会话。
const pendingCcwebMcpChildSessionFlushes = new Map();
let ccwebMcpChildSessionListBroadcastTimer = null;
// Track which session each ws is viewing: ws -> sessionId
const wsSessionMap = new Map();
@@ -8917,10 +8921,12 @@ function mergeCcwebMcpChildIntoTool(tool, child) {
};
}
function updateCcwebMcpChildToolState(sessionId, child) {
function updateCcwebMcpChildToolState(sessionId, child, persistedTool = null) {
const entry = activeCodexAppTurns.get(sessionId) || null;
let tool = findCcwebMcpChildTargetToolInToolCalls(entry?.toolCalls, child.spawnToolId);
// 持久化快照已经完成合并时直接复用,避免没有活动 turn 时再次加载父会话。
if (!tool && persistedTool) return persistedTool;
if (!tool) {
const session = loadSession(sessionId);
const messages = Array.isArray(session?.messages) ? session.messages : [];
@@ -8929,20 +8935,105 @@ function updateCcwebMcpChildToolState(sessionId, child) {
return mergeCcwebMcpChildIntoTool(tool, child);
}
function isFinalCcwebMcpChildStatus(status) {
return status === 'returned' || status === 'failed' || status === 'interrupted' || status === 'closed';
}
function snapshotCcwebMcpChildForPersist(child = {}) {
return {
...child,
planProgress: child.planProgress && typeof child.planProgress === 'object'
? { ...child.planProgress }
: child.planProgress || null,
};
}
function flushPendingCcwebMcpChildSession(sessionId) {
const pending = pendingCcwebMcpChildSessionFlushes.get(sessionId);
if (!pending) return false;
if (pending.timer) clearTimeout(pending.timer);
pendingCcwebMcpChildSessionFlushes.delete(sessionId);
// 尾随冲刷前重新读取最新会话,避免覆盖这 250ms 内由父 turn 完成等路径写入的数据。
const session = loadSession(sessionId) || pending.session;
if (!session || !Array.isArray(session.messages)) return false;
let merged = false;
for (const child of pending.children.values()) {
const targetTool = findCcwebMcpChildTargetToolInMessages(session.messages, child.spawnToolId);
if (mergeCcwebMcpChildIntoTool(targetTool, child)) merged = true;
}
if (!merged) return false;
if (!session.updated || pending.updated > session.updated) session.updated = pending.updated;
if (pending.markUnread) session.hasUnread = true;
return saveSession(session);
}
function updatePersistedCcwebMcpChildTool(sessionId, child) {
const session = loadSession(sessionId);
if (!session || !Array.isArray(session.messages)) return null;
const targetTool = findCcwebMcpChildTargetToolInMessages(session.messages, child.spawnToolId);
if (!mergeCcwebMcpChildIntoTool(targetTool, child)) return null;
session.updated = new Date().toISOString();
if (!findViewingSessionWs(sessionId)) session.hasUnread = true;
saveSession(session);
let pending = pendingCcwebMcpChildSessionFlushes.get(sessionId) || null;
const createdPending = !pending;
if (!pending) {
const session = loadSession(sessionId);
if (!session || !Array.isArray(session.messages)) return null;
pending = {
session,
children: new Map(),
updated: session.updated || '',
markUnread: false,
timer: null,
};
pendingCcwebMcpChildSessionFlushes.set(sessionId, pending);
}
const targetTool = findCcwebMcpChildTargetToolInMessages(pending.session.messages, child.spawnToolId);
if (!mergeCcwebMcpChildIntoTool(targetTool, child)) {
if (createdPending) pendingCcwebMcpChildSessionFlushes.delete(sessionId);
return null;
}
// 同一可见协作工具下可能挂多个 sibling/nested child必须按线程分别保留最新状态。
const childKey = String(child.threadId || child.spawnToolId || '').trim();
pending.children.set(childKey, snapshotCcwebMcpChildForPersist(child));
pending.updated = new Date().toISOString();
if (!findViewingSessionWs(sessionId)) pending.markUnread = true;
if (isFinalCcwebMcpChildStatus(child.status)) {
flushPendingCcwebMcpChildSession(sessionId);
} else if (!pending.timer) {
pending.timer = setTimeout(() => {
flushPendingCcwebMcpChildSession(sessionId);
}, CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS);
if (typeof pending.timer.unref === 'function') pending.timer.unref();
}
return targetTool;
}
function flushCcwebMcpChildSessionListBroadcast() {
if (ccwebMcpChildSessionListBroadcastTimer) {
clearTimeout(ccwebMcpChildSessionListBroadcastTimer);
ccwebMcpChildSessionListBroadcastTimer = null;
}
broadcastSessionList();
}
function scheduleCcwebMcpChildSessionListBroadcast(options = {}) {
if (options.immediate) {
flushCcwebMcpChildSessionListBroadcast();
return;
}
if (ccwebMcpChildSessionListBroadcastTimer) return;
ccwebMcpChildSessionListBroadcastTimer = setTimeout(() => {
ccwebMcpChildSessionListBroadcastTimer = null;
broadcastSessionList();
}, CCWEB_MCP_CHILD_UPDATE_FLUSH_DELAY_MS);
if (typeof ccwebMcpChildSessionListBroadcastTimer.unref === 'function') {
ccwebMcpChildSessionListBroadcastTimer.unref();
}
}
function sendCcwebMcpChildAgentUpdate(sessionId, child) {
const activeTool = updateCcwebMcpChildToolState(sessionId, child);
const persistedTool = updatePersistedCcwebMcpChildTool(sessionId, child);
const activeTool = updateCcwebMcpChildToolState(sessionId, child, persistedTool);
const tool = activeTool || (persistedTool ? {
id: persistedTool.id,
name: persistedTool.name,
@@ -8961,7 +9052,7 @@ function sendCcwebMcpChildAgentUpdate(sessionId, child) {
};
const targetWs = activeCodexAppTurns.get(sessionId)?.ws || findViewingSessionWs(sessionId);
if (targetWs) wsSend(targetWs, payload);
broadcastSessionList();
scheduleCcwebMcpChildSessionListBroadcast({ immediate: isFinalCcwebMcpChildStatus(child.status) });
}
function syncCcwebMcpChildAgentsFromCollabItem(routed, item = {}) {
@@ -10910,6 +11001,8 @@ function handleListCodexSessions(ws, msg = {}) {
for (const filePath of getCodexRolloutFiles()) {
const parsed = parseCodexRolloutFile(filePath);
if (!parsed?.meta?.threadId) continue;
const source = codexImportSourceLabel(parsed.meta.source);
if (source === 'subagent') continue;
if (seen.has(parsed.meta.threadId)) continue;
seen.add(parsed.meta.threadId);
const title = parsed.meta.title || parsed.meta.threadId.slice(0, 20);
@@ -10925,7 +11018,7 @@ function handleListCodexSessions(ws, msg = {}) {
cwd: parsed.meta.cwd || null,
updatedAt: parsed.meta.updatedAt || null,
cliVersion: parsed.meta.cliVersion || '',
source: codexImportSourceLabel(parsed.meta.source),
source,
sourceConversationId: sourceConversation?.id || null,
sourceConversationTitle: sourceConversation?.title || '',
duplicateCount: 1,