chore: rebuild CentOS7 release package
This commit is contained in:
368
server.js
368
server.js
@@ -99,6 +99,8 @@ const RUN_OUTPUT_RECOVERY_MAX_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_RECO
|
||||
const RUN_OUTPUT_TAILER_MAX_READ_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_TAILER_MAX_READ_BYTES', 2 * 1024 * 1024, { min: 64 * 1024 });
|
||||
const CROSS_CONVERSATION_MAX_CONTENT_CHARS = readPositiveIntEnv('CC_WEB_CROSS_CONVERSATION_MAX_CONTENT_CHARS', 64 * 1024, { min: 1024 });
|
||||
const MCP_CREATE_CONVERSATION_MAX_HOP_COUNT = readPositiveIntEnv('CC_WEB_MCP_CREATE_CONVERSATION_MAX_HOP_COUNT', 3, { min: 1, max: 20 });
|
||||
const CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS', 3, { min: 1, max: 10 });
|
||||
const CODEX_TRANSIENT_RETRY_BASE_DELAY_MS = readPositiveIntEnv('CC_WEB_CODEX_TRANSIENT_RETRY_BASE_DELAY_MS', 2000, { min: 100, max: 60000 });
|
||||
const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4000;
|
||||
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
|
||||
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
|
||||
@@ -632,6 +634,9 @@ const pendingSlashCommands = new Map();
|
||||
// Pending compact retry metadata: sessionId -> { text: string, mode: string, reason: string }
|
||||
const pendingCompactRetries = new Map();
|
||||
|
||||
// Pending Codex transient retry metadata: sessionId -> { text, runtimeText, mode, attempts, timer }
|
||||
const pendingCodexCapacityRetries = new Map();
|
||||
|
||||
// Active processes: sessionId -> { pid, ws, fullText, toolCalls, lastCost, tailer }
|
||||
const activeProcesses = new Map();
|
||||
|
||||
@@ -699,6 +704,11 @@ const DEFAULT_CODEX_CONFIG = {
|
||||
profiles: [],
|
||||
enableSearch: false,
|
||||
supportsSearch: false,
|
||||
retry: {
|
||||
mode: 'limited',
|
||||
intervalSeconds: Math.max(1, Math.ceil(CODEX_TRANSIENT_RETRY_BASE_DELAY_MS / 1000)),
|
||||
maxAttempts: CODEX_TRANSIENT_RETRY_MAX_ATTEMPTS,
|
||||
},
|
||||
};
|
||||
|
||||
function stripTomlInlineComment(value) {
|
||||
@@ -1009,6 +1019,20 @@ function saveModelConfig(config) {
|
||||
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
function normalizeCodexRetryConfig(raw = {}) {
|
||||
const defaults = DEFAULT_CODEX_CONFIG.retry;
|
||||
const mode = ['off', 'limited', 'forever'].includes(raw?.mode) ? raw.mode : defaults.mode;
|
||||
const rawInterval = Number.parseInt(String(raw?.intervalSeconds ?? raw?.interval ?? ''), 10);
|
||||
const intervalSeconds = Number.isFinite(rawInterval)
|
||||
? Math.max(1, Math.min(3600, rawInterval))
|
||||
: defaults.intervalSeconds;
|
||||
const rawMaxAttempts = Number.parseInt(String(raw?.maxAttempts ?? raw?.attempts ?? ''), 10);
|
||||
const maxAttempts = Number.isFinite(rawMaxAttempts)
|
||||
? Math.max(1, Math.min(1000, rawMaxAttempts))
|
||||
: defaults.maxAttempts;
|
||||
return { mode, intervalSeconds, maxAttempts };
|
||||
}
|
||||
|
||||
function loadCodexConfig() {
|
||||
try {
|
||||
if (fs.existsSync(CODEX_CONFIG_PATH)) {
|
||||
@@ -1024,6 +1048,7 @@ function loadCodexConfig() {
|
||||
enableSearch: false,
|
||||
supportsSearch: false,
|
||||
storedEnableSearch: !!raw.enableSearch,
|
||||
retry: normalizeCodexRetryConfig(raw.retry),
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
@@ -1040,6 +1065,7 @@ function saveCodexConfig(config) {
|
||||
apiBase: String(profile?.apiBase || '').trim(),
|
||||
})).filter((profile) => profile.name) : [],
|
||||
enableSearch: false,
|
||||
retry: normalizeCodexRetryConfig(config.retry),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
@@ -1056,6 +1082,7 @@ function getCodexConfigMasked() {
|
||||
enableSearch: false,
|
||||
supportsSearch: false,
|
||||
storedEnableSearch: !!config.storedEnableSearch,
|
||||
retry: normalizeCodexRetryConfig(config.retry),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4561,6 +4588,12 @@ function formatRuntimeError(agent, raw, context = {}) {
|
||||
if (/rate limit|quota|billing|credits/i.test(condensed)) {
|
||||
return 'Codex 请求被额度或速率限制拦截。请检查账号配额、计费状态或稍后重试。';
|
||||
}
|
||||
if (isCodexTransientCapacityError(condensed)) {
|
||||
return 'Codex 服务暂时繁忙或所选模型容量不足。cc-web 已自动重试,仍未成功;请稍后再试或临时切换模型。';
|
||||
}
|
||||
if (isCodexTransientConnectionError(condensed)) {
|
||||
return 'Codex App 连接暂时中断。cc-web 已自动重试,仍未成功;请稍后再试或检查网络代理。';
|
||||
}
|
||||
if (/network|timed out|timeout|ECONNRESET|ENOTFOUND|TLS|certificate|fetch failed/i.test(condensed)) {
|
||||
return 'Codex 运行时网络请求失败。请检查当前网络、代理或证书环境后重试。';
|
||||
}
|
||||
@@ -4634,6 +4667,144 @@ function isContextLimitError(agent, raw) {
|
||||
return /context\s+(window|length)|maximum context length|context limit|token limit|too many tokens|input.*too long|prompt.*too long|request too large|please use\s*\/compact|use\s*\/compact|reduce (the )?(input|prompt|message)|exceed(?:ed|s).*(token|context)/i.test(text);
|
||||
}
|
||||
|
||||
function isCodexTransientCapacityError(raw) {
|
||||
const text = String(raw || '');
|
||||
if (!text) return false;
|
||||
return /server_is_overloaded|service_unavailable_error|ServiceUnavailableError|servers?\s+(?:are\s+)?(?:currently\s+)?overloaded|server\s+is\s+overloaded|model\s+is\s+at\s+capacity|selected model is at capacity|model.*overloaded|503\b.*(?:overloaded|unavailable)|temporarily unavailable|please try again later/i.test(text);
|
||||
}
|
||||
|
||||
function isCodexTransientConnectionError(raw) {
|
||||
const text = String(raw || '');
|
||||
if (!text) return false;
|
||||
return /reconnecting(?:\.\.\.)?\s*\d+\/\d+|connection\s+(?:lost|closed|reset|refused|interrupted)|disconnect(?:ed|ion)|ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|ENOTFOUND|fetch failed|network.*(?:error|failed)|TLS connection.*terminated/i.test(text);
|
||||
}
|
||||
|
||||
function isCodexTransientRetryableError(raw) {
|
||||
return isCodexTransientCapacityError(raw) || isCodexTransientConnectionError(raw);
|
||||
}
|
||||
|
||||
function hasRuntimeOutput(entry) {
|
||||
if ((entry.fullText || '').trim()) return true;
|
||||
if (Array.isArray(entry.contentBlocks) && entry.contentBlocks.length > 0) return true;
|
||||
return Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0;
|
||||
}
|
||||
|
||||
function getCodexRetryConfig() {
|
||||
return normalizeCodexRetryConfig(loadCodexConfig().retry);
|
||||
}
|
||||
|
||||
function codexTransientRetryDelayMs(config) {
|
||||
return Math.max(0, (config?.intervalSeconds || DEFAULT_CODEX_CONFIG.retry.intervalSeconds) * 1000);
|
||||
}
|
||||
|
||||
function cancelCodexCapacityRetry(sessionId) {
|
||||
const retry = pendingCodexCapacityRetries.get(sessionId);
|
||||
if (!retry) return false;
|
||||
if (retry.timer) clearTimeout(retry.timer);
|
||||
pendingCodexCapacityRetries.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldRetryCodexTransientFailure(entry, rawError, context = {}) {
|
||||
if (!['codex', 'codexapp'].includes(entry.agent || 'claude')) return false;
|
||||
if (!rawError || !isCodexTransientRetryableError(rawError)) return false;
|
||||
if (getCodexRetryConfig().mode === 'off') return false;
|
||||
if (context.contextLimitExceeded || context.pendingSlash) return false;
|
||||
if (entry.crossConversationReplyRequestId) return false;
|
||||
if ((entry.agent || 'claude') !== 'codexapp' && hasRuntimeOutput(entry)) return false;
|
||||
return !!(entry.retryRequest?.text || entry.retryRequest?.runtimeText);
|
||||
}
|
||||
|
||||
function scheduleCodexCapacityRetry(sessionId, entry, rawError) {
|
||||
const retryRequest = entry.retryRequest || {};
|
||||
const previous = pendingCodexCapacityRetries.get(sessionId) || null;
|
||||
const retryConfig = getCodexRetryConfig();
|
||||
if (retryConfig.mode === 'off') {
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
return false;
|
||||
}
|
||||
const attempts = (previous?.attempts || 0) + 1;
|
||||
if (retryConfig.mode === 'limited' && attempts > retryConfig.maxAttempts) {
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
const delayMs = codexTransientRetryDelayMs(retryConfig);
|
||||
if (previous?.timer) clearTimeout(previous.timer);
|
||||
|
||||
const retry = {
|
||||
text: retryRequest.text || retryRequest.runtimeText || '',
|
||||
runtimeText: retryRequest.runtimeText || retryRequest.text || '',
|
||||
mode: retryRequest.mode || 'yolo',
|
||||
agent: retryRequest.agent || entry.agent || 'codex',
|
||||
attachments: Array.isArray(retryRequest.attachments) ? retryRequest.attachments : [],
|
||||
mcpContext: retryRequest.mcpContext || {},
|
||||
attempts,
|
||||
retryMode: retryConfig.mode,
|
||||
timer: null,
|
||||
ws: entry.ws || null,
|
||||
};
|
||||
|
||||
retry.timer = setTimeout(() => {
|
||||
const latest = pendingCodexCapacityRetries.get(sessionId);
|
||||
if (!latest || latest.timer !== retry.timer) return;
|
||||
latest.timer = null;
|
||||
|
||||
const session = loadSession(sessionId);
|
||||
if (!session) {
|
||||
pendingCodexCapacityRetries.delete(sessionId);
|
||||
return;
|
||||
}
|
||||
if (activeProcesses.has(sessionId) || activeCodexAppTurns.has(sessionId)) {
|
||||
plog('WARN', 'codex_capacity_retry_skipped_busy', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
attempt: latest.attempts,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = latest.ws && latest.ws.readyState === 1 ? latest.ws : null;
|
||||
plog('INFO', 'codex_capacity_retry_start', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
attempt: latest.attempts,
|
||||
});
|
||||
handleMessage(ws, {
|
||||
type: 'message',
|
||||
text: latest.text,
|
||||
sessionId,
|
||||
mode: latest.mode,
|
||||
agent: latest.agent || 'codex',
|
||||
attachments: latest.attachments,
|
||||
}, {
|
||||
hideInHistory: true,
|
||||
runtimeText: latest.runtimeText,
|
||||
mcpContext: latest.mcpContext,
|
||||
skipPendingCrossConversationFlush: true,
|
||||
});
|
||||
}, delayMs);
|
||||
|
||||
pendingCodexCapacityRetries.set(sessionId, retry);
|
||||
plog('WARN', 'codex_capacity_retry_scheduled', {
|
||||
sessionId: sessionId.slice(0, 8),
|
||||
attempt: attempts,
|
||||
maxAttempts: retryConfig.mode === 'limited' ? retryConfig.maxAttempts : null,
|
||||
retryMode: retryConfig.mode,
|
||||
delayMs,
|
||||
error: String(rawError || '').slice(0, 300),
|
||||
});
|
||||
if (entry.ws) {
|
||||
const attemptText = retryConfig.mode === 'forever'
|
||||
? `第 ${attempts} 次`
|
||||
: `第 ${attempts}/${retryConfig.maxAttempts} 次`;
|
||||
wsSend(entry.ws, {
|
||||
type: 'system_message',
|
||||
sessionId,
|
||||
message: `Codex 服务暂时繁忙,${retryConfig.intervalSeconds} 秒后自动重试(${attemptText})。`,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleProcessComplete(sessionId, exitCode, signal) {
|
||||
const entry = activeProcesses.get(sessionId);
|
||||
if (!entry) return;
|
||||
@@ -4699,6 +4870,19 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
||||
|
||||
// Save result to session
|
||||
const session = loadSession(sessionId);
|
||||
if (session && shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) {
|
||||
activeProcesses.delete(sessionId);
|
||||
cleanRunDir(sessionId);
|
||||
pendingSlashCommands.delete(sessionId);
|
||||
if (scheduleCodexCapacityRetry(sessionId, entry, rawCompletionError)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRetryCodexTransientFailure(entry, rawCompletionError, { contextLimitExceeded, pendingSlash })) {
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
}
|
||||
|
||||
if (session && (entry.fullText || entry.contentBlocks)) {
|
||||
session.messages.push({
|
||||
role: 'assistant',
|
||||
@@ -5468,6 +5652,7 @@ function handleSaveCodexConfig(ws, newConfig) {
|
||||
});
|
||||
}
|
||||
const requestedSearch = !!newConfig.enableSearch;
|
||||
const retry = normalizeCodexRetryConfig(newConfig.retry);
|
||||
const merged = {
|
||||
mode: newConfig.mode === 'custom' ? 'custom' : 'local',
|
||||
activeProfile: String(newConfig.activeProfile || '').trim(),
|
||||
@@ -5475,6 +5660,7 @@ function handleSaveCodexConfig(ws, newConfig) {
|
||||
enableSearch: false,
|
||||
supportsSearch: false,
|
||||
storedEnableSearch: requestedSearch,
|
||||
retry,
|
||||
};
|
||||
if (merged.mode === 'custom' && merged.profiles.length > 0 && !merged.profiles.some((profile) => profile.name === merged.activeProfile)) {
|
||||
merged.activeProfile = merged.profiles[0].name;
|
||||
@@ -5486,6 +5672,9 @@ function handleSaveCodexConfig(ws, newConfig) {
|
||||
profileCount: merged.profiles.length,
|
||||
enableSearchRequested: requestedSearch,
|
||||
enableSearchEffective: false,
|
||||
retryMode: retry.mode,
|
||||
retryIntervalSeconds: retry.intervalSeconds,
|
||||
retryMaxAttempts: retry.mode === 'limited' ? retry.maxAttempts : null,
|
||||
});
|
||||
wsSend(ws, { type: 'codex_config', config: getCodexConfigMasked() });
|
||||
wsSend(ws, {
|
||||
@@ -5740,6 +5929,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
switch (cmd) {
|
||||
case '/clear': {
|
||||
if (session) {
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
if (activeProcesses.has(sessionId)) {
|
||||
const entry = activeProcesses.get(sessionId);
|
||||
killProcess(entry.pid);
|
||||
@@ -5951,6 +6141,56 @@ function resolveConversationMode(rawMode, fallbackMode = 'yolo', options = {}) {
|
||||
return { ok: true, mode: VALID_PERMISSION_MODES.has(fallbackMode) ? fallbackMode : 'yolo' };
|
||||
}
|
||||
|
||||
function resolveConversationModel(rawModel, agent, sourceSession = null) {
|
||||
const value = String(rawModel || '').trim();
|
||||
if (value) {
|
||||
if (agent === 'codex' || agent === 'codexapp') return value;
|
||||
return MODEL_MAP[value.toLowerCase()] || value;
|
||||
}
|
||||
if (sourceSession?.model) return sourceSession.model;
|
||||
return agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : MODEL_MAP.opus;
|
||||
}
|
||||
|
||||
function buildBranchSessionTitle(sourceSession) {
|
||||
const sourceTitle = normalizeConversationTitle(sourceSession?.title, '新会话');
|
||||
return normalizeConversationTitle(`${sourceTitle} 的分支`);
|
||||
}
|
||||
|
||||
function resolveBranchSource(args = {}) {
|
||||
const sourceSessionId = sanitizeId(args.branchSourceSessionId || args.sourceSessionId || '');
|
||||
if (!sourceSessionId) return { ok: true, sourceSession: null };
|
||||
|
||||
const sourceSession = loadSession(sourceSessionId);
|
||||
if (!sourceSession) {
|
||||
return mcpToolError('branch_source_not_found', '来源会话不存在,无法创建分支。', { sourceSessionId });
|
||||
}
|
||||
|
||||
const sourceMessages = Array.isArray(sourceSession.messages) ? sourceSession.messages : [];
|
||||
if (sourceMessages.length === 0) {
|
||||
return mcpToolError('branch_source_empty', '来源会话没有可复制的上下文。', { sourceSessionId });
|
||||
}
|
||||
|
||||
const parsedIndex = Number.parseInt(String(args.branchMessageIndex ?? ''), 10);
|
||||
const sourceMessageIndex = Number.isFinite(parsedIndex)
|
||||
? Math.max(0, Math.min(sourceMessages.length - 1, parsedIndex))
|
||||
: sourceMessages.length - 1;
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
sourceSession,
|
||||
initialMessages: sourceMessages.slice(0, sourceMessageIndex + 1),
|
||||
createdFrom: {
|
||||
kind: 'branch',
|
||||
sourceSessionId: sourceSession.id,
|
||||
sourceTitle: sourceSession.title || 'Untitled',
|
||||
sourceMessageIndex,
|
||||
createdAt,
|
||||
},
|
||||
defaultTitle: buildBranchSessionTitle(sourceSession),
|
||||
};
|
||||
}
|
||||
|
||||
function createPersistentConversationSession(args = {}, options = {}) {
|
||||
const sourceSession = options.sourceSession || null;
|
||||
const strict = !!options.strict;
|
||||
@@ -5981,6 +6221,9 @@ function createPersistentConversationSession(args = {}, options = {}) {
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const agent = agentResult.agent;
|
||||
const initialMessages = Array.isArray(options.initialMessages)
|
||||
? sanitizeMessagesForPersist(options.initialMessages)
|
||||
: [];
|
||||
const session = {
|
||||
id: crypto.randomUUID(),
|
||||
title: normalizeConversationTitle(args.title),
|
||||
@@ -5991,12 +6234,16 @@ function createPersistentConversationSession(args = {}, options = {}) {
|
||||
claudeSessionId: null,
|
||||
codexThreadId: null,
|
||||
codexAppThreadId: null,
|
||||
// Codex/Codex App 读取 ~/.codex/config.toml 默认模型;Claude 继续默认 opus 1M。
|
||||
model: agent === 'codex' || agent === 'codexapp' ? getDefaultCodexModel() : MODEL_MAP.opus,
|
||||
// Codex/Codex App 默认读取 ~/.codex/config.toml;分支会话优先继承来源模型。
|
||||
model: resolveConversationModel(
|
||||
args.model,
|
||||
agent,
|
||||
options.inheritSourceModel === true ? sourceSession : null,
|
||||
),
|
||||
permissionMode: modeResult.mode,
|
||||
totalCost: 0,
|
||||
totalUsage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
messages: [],
|
||||
messages: initialMessages,
|
||||
cwd: cwdResult.path || getDefaultSessionCwd(),
|
||||
};
|
||||
if (options.createdFrom) session.createdFrom = options.createdFrom;
|
||||
@@ -6009,10 +6256,11 @@ function createPersistentConversationSession(args = {}, options = {}) {
|
||||
|
||||
function buildSessionInfoPayload(session) {
|
||||
const waitState = crossConversationWaitState(session.id);
|
||||
const messages = session.messages || [];
|
||||
return {
|
||||
type: 'session_info',
|
||||
sessionId: session.id,
|
||||
messages: session.messages || [],
|
||||
messages,
|
||||
title: session.title,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
mode: session.permissionMode || 'yolo',
|
||||
@@ -6022,6 +6270,8 @@ function buildSessionInfoPayload(session) {
|
||||
totalCost: session.totalCost || 0,
|
||||
totalUsage: session.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 },
|
||||
updated: session.updated,
|
||||
historyTotal: messages.length,
|
||||
historyBaseIndex: 0,
|
||||
hasUnread: false,
|
||||
historyPending: false,
|
||||
isRunning: false,
|
||||
@@ -6040,10 +6290,29 @@ function attachClientRequestId(payload, source = {}) {
|
||||
}
|
||||
|
||||
function handleNewSession(ws, msg) {
|
||||
const result = createPersistentConversationSession(msg || {}, {
|
||||
defaultAgent: normalizeAgent(msg?.agent),
|
||||
const request = msg || {};
|
||||
const branch = resolveBranchSource(request);
|
||||
if (!branch.ok) {
|
||||
return wsSend(ws, {
|
||||
type: 'error',
|
||||
code: branch.code,
|
||||
message: branch.message,
|
||||
});
|
||||
}
|
||||
|
||||
const createArgs = { ...request };
|
||||
if (branch.defaultTitle && !String(createArgs.title || '').trim()) {
|
||||
createArgs.title = branch.defaultTitle;
|
||||
}
|
||||
|
||||
const result = createPersistentConversationSession(createArgs, {
|
||||
defaultAgent: normalizeAgent(request.agent),
|
||||
defaultMode: 'yolo',
|
||||
allowCreateCwd: true,
|
||||
sourceSession: branch.sourceSession || null,
|
||||
initialMessages: branch.initialMessages || null,
|
||||
createdFrom: branch.createdFrom || null,
|
||||
inheritSourceModel: !!branch.sourceSession,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return wsSend(ws, {
|
||||
@@ -6079,6 +6348,7 @@ function handleLoadHistoryPage(ws, msg = {}) {
|
||||
messages: list.slice(start, end),
|
||||
remaining: 0,
|
||||
historyCursor: start,
|
||||
historyBaseIndex: start,
|
||||
historyTruncated: start > 0,
|
||||
});
|
||||
}
|
||||
@@ -6132,6 +6402,7 @@ function handleLoadSession(ws, msg) {
|
||||
historyTotal: refreshedSession.messages.length,
|
||||
historyBuffered,
|
||||
historyCursor: historyRemaining,
|
||||
historyBaseIndex: Math.max(0, refreshedSession.messages.length - recentMessages.length),
|
||||
historyTruncated: historyRemaining > 0,
|
||||
historyPending: olderChunks.length > 0,
|
||||
updated: refreshedSession.updated,
|
||||
@@ -6145,15 +6416,19 @@ function handleLoadSession(ws, msg) {
|
||||
}, msg));
|
||||
|
||||
if (olderChunks.length > 0) {
|
||||
let chunkEnd = Math.max(0, refreshedSession.messages.length - recentMessages.length);
|
||||
olderChunks.forEach((chunk, index) => {
|
||||
const chunkStart = Math.max(0, chunkEnd - chunk.length);
|
||||
wsSend(ws, {
|
||||
type: 'session_history_chunk',
|
||||
sessionId: refreshedSession.id,
|
||||
messages: chunk,
|
||||
remaining: Math.max(0, olderChunks.length - index - 1),
|
||||
historyCursor: index === olderChunks.length - 1 ? historyRemaining : null,
|
||||
historyBaseIndex: chunkStart,
|
||||
historyTruncated: historyRemaining > 0,
|
||||
});
|
||||
chunkEnd = chunkStart;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6256,6 +6531,7 @@ function deleteCodexLocalSession(session) {
|
||||
function handleDeleteSession(ws, sessionId) {
|
||||
pendingSlashCommands.delete(sessionId);
|
||||
pendingCompactRetries.delete(sessionId);
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
deleteCrossConversationRepliesForSession(sessionId);
|
||||
for (const [threadId, child] of ccwebMcpChildThreads.entries()) {
|
||||
if (child.parentSessionId === sessionId) ccwebMcpChildThreads.delete(threadId);
|
||||
@@ -6376,9 +6652,17 @@ function handleAbort(ws) {
|
||||
if (!sessionId) return;
|
||||
if (handleCodexAppAbortSession(sessionId, ws)) return;
|
||||
const entry = activeProcesses.get(sessionId);
|
||||
if (!entry) return;
|
||||
if (!entry) {
|
||||
if (cancelCodexCapacityRetry(sessionId)) {
|
||||
wsSend(ws, { type: 'system_message', sessionId, message: '已取消 Codex 自动重试。' });
|
||||
wsSend(ws, { type: 'done', sessionId });
|
||||
sendSessionList(ws);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
plog('INFO', 'user_abort', { sessionId: sessionId.slice(0, 8), pid: entry.pid });
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
killProcess(entry.pid);
|
||||
setTimeout(() => {
|
||||
killProcess(entry.pid, true);
|
||||
@@ -6465,6 +6749,9 @@ function handleMessage(ws, msg, options = {}) {
|
||||
if (!normalizedText && resolvedAttachments.length === 0) {
|
||||
return fail('empty_message', '消息内容不能为空。');
|
||||
}
|
||||
if (sessionId && !hideInHistory) {
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
}
|
||||
|
||||
const savedAttachments = resolvedAttachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
@@ -6695,6 +6982,13 @@ function handleMessage(ws, msg, options = {}) {
|
||||
lastError: null,
|
||||
errorSent: false,
|
||||
crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null,
|
||||
retryRequest: {
|
||||
text: textValue,
|
||||
runtimeText: runtimeTextValue,
|
||||
mode: session.permissionMode || 'yolo',
|
||||
attachments: savedAttachments,
|
||||
mcpContext: options.mcpContext || {},
|
||||
},
|
||||
tailer: null,
|
||||
};
|
||||
activeProcesses.set(currentSessionId, entry);
|
||||
@@ -7666,6 +7960,14 @@ function handleCodexAppServerRequest(request) {
|
||||
}
|
||||
|
||||
function handleCodexAppServerExit(signature, info = {}) {
|
||||
if (signature && signature !== codexAppClientSignature) {
|
||||
plog('INFO', 'codex_app_server_exit_stale', {
|
||||
code: info.code ?? null,
|
||||
signal: info.signal || null,
|
||||
activeTurns: activeCodexAppTurns.size,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (signature && signature === codexAppClientSignature) {
|
||||
codexAppClient = null;
|
||||
codexAppClientSignature = '';
|
||||
@@ -7821,13 +8123,27 @@ function buildCodexAppClientSpec() {
|
||||
};
|
||||
}
|
||||
|
||||
function getCodexAppClient() {
|
||||
function getCodexAppClient(options = {}) {
|
||||
const spec = buildCodexAppClientSpec();
|
||||
if (spec?.error) return { error: spec.error };
|
||||
|
||||
if (codexAppClient && codexAppClientSignature !== spec.signature) {
|
||||
if (activeCodexAppTurns.size > 0) {
|
||||
return { error: 'Codex App 配置已变更,但仍有运行中的任务。请等待任务结束后再发送新消息。' };
|
||||
const excludeSessionId = sanitizeId(options.excludeSessionId || '');
|
||||
const blockingSessionIds = Array.from(activeCodexAppTurns.keys())
|
||||
.filter((sessionId) => !excludeSessionId || sessionId !== excludeSessionId);
|
||||
if (blockingSessionIds.length > 0) {
|
||||
if (codexAppClient.isRunning()) {
|
||||
plog('WARN', 'codex_app_config_changed_reusing_active_client', {
|
||||
activeTurns: activeCodexAppTurns.size,
|
||||
blockingTurns: blockingSessionIds.length,
|
||||
excludeSessionId: excludeSessionId ? excludeSessionId.slice(0, 8) : null,
|
||||
});
|
||||
return { client: codexAppClient, staleConfig: true };
|
||||
}
|
||||
const message = 'Codex App 配置已变更,旧 app-server 已不可用,已结束残留运行任务。请重试。';
|
||||
for (const sessionId of blockingSessionIds) {
|
||||
handleCodexAppTurnFailure(sessionId, new Error(message));
|
||||
}
|
||||
}
|
||||
codexAppClient.stop();
|
||||
codexAppClient = null;
|
||||
@@ -7920,6 +8236,16 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment
|
||||
return { ok: false, code: 'empty_message', message: '消息内容不能为空。' };
|
||||
}
|
||||
|
||||
const retryAttachments = resolvedAttachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
kind: 'image',
|
||||
filename: attachment.filename,
|
||||
mime: attachment.mime,
|
||||
size: attachment.size,
|
||||
createdAt: attachment.createdAt,
|
||||
expiresAt: attachment.expiresAt,
|
||||
storageState: attachment.storageState,
|
||||
}));
|
||||
const entry = {
|
||||
ws,
|
||||
agent: 'codexapp',
|
||||
@@ -7935,6 +8261,14 @@ function handleCodexAppMessage(ws, session, runtimeTextValue, resolvedAttachment
|
||||
lastError: null,
|
||||
errorSent: false,
|
||||
crossConversationReplyRequestId: options.crossConversation?.replyRequestId || null,
|
||||
retryRequest: {
|
||||
text: runtimeTextValue,
|
||||
runtimeText: runtimeTextValue,
|
||||
mode: session.permissionMode || 'yolo',
|
||||
agent: 'codexapp',
|
||||
attachments: retryAttachments,
|
||||
mcpContext: options.mcpContext || {},
|
||||
},
|
||||
clientUserMessageId: crypto.randomUUID(),
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -7954,7 +8288,7 @@ async function startCodexAppTurn(sessionId, input) {
|
||||
const entry = activeCodexAppTurns.get(sessionId);
|
||||
if (!session || !entry) return;
|
||||
|
||||
const clientResult = getCodexAppClient();
|
||||
const clientResult = getCodexAppClient({ excludeSessionId: sessionId });
|
||||
if (clientResult.error) throw new Error(clientResult.error);
|
||||
const client = clientResult.client;
|
||||
await client.start();
|
||||
@@ -8002,6 +8336,18 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
||||
toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS,
|
||||
contentMaxChars: SESSION_MESSAGE_CONTENT_MAX_CHARS,
|
||||
});
|
||||
|
||||
if (session && shouldRetryCodexTransientFailure(entry, rawError)) {
|
||||
activeCodexAppTurns.delete(sessionId);
|
||||
cleanupCodexAppTurnState(sessionId, entry);
|
||||
if (scheduleCodexCapacityRetry(sessionId, entry, rawError)) {
|
||||
sendSessionList(entry.ws);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
cancelCodexCapacityRetry(sessionId);
|
||||
}
|
||||
|
||||
if (session && (assistantContent.trim() || assistantToolCalls.length > 0) && !hasCodexAppTurnMessage(session, turnKey)) {
|
||||
session.messages.push({
|
||||
role: 'assistant',
|
||||
|
||||
Reference in New Issue
Block a user