chore: rebuild release package and commit updates

This commit is contained in:
shiyue
2026-07-13 10:13:04 +08:00
parent dd466a69b5
commit 141a266f34
19 changed files with 1066 additions and 35 deletions

View File

@@ -0,0 +1,107 @@
# ccweb 频繁掉线诊断发现
## 当前状态2026-07-11 22:29 +08:00
- `ccweb` 当前为 `online`PID 1606383单实例 fork 模式。
- PM2 累计重启次数为 60说明不是一次性偶发。
- 当前进程 `pm_uptime=1783779949754`,约在 22:25:49 启动,距检查仅约 3 分半钟,符合用户所述“刚才掉线”。
- 当前进程内存约 76 MiB、CPU 3.1%;主机可用内存约 4.8 GiB、磁盘使用率 32%,检查时不存在资源耗尽。
- 主机已持续运行 119 天,说明刚才并非整机重启。
## 日志时间线
- 2026-07-11 22:25:49PM2 明确记录 `Stopping app:ccweb`,随后进程以 `code [0] via signal [SIGINT]` 正常退出,并在同一秒重新启动、恢复 online。
- 同日 11:21:10、12:25:37 也出现完全相同的“主动停止 → SIGINT/0 → 立即启动”序列。
- 近期大量记录都是该形态;这更像 `pm2 restart/reload/stop` 一类外部管理动作,不像应用崩溃或被系统 OOM Kill。
- `ccweb-out.log` 只看到重复的 `CC-Web server listening on 0.0.0.0:8002`,对应多次启动,没有本次异常堆栈。
- `ccweb-error.log` 最后修改时间是 2026-06-15其中确有一次 V8 堆达到约 2 GiB 后 OOM但它不是 7 月 11 日这次掉线的直接原因。
## 自动策略与系统反证
- PM2 配置:`watch=false``cron_restart=null``max_memory_restart=null`;因此本次不是文件监听、定时器或 PM2 内存阈值触发。
- 同一时间窗口的内核日志没有 OOM、Killed process 或段错误记录。
- 当前 8002 端口由 ccweb 进程正常监听,本地 HTTP 检查返回 200耗时约 2.6 ms。
- 项目源码、用户 crontab、系统 cron/timer 未发现自动执行 `pm2 restart/reload ccweb` 的配置;交互式 shell 历史也没有可归因的匹配记录。
- PM2 日志只记录了动作结果,不记录发起 restart 的客户端 PID/会话,因此现有日志能确认“外部管理动作”,但无法可靠归因到具体用户或具体对话。
- 检查时除当前对话外还有一个 English 项目的 codexapp 对话处于 running本轮没有执行服务重启。
## 频率与可归因证据
- 2026-07-01 至 2026-07-11 共发生 19 次 `Stopping app:ccweb`;逐次退出全部为 `code=0, signal=SIGINT`,没有一次属于异常退出。
- 分布7 月 1 日 3 次、2 日 6 次、3 日 1 次、5 日 4 次、7 日 2 次、11 日 3 次。
- Codex 本地会话记录能确认至少 7 月 7 日的两次重启确实由会话内显式执行 `pm2 restart ccweb --update-env` 触发,说明“开发/代理完成改动后手动重启”是真实存在的来源。
- 截至当前证据,尚未定位 7 月 11 日 22:25:49 这一次的具体发起会话。
## 初步结论(已被用户澄清推翻)
以下判断仅保留为调查过程记录,不是最终结论:
- 本次连接失败的直接原因是 ccweb 在 22:25:49 被外部 PM2 管理动作主动重启,单实例在停止/启动窗口内中断现有连接。
- “最近频繁掉线”的主因也是频繁主动重启而不是当前应用持续崩溃7 月以来已发生 19 次同类主动重启。
- 现有 PM2 日志缺少调用者审计,因此无法仅靠当前日志追溯 22:25 这一次是谁发起。
- 独立风险6 月 15 日曾发生 V8 堆 OOM且 PM2 主日志已约 157 MiB两者值得后续单独治理但与本次中断无直接因果关系。
## 用户澄清后的因果修正
- 用户确认:网页先打不开,随后由用户人工执行 PM2 重启恢复。
- 因此 22:25:49 的 `Stopping app` / `SIGINT` 只能证明恢复动作,不能解释故障起因。
- 现阶段真正待查的是:旧 PID 1163252 在退出前为何无法响应 HTTP由于重启前没有请求延迟、事件循环延迟、堆内存和活跃句柄快照历史日志证据存在明显缺口。
- `home-cc-web` 代码索引状态为 ready3072 节点、7430 条边),可继续按函数和调用链定位阻塞候选。
## 初步代码风险面
- 主服务是单 Node.js 事件循环;`server.js` 在请求和 WebSocket 热路径中存在大量同步文件系统调用。
- `plog` 每次记录都同步执行 `statSync`、可能的 rotate/unlink/rename`appendFileSync`;若日志路径所在文件系统短时阻塞,会拖住整个 HTTP 服务。
- `sendSessionList` 同步扫描会话目录并读取/解析会话元数据;会话数量或单文件体积增大时,可能形成明显事件循环停顿。
- `handleMessage` 同步读取并 base64 编码附件、同步创建输入/输出文件;大附件会放大停顿和堆占用。
- `wsSend` 直接在主线程 `JSON.stringify(data)`;历史 6 月 15 日 OOM 栈也落在 V8 `JsonStringify`,说明超大对象序列化是已发生过的真实风险,而非纯理论。
- 这些是“具备卡死能力”的候选路径;尚不能仅凭静态代码断定 22:25 具体命中了哪一条。
- `plog` 有 38 个调用方,属于广泛热路径;每次调用都同步触盘。
- `wsSend` 有 48 个调用方且统一同步序列化;只要某次 payload 意外携带超大对象,整个服务会在 `JSON.stringify` 期间停止处理新 HTTP 请求。
- `sendSessionList` 有 15 个调用方;每次都同步遍历所有会话文件,并逐文件 `statSync`,小于阈值时还会整文件 `readFileSync + JSON.parse + normalizeSession`
- 因此“网页整体打不开”更符合主事件循环被长任务/同步 I/O 占住,而不是单个 WebSocket 会话故障;但仍需用运行态日志与文件规模交叉验证。
- 当前代码已有单会话/消息截断上限:会话持久化默认 10 MiB、加载上限 32 MiB、列表元数据整文件解析阈值 512 KiBtool result 持久化默认截到 32 KiB。它们能降低风险但无法消除“很多会话逐个同步读取”或某个发送前对象尚未裁剪的阻塞。
- 在常见目录范围内暂未找到 `logs/process.log`;需要继续确认 `APP_DIR` 实际值。若结构化日志实际未落盘,正好解释了为什么本次只剩 PM2 生命周期日志而没有故障前业务事件。
- 已确认源码运行目录就是 `/home/cc-web`,结构化日志实际位于 `logs/process.log`;此前检索未命中属于检索结果异常,现已纠正。
- 当前 `sessions/` 有 112 个 JSON 会话文件,总体积约 64 MiB最大单文件约 4.09 MiB多份文件超过 1 MiB。
- 用与 `sendSessionList` 等价的同步读取策略做只读基准,一次扫描实测约 5.99 秒user 2.07 秒、sys 1.35 秒)。在这段时间内单线程 Node 无法响应 8002 上的任何 HTTP 请求。
- `sendSessionList` 又会被 turn complete、消息处理、终止、导入等至少 15 类路径触发;若短时间连续触发广播,会形成数秒级阻塞叠加,足以解释“网页完全打不开但 PM2 进程仍在线”。这是目前证据最强的根因候选。
- `process.old.log` 在 22:24:01 完成约 2 MiB 轮转,距离 22:25:49 人工重启约 1 分 48 秒;需要检查轮转前后事件是否出现会话列表广播/完成风暴。
- 22:1522:26 的结构化日志共有 1089 条 `codex_app_notification_unrouted`;其中 991 条是 `item/agentMessage/delta`,另有 32 条 item completed、30 条 item started。
- 这些通知集中指向同一个找不到路由的 app-server thread/turn频率约 12 条/秒,部分时刻同一毫秒多条。
- `handleCodexAppNotification` 对每一条未路由通知都会同步调用 `plog` 后返回;因此这 1089 条通知直接转化为 1089 次主线程 `statSync + appendFileSync`,并导致故障前日志轮转。
- 当前最可能的故障模型:孤儿/失路由 app-server 流式通知持续灌入,同步日志 I/O 占用事件循环;同时任何 session list 广播还会触发一次全量同步扫描。两者叠加时HTTP 请求长时间排队,网页表现为不可达。
- 仍需确认该 orphan thread 来自哪个会话,并复测扫描耗时以排除首次冷缓存/Node 启动成本夸大。
- 已确认 thread `019f5186…` 对应当时正在运行的 English 会话 `a21170ff…`,并有对应 22:13 启动的 Codex rollout它不是无主外部进程而是 ccweb 对一个真实活跃会话丢失了 runtime 路由。
- 热缓存下连续 5 次等价会话扫描为 188243 ms每次读取约 22 MiB因此先前约 5.99 秒结果包含 Node 冷启动/系统抖动,不能把单次会话扫描独立定为根因。
- 200 ms 级同步扫描仍会造成可感知卡顿,连续触发仍可叠加,但本次更直接的异常是“活跃 thread 持续产出通知ccweb 却无法路由”。
- 修正后的高概率链路:活跃会话 runtime 路由丢失 → 大量流式通知被判为 unrouted → 每条同步写日志并轮转;同时 UI 收不到该会话事件。是否足以让静态 HTTP 也超时,仍需结合路由查找复杂度和当时其它事件判断。
- 对应 thread 的未路由状态不是运行中途才丢失:首次出现在 22:13:44thread 启动/MCP startup 阶段),一直持续到 22:25:49 人工重启,共 1054 条,集中在同一个 turn。
- `findCodexAppRouteByRuntime` 本身只做 route 查找、一次未路由 turn 认领尝试和 child map 查询,不是高复杂度扫描;真正异常是 thread 从创建开始就没有被成功注册/认领到 `activeCodexAppTurns`
- 因而更准确的描述是“新活跃 thread 路由建立失败”,而不是“已建立路由后来丢失”。重启后的 recovery 能重新挂接该会话,解释了为什么重启会恢复。
- 未路由认领逻辑要求同时满足:方法在 adoptable 白名单、通知含 threadId+turnId、持久化会话文件已经能按 threadId 命中;任一条件不满足都会继续丢弃并写日志。
- 一旦认领成功,代码会写入 `activeCodexAppTurns`、持久化状态并记录 `codex_app_unrouted_turn_adopted`;故障窗口完全没有该事件,说明认领条件始终未满足。
- 当前会话文件只在父级 `collabAgentToolCall` 输入中引用该 child thread并不能通过 `getRuntimeSessionId` 匹配;后续确认这是原生子代理映射注册缺口,不是父会话 threadId 持久化竞态。
- 常规 route 查找会线性遍历 `activeCodexAppTurns` 两次threadId、turnId但活跃会话数量很小不足以解释整体失联。
- 已定位决定性放大器:`isCodexAppAdoptableRuntimeMethod` 明确把 `item/agentMessage/delta`、item started/completed 等高频通知列为可认领方法。
- 每条可认领但未路由通知都会进入 `findCodexAppSessionByThreadId`;该函数同步 `readdirSync` 全部会话文件,并逐个调用 `loadSession(sessionId)`,直到找到 thread 或扫描到底。
- 故障 thread 当时无法命中,因此 991 条 delta 基本都会扫描全部 112 个会话、约 64 MiB 数据。数量级约为 991 × 64 MiB ≈ 63 GiB 的同步读取/解析压力,集中在约 11 分钟内并占用 HTTP 主线程。
- 这条链路可以同时解释三个现象PM2 仍显示 online、结构化日志仍断续增长、网页却无法打开——进程没死只是事件循环被同步扫描持续压满。
- 目前只差核对 `loadSession` 的精确读取策略并做一次“thread miss”只读基准即可给出高置信结论。
- `loadSession` 确认通过 `safeReadSessionJson` 执行 `statSync + readFileSync + JSON.parse`,默认允许读取到 32 MiB当前所有 112 个会话文件都低于该上限,因此 miss 时会整批完整读取和解析。
- “thread miss”全量读取/JSON 解析的 5 次热缓存基准为 604840 ms每次实际读取 60.8 MiB这仍未包含生产代码的 `normalizeSession` 成本,因此是保守下界。
- 故障 thread 的 1054 条未路由通知覆盖约 725 秒,平均间隔约 0.69 秒,几乎等于一次全量扫描耗时。于是主线程形成连续循环:通知 → 60.8 MiB 同步扫描 → miss → 同步日志 → 下一通知。
- 按 1054 次估算,累计同步读取量约 62.6 GiB按实测下界累计阻塞时间约 637885 秒,与 725 秒故障窗口同量级。这足以高置信解释整个 HTTP 服务无响应。
- 根因已从“候选”提升为高置信:未路由高频通知触发无缓存、无退避的全量同步会话查找,压满 Node 事件循环。
- 故障 thread 的 rollout 元数据确认它是父 thread `019f514d…` 在 English 会话中通过 `spawn_agent` 启动的 depth=1 原生子代理,任务名为 `inspect_drill_tests`
- 子 thread ID 在父 ccweb 会话 JSON 中只出现在 `collabAgentToolCall``agentThreadId` 输入路径,不是父会话的 runtime session ID因此 `findCodexAppSessionByThreadId` 扫遍所有父会话也必然 miss。
- 子代理通知本应通过 `ccwebMcpChildThreads` 路由。该映射依赖父级 `collabAgentToolCall` 通知中的 `receiverThreadIds`同步函数在数组为空时直接返回。spawn 初期若还没有 receiverThreadIds而后续状态未及时补发/处理,子 thread 会永久没有映射。
- 最终根因链路:原生子代理 spawn 后 child thread 映射未及时建立 → child 高频 delta 无路由 → 每条 delta 错误进入父会话全量同步查找 → 60.8 MiB/次、约 0.60.84 秒/次 → Node 主事件循环被持续占满 → 网页打不开;人工重启通过 recovery 重建状态后恢复。
## 最终结论
- 本次并非进程崩溃或系统 OOM而是 Node 主事件循环被同步工作持续占满;用户的 PM2 重启是恢复动作。
- 触发源是 English 会话通过 `spawn_agent` 创建的子代理 `inspect_drill_tests`。child thread 映射未建立1054 条子代理通知从 22:13:44 起持续走未路由回退。
- 回退逻辑对每条高频 delta 同步扫描 112 个会话文件并完整读取/解析 60.8 MiB单次保守基准 604840 ms累计约 62.6 GiB、637885 秒阻塞,和 725 秒故障窗口吻合。
- 重启后 adoptable 未路由通知已归零;剩余 98 条仅为 rate-limit/status 类非认领通知不再触发全量会话扫描。22:45 检查服务 onlineHTTP 200响应约 2.5 ms。
- 修复应同时覆盖三层spawn 时可靠登记 child thread 路由;未路由查找改为内存索引/负缓存并禁止每 delta 全盘同步扫描;未路由日志做聚合限频和异步写入。

