修复消息渲染并更新发布包
This commit is contained in:
3
.trellis/tasks/09-27-codex-start-lock-retry/check.jsonl
Normal file
3
.trellis/tasks/09-27-codex-start-lock-retry/check.jsonl
Normal file
@@ -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":"检查实现和回归测试是否满足后端质量要求。"}
|
||||
@@ -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":"新增跨进程锁与测试需要遵循后端质量和验证要求。"}
|
||||
35
.trellis/tasks/09-27-codex-start-lock-retry/prd.md
Normal file
35
.trellis/tasks/09-27-codex-start-lock-retry/prd.md
Normal file
@@ -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 启动层实现跨进程单实例锁。
|
||||
- 锁文件固定为 `<CODEX_HOME>/.cc-web-codex.lock`;锁目录按解析后的绝对 `CODEX_HOME` 隔离。
|
||||
- 锁持有范围从启动尝试前开始,覆盖 app-server 初始化成功后的整个子进程生命周期;仅在子进程收到 `exit`/`error`、初始化失败清理完成或显式 `stop()` 后释放。
|
||||
- 锁记录使用 JSON,至少包含 `pid`、Linux `/proc/<pid>/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 语法检查、单元测试和回归测试。
|
||||
26
.trellis/tasks/09-27-codex-start-lock-retry/task.json
Normal file
26
.trellis/tasks/09-27-codex-start-lock-retry/task.json
Normal file
@@ -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": {}
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 已停止。'));
|
||||
}
|
||||
|
||||
|
||||
189
lib/codex-home-lock.js
Normal file
189
lib/codex-home-lock.js
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -9023,7 +9023,12 @@
|
||||
|
||||
function renderMarkdown(text) {
|
||||
if (!text) return '<div class="typing-indicator"><span></span><span></span><span></span></div>';
|
||||
try { return marked.parse(text); }
|
||||
try {
|
||||
const normalizedText = typeof window.ccNormalizeMarkdownText === 'function'
|
||||
? window.ccNormalizeMarkdownText(text)
|
||||
: text;
|
||||
return marked.parse(normalizedText);
|
||||
}
|
||||
catch { return escapeHtml(text); }
|
||||
}
|
||||
|
||||
|
||||
@@ -425,6 +425,7 @@
|
||||
<script>window.ccWebFrontendAssetVersion = '__CC_WEB_FRONTEND_ASSET_VERSION__';</script>
|
||||
<script src="task-board.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__"></script>
|
||||
<script src="gitea-workflow.js?v=__CC_WEB_GITEA_WORKFLOW_ASSET_VERSION__"></script>
|
||||
<script src="markdown-normalizer.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__"></script>
|
||||
<script src="app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
88
public/markdown-normalizer.js
Normal file
88
public/markdown-normalizer.js
Normal file
@@ -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');
|
||||
};
|
||||
});
|
||||
126
scripts/codex-start-lock-unit.js
Normal file
126
scripts/codex-start-lock-unit.js
Normal file
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user