diff --git a/.planning/ccweb-drop-diagnosis/findings.md b/.planning/ccweb-drop-diagnosis/findings.md new file mode 100644 index 0000000..c7ac1e7 --- /dev/null +++ b/.planning/ccweb-drop-diagnosis/findings.md @@ -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:49,PM2 明确记录 `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` 代码索引状态为 ready(3072 节点、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 KiB,tool 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:15–22:26 的结构化日志共有 1089 条 `codex_app_notification_unrouted`;其中 991 条是 `item/agentMessage/delta`,另有 32 条 item completed、30 条 item started。 +- 这些通知集中指向同一个找不到路由的 app-server thread/turn;频率约 1–2 条/秒,部分时刻同一毫秒多条。 +- `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 次等价会话扫描为 188–243 ms,每次读取约 22 MiB;因此先前约 5.99 秒结果包含 Node 冷启动/系统抖动,不能把单次会话扫描独立定为根因。 +- 200 ms 级同步扫描仍会造成可感知卡顿,连续触发仍可叠加,但本次更直接的异常是“活跃 thread 持续产出通知,ccweb 却无法路由”。 +- 修正后的高概率链路:活跃会话 runtime 路由丢失 → 大量流式通知被判为 unrouted → 每条同步写日志并轮转;同时 UI 收不到该会话事件。是否足以让静态 HTTP 也超时,仍需结合路由查找复杂度和当时其它事件判断。 +- 对应 thread 的未路由状态不是运行中途才丢失:首次出现在 22:13:44(thread 启动/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 次热缓存基准为 604–840 ms,每次实际读取 60.8 MiB;这仍未包含生产代码的 `normalizeSession` 成本,因此是保守下界。 +- 故障 thread 的 1054 条未路由通知覆盖约 725 秒,平均间隔约 0.69 秒,几乎等于一次全量扫描耗时。于是主线程形成连续循环:通知 → 60.8 MiB 同步扫描 → miss → 同步日志 → 下一通知。 +- 按 1054 次估算,累计同步读取量约 62.6 GiB;按实测下界累计阻塞时间约 637–885 秒,与 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.6–0.84 秒/次 → Node 主事件循环被持续占满 → 网页打不开;人工重启通过 recovery 重建状态后恢复。 + +## 最终结论 + +- 本次并非进程崩溃或系统 OOM,而是 Node 主事件循环被同步工作持续占满;用户的 PM2 重启是恢复动作。 +- 触发源是 English 会话通过 `spawn_agent` 创建的子代理 `inspect_drill_tests`。child thread 映射未建立,1054 条子代理通知从 22:13:44 起持续走未路由回退。 +- 回退逻辑对每条高频 delta 同步扫描 112 个会话文件并完整读取/解析 60.8 MiB;单次保守基准 604–840 ms,累计约 62.6 GiB、637–885 秒阻塞,和 725 秒故障窗口吻合。 +- 重启后 adoptable 未路由通知已归零;剩余 98 条仅为 rate-limit/status 类非认领通知,不再触发全量会话扫描。22:45 检查服务 online,HTTP 200,响应约 2.5 ms。 +- 修复应同时覆盖三层:spawn 时可靠登记 child thread 路由;未路由查找改为内存索引/负缓存并禁止每 delta 全盘同步扫描;未路由日志做聚合限频和异步写入。 diff --git a/.planning/ccweb-drop-diagnosis/progress.md b/.planning/ccweb-drop-diagnosis/progress.md new file mode 100644 index 0000000..f6c47b5 --- /dev/null +++ b/.planning/ccweb-drop-diagnosis/progress.md @@ -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-11:thread 归因到活跃 English 会话;热缓存会话扫描约 188–243 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 基准 604–840 ms,累计阻塞时间与整个故障窗口同量级,根因达到高置信。 +- 2026-07-11:确认故障 thread 是原生 spawn_agent 子代理;定位 child 映射注册缺口与错误回退到父会话全量扫描的完整因果链。 +- 2026-07-11 22:45:复核重启后无 adoptable 未路由风暴,服务 HTTP 200/约 2.5 ms;完成修正后的最终结论。本轮未改业务代码、未改配置、未重启。 diff --git a/.planning/ccweb-drop-diagnosis/task_plan.md b/.planning/ccweb-drop-diagnosis/task_plan.md new file mode 100644 index 0000000..c751bee --- /dev/null +++ b/.planning/ccweb-drop-diagnosis/task_plan.md @@ -0,0 +1,26 @@ +# ccweb 频繁掉线诊断计划 + +## 目标 + +只读排查 ccweb 最近连接失败和频繁掉线的原因,形成可验证的时间线、根因判断与风险说明;不重启、不改配置、不改业务代码。用户已澄清 22:25 的重启是故障后的人工恢复动作,因此继续追查重启前网页无响应的原因。 + +## 阶段 + +- [完成] 1. 确认服务当前状态与最近重启次数 +- [完成] 2. 分析 ccweb 错误日志与退出时间线 +- [完成] 3. 核对系统资源、OOM 与端口/依赖异常 +- [完成] 4. 归纳根因、证据与处置优先级 +- [完成] 5. 修正因果关系并核对重启前可观测证据 +- [完成] 6. 定位可能阻塞 HTTP/事件循环的代码路径 +- [完成] 7. 对照会话与子进程活动评估高概率诱因 +- [完成] 8. 输出修正后的结论与可验证的监控缺口 + +## 约束 + +- 不执行 `pm2 restart`。 +- 不修改运行配置或项目代码。 +- 日志中的敏感信息不在答复中展开。 + +## 错误记录 + +- 初次结论误把人工重启当作掉线原因;用户澄清后已修正,重启仅是恢复动作。 diff --git a/.planning/ccweb-unrouted-notification-fix/findings.md b/.planning/ccweb-unrouted-notification-fix/findings.md new file mode 100644 index 0000000..f6f3d2c --- /dev/null +++ b/.planning/ccweb-unrouted-notification-fix/findings.md @@ -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,但 8–10 秒无响应,符合事件循环阻塞而非端口未监听。 +- `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% 降至 0–2%,本地首页恢复为 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 为 1802039,PM2 restart 计数从 60 增至 61。 +- 启动日志出现 `ccweb_mcp_child_threads_recovered`,`restored=3`,证明真实恢复文件中的三个子线程路由被重建。 +- 连续 5 次本地 HTTP 探针均返回 200,后续最终探针约 3.6 ms。 +- 新进程 RSS 约 64 MiB,事件循环 P95 约 1.5 ms,短时 CPU 回落至 0–12%,没有持续占满单核。 +- 重启后的日志窗口仅包含 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` 作为权威运行标志。 diff --git a/.planning/ccweb-unrouted-notification-fix/prd.md b/.planning/ccweb-unrouted-notification-fix/prd.md new file mode 100644 index 0000000..c4156a8 --- /dev/null +++ b/.planning/ccweb-unrouted-notification-fix/prd.md @@ -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` 中的用户现有改动。 +- 不通过增大堆上限或单纯重启掩盖根因。 +- 代码注释使用简体中文;日志不得包含敏感数据。 diff --git a/.planning/ccweb-unrouted-notification-fix/progress.md b/.planning/ccweb-unrouted-notification-fix/progress.md new file mode 100644 index 0000000..240919f --- /dev/null +++ b/.planning/ccweb-unrouted-notification-fix/progress.md @@ -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 ms,CPU/内存/事件循环正常。 +- 重启后未发现新的未路由通知风暴,任务完成。 +- 回归契约已追加;旧实现缺少契约要求的索引、恢复与节流符号,必然失败。 +- 实现代理写入补丁后未及时结束,已中止并由主线程接管验证。 +- `node --check server.js` 通过。 +- `node scripts/regression.js --target codexapp-unrouted-routing` 通过。 +- 当前阶段:冷审查负缓存、日志节流和恢复一致性。 diff --git a/.planning/ccweb-unrouted-notification-fix/task_plan.md b/.planning/ccweb-unrouted-notification-fix/task_plan.md new file mode 100644 index 0000000..b757bab --- /dev/null +++ b/.planning/ccweb-unrouted-notification-fix/task_plan.md @@ -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 | 计划阶段原为中文编号列表;已改为检查脚本可识别的标准阶段标题与状态字段 | + +## 计划审查 + +- 独立计划审查代理已通过,无阻断性问题。 +- 执行时显式记录测试命令、预计修改位置和重启前运行会话检查结果。 diff --git a/.planning/subagent-card-metadata/findings.md b/.planning/subagent-card-metadata/findings.md new file mode 100644 index 0000000..33ccf08 --- /dev/null +++ b/.planning/subagent-card-metadata/findings.md @@ -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` 标记区分协议标题和派生标题。 diff --git a/.planning/subagent-card-metadata/progress.md b/.planning/subagent-card-metadata/progress.md new file mode 100644 index 0000000..0f4d58a --- /dev/null +++ b/.planning/subagent-card-metadata/progress.md @@ -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-11:Trellis 规范沉淀判断:本次为局部子代理卡片行为,核心边界已固化在行为回归中,没有形成跨模块通用约定,不更新 `.trellis/spec/`。 diff --git a/.planning/subagent-card-metadata/task_plan.md b/.planning/subagent-card-metadata/task_plan.md new file mode 100644 index 0000000..2428ffd --- /dev/null +++ b/.planning/subagent-card-metadata/task_plan.md @@ -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 三级标题与完成状态字段格式 | diff --git a/.trellis/tasks/07-11-subagent-card-metadata/check.jsonl b/.trellis/tasks/07-11-subagent-card-metadata/check.jsonl new file mode 100644 index 0000000..f097f38 --- /dev/null +++ b/.trellis/tasks/07-11-subagent-card-metadata/check.jsonl @@ -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":"核对是否避免重复逻辑"} diff --git a/.trellis/tasks/07-11-subagent-card-metadata/implement.jsonl b/.trellis/tasks/07-11-subagent-card-metadata/implement.jsonl new file mode 100644 index 0000000..c240f98 --- /dev/null +++ b/.trellis/tasks/07-11-subagent-card-metadata/implement.jsonl @@ -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":"复用现有归一化与渲染辅助函数"} diff --git a/.trellis/tasks/07-11-subagent-card-metadata/prd.md b/.trellis/tasks/07-11-subagent-card-metadata/prd.md new file mode 100644 index 0000000..d9b4ca4 --- /dev/null +++ b/.trellis/tasks/07-11-subagent-card-metadata/prd.md @@ -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。 +- 不新增依赖,不改变后端协议,不重启服务。 diff --git a/.trellis/tasks/07-11-subagent-card-metadata/task.json b/.trellis/tasks/07-11-subagent-card-metadata/task.json new file mode 100644 index 0000000..dfc102f --- /dev/null +++ b/.trellis/tasks/07-11-subagent-card-metadata/task.json @@ -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": {} +} \ No newline at end of file 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 890f03e..0d46a58 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/public/app.js b/public/app.js index 45603d3..19370b0 100644 --- a/public/app.js +++ b/public/app.js @@ -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'; diff --git a/public/style.css b/public/style.css index 3150ab0..1497bd7 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } diff --git a/scripts/regression.js b/scripts/regression.js index 3f721a1..e14eca1 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -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(); diff --git a/server.js b/server.js index 2f2ed69..ea03cc9 100644 --- a/server.js +++ b/server.js @@ -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; }