View File

@@ -0,0 +1,22 @@
# ccweb 频繁掉线诊断进度
- 2026-07-11开始只读诊断已确认不执行重启或配置修改。
- 2026-07-11 22:29完成当前状态采样发现 ccweb 刚在约 22:25:49 重启PM2 累计重启 60 次。
- 2026-07-11 22:34完成 PM2 与应用日志初查;本次为外部触发的正常 SIGINT 重启,不是异常崩溃。
- 2026-07-11 22:39排除 watch、cron、PM2 内存阈值、内核 OOM 和端口故障;未发现自动重启脚本或定时任务。
- 2026-07-11 22:43统计 7 月以来 19 次均为 SIGINT/0 的主动重启;历史会话记录证实开发代理曾显式执行重启命令。
- 2026-07-11 22:46完成一分钟窗口会话审计与结论归纳本轮未重启、未修改服务配置或业务代码。
- 2026-07-11用户澄清重启是故障后的人工恢复已撤回“主动重启导致本次掉线”的因果判断开始追查重启前无响应。
- 2026-07-11确认历史可观测数据不足以直接还原旧进程状态代码索引可用进入阻塞路径分析。
- 2026-07-11发现主线程同步日志、同步会话扫描/解析、同步附件处理和 WebSocket 大对象序列化等阻塞候选。
- 2026-07-11函数级调用面确认同步日志、全量会话列表和统一 WebSocket 序列化均处于高扇入路径。
- 2026-07-11核对会话体积阈值初次常见目录检索未发现结构化 process.log继续确认运行数据目录。
- 2026-07-11确认 112 个会话共 64 MiB同步会话扫描一次约 5.99 秒;发现日志在故障前约 1 分 48 秒轮转。
- 2026-07-11故障窗口确认 1089 条未路由通知风暴991 条为 agentMessage delta每条都会同步写日志。
- 2026-07-11thread 归因到活跃 English 会话;热缓存会话扫描约 188243 ms修正此前 5.99 秒的过度归因。
- 2026-07-11确认 thread 自 22:13:44 创建起即未成功建立路由,直到 22:25:49 重启;排除路由查找本身的复杂度问题。
- 2026-07-11确认未路由认领依赖持久化 threadId 与 adoptable 方法;故障期间从未认领成功,竞态/worker 注册遗漏成为首要代码缺陷候选。
- 2026-07-11定位高频 delta 的全量同步会话扫描放大器;估算故障窗口产生约 63 GiB 读取/解析压力,能够解释整站无响应。
- 2026-07-11精确确认 loadSession 为完整同步读取/解析miss 基准 604840 ms累计阻塞时间与整个故障窗口同量级根因达到高置信。
- 2026-07-11确认故障 thread 是原生 spawn_agent 子代理;定位 child 映射注册缺口与错误回退到父会话全量扫描的完整因果链。
- 2026-07-11 22:45复核重启后无 adoptable 未路由风暴,服务 HTTP 200/约 2.5 ms完成修正后的最终结论。本轮未改业务代码、未改配置、未重启。

View File

@@ -0,0 +1,26 @@
# ccweb 频繁掉线诊断计划
## 目标
只读排查 ccweb 最近连接失败和频繁掉线的原因,形成可验证的时间线、根因判断与风险说明;不重启、不改配置、不改业务代码。用户已澄清 22:25 的重启是故障后的人工恢复动作,因此继续追查重启前网页无响应的原因。
## 阶段
- [完成] 1. 确认服务当前状态与最近重启次数
- [完成] 2. 分析 ccweb 错误日志与退出时间线
- [完成] 3. 核对系统资源、OOM 与端口/依赖异常
- [完成] 4. 归纳根因、证据与处置优先级
- [完成] 5. 修正因果关系并核对重启前可观测证据
- [完成] 6. 定位可能阻塞 HTTP/事件循环的代码路径
- [完成] 7. 对照会话与子进程活动评估高概率诱因
- [完成] 8. 输出修正后的结论与可验证的监控缺口
## 约束
- 不执行 `pm2 restart`
- 不修改运行配置或项目代码。
- 日志中的敏感信息不在答复中展开。
## 错误记录
- 初次结论误把人工重启当作掉线原因;用户澄清后已修正,重启仅是恢复动作。

