diff --git a/.trellis/tasks/09-27-codex-start-lock-retry/check.jsonl b/.trellis/tasks/09-27-codex-start-lock-retry/check.jsonl new file mode 100644 index 0000000..99f482c --- /dev/null +++ b/.trellis/tasks/09-27-codex-start-lock-retry/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/error-handling.md","reason":"检查启动忙错误、非暂时错误与最终错误信息是否符合后端错误处理规范。"} +{"file":".trellis/spec/backend/logging-guidelines.md","reason":"检查锁竞争和退避日志是否脱敏、可追踪且不过度输出。"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"检查实现和回归测试是否满足后端质量要求。"} diff --git a/.trellis/tasks/09-27-codex-start-lock-retry/implement.jsonl b/.trellis/tasks/09-27-codex-start-lock-retry/implement.jsonl new file mode 100644 index 0000000..afbbb78 --- /dev/null +++ b/.trellis/tasks/09-27-codex-start-lock-retry/implement.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/spec/backend/error-handling.md","reason":"启动失败、可重试错误与最终错误诊断需要遵循后端错误处理约定。"} +{"file":".trellis/spec/backend/logging-guidelines.md","reason":"锁等待、重试与释放需要遵循现有日志脱敏和事件命名约定。"} +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"新增跨进程锁与测试需要遵循后端质量和验证要求。"} diff --git a/.trellis/tasks/09-27-codex-start-lock-retry/prd.md b/.trellis/tasks/09-27-codex-start-lock-retry/prd.md new file mode 100644 index 0000000..1f3cc11 --- /dev/null +++ b/.trellis/tasks/09-27-codex-start-lock-retry/prd.md @@ -0,0 +1,35 @@ +# Codex 启动单实例锁与退避重试 + +## 目标 + +避免 cc-web 启动 Codex app-server 时与其他 cc-web 实例或残留启动流程并发写入同一 `CODEX_HOME`,导致 SQLite 报 `database is locked` 并直接启动失败。 + +## 范围 + +- 在 `lib/codex-app-server-client.js` 的 app-server 启动层实现跨进程单实例锁。 +- 锁文件固定为 `/.cc-web-codex.lock`;锁目录按解析后的绝对 `CODEX_HOME` 隔离。 +- 锁持有范围从启动尝试前开始,覆盖 app-server 初始化成功后的整个子进程生命周期;仅在子进程收到 `exit`/`error`、初始化失败清理完成或显式 `stop()` 后释放。 +- 锁记录使用 JSON,至少包含 `pid`、Linux `/proc//stat` 的进程启动时间(无法读取时为 `null`)、随机 `token`、创建时间。释放前必须校验 token,避免误删后来者的锁。 +- 陈旧锁判定:PID 不存在,或 PID 存在但进程启动时间与记录不一致;记录损坏或不完整时,先按活动锁保护,超过 2 秒宽限期仍未补全才允许清理。 +- 锁获取默认最多等待 30 秒,轮询间隔 200ms 起步、最多 2 秒并加入少量随机抖动;等待超时返回 `CODEX_HOME_LOCK_BUSY`,不杀死锁持有者。 +- 对可判定为 SQLite/状态库暂时忙的初始化失败执行最多 4 次启动尝试,延迟为 500ms、1s、2s(上限 5s)并加入少量随机抖动;每次重试都等待旧子进程退出、释放本次锁后重新获取锁。默认总等待不超过 30 秒(不包含一次 30 秒初始化 RPC 超时)。 +- 可通过环境变量覆盖:`CC_WEB_CODEX_LOCK_WAIT_MS`、`CC_WEB_CODEX_START_RETRY_ATTEMPTS`、`CC_WEB_CODEX_START_RETRY_DELAY_MS`、`CC_WEB_CODEX_START_RETRY_MAX_DELAY_MS`;所有值限制在合理范围内。 +- 仅当 stderr、启动错误 message 或错误 cause 明确包含以下模式时重试:`database is locked`、`database table is locked`、`SQLITE_BUSY`、`SQLITE_BUSY_TIMEOUT`、`failed to open ... (state|log|database)`、`failed to initialize state runtime`。不因鉴权、命令不存在、参数错误、协议错误或普通网络错误重试。 +- 保留现有 app-server 客户端 API 与 worker/source 两条启动路径。 +- 增加可独立运行的 Node 回归测试,覆盖锁互斥、活动锁等待超时、陈旧锁清理、PID 启动时间不匹配保护、暂时性错误重试、非暂时错误不重试、成功/失败/显式停止后的锁释放。 + +## 约束 + +- 不删除用户数据,不改动用户的 Codex 数据库;只创建/清理 cc-web 自己的锁文件。 +- 不把外部 Codex 进程误判为可安全杀死的残留进程;锁竞争只等待或报错。 +- `SIGKILL` 等无法执行清理的场景依赖 PID + 启动时间识别陈旧锁,不保证实时删除锁文件。 +- 启动失败最终抛出的错误保留最后一次原始 stderr,并附带尝试次数、锁路径和最后一次退避原因;日志不写入 API key。 +- 重试仅发生在 `initialize` 阶段,不能自动重发 `thread/start`、`turn/start` 或其他有副作用的请求。 + +## 完成标准 + +1. 同一 `CODEX_HOME` 的两个 cc-web app-server 客户端不能同时持有锁;第二个客户端在锁释放前最多等待配置的锁等待时长,超时得到 `CODEX_HOME_LOCK_BUSY` 和锁持有者摘要。 +2. 初始化 stderr/message 命中暂时性 SQLite 模式时,按明确的有限退避策略重新启动,成功后对调用方透明;非暂时性错误只尝试一次。 +3. app-server 正常停止、初始化失败和进程退出都会释放锁;无存活 PID 或 PID 启动时间不匹配的陈旧锁不会永久阻塞启动,活动锁不会被误删。 +4. 现有 Codex App 协议行为不变,worker/source 两条路径都经过同一锁与重试逻辑;语义判断器等额外客户端若无法获取锁,应收到可诊断的忙错误,不得杀掉已有 app-server。 +5. 新增测试可稳定复现上述场景,并通过项目现有的 Node 语法检查、单元测试和回归测试。 diff --git a/.trellis/tasks/09-27-codex-start-lock-retry/task.json b/.trellis/tasks/09-27-codex-start-lock-retry/task.json new file mode 100644 index 0000000..300e441 --- /dev/null +++ b/.trellis/tasks/09-27-codex-start-lock-retry/task.json @@ -0,0 +1,26 @@ +{ + "id": "codex-start-lock-retry", + "name": "codex-start-lock-retry", + "title": "Codex 启动单实例锁与退避重试", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-09-27", + "completedAt": "2026-09-28", + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 7115719..b4bab58 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js index eb616c2..8033de4 100644 --- a/lib/codex-app-runtime.js +++ b/lib/codex-app-runtime.js @@ -761,9 +761,17 @@ function createCodexAppRuntime(deps = {}) { if (!nextText) return ''; if (!entry.agentMessageItems) entry.agentMessageItems = new Map(); const currentItemText = entry.agentMessageItems.get(itemId) || ''; + if (!entry.agentMessagePendingPrefixes) entry.agentMessagePendingPrefixes = new Map(); + const pendingPrefix = entry.agentMessagePendingPrefixes.get(itemId) || ''; + if (!currentItemText && !nextText.trim()) { + entry.agentMessagePendingPrefixes.set(itemId, `${pendingPrefix}${nextText}`); + return ''; + } + entry.agentMessagePendingPrefixes.delete(itemId); const separator = agentMessageSeparator(entry, itemId, nextText); - const appended = separator + nextText; - entry.agentMessageItems.set(itemId, appendCappedText(currentItemText, nextText, RUNTIME_AGENT_ITEM_MAX_CHARS)); + const firstText = currentItemText ? nextText : `${pendingPrefix}${nextText}`; + const appended = separator + firstText; + entry.agentMessageItems.set(itemId, appendCappedText(currentItemText, firstText, RUNTIME_AGENT_ITEM_MAX_CHARS)); entry.fullText = appendCappedText(entry.fullText || '', appended, RUNTIME_FULL_TEXT_MAX_CHARS); return capStreamDelta(appended); } @@ -773,6 +781,10 @@ function createCodexAppRuntime(deps = {}) { if (!text) return ''; if (!entry.agentMessageItems) entry.agentMessageItems = new Map(); const currentItemText = entry.agentMessageItems.get(item.id) || ''; + if (!entry.agentMessagePendingPrefixes) entry.agentMessagePendingPrefixes = new Map(); + const pendingPrefix = entry.agentMessagePendingPrefixes.get(item.id) || ''; + entry.agentMessagePendingPrefixes.delete(item.id); + if (!currentItemText && !text.trim()) return ''; if (currentItemText && text.startsWith(currentItemText)) { const remainder = text.slice(currentItemText.length); entry.agentMessageItems.set(item.id, keepTail(text, RUNTIME_AGENT_ITEM_MAX_CHARS)); @@ -780,9 +792,12 @@ function createCodexAppRuntime(deps = {}) { return capStreamDelta(remainder); } if (currentItemText === text) return ''; - const separator = agentMessageSeparator(entry, item.id, text); - const appended = separator + text; - entry.agentMessageItems.set(item.id, keepTail(text, RUNTIME_AGENT_ITEM_MAX_CHARS)); + const completedText = !currentItemText && pendingPrefix && !text.startsWith(pendingPrefix) + ? `${pendingPrefix}${text}` + : text; + const separator = agentMessageSeparator(entry, item.id, completedText); + const appended = separator + completedText; + entry.agentMessageItems.set(item.id, keepTail(completedText, RUNTIME_AGENT_ITEM_MAX_CHARS)); entry.fullText = appendCappedText(entry.fullText || '', appended, RUNTIME_FULL_TEXT_MAX_CHARS); return capStreamDelta(appended); } diff --git a/lib/codex-app-server-client.js b/lib/codex-app-server-client.js index 1ec67ec..084e5dd 100644 --- a/lib/codex-app-server-client.js +++ b/lib/codex-app-server-client.js @@ -2,6 +2,39 @@ const readline = require('readline'); const { spawn } = require('child_process'); +const { + acquireCodexHomeLock, +} = require('./codex-home-lock'); + +const DEFAULT_START_RETRY_ATTEMPTS = 4; +const DEFAULT_START_RETRY_DELAY_MS = 500; +const DEFAULT_START_RETRY_MAX_DELAY_MS = 5000; + +function positiveInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed) || parsed < min) return fallback; + return Math.min(parsed, max); +} + +function isCodexHomeBusyError(value) { + const parts = []; + const visit = (item, depth = 0) => { + if (!item || depth > 3) return; + if (typeof item === 'string') parts.push(item); + else if (typeof item === 'object') { + for (const key of ['message', 'stderr', 'cause', 'code', 'details']) visit(item[key], depth + 1); + } + }; + visit(value); + const text = parts.join(' '); + return /database\s+(?:is\s+)?locked|database\s+table\s+is\s+locked|SQLITE_BUSY(?:_TIMEOUT)?|failed\s+to\s+(?:open|initialize)\s+(?:state|log|database)|failed\s+to\s+initialize\s+state\s+runtime/i.test(text); +} + +function retryDelayMs(attempt, base, max) { + const exponential = Math.min(max, base * (2 ** Math.max(0, attempt - 1))); + const jitter = Math.floor(Math.random() * Math.max(1, Math.min(100, exponential * 0.2))); + return Math.min(max, exponential + jitter); +} function createCodexAppServerClient(options = {}) { const command = options.command || 'codex'; @@ -26,6 +59,11 @@ function createCodexAppServerClient(options = {}) { let nextId = 1; let initPromise = null; let exited = false; + let processExitPromise = null; + let resolveProcessExit = null; + let lockHandle = null; + let lastStartError = null; + let stopRequested = false; const pending = new Map(); function rejectAllPending(err) { @@ -137,8 +175,14 @@ function createCodexAppServerClient(options = {}) { return request('config/mcpServer/reload', {}, 30000); } - function start() { - if (initPromise) return initPromise; + function releaseLock() { + const current = lockHandle; + lockHandle = null; + if (current) Promise.resolve(current.release()).catch(() => {}); + } + + function spawnAndInitialize(lock) { + lockHandle = lock; exited = false; proc = spawn(command, args, { env, @@ -147,6 +191,8 @@ function createCodexAppServerClient(options = {}) { windowsHide: true, }); + processExitPromise = new Promise((resolve) => { resolveProcessExit = resolve; }); + let stderr = ''; proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); @@ -164,15 +210,21 @@ function createCodexAppServerClient(options = {}) { err.signal = signal; err.stderr = stderr; rejectAllPending(err); + releaseLock(); + if (resolveProcessExit) resolveProcessExit({ code, signal, stderr }); + resolveProcessExit = null; onExit({ code, signal, stderr }); }); proc.on('error', (err) => { rejectAllPending(err); + releaseLock(); + if (resolveProcessExit) resolveProcessExit({ code: null, signal: null, stderr: err.message }); + resolveProcessExit = null; onExit({ code: null, signal: null, stderr: err.message }); }); - initPromise = request('initialize', { + return request('initialize', { clientInfo, capabilities: { experimentalApi: true }, }, 30000) @@ -182,28 +234,108 @@ function createCodexAppServerClient(options = {}) { return result; }) .catch((err) => { - stop(); throw err; }); + } + + async function terminateProcessAndWait() { + const child = proc; + if (!child || exited) { + releaseLock(); + return; + } + try { child.kill('SIGTERM'); } catch {} + const timer = setTimeout(() => { + try { if (!child.killed) child.kill('SIGKILL'); } catch {} + }, 3000); + try { + await processExitPromise; + } finally { + clearTimeout(timer); + } + } + + async function startWithRetry() { + const env = options.env || process.env; + const attempts = positiveInt( + options.startRetryAttempts ?? env.CC_WEB_CODEX_START_RETRY_ATTEMPTS, + DEFAULT_START_RETRY_ATTEMPTS, + { min: 1, max: 8 }, + ); + const baseDelay = positiveInt( + options.startRetryDelayMs ?? env.CC_WEB_CODEX_START_RETRY_DELAY_MS, + DEFAULT_START_RETRY_DELAY_MS, + { min: 0, max: 30_000 }, + ); + const maxDelay = positiveInt( + options.startRetryMaxDelayMs ?? env.CC_WEB_CODEX_START_RETRY_MAX_DELAY_MS, + DEFAULT_START_RETRY_MAX_DELAY_MS, + { min: baseDelay || 1, max: 60_000 }, + ); + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + if (stopRequested) { + const aborted = new Error('Codex app-server 启动已取消。'); + aborted.code = 'CODEX_APP_START_CANCELLED'; + throw aborted; + } + const lock = await acquireCodexHomeLock({ + env, + waitMs: options.lockWaitMs, + lockPath: options.lockPath, + }); + if (stopRequested) { + await lock.release(); + const aborted = new Error('Codex app-server 启动已取消。'); + aborted.code = 'CODEX_APP_START_CANCELLED'; + throw aborted; + } + try { + const result = await spawnAndInitialize(lock); + lastStartError = null; + return result; + } catch (error) { + lastStartError = error; + await terminateProcessAndWait(); + if (!isCodexHomeBusyError(error) || attempt >= attempts) { + const detail = error?.stderr || error?.message || String(error || ''); + const finalError = new Error(`Codex app-server 启动失败(尝试 ${attempt}/${attempts}):${detail}`); + finalError.code = error?.code || 'CODEX_APP_START_FAILED'; + finalError.cause = error; + finalError.stderr = error?.stderr || ''; + finalError.lockPath = lock.path; + throw finalError; + } + const delay = retryDelayMs(attempt, baseDelay, maxDelay); + onLog('WARN', 'codex_app_server_start_retry', { + attempt, + nextAttempt: attempt + 1, + delayMs: delay, + reason: 'sqlite_busy', + lockPath: lock.path, + }); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw lastStartError || new Error('Codex app-server 启动失败。'); + } + + function start() { + if (initPromise) return initPromise; + stopRequested = false; + initPromise = startWithRetry(); return initPromise; } function stop() { + stopRequested = true; initPromise = null; if (rl) { try { rl.close(); } catch {} rl = null; } - if (proc && !exited) { - try { proc.kill('SIGTERM'); } catch {} - setTimeout(() => { - try { - if (proc && !proc.killed) proc.kill('SIGKILL'); - } catch {} - }, 3000); - } - proc = null; + terminateProcessAndWait().catch(() => {}); rejectAllPending(new Error('Codex app-server 已停止。')); } diff --git a/lib/codex-home-lock.js b/lib/codex-home-lock.js new file mode 100644 index 0000000..3962bc4 --- /dev/null +++ b/lib/codex-home-lock.js @@ -0,0 +1,189 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const DEFAULT_LOCK_WAIT_MS = 30_000; +const DEFAULT_LOCK_POLL_MS = 200; +const DEFAULT_LOCK_POLL_MAX_MS = 2_000; +const INVALID_LOCK_GRACE_MS = 2_000; + + +function positiveInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) { + const parsed = Number.parseInt(String(value ?? ''), 10); + if (!Number.isFinite(parsed) || parsed < min) return fallback; + return Math.min(parsed, max); +} + +function resolveCodexHome(env = process.env) { + const explicit = String(env?.CODEX_HOME || '').trim(); + if (explicit) return path.resolve(explicit); + const home = String(env?.HOME || env?.USERPROFILE || os.homedir() || '').trim(); + return home ? path.join(home, '.codex') : ''; +} + +function readProcessStartTime(pid) { + if (!Number.isInteger(pid) || pid <= 0 || process.platform === 'win32') return null; + try { + const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8'); + const closingParen = stat.lastIndexOf(')'); + if (closingParen < 0) return null; + const fields = stat.slice(closingParen + 1).trim().split(/\s+/); + // The remainder starts at stat field 3; field 22 is index 19 here. + return fields[19] || null; + } catch { + return null; + } +} + +function currentProcessOwner() { + return { + pid: process.pid, + startTime: readProcessStartTime(process.pid), + token: crypto.randomBytes(16).toString('hex'), + createdAt: new Date().toISOString(), + }; +} + +function readLock(lockPath) { + try { + const stat = fs.statSync(lockPath); + let owner = null; + try { + const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (parsed && typeof parsed === 'object') owner = parsed; + } catch {} + return { owner, mtimeMs: stat.mtimeMs }; + } catch (error) { + if (error?.code === 'ENOENT') return null; + return { owner: null, mtimeMs: 0 }; + } +} + +function processAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +function lockOwnerActive(owner) { + if (!owner || !Number.isInteger(Number(owner.pid))) return false; + const pid = Number(owner.pid); + if (!processAlive(pid)) return false; + const recordedStart = owner.startTime == null ? null : String(owner.startTime); + const currentStart = readProcessStartTime(pid); + if (recordedStart && currentStart && recordedStart !== currentStart) return false; + // On platforms without /proc, a live PID is the strongest available signal. + return true; +} + +function lockOwnerSummary(owner) { + if (!owner || typeof owner !== 'object') return null; + return { + pid: Number.isInteger(Number(owner.pid)) ? Number(owner.pid) : null, + startTime: owner.startTime || null, + createdAt: owner.createdAt || null, + }; +} + +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function acquireCodexHomeLock(options = {}) { + const lockPath = options.lockPath + ? path.resolve(String(options.lockPath)) + : (() => { + const home = resolveCodexHome(options.env || process.env); + return home ? path.join(home, '.cc-web-codex.lock') : ''; + })(); + if (!lockPath) { + return { path: '', owner: null, release: async () => {} }; + } + + const waitMs = positiveInt( + options.waitMs ?? options.env?.CC_WEB_CODEX_LOCK_WAIT_MS, + DEFAULT_LOCK_WAIT_MS, + { min: 0, max: 10 * 60 * 1000 }, + ); + const pollMs = positiveInt(options.pollMs, DEFAULT_LOCK_POLL_MS, { min: 10, max: 5000 }); + const pollMaxMs = positiveInt(options.pollMaxMs, DEFAULT_LOCK_POLL_MAX_MS, { min: pollMs, max: 10_000 }); + const owner = currentProcessOwner(); + const startedAt = Date.now(); + let lastOwner = null; + + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + while (true) { + try { + const fd = fs.openSync(lockPath, 'wx', 0o600); + try { + fs.writeFileSync(fd, `${JSON.stringify(owner)}\n`, 'utf8'); + } finally { + fs.closeSync(fd); + } + return createLockHandle(lockPath, owner); + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + const existing = readLock(lockPath); + lastOwner = existing?.owner || null; + const ageMs = existing ? Math.max(0, Date.now() - Number(existing.mtimeMs || 0)) : 0; + const stale = existing && ( + (existing.owner && !lockOwnerActive(existing.owner)) + || (!existing.owner && ageMs >= INVALID_LOCK_GRACE_MS) + ); + if (stale) { + try { + const latest = readLock(lockPath); + if (latest?.owner?.token === existing.owner?.token || (!latest?.owner && ageMs >= INVALID_LOCK_GRACE_MS)) { + fs.unlinkSync(lockPath); + continue; + } + } catch (unlinkError) { + if (unlinkError?.code === 'ENOENT') continue; + } + } + + if (Date.now() - startedAt >= waitMs) { + const err = new Error(`Codex CODEX_HOME 已被占用: ${lockPath}`); + err.code = 'CODEX_HOME_LOCK_BUSY'; + err.lockPath = lockPath; + err.owner = lockOwnerSummary(lastOwner); + throw err; + } + const elapsed = Date.now() - startedAt; + const base = Math.min(pollMaxMs, pollMs * (2 ** Math.min(4, Math.floor(elapsed / 1000)))); + const jitter = Math.floor(Math.random() * Math.max(1, Math.min(100, base * 0.2))); + await wait(Math.min(pollMaxMs, base + jitter)); + } + } +} + +function createLockHandle(lockPath, owner) { + let released = false; + return { + path: lockPath, + owner, + release: async () => { + if (released) return; + released = true; + try { + const onDisk = readLock(lockPath)?.owner; + if (onDisk?.token === owner.token) fs.unlinkSync(lockPath); + } catch {} + }, + }; +} + +module.exports = { + DEFAULT_LOCK_WAIT_MS, + acquireCodexHomeLock, + lockOwnerActive, + readProcessStartTime, + resolveCodexHome, +}; diff --git a/package.json b/package.json index 288dffa..81e6c3c 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "scripts": { "start": "node server.js", "build:single-exe": "node scripts/build-single-exe.js", - "regression": "node scripts/regression.js" + "regression": "node scripts/regression.js", + "test:codex-start-lock": "node scripts/codex-start-lock-unit.js" }, "dependencies": { "echarts": "5.6.0", diff --git a/public/app.js b/public/app.js index da09387..abf56a4 100644 --- a/public/app.js +++ b/public/app.js @@ -9023,7 +9023,12 @@ function renderMarkdown(text) { if (!text) return '
'; - try { return marked.parse(text); } + try { + const normalizedText = typeof window.ccNormalizeMarkdownText === 'function' + ? window.ccNormalizeMarkdownText(text) + : text; + return marked.parse(normalizedText); + } catch { return escapeHtml(text); } } diff --git a/public/index.html b/public/index.html index 34f9c72..463d52b 100644 --- a/public/index.html +++ b/public/index.html @@ -425,6 +425,7 @@ + diff --git a/public/markdown-normalizer.js b/public/markdown-normalizer.js new file mode 100644 index 0000000..774c378 --- /dev/null +++ b/public/markdown-normalizer.js @@ -0,0 +1,88 @@ +(function (root, factory) { + const normalize = factory(); + if (typeof module === 'object' && module.exports) module.exports = normalize; + if (root) root.ccNormalizeMarkdownText = normalize; +})(typeof globalThis !== 'undefined' ? globalThis : this, function () { + const CODE_LIKE_PATTERN = /(?:=>|[{};]|^\s*(?:const|let|var|function|class|return|import|export|def|print|console\.)\b|<\/?[a-z][^>]*>)/im; + const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; + + function indentationWidth(line) { + let width = 0; + for (const character of line.match(/^[ \t]*/)?.[0] || '') { + width = character === '\t' ? width + (4 - (width % 4)) : width + 1; + } + return width; + } + + function removeIndent(line, width) { + let column = 0; + let index = 0; + while (index < line.length && column < width) { + const character = line[index]; + if (character !== ' ' && character !== '\t') break; + const nextColumn = character === '\t' ? column + (4 - (column % 4)) : column + 1; + index += 1; + if (nextColumn > width) return `${' '.repeat(nextColumn - width)}${line.slice(index)}`; + column = nextColumn; + } + return line.slice(index); + } + + function isNaturalLanguage(lines) { + const text = lines.map((line) => line.trim()).join(' ').trim(); + if (!text || CODE_LIKE_PATTERN.test(text)) return false; + + const cjkCount = (text.match(/[\u3400-\u9fff]/g) || []).length; + if (cjkCount >= 4) return /[。!?;:]/.test(text); + + const wordCount = (text.match(/[a-z][a-z'-]*/gi) || []).length; + return wordCount >= 8 && /[.!?]/.test(text); + } + + function normalizeParagraph(lines) { + const contentLines = lines.filter((line) => line.trim()); + if (contentLines.length === 0) return lines; + + const commonIndent = Math.min(...contentLines.map(indentationWidth)); + if (commonIndent < 4 || !isNaturalLanguage(contentLines)) return lines; + + return lines.map((line) => (line.trim() ? removeIndent(line, commonIndent) : line)); + } + + return function normalizeMarkdownText(value) { + const lines = String(value ?? '').split('\n'); + const output = []; + let paragraph = []; + let fence = null; + + function flushParagraph() { + if (paragraph.length > 0) output.push(...normalizeParagraph(paragraph)); + paragraph = []; + } + + for (const line of lines) { + const fenceMatch = line.match(FENCE_PATTERN); + if (fence) { + output.push(line); + if (fenceMatch && fenceMatch[1][0] === fence.character && fenceMatch[1].length >= fence.length) { + fence = null; + } + continue; + } + + if (fenceMatch) { + flushParagraph(); + fence = { character: fenceMatch[1][0], length: fenceMatch[1].length }; + output.push(line); + } else if (!line.trim() || /^\s{0,3}<\/?[a-z][^>]*>/i.test(line)) { + flushParagraph(); + output.push(line); + } else { + paragraph.push(line); + } + } + + flushParagraph(); + return output.join('\n'); + }; +}); diff --git a/scripts/codex-start-lock-unit.js b/scripts/codex-start-lock-unit.js new file mode 100644 index 0000000..ab0026c --- /dev/null +++ b/scripts/codex-start-lock-unit.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { createCodexAppServerClient } = require('../lib/codex-app-server-client'); +const { + acquireCodexHomeLock, + readProcessStartTime, +} = require('../lib/codex-home-lock'); + +const FAKE_SERVER = path.join(os.tmpdir(), `ccweb-fake-codex-${process.pid}.js`); +fs.writeFileSync(FAKE_SERVER, String.raw` +'use strict'; +const fs = require('fs'); +const readline = require('readline'); +const attemptFile = process.env.FAKE_ATTEMPT_FILE; +const failureMode = process.env.FAKE_FAILURE_MODE || 'busy'; +let attempt = 0; +try { attempt = Number(fs.readFileSync(attemptFile, 'utf8')) || 0; } catch {} +attempt += 1; +fs.writeFileSync(attemptFile, String(attempt)); +if (attempt <= Number(process.env.FAKE_BUSY_ATTEMPTS || 0)) { + process.stderr.write(failureMode === 'busy' ? 'database is locked (code: 5)\n' : 'fatal config error\n'); + process.exit(1); +} +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + let message; + try { message = JSON.parse(line); } catch { return; } + if (message.id && message.method === 'initialize') { + process.stdout.write(JSON.stringify({ id: message.id, result: { serverInfo: { name: 'fake' } } }) + '\n'); + } +}); +`, 'utf8'); + +function tempHome() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-codex-home-')); +} + +async function testLockMutualExclusion() { + const home = tempHome(); + const first = await acquireCodexHomeLock({ env: { CODEX_HOME: home } }); + await assert.rejects( + () => acquireCodexHomeLock({ env: { CODEX_HOME: home }, waitMs: 80, pollMs: 10, pollMaxMs: 20 }), + (error) => error.code === 'CODEX_HOME_LOCK_BUSY' && error.owner?.pid === process.pid, + ); + await first.release(); + assert.equal(fs.existsSync(path.join(home, '.cc-web-codex.lock')), false); +} + +async function testStaleLockAndPidReuseProtection() { + const home = tempHome(); + const lockPath = path.join(home, '.cc-web-codex.lock'); + fs.writeFileSync(lockPath, JSON.stringify({ + pid: process.pid, + startTime: 'definitely-not-the-current-start-time', + token: 'stale', + createdAt: new Date().toISOString(), + })); + const lock = await acquireCodexHomeLock({ env: { CODEX_HOME: home }, waitMs: 100 }); + assert.equal(lock.owner.pid, process.pid); + await lock.release(); + assert.equal(fs.existsSync(lockPath), false); + assert.equal(typeof readProcessStartTime(process.pid), 'string'); +} + +async function createClient(home, attemptFile, extraEnv = {}) { + return createCodexAppServerClient({ + command: process.execPath, + args: [FAKE_SERVER], + env: { + ...process.env, + CODEX_HOME: home, + FAKE_ATTEMPT_FILE: attemptFile, + ...extraEnv, + }, + startRetryDelayMs: 1, + startRetryMaxDelayMs: 2, + lockWaitMs: 100, + }); +} + +async function testBusyRetryAndRelease() { + const home = tempHome(); + const attempts = path.join(home, 'attempts'); + fs.writeFileSync(attempts, '0'); + const client = await createClient(home, attempts, { FAKE_BUSY_ATTEMPTS: '2' }); + await client.start(); + assert.equal(fs.readFileSync(attempts, 'utf8'), '3'); + client.stop(); + for (let i = 0; i < 30 && fs.existsSync(path.join(home, '.cc-web-codex.lock')); i += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(fs.existsSync(path.join(home, '.cc-web-codex.lock')), false); +} + +async function testNonBusyDoesNotRetry() { + const home = tempHome(); + const attempts = path.join(home, 'attempts'); + fs.writeFileSync(attempts, '0'); + const client = await createClient(home, attempts, { FAKE_BUSY_ATTEMPTS: '1', FAKE_FAILURE_MODE: 'fatal' }); + await assert.rejects(() => client.start(), (error) => error.code === 'CODEX_APP_START_FAILED'); + assert.equal(fs.readFileSync(attempts, 'utf8'), '1'); + assert.equal(fs.existsSync(path.join(home, '.cc-web-codex.lock')), false); +} + +async function main() { + try { + await testLockMutualExclusion(); + await testStaleLockAndPidReuseProtection(); + await testBusyRetryAndRelease(); + await testNonBusyDoesNotRetry(); + console.log('codex-start-lock-unit: ok'); + } finally { + try { fs.unlinkSync(FAKE_SERVER); } catch {} + } +} + +main().catch((error) => { + console.error(error.stack || error); + process.exitCode = 1; +}); diff --git a/scripts/regression.js b/scripts/regression.js index b4b39ad..632a5b0 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -1613,6 +1613,91 @@ function assertFrontendMarkdownLinkContract() { assert(styleSource.includes('.msg-bubble a.local-file-link'), 'Local file links should have a distinct message style'); } +function assertMarkdownRenderingContract() { + const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); + const normalizeMarkdownText = require(path.join(PUBLIC_DIR, 'markdown-normalizer.js')); + const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime')); + const assistantRendererStart = source.indexOf('function renderAssistantContent'); + const assistantRendererEnd = source.indexOf('function createAssistantImageElement', assistantRendererStart); + const assistantRenderer = assistantRendererStart >= 0 && assistantRendererEnd > assistantRendererStart + ? source.slice(assistantRendererStart, assistantRendererEnd) + : ''; + + const indentedChineseText = ' 目前代码里 `PlanList` 的运行时和前端渲染逻辑仍然存在,问题更像是事件没有被识别。'; + assert( + normalizeMarkdownText(indentedChineseText).startsWith('目前代码里'), + 'Clearly indented Chinese prose should be normalized before Markdown parsing' + ); + assert( + normalizeMarkdownText(' 这是一段普通说明。\n\n 这是下一段普通说明。') === '这是一段普通说明。\n\n这是下一段普通说明。', + 'Multi-paragraph prose indentation should be removed independently' + ); + assert(normalizeMarkdownText('\t这是一段普通说明。') === '这是一段普通说明。', 'Tab-indented prose should also be normalized'); + const fencedCode = '说明文字。\n\n```js\n const value = 1;\n```'; + assert(normalizeMarkdownText(fencedCode) === fencedCode, 'Explicit fenced code blocks should remain byte-for-byte unchanged'); + const indentedCode = ' const value = 1;\n console.log(value);'; + assert(normalizeMarkdownText(indentedCode) === indentedCode, 'Indented source code should retain its indentation'); + const whitespaceOnly = '结论一。\n \n结论二。'; + assert(normalizeMarkdownText(whitespaceOnly) === whitespaceOnly, 'Whitespace-only lines should never be treated as prose indentation'); + assert( + assistantRenderer.includes("block.type === 'todo_list'") && assistantRenderer.includes('createTodoListElement(block)'), + 'Structured todo_list blocks should continue to render through the PlanList card path' + ); + assert( + indexSource.includes('markdown-normalizer.js') + && source.includes('typeof window.ccNormalizeMarkdownText === \'function\'') + && source.includes('return marked.parse(normalizedText)'), + 'Assistant Markdown rendering should load and apply the shared text normalizer' + ); + + const streamed = []; + const runtime = createCodexAppRuntime({ + wsSend: (_ws, payload) => streamed.push(payload), + loadSession: () => null, + saveSession: () => {}, + }); + const entry = { ws: {}, toolCalls: [], fullText: '' }; + runtime.processCodexAppNotification(entry, { + method: 'item/agentMessage/delta', + params: { itemId: 'msg-first', delta: '先给出的结论。' }, + }, 'markdown-session'); + runtime.processCodexAppNotification(entry, { + method: 'item/completed', + params: { item: { id: 'msg-first', type: 'agentMessage', text: '先给出的结论。' } }, + }, 'markdown-session'); + ['rs-noise-1', 'rs-noise-2', 'rs-noise-3'].forEach((itemId) => { + runtime.processCodexAppNotification(entry, { + method: 'item/agentMessage/delta', + params: { itemId, delta: ' ' }, + }, 'markdown-session'); + runtime.processCodexAppNotification(entry, { + method: 'item/completed', + params: { item: { id: itemId, type: 'agentMessage', text: ' ' } }, + }, 'markdown-session'); + }); + runtime.processCodexAppNotification(entry, { + method: 'item/agentMessage/delta', + params: { itemId: 'msg-second', delta: '普通正文不应带缩进。' }, + }, 'markdown-session'); + const lastLine = entry.fullText.split('\n').filter((line) => line.trim()).pop() || ''; + assert(lastLine === '普通正文不应带缩进。', 'Whitespace-only agent message chunks must not indent the following message text'); + assert(entry.fullText.startsWith('先给出的结论。'), 'Whitespace-only chunks must not create leading whitespace on the first message'); + assert( + !streamed.some((payload) => payload.type === 'text_delta' && !payload.text.trim()), + 'Whitespace-only chunks must not be streamed to the transcript' + ); + runtime.processCodexAppNotification(entry, { + method: 'item/agentMessage/delta', + params: { itemId: 'msg-code', delta: ' ' }, + }, 'markdown-session'); + runtime.processCodexAppNotification(entry, { + method: 'item/agentMessage/delta', + params: { itemId: 'msg-code', delta: 'const value = 1;' }, + }, 'markdown-session'); + assert(entry.fullText.includes(' const value = 1;'), 'Leading indentation should be retained when later chunks complete the same code message'); +} + function assertFrontendMcpReloadContract() { const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); assert(source.includes('function mcpStartupStatusToastText(status)'), 'Frontend should format MCP startup status toast text'); @@ -7162,6 +7247,12 @@ async function main() { console.log('Plan List progress regression checks passed.'); return; } + if (regressionTarget === 'markdown-rendering') { + assertMarkdownRenderingContract(); + assertPlanListProgressContract(); + console.log('Markdown rendering regression checks passed.'); + return; + } if (regressionTarget === 'runtime-image-send') { await runRuntimeImageSendTarget(); console.log('Runtime image send regression checks passed.'); @@ -7300,6 +7391,7 @@ async function main() { assertComposerSlashRoutingContract(); assertFrontendCcwebPromptContract(); assertFrontendMarkdownLinkContract(); + assertMarkdownRenderingContract(); assertMockCodexAppPromptUserNotTextTriggered(); assertFrontendMcpReloadContract(); assertCcwebMcpRecoveryContract(); diff --git a/server.js b/server.js index 7682a6d..230471c 100644 --- a/server.js +++ b/server.js @@ -8677,6 +8677,9 @@ async function judgeJavascriptSemanticBranch(lastMessage, candidates, cwd) { args: spec.args, env: spec.env, cwd: cwd || process.cwd(), + // 语义判断器是一次性辅助客户端;主 Codex App 已占用同一 CODEX_HOME + // 时快速失败,避免把一个不影响主会话的判断请求排队 30 秒。 + lockWaitMs: 1000, clientInfo: { name: 'ccweb_semantic_judge', title: 'CC-Web Semantic Judge', version: '1.0.0' }, postInitialize: codexAppPostInitialize, onNotification(notification) {