View File

@@ -0,0 +1,50 @@
# 调查结论
## 2026-07-12 根因证据
- `ccweb` PID 1606383 持续占满单个 CPU 核心RSS 约 130 MiB系统可用内存约 4.4 GiB。
- `curl http://127.0.0.1:8002/` 能立即建立 TCP但 810 秒无响应,符合事件循环阻塞而非端口未监听。
- `logs/process.log` 每约 0.6 秒出现 `codex_app_notification_unrouted`,来源主要是三个 Codex App threadId。
- 三个 threadId 均存在于当前 `sessions/*-run/codexapp-state.json`,却未恢复进内存路由映射。
- `findCodexAppRouteByRuntime` 在内存命中失败后调用 `adoptCodexAppUnroutedTurn`
- `findCodexAppSessionByThreadId` 对每条未路由通知执行 `readdirSync(SESSIONS_DIR)`,再逐个 `loadSession`;当前 113 个会话 JSON 合计约 61.3 MiB。
- 高频 delta × 同步全量扫描导致 Node 主线程单核 100%,所有 HTTP 请求饥饿。
- PM2 error log 中的 V8 OOM 修改时间为 2026-06-15是历史故障不是当前直接原因。
- 当未路由子线程通知停止后,未部署补丁的旧进程 CPU 从单核 100% 降至 02%,本地首页恢复为 HTTP 200/约 2.3 ms这证明故障与通知风暴强相关而非持续内存不足或端口问题。
## 工作区约束
- 用户已有修改:`public/app.js``public/style.css``scripts/regression.js`
- 现有未跟踪计划/任务目录属于其他工作,不得覆盖或删除。
- `.trellis/.current-task` 当前指向 `00-bootstrap-guidelines`,另有 `07-11-subagent-card-metadata` 进行中;不得切换共享 current-task以免干扰并发会话。
- 项目要求重启前检查运行会话;只有除当前会话外没有其他 running 会话时才能重启。
- 当前磁盘运行态检查只发现本对话 `4b9700f7-...` 的一个 `codexapp-state.json`,状态为 `running`;没有发现其他会话 run 目录或 classic PID 文件。最终重启前仍需再次复核。
- 重启前通过运行中 ccweb 的内部 MCP `ccweb_list_conversations` 检查:仅当前对话为 `running`,其余全部为 `idle`,满足项目重启条件。
## 部署验证
- 执行 `pm2 restart ccweb --update-env` 成功,新 PID 为 1802039PM2 restart 计数从 60 增至 61。
- 启动日志出现 `ccweb_mcp_child_threads_recovered``restored=3`,证明真实恢复文件中的三个子线程路由被重建。
- 连续 5 次本地 HTTP 探针均返回 200后续最终探针约 3.6 ms。
- 新进程 RSS 约 64 MiB事件循环 P95 约 1.5 ms短时 CPU 回落至 012%,没有持续占满单核。
- 重启后的日志窗口仅包含 recovery/server_start 事件,没有新的 `codex_app_notification_unrouted` 风暴。
## 项目上下文
- Trellis 将项目识别为单仓库,规范层为 backend/frontend本次只涉及 backend。
- codebase-memory 项目 `home-cc-web` 索引状态为 `ready`,当前 3091 个节点、7446 条边。
- 独立计划审查已通过;建议在实施记录中明确测试入口与重启检查。
- 项目测试入口为 `npm run regression`(实际执行 `node scripts/regression.js`),服务入口为 `node server.js`
- backend 规范目录当前仍是模板状态,没有额外项目特定限制;本次重点遵守现有结构化 `plog`、同步 I/O 热路径规避和回归脚本惯例。
- 预计修改位置:`recoverCodexAppTurnState``findCodexAppSessionByThreadId``adoptCodexAppUnroutedTurn``handleCodexAppNotification`,以及独立/现有回归脚本。
- 当前 `codexapp-state.json` 只持久化父线程 entry子线程 ID 仅间接存在于 `toolCalls[*].input.agentThreadId` 等协作工具数据中,`recoverCodexAppTurnState` 没有重建 `ccwebMcpChildThreads`
- `findCodexAppRouteByRuntime` 当前先调用父线程磁盘收养逻辑,再检查 `ccwebMcpChildThreads`;对子线程通知会先触发昂贵的父会话全盘扫描,路由顺序本身也需要调整。
- 现有 `scripts/regression.js` 含用户未提交的“子代理卡片元数据”测试,修复必须追加且保留这些改动。
- 真实恢复文件中,协作子线程以 `toolCalls[].name/kind = subAgentActivity` 持久化;`input.agentThreadId` 是路由键,`input.agentPath` 可作为恢复标签,`input.kind` 表示 started/interacted 等活动。
## HAPI 对比
- HAPI 的“归档”主要是会话生命周期操作:将 active session 断开并把 `metadata.lifecycleState` 设为 `archived`,支持后续 reopen它不是专门为解决 ccweb 本次每条通知全盘扫描而设计。
- HAPI 的归档确实能减少活动会话集合和实时订阅压力,但历史记录仍保留在存储/缓存层,因此只有配合索引化 SessionCache/SyncEngine 才能避免热路径扫描。
- 对 ccweb 而言,未来增加归档有产品和容量管理价值,但不能替代当前 threadId 路由索引、恢复映射和负缓存修复。
- 源码确认 HAPI `archiveSession` 调用 `rpcGateway.killSession` 后进入 `handleSessionEnd`Hub 存储基于 `bun:sqlite``SessionCache` 维护内存态并以 `active` 作为权威运行标志。

View File

@@ -0,0 +1,27 @@
# ccweb 未路由通知阻塞修复需求
## 背景
ccweb 重启后,仍在运行的 Codex App 协作子线程继续发送通知,但 `ccwebMcpChildThreads` 是进程内 Map没有从 `codexapp-state.json` 恢复。通知无法命中路由,随后对每条 delta 同步遍历并解析全部会话 JSON导致 Node 主线程单核 100% 和 HTTP 524。
## 必须实现
1. 恢复父会话状态时,从已持久化的协作 toolCalls 中重建足够的子线程路由信息,至少包含 child threadId、parentSessionId、parentThreadId、spawnToolId、状态和可用标签。
2. 子线程内存路由必须在父线程磁盘收养之前判断,避免已知子线程也触发全盘扫描。
3. 对真正未知的 threadId 增加有界负缓存,使高频 delta 在 TTL 内最多触发一次磁盘查找。
4. `codex_app_notification_unrouted` 日志按 threadId/method 节流,保留诊断能力但不逐条写盘。
5. 会话创建、更新、删除后应正确维护 threadId 索引或使缓存失效,不产生长期错误路由。
6. 保留现有行为:父线程未路由通知仍可被收养;新产生的 collabAgentToolCall 仍可注册和更新子线程。
## 测试要求
- 先添加在旧实现上失败的回归断言,再实现最小修复。
- 覆盖恢复态子线程映射重建、子线程优先路由、未知线程负缓存和日志节流。
- 运行定向测试、`node --check server.js``npm run regression`
- 测试总超时不超过 60 秒。
## 约束
- 不覆盖 `public/app.js``public/style.css``scripts/regression.js` 中的用户现有改动。
- 不通过增大堆上限或单纯重启掩盖根因。
- 代码注释使用简体中文;日志不得包含敏感数据。

View File

@@ -0,0 +1,32 @@
# 进度日志
## 2026-07-12
- 完成当前服务状态复核:确认 HTTP 超时、单核 100%、内存正常。
- 确认未路由通知来自恢复态 Codex App 子线程。
- 建立持久化修复计划、调查记录和验收标准。
- 检查 Trellis 状态;由于共享 current-task 正被其他任务使用,决定不切换该全局指针。
- 独立计划审查通过;根因、范围、验收标准与并发约束已固化。
- 明确测试入口 `npm run regression`codebase-memory 后续查询因传输关闭降级为本地精确读取。
- 确认恢复状态没有直接持久化/重建子线程映射,且当前路由顺序会让子线程通知先走父线程磁盘扫描。
- 初步运行会话检查只发现当前父会话处于 running等待实现代理完成后将在重启前再次检查。
- 首轮实现代理因检索耗时被中止;第二轮已追加失败回归契约并开始最小实现。
- 对比 HAPI归档是生命周期/活动集合管理能力,可降低长期压力但不是本次热路径问题的直接修复。
- 实现代理已写入 `server.js` 核心补丁和回归契约;正在收口语法与定向验证。
- 旧进程在通知风暴结束后暂时恢复 HTTP 200但补丁尚未部署仍需完成审查和重启验证。
- 独立 check 代理已追加审查修正,等待其验证回报。
- check 代理未及时结束,已中止;主线程复核确认负缓存与日志节流 Map 均有 1000 项上限和 30 秒 TTL/节流窗口。
- 当前阶段:重新运行定向回归并处理问题。
- 定向契约复跑通过,`server.js` 语法检查通过。
- 当前阶段:执行完整回归与静态检查。
- `timeout 60s npm run regression` 完整回归通过。
- 当前阶段:最终差异审查与运行风险确认。
- 重启前会话列表确认仅当前对话 running其余全部 idle。
- 执行 PM2 重启成功,新进程恢复 3 个子线程路由。
- 连续 HTTP 探针均为 200最终响应约 3.6 msCPU/内存/事件循环正常。
- 重启后未发现新的未路由通知风暴,任务完成。
- 回归契约已追加;旧实现缺少契约要求的索引、恢复与节流符号,必然失败。
- 实现代理写入补丁后未及时结束,已中止并由主线程接管验证。
- `node --check server.js` 通过。
- `node scripts/regression.js --target codexapp-unrouted-routing` 通过。
- 当前阶段:冷审查负缓存、日志节流和恢复一致性。

View File

@@ -0,0 +1,72 @@
# ccweb 未路由通知阻塞修复计划
## 目标
修复 Codex App 子线程通知在进程恢复后无法路由、反复同步扫描全部会话文件并占满 Node.js 主线程的问题,恢复 ccweb HTTP 可用性并防止复发。
## 阶段
### Phase 1建立任务上下文并固化根因与验收标准
**Status:** complete
### Phase 2补充可复现的失败回归测试
**Status:** complete
### Phase 3实现线程路由索引与恢复逻辑
**Status:** complete
### Phase 4限制未路由通知的磁盘扫描与日志风暴
**Status:** complete
### Phase 5运行定向回归测试并修正问题
**Status:** complete
### Phase 6执行完整回归与静态检查
**Status:** complete
### Phase 7审查变更与运行风险
**Status:** complete
### Phase 8安全重启并验证本地/外部访问
**Status:** complete
## 验收标准
- 恢复后,父线程及子线程通知均能通过内存索引命中,不对每个 delta 同步扫描全部会话 JSON。
- 未知 threadId 的高频通知采用有界、缓存或节流策略,不造成日志风暴和单核持续 100%。
- 回归测试覆盖恢复态子线程路由、未知线程负缓存/节流和现有通知路由行为。
- 定向回归、完整回归和语法检查通过。
- 重启前确认除当前会话外没有其他 running 会话;满足条件才重启。
- 重启后本地 8002 健康响应CPU 回落且日志不再持续刷未路由通知。
## 决策
- 不通过增大 Node.js 堆上限掩盖问题。
- 优先建立 O(1) threadId 路由索引,并在恢复 `codexapp-state.json` 时重建子线程映射。
- 为真正未知的通知增加负缓存/节流,避免重复全量磁盘扫描与逐条日志。
- 保留用户已有未提交改动,不覆盖无关文件。
## 错误记录
| 错误 | 尝试 | 处理 |
|---|---:|---|
| `strace` 附加进程被系统拒绝 | 1 | 改用实时 CPU、HTTP 探针、日志频率和代码调用链交叉定位 |
| codebase-memory `search_graph` 返回 `Transport closed` | 1 | 索引状态此前为 ready但运行时传输关闭按项目降级规则改用已确认的 qualified_name 与 `rg`/`sed` 精确读取 |
| 更新计划文件时上下文定位失败 | 1 | 重新读取当前文件结构后使用更精确的补丁上下文 |
| 首个实现代理在检索阶段长时间无补丁 | 1 | 中止该轮后复用同一代理,禁止 MCP提供精确状态形状并收敛为本地 TDD 补丁任务 |
| 第二轮实现代理写入补丁后未及时结束回报 | 1 | 中止代理并由主线程接管语法、定向与完整回归验证 |
| 首次读取运行中 app-server 的本地 MCP token 使用了错误环境变量名 | 1 | 只列出环境变量键名确认实际为 `CC_WEB_CODEX_APP_MCP_TOKEN`,未输出 token 值,并成功完成会话状态检查 |
| planning-with-files 完成检查首次报告 `0/0 phases` | 1 | 计划阶段原为中文编号列表;已改为检查脚本可识别的标准阶段标题与状态字段 |
## 计划审查
- 独立计划审查代理已通过,无阻断性问题。
- 执行时显式记录测试命令、预计修改位置和重启前运行会话检查结果。

View File

@@ -0,0 +1,17 @@
# 发现记录
- 前端 `collabAgentStateEntries` 已读取 `label/title/name``description/summary/message`
- 当前渲染层仅把任务提示词和结果简介写入 DOM `title`,卡片正文没有显示简介。
- 真实 Codex App `spawnAgent` 记录通常提供 `prompt`,但 `agentsStates` 在启动时为空,完成时 `name` 常为线程 ID。
- 当前 `mergeCollabAgentTools` 只保留一个全局 `prompt`,需要把各次 spawn 的 prompt 写入对应代理状态。
- 工作树开始时干净;已有 `.planning/codex-app-worker-timeout` 和根目录规划文件,不能覆盖。
- Trellis 前端规范目前大多是模板,因此实施以现有 `public/app.js` / `public/style.css` 约定和回归测试为主要依据。
- 原 Trellis 当前任务是 `00-bootstrap-guidelines`,本任务结束后需要恢复。
- 样式集中在 `public/style.css``.collab-agent-*` 区块;需补齐 `min-width: 0` / `max-width: 100%` 收缩链,并为新增简介采用两行 clamp。
- 回归入口是 `scripts/regression.js` 的 Codex App 子代理端到端段;现有测试验证事件链但未覆盖标题/简介展示语义。
- mock 已提供两个子代理及不同 `name/role/summary`,可扩展为通用名称 + 不同 prompt验证前端合并不串任务上下文。
- 计划复审通过;实现范围保持前端与回归测试,不改后端协议、不重启服务。
- 独立质量检查发现 UUID 正则仅覆盖 v1-v5会漏掉 UUID v7需要改为通用 UUID 形状判断。
- 静态源码契约不足以证明多 spawn 行为;需要提取并执行实际纯函数源码,覆盖独立 prompt 与 wait/close 保留。
- 后续状态会携带归一化空标题字段;合并时只有新状态含可读标题才允许覆盖旧标题。
- 归一化 `entry.label` 可能由 prompt 派生,必须携带 `hasReadableSourceTitle` 标记区分协议标题和派生标题。

View File

@@ -0,0 +1,16 @@
# 进度记录
- 2026-07-11完成现状定位与真实会话载荷核验。
- 2026-07-11确定紧凑双层卡片方向开始计划审查。
- 2026-07-11创建 Trellis 任务、PRD并配置实现/检查上下文。
- 2026-07-11首次计划审查发现标题优先级、空 prompt、悬浮信息共存和范围约束缺口已修订并提交复审。
- 2026-07-11计划复审通过进入回归断言设计。
- 2026-07-11并行完成样式和回归入口研究准备交给 Trellis 实现代理测试先行落地。
- 2026-07-11实现标题选择、每代理 taskDescription、可见简介节点与窄屏样式进入独立验证。
- 2026-07-11完整回归通过独立检查提出 UUID v7 与行为测试覆盖问题,进入修正循环。
- 2026-07-11行为测试首次运行被多行源码契约误报阻断已改用跨行正则。
- 2026-07-11复核发现状态更新覆盖可读标题边界已改为条件更新并补回归。
- 2026-07-11进一步区分协议原生标题与 prompt 派生标题,避免派生 label 覆盖稳定协议标题。
- 2026-07-11语法、diff、完整 regression 和独立复核全部通过;进入交付清理。
- 2026-07-11最终 diff 与行号核验完成。实时服务在线但两次 5 秒请求无响应,环境无 Playwright/Chromium因此未截图且未重启服务。
- 2026-07-11Trellis 规范沉淀判断:本次为局部子代理卡片行为,核心边界已固化在行为回归中,没有形成跨模块通用约定,不更新 `.trellis/spec/`

View File

@@ -0,0 +1,78 @@
# 子代理标题与简介展示计划
## 目标
在协作子代理卡片中稳定展示可读标题和任务简介,并确保多子代理合并、运行中与完成态都不丢失各自的任务上下文。
## 视觉与交互方向
- 视觉主张:延续现有深色紧凑工具卡,以清晰文字层级替代额外装饰。
- 内容计划:第一行标题与状态,第二层展示两行任务简介,角色作为低权重辅助信息,结果继续保留在悬浮详情中。
- 交互主张:保留卡片点击复制线程 ID、关闭按钮状态反馈和现有折叠行为不新增装饰性动效。
## 当前阶段
已完成
## 阶段
### Phase 1: 建立实施计划并完成独立审查
- **Status:** complete
### Phase 2: 补充标题与简介的数据归一化回归断言
- **Status:** complete
### Phase 3: 实现每个子代理独立的标题与简介合并
- **Status:** complete
### Phase 4: 实现紧凑双层卡片渲染与样式
- **Status:** complete
### Phase 5: 运行语法检查与自动化回归测试
- **Status:** complete
### Phase 6: 检查真实页面效果并完成交付清理
- **Status:** complete
## 完成标准
-`label → title → nickname → name` 选择第一个非通用、非线程 ID 的协议标题。
- 仅有线程 ID 或 `子代理` / `子代理 N` 等通用名称时,从该子代理自己的任务提示词生成稳定标题。
- 每个子代理保留自己的简介,多子代理合并不会共用第一个提示词。
- 无 prompt 时回退为短线程 ID空字段不会导致异常。
- 简介最多显示两行,完整简介挂在简介 DOM 的悬浮提示;运行结果保留在卡片容器的独立悬浮详情,两者不互相覆盖。
- 现有状态、关闭、复制线程 ID 功能不回退。
- 窄屏下标题、状态和关闭按钮不横向溢出。
- 相关回归测试通过。
## 回归场景
1. 可读协议标题按字段优先级展示。
2. 通用标题从各自 prompt 提炼。
3. 多代理不同 prompt 的标题和简介不串联。
4. 无 prompt 时回退短线程 ID。
## 范围约束
- 不新增依赖。
- 不改变后端协议。
- 不重启服务。
## 错误记录
| 错误 | 尝试 | 处理 |
|---|---:|---|
| 暂无 | 0 | - |
| UUID v7 未被识别为线程式标题 | 1 | 扩展为通用 UUID 形状判断并补行为测试 |
| 回归仅验证源码字符串契约 | 1 | 提取实际纯函数源码执行多 spawn / wait / close 用例 |
| 新增集成契约按单行字符串匹配多行调用 | 1 | 改为允许空白与换行的正则匹配 |
| 后续空标题字段覆盖首次可读协议标题 | 1 | 合并时无可读新标题则保留旧标题字段,并补行为回归 |
| 多文件补丁缺少合法 hunk 边界 | 1 | 拆分为独立小补丁应用 |
| prompt 派生 label 被误判为协议标题 | 1 | 归一化阶段携带标题来源标记并补行为回归 |
| 规划文档阶段格式未被完成检查器识别 | 1 | 改为标准 Phase 三级标题与完成状态字段格式 |

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"核对前端质量与回归验证"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"核对 UI 结构一致性"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"核对是否避免重复逻辑"}

View File

@@ -0,0 +1,3 @@
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"遵循前端质量与验证约定"}
{"file":".trellis/spec/frontend/component-guidelines.md","reason":"保持现有 UI 组件组织方式"}
{"file":".trellis/spec/guides/code-reuse-thinking-guide.md","reason":"复用现有归一化与渲染辅助函数"}

View File

@@ -0,0 +1,39 @@
# 子代理标题与简介展示
## 背景
当前协作子代理卡片经常只显示“子代理”或线程 ID。真实 Codex App 事件在启动阶段通常只提供任务 `prompt``agentsStates` 为空;完成阶段的 `name` 也可能仍是线程 ID。前端虽然读取了标题和详情字段但简介只存在于悬浮提示中。
## 目标
让每个子代理卡片在运行中和完成后都显示稳定、可扫描的任务标题与简介。
## 功能要求
1. 标题优先使用非通用、非线程 ID 的 `label/title/nickname/name`
2. 没有可读标题时,从该子代理的任务提示词生成稳定标题;不得显示完整 UUID 作为标题。
3. 简介优先保留该子代理的任务提示词摘要,限制为两行;完整简介通过 `title` 属性可查看。
4. 多次 `spawn_agent/spawnAgent` 合并时,每个子代理必须保存自己的标题和简介,不能全部复用第一个 prompt。
5. 运行结果继续作为悬浮详情提供,不覆盖任务简介。
6. 保持现有状态标签、关闭按钮、点击复制线程 ID、折叠和聚合行为。
## 视觉要求
- 延续现有深色、紧凑的工具调用样式。
- 标题是卡片主信息;简介使用较低对比度与较小字号,最多两行。
- 角色信息仍为辅助信息,不增加新的装饰性图标、渐变或动画。
- 窄屏下标题、状态和关闭按钮不得造成横向溢出。
## 验收标准
- 有可读协议标题时原样显示。
- 标题等于线程 ID、`子代理``子代理 N` 时,能从对应 prompt 得到可读标题。
- 两个不同 prompt 的子代理合并后展示不同标题和简介。
- 简介 DOM 可见且应用两行截断样式。
- 无 prompt 时仍能回退到短线程 ID不报错。
- JavaScript 语法检查及项目回归测试通过。
## 范围
- 允许修改:`public/app.js``public/style.css`、相关回归测试与 mock。
- 不新增依赖,不改变后端协议,不重启服务。

View File

@@ -0,0 +1,26 @@
{
"id": "subagent-card-metadata",
"name": "subagent-card-metadata",
"title": "子代理标题与简介展示",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "shiyue",
"assignee": "shiyue",
"createdAt": "2026-07-11",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -6470,12 +6470,111 @@
}
}
function cleanCollabAgentText(value) {
return value == null ? '' : String(value).trim().replace(/\s+/g, ' ');
}
function summarizePrompt(prompt) {
const text = typeof prompt === 'string' ? prompt.trim().replace(/\s+/g, ' ') : '';
const text = cleanCollabAgentText(prompt);
if (!text) return '';
return text.length > 140 ? `${text.slice(0, 140)}` : text;
}
function isThreadLikeCollabAgentLabel(label, id) {
const value = cleanCollabAgentText(label);
if (!value) return false;
if (id && value === String(id)) return true;
const shortId = id ? shortChildAgentId(id) : '';
if (shortId && (value === shortId || value === `ID ${shortId}`)) return true;
const compact = value.toLowerCase();
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(compact)) return true;
if (/^(thread|child-thread|agent-thread|codex-thread)[-_:][a-z0-9][a-z0-9_.:-]{5,}$/i.test(value)) return true;
return !/\s/.test(value) && value.length >= 18 && /thread/i.test(value);
}
function isGenericCollabAgentLabel(label, id) {
const value = cleanCollabAgentText(label);
if (!value) return true;
if (/^子代理\s*\d*$/i.test(value)) return true;
if (/^(sub[-_\s]*agent|agent)\s*\d*$/i.test(value)) return true;
return isThreadLikeCollabAgentLabel(value, id);
}
function collabAgentTitleFromPrompt(prompt) {
const text = cleanCollabAgentText(prompt)
.replace(/^#+\s*/, '')
.replace(/^(任务|目标|请|请你|请帮我|帮我|负责|实现|处理)[:,\s]*/i, '')
.trim();
if (!text) return '';
const firstSegment = text.split(/[。.!?;:\n]/).find(Boolean) || text;
const title = firstSegment.trim();
if (!title) return '';
return title.length > 36 ? `${title.slice(0, 36)}` : title;
}
function collabAgentTaskDescription(state = {}, fallbackPrompt = '') {
return cleanCollabAgentText(
state.taskDescription
|| state.task_description
|| state.taskPrompt
|| state.task_prompt
|| state.prompt
|| state.inputPrompt
|| state.input_prompt
|| fallbackPrompt
);
}
function pickCollabAgentTitle(state, id, index) {
const taskDescription = cleanCollabAgentText(state?.taskDescription || state?.prompt || '');
const titleCandidates = [
state.label,
state.title,
state.nickname,
state.name,
];
for (const value of titleCandidates) {
const candidate = cleanCollabAgentText(value);
if (candidate && !isGenericCollabAgentLabel(candidate, id)) return candidate;
}
const promptTitle = collabAgentTitleFromPrompt(taskDescription);
if (promptTitle) return promptTitle;
return id ? `ID ${shortChildAgentId(id)}` : `子代理 ${index + 1}`;
}
function hasReadableCollabAgentTitle(state = {}, id = '') {
return [state.label, state.title, state.nickname, state.name].some((value) => {
const candidate = cleanCollabAgentText(value);
return candidate && !isGenericCollabAgentLabel(candidate, id);
});
}
function mergeCollabAgentTaskState(previousState = {}, incomingState = {}, fallbackPrompt = '', id = '', index = 0) {
const taskDescription = collabAgentTaskDescription(incomingState, fallbackPrompt)
|| cleanCollabAgentText(previousState.taskDescription);
const nextState = {
...previousState,
...incomingState,
taskDescription,
};
const incomingHasReadableSourceTitle = incomingState.hasReadableSourceTitle == null
? hasReadableCollabAgentTitle(incomingState, id)
: incomingState.hasReadableSourceTitle === true;
if (incomingHasReadableSourceTitle) {
nextState.label = pickCollabAgentTitle(incomingState, id, index);
} else {
['label', 'title', 'nickname', 'name'].forEach((key) => {
const previousValue = cleanCollabAgentText(previousState[key]);
if (previousValue && !isGenericCollabAgentLabel(previousValue, id)) nextState[key] = previousValue;
});
}
return {
...nextState,
label: pickCollabAgentTitle(nextState, id, index),
taskDescription,
};
}
function normalizeCollabAgentAction(value) {
const raw = String(value || '').trim();
if (!raw) return '';
@@ -6521,12 +6620,24 @@
if (!states || typeof states !== 'object') return [];
return Object.entries(states).map(([id, value], index) => {
const state = value && typeof value === 'object' ? value : { status: value };
const label = String(state.label || state.title || state.nickname || state.name || `子代理 ${index + 1}`);
const role = String(state.role || state.agent || state.agentType || '').trim();
let status = String(state.status || state.state || 'pending').trim() || 'pending';
const taskDescription = collabAgentTaskDescription(state, data?.prompt || '');
const label = pickCollabAgentTitle({ ...state, taskDescription }, id, index);
const role = cleanCollabAgentText(state.role || state.agent || state.agentType || '');
let status = cleanCollabAgentText(state.status || state.state || 'pending') || 'pending';
if (state.closedAt && collabStateTone(status) !== 'closed') status = 'closed';
const detail = String(state.candidateResult || state.finalMessage || state.summary || state.message || state.lastMessage || state.step || state.description || '').trim();
return { id, label, role, status, detail };
const detail = cleanCollabAgentText(state.candidateResult || state.finalMessage || state.result || state.output || state.summary || state.message || state.lastMessage || state.step || state.description || '');
return {
id,
label,
title: cleanCollabAgentText(state.title || ''),
nickname: cleanCollabAgentText(state.nickname || ''),
name: cleanCollabAgentText(state.name || ''),
role,
status,
detail,
taskDescription,
hasReadableSourceTitle: hasReadableCollabAgentTitle(state, id),
};
});
}
@@ -6604,13 +6715,6 @@
getClosedCollabAgentIdsFromTool(tool).forEach((id) => closedCollabAgentIds.add(id));
}
function isGenericCollabAgentLabel(label, id) {
const value = String(label || '').trim();
if (!value) return true;
if (/^子代理\s*\d+$/i.test(value)) return true;
return !!id && value === String(id);
}
function mergeCollabAgentTools(tools, options = {}) {
const list = Array.isArray(tools) ? tools.filter((tool) => toolKind(tool) === 'collab_agent_tool_call') : [];
if (list.length === 0) return null;
@@ -6639,9 +6743,15 @@
collabAgentStateEntries(data).forEach((entry) => {
if (!entry.id) return;
const nextState = mergeCollabAgentTaskState(
states[entry.id],
entry,
data.prompt,
entry.id,
receiverThreadIds.indexOf(entry.id)
);
states[entry.id] = {
...(states[entry.id] || {}),
...entry,
...nextState,
status: isCloseAction || localClosedIds.has(entry.id) ? 'closed' : entry.status,
};
if (collabStateTone(states[entry.id].status) === 'closed') localClosedIds.add(entry.id);
@@ -6649,9 +6759,15 @@
getCollabAgentIdsFromTool(tool).forEach((id) => {
if (!receiverThreadIds.includes(id)) receiverThreadIds.push(id);
const nextState = mergeCollabAgentTaskState(
states[id],
{},
data.prompt,
id,
receiverThreadIds.indexOf(id)
);
states[id] = {
...(states[id] || {}),
label: states[id]?.label || `子代理 ${receiverThreadIds.length}`,
...nextState,
status: isCloseAction || localClosedIds.has(id)
? 'closed'
: (data.status || states[id]?.status || (tool.done ? 'completed' : 'running')),
@@ -6662,7 +6778,7 @@
const fallbackId = tool.id || `tool-${toolIndex + 1}`;
receiverThreadIds.push(fallbackId);
states[fallbackId] = {
label: '子代理',
...mergeCollabAgentTaskState({}, { label: '子代理' }, data.prompt, fallbackId, toolIndex),
status: isCloseAction ? 'closed' : (data.status || (tool.done ? 'completed' : 'running')),
};
}
@@ -6670,8 +6786,7 @@
receiverThreadIds.forEach((id, index) => {
states[id] = {
...(states[id] || {}),
label: states[id]?.label || `子代理 ${index + 1}`,
...mergeCollabAgentTaskState(states[id], {}, '', id, index),
status: localClosedIds.has(id) ? 'closed' : (states[id]?.status || 'pending'),
};
});
@@ -6773,10 +6888,12 @@
list.className = 'collab-agent-list';
stateEntries.forEach((entry, index) => {
const tone = collabStateTone(entry.status);
const displayTitle = pickCollabAgentTitle(entry, entry.id, index);
const descriptionText = summarizePrompt(entry.taskDescription);
const item = document.createElement('div');
item.className = 'collab-agent-item';
item.title = [
entry.label || `子代理 ${index + 1}`,
displayTitle,
entry.role ? `角色: ${entry.role}` : '',
entry.detail ? `结果: ${entry.detail}` : '',
entry.id ? `ID: ${entry.id}` : '',
@@ -6800,9 +6917,7 @@
const label = document.createElement('div');
label.className = 'collab-agent-item-label';
label.textContent = !isGenericCollabAgentLabel(entry.label, entry.id)
? entry.label
: `ID ${shortChildAgentId(entry.id || '')}`;
label.textContent = pickCollabAgentTitle(entry, entry.id, index);
row.appendChild(label);
const chip = document.createElement('span');
@@ -6830,6 +6945,14 @@
}
item.appendChild(row);
if (descriptionText) {
const description = document.createElement('div');
description.className = 'collab-agent-item-description';
description.textContent = descriptionText;
description.title = entry.taskDescription;
item.appendChild(description);
}
if (entry.id || entry.role) {
const footer = document.createElement('div');
footer.className = 'collab-agent-item-footer';

View File

@@ -5629,10 +5629,12 @@ html[data-theme='coolvibe'] .settings-back:hover {
.collab-agent-item {
appearance: none;
max-width: 100%;
min-width: 0;
min-height: 40px;
flex: 1 1 220px;
display: inline-flex;
flex-direction: row;
align-items: center;
flex-direction: column;
align-items: stretch;
gap: 4px;
padding: 5px 10px;
border: 1px solid rgba(91, 126, 161, 0.16);
@@ -5646,11 +5648,13 @@ html[data-theme='coolvibe'] .settings-back:hover {
.collab-agent-item-row {
display: flex;
align-items: center;
min-width: 0;
gap: 4px;
}
.collab-agent-item-label {
flex: 1 1 auto;
min-width: 0;
max-width: 150px;
max-width: 220px;
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
@@ -5669,6 +5673,17 @@ html[data-theme='coolvibe'] .settings-back:hover {
color: var(--text-secondary);
line-height: 1.55;
}
.collab-agent-item-description {
min-width: 0;
font-size: 12px;
line-height: 1.35;
color: var(--text-muted);
overflow: hidden;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.collab-agent-item-footer {
font-size: 11px;
color: var(--text-muted);
@@ -5766,12 +5781,19 @@ html[data-theme='coolvibe'] .settings-back:hover {
.collab-agent-header {
align-items: flex-start;
}
.collab-agent-list {
min-width: 0;
}
.collab-agent-title-wrap {
flex-wrap: wrap;
}
.collab-agent-actions {
margin-left: auto;
}
.collab-agent-item {
min-width: 0;
flex: 1 1 min(100%, 180px);
}
.collab-agent-item-label {
max-width: 96px;
}

View File

@@ -647,6 +647,108 @@ function assertFrontendMcpReloadContract() {
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
}
function assertFrontendSubagentCardMetadataContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
const metadataStart = source.indexOf(' function cleanCollabAgentText(value)');
const metadataEnd = source.indexOf(' function normalizeCollabAgentAction(value)', metadataStart);
assert(metadataStart >= 0 && metadataEnd > metadataStart, 'Frontend should expose an isolated sub-agent metadata helper block');
const metadataApi = new Function(`
function shortChildAgentId(id) {
const value = String(id || '');
return value.length > 12 ? value.slice(0, 8) : value;
}
${source.slice(metadataStart, metadataEnd)}
return {
isGenericCollabAgentLabel,
pickCollabAgentTitle,
mergeCollabAgentTaskState,
};
`)();
assert(source.includes('function pickCollabAgentTitle(state, id, index)'), 'Frontend should pick sub-agent titles through a dedicated helper');
assert(
/const titleCandidates = \[\s*state\.label,\s*state\.title,\s*state\.nickname,\s*state\.name,\s*\]/.test(source),
'Sub-agent title priority should be label -> title -> nickname -> name'
);
assert(source.includes('isGenericCollabAgentLabel(candidate, id)'), 'Sub-agent title picker should skip generic labels and thread IDs');
assert(source.includes('collabAgentTitleFromPrompt(taskDescription)'), 'Generic sub-agent titles should be derived from that agent prompt');
assert(source.includes('return id ? `ID ${shortChildAgentId(id)}`'), 'Sub-agent title picker should fall back to a short thread id without a prompt');
assert(source.includes('function mergeCollabAgentTaskState(previousState = {}, incomingState = {}'), 'Frontend should centralize per-agent task metadata merging');
assert(source.includes('hasReadableSourceTitle: hasReadableCollabAgentTitle(state, id)'), 'Normalized child states should preserve whether a title came from protocol fields');
assert(/mergeCollabAgentTaskState\(\s*states\[entry\.id\]/.test(source), 'Structured child states should use the tested metadata merge helper');
assert(/mergeCollabAgentTaskState\(\s*states\[id\]/.test(source), 'Receiver-only child states should use the tested metadata merge helper');
assert(source.includes('entry.detail ? `结果: ${entry.detail}` :'), 'Card title should keep runtime result in the container title');
assert(source.includes("description.className = 'collab-agent-item-description'"), 'Sub-agent cards should render a visible task intro node');
assert(source.includes('description.title = entry.taskDescription'), 'Task intro node should expose the full task intro in its title attribute');
assert(source.includes('label.textContent = pickCollabAgentTitle(entry, entry.id, index);'), 'Rendered card label should use normalized title selection');
assert(/\.collab-agent-item-description\s*\{[\s\S]*?-webkit-line-clamp:\s*2;/.test(styleSource), 'Sub-agent task intro should use two-line truncation');
assert(/\.collab-agent-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?flex-direction:\s*column;/.test(styleSource), 'Sub-agent cards should be vertically composed and flex-shrink on narrow screens');
assert(/@media \(max-width:\s*640px\)[\s\S]*?\.collab-agent-item\s*\{[\s\S]*?min-width:\s*0;[\s\S]*?\}/.test(styleSource), 'Narrow screens should let sub-agent cards shrink without horizontal overflow');
const uuidV7 = '0190f01d-7b3e-7f03-9a5a-123456789abc';
assert(metadataApi.isGenericCollabAgentLabel(uuidV7, 'different-thread-id'), 'UUID v7 labels should be treated as thread identifiers');
assert(
metadataApi.pickCollabAgentTitle({ label: '子代理', title: '架构审查', name: uuidV7 }, 'child-a', 0) === '架构审查',
'Title selection should skip generic labels and preserve readable field priority'
);
const firstSpawn = metadataApi.mergeCollabAgentTaskState(
{},
{ label: '子代理', status: 'running' },
'请审查前端实现。核对标题和简介。',
'child-thread-a',
0
);
const secondSpawn = metadataApi.mergeCollabAgentTaskState(
{},
{ label: '子代理', status: 'running' },
'请验证后端回归。核对状态同步。',
'child-thread-b',
1
);
assert(firstSpawn.taskDescription !== secondSpawn.taskDescription, 'Independent spawns should retain different task introductions');
assert(firstSpawn.label !== secondSpawn.label, 'Independent spawns should derive different titles from their own prompts');
const afterWait = metadataApi.mergeCollabAgentTaskState(firstSpawn, { status: 'completed' }, '', 'child-thread-a', 0);
const afterClose = metadataApi.mergeCollabAgentTaskState(afterWait, { status: 'closed' }, '', 'child-thread-a', 0);
assert(afterWait.taskDescription === firstSpawn.taskDescription, 'Wait updates without a prompt should preserve the original task introduction');
assert(afterClose.taskDescription === firstSpawn.taskDescription, 'Close updates without a prompt should preserve the original task introduction');
const namedSpawn = metadataApi.mergeCollabAgentTaskState({}, { name: '实现代理', status: 'running' }, '', 'child-thread-c', 2);
const namedAfterWait = metadataApi.mergeCollabAgentTaskState(
namedSpawn,
{ label: 'ID child-th', title: '', nickname: '', name: '', status: 'completed' },
'',
'child-thread-c',
2
);
assert(namedAfterWait.label === '实现代理', 'Status updates without a readable title should preserve the existing protocol title');
const namedAfterPromptDerivedUpdate = metadataApi.mergeCollabAgentTaskState(
namedSpawn,
{
label: '整理前端改动并回报状态',
name: 'child-thread-c',
taskDescription: '请整理前端改动并回报状态。',
hasReadableSourceTitle: false,
status: 'completed',
},
'',
'child-thread-c',
2
);
assert(namedAfterPromptDerivedUpdate.label === '实现代理', 'Prompt-derived labels should not replace an existing protocol title');
assert(
namedAfterPromptDerivedUpdate.taskDescription === '请整理前端改动并回报状态。',
'Prompt-derived updates may refresh the task introduction while preserving the protocol title'
);
const noPrompt = metadataApi.mergeCollabAgentTaskState({}, { label: '子代理' }, '', uuidV7, 0);
assert(/^ID\s/.test(noPrompt.label) && noPrompt.label !== uuidV7, 'Missing prompts should fall back to a short thread id');
}
function assertFrontendPrimaryCodexAppUiContract() {
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
@@ -812,7 +914,88 @@ function assertSessionSwitchResilienceContract() {
);
}
function extractFunctionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert(start >= 0, `Server should define ${name}`);
let parenDepth = 0;
let signatureEnd = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnd = i;
break;
}
}
}
assert(signatureEnd > start, `Server function ${name} should have a complete signature`);
const open = source.indexOf('{', signatureEnd);
assert(open > start, `Server function ${name} should have a body`);
let depth = 0;
for (let i = open; i < source.length; i += 1) {
const ch = source[i];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) return source.slice(start, i + 1);
}
}
throw new Error(`Could not parse function body for ${name}`);
}
function assertCodexAppUnroutedNotificationRoutingContract() {
const source = fs.readFileSync(SERVER_PATH, 'utf8');
assert(source.includes('const codexAppThreadSessionIndex = new Map();'), 'Server should keep an O(1) Codex App thread -> session index');
assert(source.includes('const codexAppUnknownThreadMisses = new Map();'), 'Server should keep a bounded negative cache for unknown Codex App threads');
assert(source.includes('const codexAppUnroutedNotificationLogTimes = new Map();'), 'Server should throttle unrouted notification logs by thread/method');
assert(source.includes('function recoverCcwebMcpChildThreadsFromPersistedToolCalls'), 'Server should restore child-thread routes from persisted collaboration tool calls');
assert(
/recoverCcwebMcpChildThreadsFromPersistedToolCalls\(\s*sessionId,\s*state,\s*toolCalls\s*\)/.test(source),
'Codex App recovery should rebuild persisted child-thread routes before cleaning run state'
);
assert(source.includes('function updateSessionRuntimeThreadIndex'), 'Server should centralize session thread index maintenance');
assert(
/function saveSession\(session\)[\s\S]*?updateSessionRuntimeThreadIndex\(session\)/.test(source),
'Saving a session should refresh the runtime thread index'
);
assert(
/function handleDeleteSession\(ws, sessionId\)[\s\S]*?removeSessionRuntimeThreadIndex\(sessionId\)/.test(source),
'Deleting a session should remove its runtime thread index entry'
);
const lookupBlock = extractFunctionSource(source, 'findCodexAppSessionByThreadId');
assert(lookupBlock.includes('codexAppThreadSessionIndex.get(targetThreadId)'), 'Thread lookup should consult the O(1) index');
assert(lookupBlock.includes('codexAppUnknownThreadMisses'), 'Thread lookup should use the unknown-thread negative cache');
assert(!lookupBlock.includes('fs.readdirSync(SESSIONS_DIR)'), 'Thread lookup must not synchronously scan all session files on the notification hot path');
const routeBlock = extractFunctionSource(source, 'findCodexAppRouteByRuntime');
const childIndex = routeBlock.indexOf("role: 'child'");
const adoptIndex = routeBlock.indexOf('adoptCodexAppUnroutedTurn');
assert(childIndex >= 0, 'Runtime routing should return child routes');
assert(adoptIndex >= 0, 'Runtime routing should still adopt parent notifications from disk state');
assert(childIndex < adoptIndex, 'Known child threads should route before parent disk adoption');
const notificationBlock = extractFunctionSource(source, 'handleCodexAppNotification');
assert(
notificationBlock.includes('shouldLogCodexAppUnroutedNotification(notification)'),
'Unrouted notification logging should be throttled by a helper'
);
}
async function main() {
const targetIndex = process.argv.indexOf('--target');
const regressionTarget = targetIndex >= 0 ? String(process.argv[targetIndex + 1] || '').trim() : String(process.env.CC_WEB_REGRESSION_TARGET || '').trim();
if (regressionTarget) {
if (regressionTarget !== 'codexapp-unrouted-routing') {
throw new Error(`Unknown regression target: ${regressionTarget}`);
}
assertCodexAppUnroutedNotificationRoutingContract();
console.log('Codex App unrouted routing regression checks passed.');
return;
}
assertFrontendGenerationControlsContract();
assertFrontendComposerMcpContract();
assertFrontendSlashDraftPreservationContract();
@@ -820,6 +1003,7 @@ async function main() {
assertFrontendMarkdownLinkContract();
assertMockCodexAppPromptUserNotTextTriggered();
assertFrontendMcpReloadContract();
assertFrontendSubagentCardMetadataContract();
assertFrontendPrimaryCodexAppUiContract();
assertSetTitleMcpContract();
assertSessionSwitchResilienceContract();

200
server.js
View File

@@ -672,6 +672,14 @@ const activeCodexAppGoalCommands = new Map();
// ccweb MCP child agents tracked from Codex App native collaboration mode:
// childThreadId -> { parentSessionId, parentThreadId, spawnToolId, ...state }
const ccwebMcpChildThreads = new Map();
const codexAppThreadSessionIndex = new Map();
const codexAppSessionThreadIndex = new Map();
const codexAppUnknownThreadMisses = new Map();
const codexAppUnroutedNotificationLogTimes = new Map();
const CODEX_APP_UNKNOWN_THREAD_CACHE_TTL_MS = 30 * 1000;
const CODEX_APP_UNKNOWN_THREAD_CACHE_MAX = 1000;
const CODEX_APP_UNROUTED_NOTIFICATION_LOG_THROTTLE_MS = 30 * 1000;
const CODEX_APP_UNROUTED_NOTIFICATION_LOG_MAX = 1000;
const CODEX_APP_MCP_STARTUP_STATUS_METHOD = 'mcpServer/startupStatus/updated';
const CODEX_APP_MCP_DEFAULT_SERVER = 'ccweb';
const CODEX_APP_MCP_RELOAD_STATUS_WAIT_MS = 1200;
@@ -2954,6 +2962,67 @@ function getRuntimeSessionId(session) {
return session.claudeSessionId || null;
}
function normalizeCodexAppThreadId(value) {
const text = String(value || '').trim();
return text || null;
}
function pruneOldestMapEntries(map, maxSize) {
if (!map || map.size <= maxSize) return;
for (const key of map.keys()) {
map.delete(key);
if (map.size <= maxSize) return;
}
}
function removeSessionRuntimeThreadIndex(sessionId) {
const normalizedId = sanitizeId(sessionId || '');
if (!normalizedId) return;
const previousThreadId = codexAppSessionThreadIndex.get(normalizedId);
if (previousThreadId) {
codexAppThreadSessionIndex.delete(previousThreadId);
codexAppUnknownThreadMisses.delete(previousThreadId);
}
codexAppSessionThreadIndex.delete(normalizedId);
for (const [threadId, indexedSessionId] of codexAppThreadSessionIndex.entries()) {
if (indexedSessionId === normalizedId) codexAppThreadSessionIndex.delete(threadId);
}
}
function updateSessionRuntimeThreadIndex(session) {
const sessionId = sanitizeId(session?.id || '');
if (!sessionId) return;
const previousThreadId = codexAppSessionThreadIndex.get(sessionId);
if (previousThreadId) {
codexAppThreadSessionIndex.delete(previousThreadId);
codexAppSessionThreadIndex.delete(sessionId);
}
const threadId = isCodexAppSession(session) ? normalizeCodexAppThreadId(getRuntimeSessionId(session)) : null;
if (!threadId) return;
codexAppThreadSessionIndex.set(threadId, sessionId);
codexAppSessionThreadIndex.set(sessionId, threadId);
codexAppUnknownThreadMisses.delete(threadId);
}
function hasFreshCodexAppUnknownThreadMiss(threadId) {
const targetThreadId = normalizeCodexAppThreadId(threadId);
if (!targetThreadId) return false;
const expiresAt = codexAppUnknownThreadMisses.get(targetThreadId) || 0;
if (!expiresAt) return false;
if (expiresAt <= Date.now()) {
codexAppUnknownThreadMisses.delete(targetThreadId);
return false;
}
return true;
}
function rememberCodexAppUnknownThreadMiss(threadId) {
const targetThreadId = normalizeCodexAppThreadId(threadId);
if (!targetThreadId) return;
codexAppUnknownThreadMisses.set(targetThreadId, Date.now() + CODEX_APP_UNKNOWN_THREAD_CACHE_TTL_MS);
pruneOldestMapEntries(codexAppUnknownThreadMisses, CODEX_APP_UNKNOWN_THREAD_CACHE_MAX);
}
function mcpStatusObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
}
@@ -3762,7 +3831,9 @@ function loadSession(id) {
try {
const filePath = sessionPath(normalizedId);
if (!fs.existsSync(filePath)) return null;
return normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
const session = normalizeSession(safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId }));
updateSessionRuntimeThreadIndex(session);
return session;
} catch (err) {
plog('WARN', 'session_load_failed', {
sessionId: normalizedId.slice(0, 8),
@@ -3788,6 +3859,7 @@ function saveSession(session) {
attempts: result.attempts,
});
}
updateSessionRuntimeThreadIndex(session);
return true;
} catch (err) {
plog('ERROR', 'session_save_failed', {
@@ -4310,6 +4382,7 @@ function recoverCodexAppTurnState(sessionId) {
toolResultMaxChars: SESSION_TOOL_RESULT_MAX_CHARS,
contentMaxChars: CODEX_APP_STATE_FULL_TEXT_MAX_CHARS,
});
recoverCcwebMcpChildThreadsFromPersistedToolCalls(sessionId, state, toolCalls);
const hasRecoverableContent = fullText.trim() || toolCalls.length > 0;
const turnKey = codexAppTurnKey(sessionId, state);
let changed = false;
@@ -7710,6 +7783,7 @@ function handleDeleteSession(ws, sessionId) {
pendingSlashCommands.delete(sessionId);
pendingCompactRetries.delete(sessionId);
cancelCodexCapacityRetry(sessionId);
removeSessionRuntimeThreadIndex(sessionId);
if (activeCodexAppGoalCommands.has(sessionId)) {
const entry = activeCodexAppGoalCommands.get(sessionId);
entry.cancelled = true;
@@ -8344,6 +8418,24 @@ function findCodexAppEntryByRuntime(params = {}) {
function findCodexAppSessionByThreadId(threadId) {
const targetThreadId = String(threadId || '').trim();
if (!targetThreadId) return null;
const cachedSessionId = codexAppThreadSessionIndex.get(targetThreadId);
if (cachedSessionId) {
const session = loadSession(cachedSessionId);
if (session && isCodexAppSession(session) && getRuntimeSessionId(session) === targetThreadId) {
return { sessionId: session.id, session };
}
removeSessionRuntimeThreadIndex(cachedSessionId);
}
// 通过 codexAppUnknownThreadMisses 限制未知线程重复磁盘查找。
if (hasFreshCodexAppUnknownThreadMiss(targetThreadId)) return null;
const matched = scanCodexAppSessionByThreadId(targetThreadId);
if (matched) return matched;
rememberCodexAppUnknownThreadMiss(targetThreadId);
return null;
}
function scanCodexAppSessionByThreadId(targetThreadId) {
try {
for (const file of fs.readdirSync(SESSIONS_DIR)) {
if (!file.endsWith('.json')) continue;
@@ -8511,6 +8603,83 @@ function ccwebMcpChildLabel(state = {}, fallbackThreadId = '') {
return String(label || fallbackThreadId || '子代理').trim();
}
function ccwebMcpRecoveredChildLabel(input = {}, fallbackThreadId = '') {
const agentPath = String(input.agentPath || input.agent_path || '').trim();
const fromPath = agentPath ? path.basename(agentPath) : '';
return String(input.label || input.title || input.nickname || input.name || input.agent || fromPath || fallbackThreadId || '子代理').trim();
}
function ccwebMcpRecoveredChildStatus(input = {}, tool = {}) {
const direct = input.status || input.state || tool.status || '';
if (direct) return ccwebMcpChildStatus(direct, 'running');
const kind = String(input.kind || tool.kind || '').trim().toLowerCase();
if (/fail|error/.test(kind)) return 'failed';
if (/close|closed|cancel|abort|interrupt/.test(kind)) return 'closed';
if (/return|complete|done|finish|success/.test(kind)) return 'returned';
return 'running';
}
function isSubAgentActivityTool(tool = {}, input = {}) {
return tool.name === 'subAgentActivity'
|| tool.kind === 'subAgentActivity'
|| input.type === 'subAgentActivity'
|| input.activityType === 'subAgentActivity';
}
function recoverCcwebMcpChildThreadsFromPersistedToolCalls(sessionId, state = {}, toolCalls = []) {
const parentSessionId = sanitizeId(sessionId || '');
if (!parentSessionId || !Array.isArray(toolCalls) || toolCalls.length === 0) return 0;
const parentThreadId = normalizeCodexAppThreadId(state.threadId) || '';
const now = new Date().toISOString();
let restored = 0;
for (const tool of toolCalls) {
const input = parseMaybeJsonObject(tool?.input) || (tool?.input && typeof tool.input === 'object' ? tool.input : {});
if (!isSubAgentActivityTool(tool, input)) continue;
const threadId = normalizeCodexAppThreadId(
input.agentThreadId || input.agent_thread_id || input.threadId || input.thread_id
);
if (!threadId) continue;
const existing = ccwebMcpChildThreads.get(threadId);
const child = existing || {
threadId,
turnId: null,
parentSessionId,
parentThreadId,
spawnToolId: tool.id || '',
label: ccwebMcpRecoveredChildLabel(input, threadId),
role: String(input.role || input.agentRole || input.agent_role || '').trim(),
lastAssistantMessage: '',
candidateResult: '',
finalMessage: '',
status: 'running',
summaryAttempts: 0,
createdAt: now,
updatedAt: now,
};
child.parentSessionId = child.parentSessionId || parentSessionId;
child.parentThreadId = child.parentThreadId || parentThreadId;
child.spawnToolId = child.spawnToolId || tool.id || '';
child.label = ccwebMcpRecoveredChildLabel(input, child.label || threadId);
child.role = String(input.role || input.agentRole || input.agent_role || child.role || '').trim();
if (child.status !== 'closed') child.status = ccwebMcpRecoveredChildStatus(input, tool);
child.updatedAt = child.updatedAt || now;
child.recoveredFromState = true;
ccwebMcpChildThreads.set(threadId, child);
restored += existing ? 0 : 1;
}
if (restored > 0) {
plog('INFO', 'ccweb_mcp_child_threads_recovered', {
sessionId: parentSessionId.slice(0, 8),
parentThreadId: parentThreadId || null,
restored,
});
}
return restored;
}
function ccwebMcpChildSummary(child = {}) {
const candidate = String(child.candidateResult || child.finalMessage || child.lastAssistantMessage || '').replace(/\s+/g, ' ').trim();
return candidate ? truncateTextValue(candidate, 180, '...') : '';
@@ -8755,8 +8924,6 @@ function processCcwebMcpChildNotification(child, notification) {
function findCodexAppRouteByRuntime(params = {}, method = '') {
const parent = findCodexAppEntryByRuntime(params);
if (parent) return { ...parent, role: 'parent' };
const recoveredParent = adoptCodexAppUnroutedTurn(params, method);
if (recoveredParent) return { ...recoveredParent, role: 'parent' };
const threadId = codexAppRuntimeThreadId(params);
if (threadId && ccwebMcpChildThreads.has(threadId)) {
const child = ccwebMcpChildThreads.get(threadId);
@@ -8767,18 +8934,35 @@ function findCodexAppRouteByRuntime(params = {}, method = '') {
child,
};
}
const recoveredParent = adoptCodexAppUnroutedTurn(params, method);
if (recoveredParent) return { ...recoveredParent, role: 'parent' };
return null;
}
function shouldLogCodexAppUnroutedNotification(notification) {
const method = String(notification?.method || '').trim() || 'unknown';
const params = notification?.params || {};
const threadId = normalizeCodexAppThreadId(params.threadId || params.thread?.id) || 'unknown';
const key = `${threadId}:${method}`;
const now = Date.now();
const lastLoggedAt = codexAppUnroutedNotificationLogTimes.get(key) || 0;
if (lastLoggedAt && now - lastLoggedAt < CODEX_APP_UNROUTED_NOTIFICATION_LOG_THROTTLE_MS) return false;
codexAppUnroutedNotificationLogTimes.set(key, now);
pruneOldestMapEntries(codexAppUnroutedNotificationLogTimes, CODEX_APP_UNROUTED_NOTIFICATION_LOG_MAX);
return true;
}
function handleCodexAppNotification(notification) {
const routed = findCodexAppRouteByRuntime(notification?.params || {}, notification?.method || '');
if (handleCodexAppMcpStartupStatusNotification(notification, routed)) return;
if (!routed) {
plog('INFO', 'codex_app_notification_unrouted', {
method: notification?.method || '',
threadId: notification?.params?.threadId || notification?.params?.thread?.id || null,
turnId: notification?.params?.turnId || notification?.params?.turn?.id || null,
});
if (shouldLogCodexAppUnroutedNotification(notification)) {
plog('INFO', 'codex_app_notification_unrouted', {
method: notification?.method || '',
threadId: notification?.params?.threadId || notification?.params?.thread?.id || null,
turnId: notification?.params?.turnId || notification?.params?.turn?.id || null,
});
}
return;
}