改用 HTTP 加载会话历史并更新 CentOS 7 发布包

This commit is contained in:
shiyue
2026-10-15 00:36:26 +08:00
parent 2b1c91488d
commit fb2c3360c9
22 changed files with 1466 additions and 175 deletions

View File

@@ -21,6 +21,7 @@ This directory contains guidelines for backend development. Fill in each file wi
| [Logging Guidelines](./logging-guidelines.md) | Structured logging, log levels | To fill |
| [Codex MCP 配置输出](./codex-mcp-config.md) | 旧类型配置兼容及 Codex 配置出口 | 已记录 |
| [完整历史保存](./session-history.md) | 完整档案、快照缓存、并发及分页契约 | 已记录 |
| [会话查询与实时订阅](./session-query.md) | HTTP 历史、运行快照、独立订阅与竞态隔离 | 已记录 |
---

View File

@@ -0,0 +1,100 @@
# 会话查询与实时订阅契约
## 1. 适用范围与触发条件
浏览器打开会话、查看更早消息、索引用户目录、定位消息和恢复运行视图时适用。
已有实现把这些读取绑定到 WebSocket 的 OPEN、鉴权和运行绑定;假 OPEN 时请求可能
丢在旧连接里,直到45秒超时。历史查询必须有独立 HTTP 通道,重连只恢复实时订阅。
这项代码事实不能用于断言每次现场故障都来自同一原因。
## 2. 接口与函数签名
```text
GET /api/sessions/:id?requestId=<id>
GET /api/sessions/:id/history?before=<非负整数>&requestId=<id>
Authorization: Bearer <已有登录令牌>
handleSessionQueryApi(req, res, url, encodedId, historyPage = false)
resolveSessionHistory(session, { count, before, persistedOnly: true })
loadSession(id, { metadata: true, throwOnError: true })
WS → { type: 'subscribe_session', sessionId, requestId, historyRevision }
WS ← { type: 'session_subscribed', sessionId, requestId, historyRevision, historyChanged, isRunning }
```
## 3. 请求、响应与并发约束
- 首屏返回 `type='session_info'`、最近12条、`historyPending=false` 和会话元数据。
分页返回 `type='session_history_chunk'`,向前最多24条,`remaining=0`。
- 两者都提供 `sessionId/requestId/messages/historyTotal/historyBaseIndex/historyCursor/`
`historyAvailable/historySource/historyRevision`;`requestId` 最多160字符,浏览器生成
有界且唯一的代次标识,成功响应必须同时匹配会话和请求。
- 首屏的 `runtime` 是完整 `resume_generating` 快照或 `null`,即使没有 WebSocket
也能显示已产生输出。停止运行但尚未归档的保留态显示后须收束生成状态。
- HTTP 只读取 cc-web 自身已保存记录;首屏不扫描原生日志、不协调跨会话、不清未读。
归档按块有界读取;旧快照保留来源待核实状态。磁盘和 JSON 处理仍在同一服务进程,
传输拆分不代表所有同步 IO 已异步化。
- 订阅校验元数据、绑定实时输出、清未读并冲刷已有跨会话回复,不发送历史消息。
比较清未读前的归档修订;有差异时由浏览器再通过 HTTP 对账。确认后返回清未读
产生的新修订,避免元数据保存导致重复对账。
- 首屏、正文分页、目录分页和消息定位均不检查 WebSocket 是否已连接或鉴权。
WebSocket 关闭不能取消 HTTP 读取、清除分页状态或结束首屏加载。
- 新选择、同会话新代次和退出视图取消旧 HTTP;控制器、视图代次和活动分页对象
三重隔离迟到结果。错误恢复保留已有视图和分页游标,取消后清除加载标志。
- `session_subscribed` 和带请求标识的运行快照必须属于当前订阅或视图。
订阅触发的历史对账保留实时输出,不再应用较早的 HTTP runtime,也不重复订阅。
- 若查询期间已收到该会话的 `done`,弃用较早快照并重新查询终态;不能让完成后的
页面被旧 `isRunning=true` 快照覆盖。
- HTTP 45秒硬超时只限制真实请求;每个成功、失败或取消出口都清理计时器。
`Server-Timing` 和 `session_http_query` 记录请求、阶段、状态、数量和耗时,不记录正文。
## 4. 校验与错误矩阵
| 条件 | HTTP / 订阅行为 |
|---|---|
| 缺失或无效 Bearer | 401 `not_authenticated` |
| URI 解码失败或 ID 含字母数字、连字符之外字符 | 400 `history_invalid_id`;不得清洗后别名到另一会话 |
| 游标为负数、小数或非安全整数 | 400 `history_invalid_cursor` |
| 会话确实缺失 | 404 `session_not_found` |
| 元数据、清单或历史块读取失败 | 500 `history_read_failed`,禁止吞成404 |
| 实时绑定失败 | WS `session_subscribe_failed`,历史继续可读 |
| 新选择取消、迟到响应或不匹配订阅 | 不修改新视图及其加载状态 |
所有 HTTP 错误携带 `type='error'`、`code/message/sessionId/requestId`。未完成身份
校验或 URI 解码时 `sessionId` 可以为空,不泄露磁盘路径及消息内容。
## 5. 正常、基本与失败场景
- 正常:57条归档关闭 WS 后首屏12条、继续分页24条和21条,按原顺序且无重复。
- 基本:运行中会话在假 OPEN 时仍显示历史和完整运行输出,实时订阅独立恢复。
- 交错:HTTP 读完后任务完成,订阅修订变化触发 HTTP 对账,最终回复显示且无生成占位。
- 失败:A 响应晚于 B,A 的正文和运行态都不能覆盖 B;坏历史返回500并保留当前视图。
## 6. 必需回归与断言
- `scripts/session-http-load-frontend-unit.js` 执行生产请求函数:无 WS 读取、真实 abort、
忽略取消后依然到达的响应、同会话代次、分页取消、完成交错、500和超时计时器释放。
- `node scripts/regression.js --target session-http-load`:隔离真实服务,断 WS 后查询、
首屏/分页、鉴权、坏 ID/游标、损坏清单/块、独立订阅、修订变化和完整运行快照。
- `node scripts/session-http-load-regression.js --browser`:真实 Firefox 经转发层测试
假 OPEN、读取途中断 WS、完全断连后切换、分页、迟到 A 响应、完成间隙及历史500。
断言浏览器不发送 `load_session/load_history_page/resume_session` 查询历史。
- 外部 CDN 不稳定时可设置 `CC_WEB_BROWSER_CDN_DIR`,使用与页面相同版本的原始
`marked.min.js/highlight.min.js/mermaid.min.js/atom-one-dark.min.css` 本地夹具;仅转发层
改写这些静态 URL,生产请求函数与渲染器保持原样,避免把登录前静态资源超时当作切换失败。
- 所有数据、原生目录和 Codex home 锁路径必须隔离;不修改 HOME/CODEX_HOME 或生产锁。
单次测试最多60秒,不能用生产会话的读写代替隔离验证。
## 7. 错误与正确示例
```js
// 错误:已有令牌也必须先等长连接;假 OPEN 时只能等待业务超时。
if (ws.readyState === 1 && wsAuthenticated) ws.send(JSON.stringify({ type: 'load_session', sessionId }));
// 正确:历史独立读取,成功应用后再接入实时订阅。
const snapshot = await fetchSessionQuery(sessionId, requestId, { signal });
handleServerMessage(snapshot);
requestSessionResume(sessionId);
```
生产函数还必须执行控制器身份、请求代次和完成事件版本校验,不能仅复制此示意代码。

View File

@@ -1,5 +1,9 @@
# State Management
会话视图的 HTTP 读取、实时订阅、请求代次和完成事件交错规则,见
[会话查询与实时订阅契约](../backend/session-query.md)。历史读取不能依赖 WebSocket
连接、鉴权或重连;长连接断开不能取消正在进行的 HTTP 请求。
> How state is managed in this project.
---

View File

@@ -0,0 +1,3 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file":".trellis/spec/frontend/state-management.md","reason":"验证切换状态与竞态"}
{"file":".trellis/spec/backend/session-history.md","reason":"验证加载错误与运行态历史"}

View File

@@ -0,0 +1,16 @@
# 调查发现
- 继承调查确认切换经 WebSocket load_session,服务端 handleLoadSession。
- 前端 12 秒释放遮罩、45 秒取消请求;目前尚未有请求级日志证实现场根因。
- 浏览器显示 OPEN 不等于连接可收发;后台会话 running 与页面连接状态独立。
- 待核查:切换请求在重连后的恢复、晚到响应、loadSession 渲染异常以及历史读取异常。
- 用户拒绝继续只补重试、重连和日志;此次交付改为 HTTP 首屏及分页、WebSocket 独立实时订阅。
- 不能断言 WebSocket 必然超时;已证实架构把历史读取与单条长连接绑定,原日志不能确认每次现场超时的唯一原因。
- 已确认目录连续分页和单条定位也发送 load_history_page,必须一并迁移。
- 新契约:GET /api/sessions/:id(首屏12条及运行态快照)、GET /api/sessions/:id/history?before=...(24条);subscribe_session 只绑定实时连接,按修订号检测读取与订阅之间的提交变化。
- HTTP 查询不协调跨会话、不清未读、不扫描原生日志;实时订阅清未读并协调跨会话。归档异常不能吞成404;仅缺失会话返回404。
- 可复现证据:隔离 Firefox 将 WS 业务帧黑洞化且保留 OPEN,首屏和运行输出仍经 HTTP 显示;主动断 WS、拒绝重连后仍可切换和查看。
- HTTP 读取已完成但尚未订阅时,任务可能刚好完成;订阅比较历史修订并触发 HTTP 对账。已经接到 done 后的旧快照也须按完成版本重新读取,防止生成状态复活。
- 除首屏外,目录、定位和手动分页全部迁移;只改首屏会遗留同类连接依赖。
- 错误归类:跨层契约和隐含连接假设。重试/重连只能改善连接恢复,不会取消历史查询对长连接的依赖。新回归直接注入假 OPEN 和消息交错,避免只验证 ws.send 被调用。
- 范围限制:同步磁盘及 JSON 处理未整体异步化;旧版存储自身迁移仍由存储层负责,不能把通道拆分描述为所有服务端阻塞都已解决。

View File

@@ -0,0 +1,5 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
{"file":".trellis/spec/frontend/state-management.md","reason":"切换请求状态机与重连恢复"}
{"file":".trellis/spec/frontend/quality-guidelines.md","reason":"前端回归与异常处理"}
{"file":".trellis/spec/backend/session-history.md","reason":"完整历史读取与分页契约"}
{"file":".trellis/tasks/10-14-session-switch-timeout/findings.md","reason":"已证实故障与交付约束"}

View File

@@ -0,0 +1,9 @@
# HTTP 历史查询与实时订阅拆分
- HTTP 使用现有 Bearer 鉴权,响应保留 requestId/sessionId;首次12条,向前24条,不等待 WebSocket。
- 所有 HTTP 请求均可取消;新会话选择、同会话新代次以及退出视图取消旧请求。响应进入现有请求代次校验后渲染。
- HTTP 首屏同时提供完整运行态快照,让断开 WebSocket 时仍可查看正在产生的输出。
- WebSocket subscribe_session 仅校验元数据、绑定运行态并发送最新运行快照;不读取历史。历史修订已变更时通知前端通过 HTTP 对账,覆盖读取与订阅之间刚好完成的任务。
- 完整归档直接读取有界块。旧快照仅返回自身已有消息,不因打开页面触发原生日志扫描或历史迁移。
- HTTP 错误显式返回状态码和请求标识;诊断只含脱敏标识、阶段、消息数量及耗时。
- 保留既有服务端 WebSocket 协议供已存在调用方使用;本次浏览器读取链路全部迁到 HTTP。

View File

@@ -0,0 +1,14 @@
# 修复会话切换加载超时
## 症状
用户多次反馈:切换到运行中的会话时偶发一直加载并超时,刷新页面后会话立即显示。
## 交付要求
- 找出代码层面可证实且可复现的故障,明确区分未证实的现场推测。
- 首屏、历史分页和目录查询走带鉴权的 HTTP;WebSocket 断开或假 OPEN 时仍能查看历史。
- WebSocket 只接入实时会话状态和增量,重新订阅不得重读或重发历史。
- 运行中会话的历史和运行态快照能通过 HTTP 显示,订阅接入时对齐最新运行状态。
- 保留快速连续切换的请求标识隔离,过期响应不得覆盖最新选择。
- 增加足够诊断以定位同类超时,不记录消息正文或敏感内容。
- 用隔离测试验证运行中会话、断线恢复、请求失败和乱序响应。
- 测试通过后检查其他运行中对话,满足项目重启条件才重启服务。

View File

@@ -0,0 +1,23 @@
# 工作进度
- 2026-10-14:接续已有排查;确认工作区无未提交源码修改。
- 已回答用户:当前会话切换依赖 WebSocket,但不能据此认定网络故障。
- 已建立任务计划和根目录 TODO CSV;开始并行复核前后端链路。
- 已停止两个审计子代理,按用户要求由主代理直接实现 HTTP 查询与实时订阅分离。
- 已恢复规划与研究文件、确认索引 ready;源码尚无修改。
- 阶段1完成:已核对首屏、手动分页、目录分页、定位分页、运行态恢复和旧回归依赖;开始主线程实现 HTTP 查询与独立订阅。
- 阶段2完成:后端 HTTP 首屏/分页和独立订阅接通;已核实浏览器首屏、目录与定位均调用 HTTP,断 WS 不取消这些读取。
- 隔离接口回归通过:关闭 WS 后读取首屏12条、分页24条;鉴权、坏游标、损坏块/清单、修订变更及完整运行快照均通过。
- 测试夹具的 worker 曾等待生产 Codex home 锁;通过继承 preload 指定独立测试锁路径解决,未修改生产锁或运行任务。
- 进入浏览器阶段,验证假 OPEN、连续切换、迟到 HTTP 响应和任务在读取与订阅之间完成。
- Firefox 首轮验收通过,证据 /tmp/cc-web-session-http-8VUUff/browser-evidence.json;补充读取途中断连接、历史500和完成事件版本保护后复验中。
- 定向 HTTP 目标(含连接契约与真实前端请求函数)通过;session-switch-race、title-history-outline、frontend-asset-version、history-frontend-unit、history-runtime-unit、session-history-unit(12项)、history-runtime-completion-unit(15项)通过。
- history-recall 大套件及 session-history-store-unit 全套各在60秒上限停止;存储单测已显示前24项通过,不将未完成套件计为通过,未无界等待或修改测试上限。
- 已记录跨层规范 backend/session-query.md;已查询运行会话,目前只有当前对话,最终激活前需再次核实。
- 最终 Firefox 验收通过:/tmp/cc-web-session-http-FcFyDq/browser-evidence.json。假 OPEN 首屏/运行输出94ms,读取途中断 WS 后167ms显示(仅隔离夹具测量);58条分页顺序与去重、迟到响应、完成间隙、历史500均通过。
- 浏览器夹具先后遇到登录早于WS打开、外部CDN阻塞导航;前者等待登录就绪再提交,后者使用同版本原始静态文件的本地转发夹具。生产请求和渲染函数未替换。
- 新增响应结构及渲染异常退出、已接到done后的旧快照重读、未保存保留态收束和未读标记确认;对应生产前端函数单测通过。
- 阶段3完成;阶段4只剩再次确认运行会话、激活服务及本机只读冒烟检查。
- 2026-10-15:重启前已再次核实除当前对话外无其他运行会话;重启调用虽因服务切换返回 aborted,实际重启成功。PM2 的 PID 从2874011变为2353800,重启次数从85变为86,状态 online。
- 本机只读冒烟验证成功:8002端口新HTTP接口返回401/not_authenticated并回显requestId;页面脚本版本953d5e1a0bbdcf56,实际提供的app.js与工作区逐字一致,包含HTTP查询及独立实时订阅。
- 阶段4完成:服务端及提供的前端脚本已生效;未检查用户旧标签页内存中的版本,旧标签页可刷新一次取得新脚本。验证记录已保存于validation.json,任务跟踪已收尾。

View File

@@ -0,0 +1,118 @@
# 前端会话切换与 WebSocket 状态机调查
调查时间:2026-10-14(Asia/Shanghai)
## 结论
会话是否“运行中”不决定切换请求是否能够被接收。前端切换完全依赖当前
WebSocket:点击会话后构造 `load_session` 请求,只有该请求收到带匹配
`requestId` 的 `session_info`/历史尾块,才会结束加载状态。因而“刷新后当前
会话立刻正常”与旧 WebSocket 处于半开、浏览器仍报告 `OPEN` 的时序高度吻合。
本轮没有找到 `shared/session-switch-controller.js`;仓库内不存在该文件,切换
状态机实际集中在 `public/app.js`。
## 可核对的前端调用链
1. `openSession`(`public/app.js:7628-7672`)根据缓存命中情况进入
`beginSessionSwitch`。未命中或弱缓存会执行阻塞加载;运行中只影响快照/恢复
行为,不会绕过 WebSocket。
2. `beginSessionSwitch`(`:7508-7528`)先调用 `setSessionLoading` 创建
`activeSessionLoad={sessionId,requestId,...}`,再调用 `requestSessionLoad`。
3. `requestSessionLoad`(`:7530-7547`)把同一代次写入
`pendingSessionSwitchRequest`。当 `ws.readyState===1 && wsAuthenticated` 时立即
调用 `flushPendingSessionSwitch`;否则等待 `onopen`/`auth_result`。
4. `flushPendingSessionSwitch`(`:7582-7603`)先检查 `OPEN`,随后**先将
`pendingSessionSwitchRequest` 置空**(`:7585-7587`),再直接执行裸
`ws.send`(`:7596-7601`)。没有发送异常处理,也没有在发送后复核 socket 仍是
当前 socket。这是可直接证明的丢请求窗口:假 `OPEN` 或发送时 socket 已关闭时,
请求可能既未送达也未回队列,而 `activeSessionLoad` 仍等待其 45 秒定时器。
5. 服务端消息处理(`:8442-8512`)只接受匹配活动加载的 `sessionId` 与
`requestId`;晚到的旧响应会被丢弃。正常 `session_info` 后,若历史分片存在,
要等 `session_history_chunk` 最后一块(`:8597-8646`)才调用
`finalizeLoadedSession`,最终关闭加载状态。
## 触发“45 秒后超时”的可复现时序
### 时序 A:半开/假 OPEN(最符合“刷新后正常”)
```text
t0 旧 socket 在浏览器对象中仍为 OPEN,但服务端/代理已不再处理它
t1 用户点击运行中会话 B;activeSessionLoad=B,pending=B
t1 flush 通过 readyState 检查,清空 pending,向旧 socket send load_session
t1..t1+? 服务端没有 session_info;浏览器也没有触发 onclose
t1+12s 只释放遮罩,activeSessionLoad 仍在等待
t1+45s scheduleSessionLoadRequestTimeout 清空 active load 并提示“加载超时”
t1+45s 用户刷新,旧 socket 被销毁,新 socket 认证后重新发请求,B 立即显示
```
客户端应用心跳在认证成功后才开启(`:8345-8355`),间隔 15 秒、等待 ack 10
秒(`:8151-8176`)。假连接在下一次检测前仍可保持 `OPEN`;移动端后台节流还
可能推迟心跳计时器。只有 `onclose` 触发,才会在 `:8243-8264` 将活动请求放回
`pendingSessionSwitchRequest` 并安排重连。也就是说,heartbeat 不是切换协议,
只是延迟发现坏连接;在发现之前的窗口内,`load_session` 可以被写入黑洞。
### 时序 B:发送调用本身抛错
`flushPendingSessionSwitch` 在置空 pending 后调用裸 `ws.send`。若浏览器在检查
`readyState` 与 `send` 之间将 socket 转为 closing/closed,`send` 会同步抛出;
该异常从点击事件或 `auth_result` 处理路径冒出。`onmessage` 的总捕获
(`:8195-8206`)只记录异常并释放遮罩,**不会关闭 socket、重排队或清除活动
定时器**。结果仍是活动加载挂到 45 秒后才提示超时。
同类问题也存在于 `flushPendingSessionResume`(`:7605-7611`)的裸 `ws.send`,
但它主要影响运行中会话重连,不是普通切换的首要路径。
### 时序 C:已有关闭事件时的正确恢复路径(用于对照)
若 socket 先触发 `onclose`,代码会把活动加载复制回 pending(`:8243-8251`),
调用 `clearSessionLoading`,然后 `scheduleReconnect`。新连接认证成功后
`auth_result` 再调用 `flushPendingSessionSwitch`(`:8362-8373`)。这条路径本身
保留 `requestId`,可恢复快速切换的代次隔离;因此仅凭“切换失败”不能推断服务
端会话未运行,必须区分“已 close 并重试”和“仍 OPEN 未重试”。
## 现有代次隔离与缺口
- `activeSessionLoad`、`pendingSessionSwitchRequest` 和消息中的 `requestId` 已
防止 A 的晚到响应覆盖 B(`:8459-8477`、`:7472-7506`)。
- `session_history_chunk` 对活动请求同样检查 `requestId`(`:8514-8521`)。
- 45 秒计时器只做取消:`scheduleSessionLoadRequestTimeout`(`:7218-7232`)
清空 pending、清除加载并显示提示,没有执行一次强制断开/重连,也没有再次发送
同代次请求。因此它是用户可见兜底,不是恢复机制。
- `onmessage` 异常路径仅释放遮罩(`:8200-8206`);若渲染/快照处理抛错,活动
请求和计时器仍在,且连接继续被视为可用。
- 现有 `scripts/regression.js` 的 `assertSessionSwitchResilienceContract`
(`:3768-3926`)主要是源码字符串契约,虽然断言存在 heartbeat、超时和 close
重试代码,但没有模拟 `OPEN` 黑洞、`ws.send` 抛错、onmessage 异常或 45 秒
内的请求回队列。因此它无法覆盖本故障的关键时序。
## 最小修复建议(供主任务实现)
1. 将 `flushPendingSessionSwitch` 的 pending 清空动作移到成功 `send` 之后,
对 `send` 使用 `try/catch`。失败时保留原对象和 `requestId`,只关闭当前 socket
并调用既有重连逻辑;不要生成新代次,避免同一切换重复响应污染。
2. 发送前确认 `ws === socket`、`readyState===OPEN`、已认证;发送后若状态已变为
closing/closed,立即走与 `onclose` 相同的回队列路径。可抽取一个不重复覆盖新
请求的 `requeueActiveSessionLoad` 辅助函数。
3. 在 45 秒超时回调中,若请求仍是同一代次,先做一次受控的 socket 失效与重连
尝试;只有重试已发生且仍无响应才给用户“已取消”提示。重试必须保留
`requestId`/最新会话选择,不能让旧 A 响应关闭新 B。
4. `onmessage` 捕获到处理异常时应记录脱敏的消息类型、活动会话和 requestId,
清理/回队列并关闭该 socket;不能只释放遮罩后继续挂起。
## 隔离验证建议
在不接触真实会话的浏览器/Node 夹具中验证以下四种情况,单项超时上限 60 秒:
1. `FakeWebSocket.readyState=OPEN` 但 `send` 不触发服务端:点击 B 后应在心跳
检测/受控重连后自动完成,不应等到 45 秒才失败。
2. `send` 同步抛出 `InvalidStateError`:pending 必须仍保留,socket 被关闭并在
新认证后只发送一次 B;活动加载计时器应绑定同一 requestId。
3. 发送 A 后快速点击 B,先注入 A 的 `session_info`/历史尾块,再注入 B;A 不得
改变当前会话、关闭 B 的加载或清除 B 的计时器。
4. 让 `handleServerMessage` 对一条 `session_info` 抛异常:应记录并重连/重试,
不应只释放遮罩后 45 秒静默超时。
验收时同时记录 `load_session` 发送时间、socket 代号、requestId、首次响应时间
和重连次数(不记录消息正文),即可区分假 OPEN、服务端处理慢和前端渲染异常。

View File

@@ -0,0 +1,93 @@
# 服务端切换链路调查
调查时间:2026-10-14(Asia/Shanghai)
## 结论
会话“运行中”与切换请求是否能完成是两条链路。运行状态只决定
`handleLoadSession` 在发送 `session_info` 后是否调用
`attachActiveRuntimeToWs`;切换本身通过同一条 WebSocket 发送
`load_session`。因此刷新后恢复首先指向旧 WebSocket 已经半开、假 `OPEN`、
或服务端事件循环暂时无法处理消息,而不是模型仍在运行导致切换必须等待。
## 可核对的调用链
1. 前端 `public/app.js:7530-7547` 记录待切换会话;
`:7582-7602` 检查 `readyState === 1` 后直接发送
`{ type: 'load_session', sessionId, requestId }`。发送前
`pendingSessionSwitchRequest` 已被置空(`:7583-7587`),`ws.send` 没有
`try/catch`。
2. 服务端 `server.js:10328-10330` 在 WebSocket `message` 分发中同步调用
`handleLoadSession`,该分支没有请求级 `try/catch`。
3. `handleLoadSession`(`server.js:12081-12200`)先做跨会话状态协调和两次
`loadSession(..., { metadata: true })`,然后在 `:12106-12120` 同步解析
首屏历史;只有完成这些步骤后,才在 `:12143-12176` 发送
`session_info`,最后在 `:12198-12199` 绑定运行时输出。
4. 运行态绑定(`attachActiveRuntimeToWs`,`server.js:11959-12037`)只更新
`entry.ws` 并发送 `resume_generating` 或 Codex App 恢复消息。它不等待模型
回合结束,也不决定 `load_session` 是否被接收。
## 为什么“刷新后正常”能定位到连接层
- 前端切换请求超时为 45 秒(`public/app.js:105-106,7218-7231`),遮罩
12 秒后先释放;这正是“看起来一直加载,随后取消”的用户提示来源。
- 服务器应用心跳周期为 30 秒,连续 4 次漏 pong 才 `terminate`(
`server.js:10189-10190,10440-10459`)。客户端应用心跳每 15 秒,10 秒无
ack 才主动关闭(`public/app.js:105-110,8151-8168`)。在这段窗口内,
浏览器可能仍把 TCP 半开连接报告为 `OPEN`,于是 `flushPendingSessionSwitch`
会把请求写入一条实际上不会到达服务端的连接,并一直等 45 秒。
- `onclose` 才会把当前加载重新放回待发送队列(`public/app.js:8243-8264`)。
半开连接未触发 `close` 时不会重排队;刷新页面会销毁旧 socket、创建新 socket,
认证后重新发送请求,所以当前会话很快显示。
- `logs/process.log` 有持续的连接失效证据:从 2026-09-27 到 2026-10-13
共 40 次 `ws_heartbeat_terminate`;例如 2026-10-13 15:13:42 的
`wsId=97f7015d`,`missedPongs=4`、`lastActivityAgeMs=170270`,随后会有
`ws_disconnect`。这证明服务确实遇到过失活 WebSocket,不能把现象归因于
用户网络波动。
## 服务端历史读取的第二个无响应风险
- 没有归档清单时,`loadSession`(`server.js:6238-6272`)回退到
`safeReadSessionJson`,同步 `statSync`、`readFileSync` 和 `JSON.parse`;当前
目录中可见 4--7 MiB 的旧会话快照。这会阻塞 Node 单线程,期间同一 socket
的心跳和后续消息都无法处理。
- 有归档时,`resolveSessionHistory`(`server.js:4813-4871`)调用
`sessionHistoryStore.loadHistoryPage`;`lib/session-history-store.js:167-183`
用同步 `readFileSync`、校验哈希并 `JSON.parse` 块。归档块损坏或缺失会抛出
异常(`:172-182`),而 `handleLoadSession` 外层没有捕获并返回带
`requestId` 的 `error`。这属于可证实的“服务端无响应/进程异常”路径,需与
半开 socket 分开记录。
- `wsSend`(`server.js:2227-2240`)只在 `readyState === 1` 时发送;发送失败
仅记录 `ws_send_failed`,不会把原请求重新入队。因此即使服务端已完成读取,
socket 在发送窗口失效也不会自动补发。
目前日志没有 `load_session` 的开始、结束、耗时或 `requestId`,所以不能从历史
日志判断某次现场究竟属于半开连接还是同步历史读取;两条路径都需要补诊断后再
做最终归因。
## 隔离验证
- 现有静态回归目标必须使用 `--target`:
`node scripts/regression.js --target session-switch-race`,本次在 2 秒内退出
0 并打印 `Session switch race regression checks passed.`。
- 先前执行的 `node scripts/regression.js session-switch-race` 没有传
`--target`,因此没有选择该目标而是进入完整回归;60 秒无输出不能作为切换
失败证据。
- 可在不接触真实会话的临时 `CC_WEB_SESSIONS_DIR` 中做两组小夹具:
(a) 关闭/黑洞 WebSocket 后发送 `load_session`,观察浏览器 45 秒计时和
`onclose` 重试;(b) 构造缺失归档块,发送同一请求,确认服务端返回带
`requestId` 的错误而非等待。两组测试都应以 60 秒为上限。
## 推荐的最小修复方向(供主任务实现)
1. 前端发送 `load_session` 前后都捕获 `ws.send` 异常;只有确认发送成功才清空
`pendingSessionSwitchRequest`。若 socket 在发送前后不是有效连接,立即关闭并
触发重连,保留原 `requestId`。
2. 服务端在 `load_session` 分发处包住 `handleLoadSession`,捕获历史读取异常,
发送 `{ type: 'error', code, sessionId, requestId }`;不得把异常静默成
`session_not_found`。
3. 给 `load_session` 记录开始、结束、耗时、脱敏 `sessionId`/`requestId`、
history source/size 和异常代码,不记录消息正文。
4. 对超过阈值的旧快照避免在 WebSocket 事件回调内全量同步解析;首屏只读取
有界尾部,完整历史继续分页。

View File

@@ -0,0 +1,26 @@
{
"id": "session-switch-timeout",
"name": "session-switch-timeout",
"title": "修复会话切换加载超时",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "shiyue",
"assignee": "shiyue",
"createdAt": "2026-10-14",
"completedAt": "2026-10-15",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -0,0 +1,19 @@
# 修复会话切换加载超时
## 目标
将会话首屏、历史分页改为独立 HTTP 查询,消除对 WebSocket 鉴权、重连和运行态绑定的依赖;实时消息由独立订阅接入,用隔离测试验证。
## 阶段
1. [complete] 确认 HTTP 加载与实时订阅的边界及并发契约。
2. [complete] 实现 HTTP 会话快照、历史分页与独立实时订阅。
3. [complete] 验证断开 WebSocket、运行中切换、连续切换和历史异常。
4. [complete] 检查生效条件并交付根因、验证结果与限制。
## 约束
- 不把刷新恢复直接归因于网络;区分连接、请求、服务端和渲染。
- 不删除或修改用户会话历史,不中断其他运行中对话。
- 单次测试设置 60 秒超时,使用隔离测试数据。
- 用户明确禁止子代理,全部工作由主代理直接完成。
## 已遇到的问题
- 上一轮运行 session-switch-race 60 秒无输出;先检查测试入口再选择隔离用例,避免原样重复。

View File

@@ -0,0 +1,46 @@
{
"browser": "Firefox 157.0.1",
"browserEvidence": "/tmp/cc-web-session-http-FcFyDq/browser-evidence.json",
"apiEvidence": "/tmp/cc-web-session-http-VTWGN4",
"verified": [
"WebSocket 假 OPEN 下首屏和完整运行输出",
"HTTP 读取途中断开 WebSocket 仍完成",
"拒绝 WebSocket 重连后仍可切换",
"HTTP 分页58条顺序正确且无重复",
"A 迟到响应不会覆盖 B",
"读取与订阅之间完成的任务通过修订对账补齐",
"损坏历史500立即结束加载并保留当前视图",
"浏览器不发送 WebSocket 历史读取请求",
"同会话代次、分页取消、完成交错、错误响应和渲染异常",
"HTTP 鉴权、非法ID/游标、损坏块/清单、修订变化与运行快照"
],
"otherPassed": [
"session-switch-race",
"title-history-outline",
"frontend-asset-version",
"history-frontend-unit",
"history-runtime-unit",
"session-history-unit: 12项",
"history-runtime-completion-unit: 15项",
"JavaScript语法和git diff --check"
],
"incompleteAt60Seconds": [
"history-recall 大套件",
"session-history-store-unit 全套:前24项已显示通过"
],
"activation": "verified",
"activationEvidence": {
"checkedAt": "2026-10-15T00:17:13.907+08:00",
"status": "online",
"pid": 2353800,
"restarts": 86,
"port": 8002,
"version": "953d5e1a0bbdcf56",
"httpStatus": 401,
"httpErrorCode": "not_authenticated",
"requestIdEchoed": true,
"servedFrontendMatchesSource": true,
"hasHttpQuery": true,
"hasRealtimeSubscription": true
}
}

View File

@@ -104,7 +104,6 @@
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000;
const SESSION_RESUME_FALLBACK_MS = 1_500;
// session_list 可能在重连/原子写入窗口中暂时缺少当前会话;不要把一次快照抖动当成删除。
const SESSION_LIST_MISSING_CONFIRM_MS = 1_500;
const WS_CLIENT_HEARTBEAT_INTERVAL_MS = 15_000;
@@ -231,7 +230,6 @@
let isGenerating = false;
let reconnectAttempts = 0;
let reconnectTimer = null;
let lastWsDisconnectNoticeAt = 0;
let isPageUnloading = false;
let pendingText = '';
let renderTimer = null;
@@ -281,7 +279,6 @@
};
let sessionLoadOverlayTimer = null;
let sessionLoadRequestTimer = null;
let sessionResumeFallbackTimer = null;
let sidebarSwipe = null;
let activeComposerToken = null;
let composerSuggestionTimer = null;
@@ -302,7 +299,11 @@
let codexAppApprovalModal = null;
let codexAppElicitationModal = null;
let pendingNewSessionRequest = null;
let pendingSessionSwitchRequest = null;
let sessionLoadAbortController = null;
let currentSessionViewRequestId = '';
let currentSessionHistoryRevision = null;
let currentSessionSubscriptionRequestId = '';
const sessionCompletionVersions = new Map();
let pendingSessionResumeRequest = null;
let pendingMissingSessionConfirmation = null;
let sessionSwitchRequestSeq = 0;
@@ -2083,6 +2084,8 @@
}
function resetOutlineHistoryState(sessionId = null) {
activeOutlineHistoryRequest?.controller?.abort();
activeOutlineTargetRequest?.controller?.abort();
activeOutlineHistoryRequest = null;
activeOutlineTargetRequest = null;
currentOutlineHistoryState = createOutlineHistoryState(sessionId);
@@ -2327,7 +2330,7 @@
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (activeOutlineTargetRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return;
if (activeOutlineTargetRequest) return;
pendingAdvancedSearchJump = {
sessionId: currentSessionId,
messageIndex: Math.trunc(normalizedIndex),
@@ -2341,13 +2344,7 @@
messageIndex: Math.trunc(normalizedIndex),
};
closeUserOutlinePanel();
send({
type: 'load_history_page',
sessionId: currentSessionId,
before: Math.trunc(normalizedIndex) + 1,
requestId,
purpose: 'outline-target',
});
fetchHistoryPage(activeOutlineTargetRequest, Math.trunc(normalizedIndex) + 1);
}
function scrollToMessage(anchorId) {
@@ -2929,6 +2926,7 @@
historyTruncated,
historyAvailable: payload.historyAvailable !== false,
historySource: payload.historySource || 'snapshot',
historyRevision: payload.historyRevision ?? null,
historyPending,
complete: !historyPending
&& !historyTruncated
@@ -6953,7 +6951,6 @@
// 当前会话正在等待明确的加载/重连响应时,不能把等待中的视图清掉。
const hasPendingLoad = !!(
activeSessionLoad?.sessionId === sessionId
|| pendingSessionSwitchRequest?.sessionId === sessionId
|| pendingSessionResumeRequest?.sessionId === sessionId
);
if (pending.missingCount < 2 || hasPendingLoad) {
@@ -7004,6 +7001,10 @@
function resetChatView(agent) {
clearPendingMissingSessionConfirmation();
currentSessionViewRequestId = '';
currentSessionHistoryRevision = null;
currentSessionSubscriptionRequestId = '';
pendingSessionResumeRequest = null;
setCurrentAgent(agent);
closeUserOutlinePanel();
closeCcwebPromptOutlinePanel();
@@ -7184,25 +7185,11 @@
sessionLoadRequestTimer = null;
}
function clearSessionResumeFallbackTimer() {
if (!sessionResumeFallbackTimer) return;
clearTimeout(sessionResumeFallbackTimer);
sessionResumeFallbackTimer = null;
}
function clearPendingSessionSwitchRequest(sessionId, requestId) {
if (!pendingSessionSwitchRequest) return;
if (sessionId && pendingSessionSwitchRequest.sessionId !== sessionId) return;
if (requestId && pendingSessionSwitchRequest.requestId !== requestId) return;
pendingSessionSwitchRequest = null;
}
function clearPendingSessionResumeRequest(sessionId, requestId) {
if (!pendingSessionResumeRequest) return;
if (sessionId && pendingSessionResumeRequest.sessionId !== sessionId) return;
if (requestId && pendingSessionResumeRequest.requestId !== requestId) return;
pendingSessionResumeRequest = null;
clearSessionResumeFallbackTimer();
}
function notifySessionLoadTimeout(sessionId) {
@@ -7225,7 +7212,6 @@
activeSessionLoad.requestId !== requestId) {
return;
}
clearPendingSessionSwitchRequest(sessionId, requestId);
clearSessionLoading(sessionId);
notifySessionLoadTimeout(sessionId);
}, SESSION_LOAD_REQUEST_TIMEOUT_MS);
@@ -7246,6 +7232,8 @@
}
function setSessionLoading(sessionId, options = {}) {
sessionLoadAbortController?.abort();
sessionLoadAbortController = null;
clearSessionLoadOverlayTimer();
clearSessionLoadRequestTimer();
const loading = !!sessionId;
@@ -7258,7 +7246,7 @@
requestId,
overlayReleased: false,
recoverCurrent: options.recoverCurrent === true,
targetMessageIndex: Number.isFinite(Number(options.targetMessageIndex))
targetMessageIndex: options.targetMessageIndex != null && Number.isFinite(Number(options.targetMessageIndex))
? Math.max(0, Number(options.targetMessageIndex))
: null,
} : null;
@@ -7292,6 +7280,7 @@
}
function resetHistoryLoadState(sessionId = null) {
activeHistoryPageRequest?.controller?.abort();
activeHistoryPageRequest = null;
currentHistoryState = {
sessionId,
@@ -7368,7 +7357,7 @@
function requestOlderHistory() {
const state = currentHistoryState;
if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0) return;
if (activeHistoryPageRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return;
if (activeHistoryPageRequest) return;
historyPageRequestSeq += 1;
const requestId = `history-page-${Date.now()}-${historyPageRequestSeq}`;
activeHistoryPageRequest = {
@@ -7378,34 +7367,24 @@
};
currentHistoryState = { ...state, loading: true, error: '' };
renderHistoryLoadControl();
send({
type: 'load_history_page',
sessionId: state.sessionId,
before: state.cursor,
requestId,
});
fetchHistoryPage(activeHistoryPageRequest, state.cursor);
}
function requestOutlineHistoryPage() {
const state = currentOutlineHistoryState;
if (!state.sessionId || state.sessionId !== currentSessionId || state.cursor <= 0) return;
if (activeOutlineHistoryRequest || !ws || ws.readyState !== 1 || !wsAuthenticated) return;
if (activeOutlineHistoryRequest) return;
outlineHistoryRequestSeq += 1;
const requestId = `outline-history-${Date.now()}-${outlineHistoryRequestSeq}`;
activeOutlineHistoryRequest = {
sessionId: state.sessionId,
requestId,
before: state.cursor,
purpose: 'outline',
};
currentOutlineHistoryState = { ...state, loading: true, error: '' };
updateUserOutlinePanel();
send({
type: 'load_history_page',
sessionId: state.sessionId,
before: state.cursor,
requestId,
purpose: 'outline',
});
fetchHistoryPage(activeOutlineHistoryRequest, state.cursor);
}
function mergeHistoryChunkIntoCachedSnapshot(sessionId, messages, options = {}) {
@@ -7529,21 +7508,114 @@
function requestSessionLoad(sessionId, options = {}) {
if (!sessionId) return;
pendingSessionSwitchRequest = {
sessionId,
blocking: options.blocking !== false,
label: options.label || '',
requestId: options.requestId || activeSessionLoad?.requestId || createSessionSwitchRequestId(sessionId),
recoverCurrent: options.recoverCurrent === true,
targetMessageIndex: Number.isFinite(Number(options.targetMessageIndex))
? Math.max(0, Number(options.targetMessageIndex))
: activeSessionLoad?.targetMessageIndex ?? null,
};
if (ws && ws.readyState === 1 && wsAuthenticated) {
flushPendingSessionSwitch();
return;
const requestId = options.requestId
|| (activeSessionLoad?.sessionId === sessionId ? activeSessionLoad.requestId : '')
|| createSessionSwitchRequestId(sessionId);
const targetMessageIndex = options.targetMessageIndex != null && Number.isFinite(Number(options.targetMessageIndex))
? Math.max(0, Number(options.targetMessageIndex))
: activeSessionLoad?.targetMessageIndex ?? null;
if (!activeSessionLoad || activeSessionLoad.sessionId !== sessionId || activeSessionLoad.requestId !== requestId) {
setSessionLoading(sessionId, { ...options, requestId, targetMessageIndex });
}
if (!ws || ws.readyState > 1) connect();
sessionLoadAbortController?.abort();
if (activeHistoryPageRequest) {
currentHistoryState = { ...currentHistoryState, loading: false };
renderHistoryLoadControl();
}
if (activeOutlineHistoryRequest) currentOutlineHistoryState = { ...currentOutlineHistoryState, loading: false };
activeHistoryPageRequest?.controller?.abort();
activeOutlineHistoryRequest?.controller?.abort();
activeOutlineTargetRequest?.controller?.abort();
activeHistoryPageRequest = null;
activeOutlineHistoryRequest = null;
activeOutlineTargetRequest = null;
const previousViewRequestId = currentSessionViewRequestId;
currentSessionViewRequestId = requestId;
if (options.subscribe !== false) {
currentSessionSubscriptionRequestId = '';
pendingSessionResumeRequest = null;
}
const controller = new AbortController();
sessionLoadAbortController = controller;
clearSessionLoadRequestTimer();
const completionVersion = sessionCompletionVersions.get(sessionId) || 0;
let applyingSnapshot = false;
// 历史查询不等待 WebSocket;控制器和请求代次共同隔离迟到响应。
fetchSessionQuery(sessionId, requestId, { signal: controller.signal }).then(payload => {
if (sessionLoadAbortController !== controller || currentSessionViewRequestId !== requestId) return;
// 已经收到完成事件的页面不能再被更早读出的运行快照覆盖。
if ((sessionCompletionVersions.get(sessionId) || 0) !== completionVersion) {
requestSessionLoad(sessionId, { ...options, targetMessageIndex,
requestId: createSessionSwitchRequestId(sessionId) });
return;
}
applyingSnapshot = true;
sessionLoadAbortController = null;
currentSessionHistoryRevision = payload.historyRevision ?? null;
handleServerMessage(payload);
if (currentSessionId !== sessionId || currentSessionViewRequestId !== requestId) return;
// 订阅后的历史对账保留最新实时输出,不能用较早的 HTTP 运行快照覆盖它。
if (payload.runtime && options.subscribe !== false) {
handleServerMessage({ ...payload.runtime, requestId });
if (payload.runtime.isRunning === false) finishGenerating(sessionId);
}
if (options.subscribe !== false) requestSessionResume(sessionId);
if (targetMessageIndex !== null) requestOutlineMessageTarget(targetMessageIndex);
applyingSnapshot = false;
}).catch(error => {
if ((!applyingSnapshot && sessionLoadAbortController !== controller) || currentSessionViewRequestId !== requestId) return;
currentSessionViewRequestId = previousViewRequestId;
clearSessionLoading(sessionId);
appendError(error.message || '会话历史加载失败,请重试', { transient: true, autoDismissMs: 9000 });
if (currentSessionId && options.subscribe !== false) requestSessionResume(currentSessionId);
}).finally(() => {
if (sessionLoadAbortController === controller) sessionLoadAbortController = null;
});
}
async function fetchSessionQuery(sessionId, requestId, options = {}) {
const controller = new AbortController();
const abort = () => controller.abort();
if (options.signal?.aborted) abort();
options.signal?.addEventListener('abort', abort, { once: true });
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; abort(); }, SESSION_LOAD_REQUEST_TIMEOUT_MS);
try {
const parameters = new URLSearchParams({ requestId });
if (options.before !== undefined) parameters.set('before', String(options.before));
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}${options.historyPage ? '/history' : ''}?${parameters}`, {
method: 'GET', headers: { Authorization: `Bearer ${authToken}` }, cache: 'no-store', signal: controller.signal,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message || '会话历史加载失败,请重试');
if (payload.sessionId !== sessionId || payload.requestId !== requestId) throw new Error('会话历史响应与请求不匹配');
if (payload.type !== (options.historyPage ? 'session_history_chunk' : 'session_info') || !Array.isArray(payload.messages)) {
throw new Error('会话历史响应格式无效');
}
return payload;
} catch (error) {
if (timedOut) throw new Error('会话历史 HTTP 请求超时,请重试');
throw error;
} finally {
clearTimeout(timer);
options.signal?.removeEventListener('abort', abort);
}
}
function fetchHistoryPage(request, before) {
request.controller = new AbortController();
const viewRequestId = currentSessionViewRequestId;
const isActive = () => currentSessionId === request.sessionId
&& currentSessionViewRequestId === viewRequestId
&& [activeHistoryPageRequest, activeOutlineHistoryRequest, activeOutlineTargetRequest].includes(request);
fetchSessionQuery(request.sessionId, request.requestId, {
historyPage: true, before, signal: request.controller.signal,
}).then(payload => {
if (isActive()) handleServerMessage(payload);
}).catch(error => {
if (isActive()) handleServerMessage({ type: 'error', sessionId: request.sessionId,
requestId: request.requestId, message: error.message || '加载更早消息失败,请重试' });
});
}
function requestSessionResume(sessionId, options = {}) {
@@ -7552,6 +7624,7 @@
sessionId,
requestId: options.requestId || createSessionSwitchRequestId(sessionId),
};
currentSessionSubscriptionRequestId = pendingSessionResumeRequest.requestId;
if (ws && ws.readyState === 1 && wsAuthenticated) {
flushPendingSessionResume();
return;
@@ -7559,56 +7632,18 @@
if (!ws || ws.readyState > 1) connect();
}
function scheduleSessionResumeFallback(sessionId, requestId) {
clearSessionResumeFallbackTimer();
if (!sessionId || !requestId) return;
sessionResumeFallbackTimer = setTimeout(() => {
sessionResumeFallbackTimer = null;
if (!pendingSessionResumeRequest ||
pendingSessionResumeRequest.sessionId !== sessionId ||
pendingSessionResumeRequest.requestId !== requestId) {
return;
}
pendingSessionResumeRequest = null;
requestSessionLoad(sessionId, {
blocking: false,
label: '正在恢复运行输出…',
requestId: createSessionSwitchRequestId(sessionId),
recoverCurrent: true,
});
}, SESSION_RESUME_FALLBACK_MS);
}
function flushPendingSessionSwitch() {
if (!pendingSessionSwitchRequest) return false;
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false;
const request = pendingSessionSwitchRequest;
pendingSessionSwitchRequest = null;
if (!activeSessionLoad) {
setSessionLoading(request.sessionId, {
blocking: request.blocking,
label: request.label || undefined,
requestId: request.requestId,
recoverCurrent: request.recoverCurrent === true,
targetMessageIndex: request.targetMessageIndex,
});
}
ws.send(JSON.stringify({
type: 'load_session',
sessionId: request.sessionId,
requestId: request.requestId,
...(Number.isFinite(request.targetMessageIndex) ? { targetMessageIndex: request.targetMessageIndex } : {}),
}));
return true;
}
function flushPendingSessionResume() {
if (!pendingSessionResumeRequest) return false;
if (!ws || ws.readyState !== 1 || !wsAuthenticated) return false;
const request = pendingSessionResumeRequest;
ws.send(JSON.stringify({ type: 'resume_session', sessionId: request.sessionId, requestId: request.requestId }));
scheduleSessionResumeFallback(request.sessionId, request.requestId);
return true;
try {
ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: request.sessionId,
requestId: request.requestId, historyRevision: currentSessionHistoryRevision }));
return true;
} catch {
showToast('实时订阅暂未连接,历史仍可查看');
return false;
}
}
function showCachedSession(sessionId) {
@@ -7622,7 +7657,13 @@
closeCcwebPromptOutlinePanel();
clearSessionLoading();
touchSessionCache(sessionId);
return applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true });
const applied = applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true });
if (applied) {
currentSessionViewRequestId = createSessionSwitchRequestId(sessionId);
currentSessionHistoryRevision = snapshot.historyRevision ?? null;
requestSessionResume(sessionId);
}
return applied;
}
function openSession(sessionId, options = {}) {
@@ -8212,55 +8253,14 @@
wsAuthenticated = false;
wsSupportsClientHeartbeat = false;
clearWsHeartbeat();
if (activeOutlineHistoryRequest) {
activeOutlineHistoryRequest = null;
currentOutlineHistoryState = {
...currentOutlineHistoryState,
loading: false,
error: '连接中断,连接恢复后可重试',
};
if (userOutlinePanel && !userOutlinePanel.hidden) updateUserOutlinePanel();
}
if (activeOutlineTargetRequest) {
activeOutlineTargetRequest = null;
pendingAdvancedSearchJump = null;
if (!isPageUnloading) {
appendError('连接中断,定位消息加载失败;连接恢复后请重新点击该条目。', {
transient: true,
autoDismissMs: 7000,
});
}
}
if (activeHistoryPageRequest) {
activeHistoryPageRequest = null;
currentHistoryState = {
...currentHistoryState,
loading: false,
error: '连接中断,连接恢复后可重试',
};
renderHistoryLoadControl();
}
if (activeSessionLoad?.sessionId && !isPageUnloading) {
pendingSessionSwitchRequest = {
sessionId: activeSessionLoad.sessionId,
blocking: false,
label: sessionLoadingLabel?.textContent || '',
requestId: activeSessionLoad.requestId || createSessionSwitchRequestId(activeSessionLoad.sessionId),
recoverCurrent: activeSessionLoad.recoverCurrent === true,
targetMessageIndex: activeSessionLoad.targetMessageIndex,
};
const now = Date.now();
if (now - lastWsDisconnectNoticeAt >= 5000) {
lastWsDisconnectNoticeAt = now;
showToast('连接中断,正在重连并自动重试会话切换…');
}
} else if (currentSessionId && (isGenerating || currentSessionRunning) && !isPageUnloading) {
// 长连接中断只影响实时订阅,正在进行的 HTTP 首屏和分页继续完成。
if (currentSessionId && !isPageUnloading) {
pendingSessionResumeRequest = {
sessionId: currentSessionId,
requestId: createSessionSwitchRequestId(currentSessionId),
};
currentSessionSubscriptionRequestId = pendingSessionResumeRequest.requestId;
}
clearSessionLoading();
scheduleReconnect();
};
socket.onerror = () => {};
@@ -8359,14 +8359,11 @@
app.hidden = false;
setUsageDashboardFeatureEnabled(msg.features?.usageStatistics === true);
setTaskBoardFeatureEnabled(msg.features?.taskBoard === true);
const flushedSessionSwitch = flushPendingSessionSwitch();
const flushedSessionResume = flushedSessionSwitch ? false : flushPendingSessionResume();
if (!flushedSessionSwitch &&
const flushedSessionResume = sessionLoadAbortController ? false : flushPendingSessionResume();
if (!sessionLoadAbortController &&
!flushedSessionResume &&
!pendingSessionSwitchRequest &&
!pendingSessionResumeRequest &&
currentSessionId &&
(isGenerating || currentSessionRunning)) {
currentSessionId) {
requestSessionResume(currentSessionId, {
requestId: createSessionSwitchRequestId(currentSessionId),
});
@@ -8381,9 +8378,8 @@
} else {
clearWsHeartbeat();
wsSupportsClientHeartbeat = false;
pendingSessionSwitchRequest = null;
pendingSessionResumeRequest = null;
clearSessionResumeFallbackTimer();
currentSessionSubscriptionRequestId = '';
clearSessionLoading();
authToken = null;
wsAuthenticated = false;
@@ -8841,6 +8837,10 @@
break;
case 'done':
if (msg.sessionId) {
sessionCompletionVersions.set(msg.sessionId, (sessionCompletionVersions.get(msg.sessionId) || 0) + 1);
if (sessionCompletionVersions.size > 2048) sessionCompletionVersions.delete(sessionCompletionVersions.keys().next().value);
}
if (!isCurrentSessionEvent(msg)) {
if (msg.sessionId) {
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.isRunning = msg.goalActive === true; });
@@ -8927,8 +8927,29 @@
}
break;
case 'session_subscribed':
if (msg.sessionId !== currentSessionId || String(msg.requestId || '') !== currentSessionSubscriptionRequestId) break;
clearPendingSessionResumeRequest(msg.sessionId, msg.requestId);
const subscribedMeta = getSessionMeta(msg.sessionId);
if (subscribedMeta) subscribedMeta.hasUnread = false;
updateCachedSession(msg.sessionId, cached => { cached.hasUnread = false; });
renderSessionList();
if (msg.historyChanged) {
requestSessionLoad(msg.sessionId, {
requestId: createSessionSwitchRequestId(msg.sessionId), blocking: false,
recoverCurrent: true, subscribe: false,
});
} else {
currentSessionHistoryRevision = msg.historyRevision ?? null;
updateCachedSession(msg.sessionId, cached => { cached.historyRevision = currentSessionHistoryRevision; });
setCurrentSessionRunningState(!!msg.isRunning);
if (!msg.isRunning && isGenerating) finishGenerating(msg.sessionId);
}
break;
case 'resume_generating':
if (!isCurrentSessionEvent(msg)) break;
if (msg.requestId && msg.requestId !== currentSessionViewRequestId && msg.requestId !== currentSessionSubscriptionRequestId) break;
clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId);
// Server has an active process for this session — resume streaming
setCurrentSessionRunningState(true);
@@ -9002,6 +9023,11 @@
break;
}
const errorRequestId = String(msg.requestId || '');
if (errorRequestId && errorRequestId === currentSessionSubscriptionRequestId) {
clearPendingSessionResumeRequest(msg.sessionId, errorRequestId);
showToast(msg.message || '实时订阅失败,历史仍可查看');
break;
}
const matchesOutlineTargetError = !!(activeOutlineTargetRequest
&& (!msg.sessionId || msg.sessionId === activeOutlineTargetRequest.sessionId)
&& errorRequestId === activeOutlineTargetRequest.requestId);
@@ -9044,9 +9070,6 @@
(!msg.sessionId || msg.sessionId === activeSessionLoad.sessionId) &&
(!errorRequestId || errorRequestId === activeSessionLoad.requestId));
if (!matchesActiveLoadError && !isCurrentSessionEvent(msg)) break;
if (matchesActiveLoadError) {
clearPendingSessionSwitchRequest(activeSessionLoad.sessionId, activeSessionLoad.requestId);
}
if (msg.code === 'new_session_cwd_missing' && pendingNewSessionRequest?.cwd) {
const request = pendingNewSessionRequest;
pendingNewSessionRequest = null;

View File

@@ -113,6 +113,7 @@ function createHarness() {
let collabAgentIdsByToolUseId = new Map();
let closedCollabAgentIdsByToolUseId = new Map();
let currentSessionId = 'running-session';
const sessionCompletionVersions = new Map();
let currentSessionMessageCount = 0;
let isGenerating = false;
let generatingSessionId = null;
@@ -224,6 +225,7 @@ function createPaginationHarness() {
let activeHistoryPageRequest = null;
let activeOutlineHistoryRequest = null;
let activeOutlineTargetRequest = null;
const currentSessionSubscriptionRequestId = '';
let historyPageRequestSeq = 0;
let outlineHistoryRequestSeq = 0;
const activeSessionLoad = null;
@@ -238,6 +240,7 @@ function createPaginationHarness() {
const requests = [];
const document = { createElement(tagName) { return new Element(tagName); } };
function send(request) { requests.push(request); }
function fetchHistoryPage(request, before) { request.before = before; requests.push(request); }
function shortMessagePreview(value, length) { return String(value).slice(0, length); }
function escapeHtml(value) { return String(value); }
function isBlockingSessionLoad() { return false; }
@@ -381,7 +384,7 @@ for (const outline of [false, true]) {
api.connect();
api.select('其他会话');
requestPage();
assert.equal(api.requests.length, 0, '保留认证、连接状态和会话隔离门禁');
assert.equal(api.requests.length, 1, 'HTTP 分页不依赖 WebSocket,重复请求及其他会话仍被隔离');
api.select('running-session');
requestPage();
assert.equal(api.requests.length, 1);

View File

@@ -3793,12 +3793,13 @@ function assertSessionSwitchResilienceContract() {
'Frontend should actively detect an OPEN-but-stale WebSocket before session load timeout'
);
assert(frontendSource.includes('sessionLoadRequestTimer'), 'Frontend should track the session load request timeout timer');
assert(frontendSource.includes('function clearPendingSessionSwitchRequest'), 'Frontend should be able to cancel stale pending session switch requests');
assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should schedule cancellation for stuck load_session requests');
assert(frontendSource.includes('sessionLoadAbortController?.abort()'), 'Frontend should cancel superseded HTTP session queries');
assert(frontendSource.includes('function scheduleSessionLoadRequestTimeout'), 'Frontend should bound the duration of session queries');
assert(frontendSource.includes('pendingSessionResumeRequest'), 'Frontend should track lightweight running-session resume requests');
assert(frontendSource.includes('SESSION_RESUME_FALLBACK_MS'), 'Frontend should keep a compatibility fallback for servers without resume_session');
assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload');
assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations');
assert(frontendSource.includes("type: 'subscribe_session'"), 'Frontend should subscribe to real-time events without loading history through WebSocket');
assert(!frontendSource.includes("type: 'load_session'") && !frontendSource.includes("type: 'load_history_page'"), 'Browser history reads must use HTTP');
assert(frontendSource.includes("case 'session_subscribed':"), 'Frontend should handle independent subscription results');
assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results');
assert(
/case 'resume_session_result':[\s\S]*?if \(!msg\.isRunning && currentSessionId && msg\.sessionId === currentSessionId\) \{[\s\S]*?finishGenerating\(msg\.sessionId \|\| currentSessionId\);[\s\S]*?\}[\s\S]*?break;/.test(frontendSource),
@@ -3812,7 +3813,7 @@ function assertSessionSwitchResilienceContract() {
/preserveStreaming[\s\S]*?reconcileRenderedSessionMessages\(snapshot\.messages/.test(frontendSource),
'Frontend running-session snapshots should reconcile persisted bubbles instead of skipping history'
);
assert(frontendSource.includes('recoverCurrent: true'), 'Frontend fallback load_session should preserve the current running view');
assert(frontendSource.includes('recoverCurrent: true'), 'HTTP reconciliation should preserve the current running view');
const visibilityStart = frontendSource.indexOf("document.addEventListener('visibilitychange'");
const visibilityEnd = visibilityStart >= 0 ? frontendSource.indexOf("if (!authToken)", visibilityStart) : -1;
const visibilitySource = visibilityStart >= 0 && visibilityEnd > visibilityStart
@@ -3823,12 +3824,12 @@ function assertSessionSwitchResilienceContract() {
assert(!visibilitySource.includes("type: 'load_session'"), 'Visibility restore must not force load_session and rerender the current conversation');
assert(!visibilitySource.includes('beginSessionSwitch('), 'Visibility restore must not force a session switch and scroll to bottom');
assert(
/else if \(currentSessionId && \(isGenerating \|\| currentSessionRunning\)[\s\S]*?pendingSessionResumeRequest\s*=\s*\{/.test(frontendSource),
'Frontend should queue a lightweight resume request for running conversations when WS closes'
/if \(currentSessionId && !isPageUnloading\)[\s\S]*?pendingSessionResumeRequest\s*=\s*\{/.test(frontendSource),
'Frontend should queue a real-time subscription when WS closes'
);
assert(
/const flushedSessionResume = flushedSessionSwitch \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource),
'Frontend should resume the current running session after auth without forcing load_session'
/const flushedSessionResume = sessionLoadAbortController \? false : flushPendingSessionResume\(\);[\s\S]*?requestSessionResume\(currentSessionId/.test(frontendSource),
'Frontend should subscribe to the current view after auth without interrupting an HTTP query'
);
assert(
frontendSource.includes("send({ type: 'abort', sessionId: currentSessionId })"),
@@ -3851,12 +3852,12 @@ function assertSessionSwitchResilienceContract() {
'Frontend should ignore history chunks from recovery fallback to avoid duplicating/redrawing bubbles'
);
assert(
/function flushPendingSessionSwitch\(\)[\s\S]*?return false[\s\S]*?return true/.test(frontendSource),
'Frontend flushPendingSessionSwitch should report whether it sent a load_session request'
/fetch\(`\/api\/sessions\//.test(frontendSource),
'Frontend should load sessions and history pages through the authenticated HTTP endpoint'
);
assert(
/pendingSessionSwitchRequest\s*=\s*\{[\s\S]*?blocking:\s*false[\s\S]*?requestId:\s*activeSessionLoad\.requestId/.test(frontendSource),
'Frontend should retry load_session after WS close without re-blocking the UI'
!extractFunctionSource(frontendSource, 'connect').slice(extractFunctionSource(frontendSource, 'connect').indexOf('socket.onclose')).includes('clearSessionLoading()'),
'WebSocket close must not cancel an independent HTTP history query'
);
assert(
frontendSource.includes('!messageRequestId && !currentSessionId && !activeLoad && !pendingNewSession'),
@@ -7723,6 +7724,15 @@ async function main() {
console.log('Session switch race regression checks passed.');
return;
}
if (regressionTarget === 'session-http-load') {
assertSessionSwitchResilienceContract();
const result = spawnSync(process.execPath, [path.join(REPO_DIR, 'scripts', 'session-http-load-regression.js')], {
encoding: 'utf8', timeout: 60000,
});
assert(result.status === 0, result.stderr || result.stdout || result.error?.message || 'HTTP 会话回归失败');
console.log(result.stdout.trim());
return;
}
if (regressionTarget === 'history-recall') {
assertHistoryRecallContract();
console.log('History recall regression checks passed.');

View File

@@ -0,0 +1,241 @@
'use strict';
// 使用真实浏览器和隔离转发层模拟假 OPEN、断连及迟到响应。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { WebSocket, WebSocketServer } = require('ws');
const FIREFOX = process.env.CC_WEB_BROWSER_FIREFOX || '/snap/firefox/current/usr/lib/firefox/firefox';
const GECKODRIVER = process.env.CC_WEB_BROWSER_GECKODRIVER || '/snap/firefox/current/usr/lib/firefox/geckodriver';
const CDN_FIXTURE_DIR = process.env.CC_WEB_BROWSER_CDN_DIR || '';
const CDN_FILES = ['marked.min.js', 'highlight.min.js', 'mermaid.min.js', 'atom-one-dark.min.css'];
class FirefoxDriver {
constructor(port) { this.port = port; this.id = ''; }
async request(method, endpoint, body) {
const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, {
method, headers: body === undefined ? {} : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10000),
});
const payload = await response.json();
if (!response.ok || payload.value?.error) throw new Error(JSON.stringify(payload.value || payload));
return payload.value;
}
async start() {
const value = await this.request('POST', '/session', { capabilities: { alwaysMatch: {
browserName: 'firefox', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'] },
} } });
this.id = value.sessionId;
await this.request('POST', `/session/${this.id}/window/rect`, { width: 1280, height: 1000 });
return value.capabilities.browserVersion;
}
execute(script, args = []) { return this.request('POST', `/session/${this.id}/execute/sync`, { script, args }); }
navigate(url) { return this.request('POST', `/session/${this.id}/url`, { url }); }
async close() { if (this.id) await this.request('DELETE', `/session/${this.id}`); }
}
async function startRelay(port, targetPort, evidence) {
const sockets = new Set();
const state = { dropWs: false, blockUpgrades: false, holdId: '', held: [] };
const relay = http.createServer((request, response) => {
if (CDN_FIXTURE_DIR && request.url.startsWith('/fixture-cdn/')) {
const name = request.url.slice('/fixture-cdn/'.length);
if (!CDN_FILES.includes(name)) { response.writeHead(404); response.end(); return; }
response.writeHead(200, { 'Content-Type': name.endsWith('.css') ? 'text/css' : 'application/javascript' });
response.end(fs.readFileSync(path.join(CDN_FIXTURE_DIR, name)));
return;
}
const isQuery = request.url.startsWith('/api/sessions/');
const hold = state.holdId && request.url.startsWith('/api/sessions/' + state.holdId + '?');
if (isQuery) evidence.http.push({ url: request.url, start: Date.now() });
const upstream = http.request({ hostname: '127.0.0.1', port: targetPort,
path: request.url, method: request.method, headers: request.headers }, result => {
if (CDN_FIXTURE_DIR && result.headers['content-type']?.includes('text/html')) {
const chunks = [];
result.on('data', chunk => chunks.push(chunk));
result.on('end', () => {
const body = Buffer.concat(chunks).toString().replace(/https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/[^"']+/g,
url => '/fixture-cdn/' + url.slice(url.lastIndexOf('/') + 1));
const headers = { ...result.headers }; delete headers['content-length'];
response.writeHead(result.statusCode, headers); response.end(body);
});
return;
}
if (!hold) { response.writeHead(result.statusCode, result.headers); result.pipe(response); return; }
const chunks = [];
result.on('data', chunk => chunks.push(chunk));
result.on('end', () => state.held.push({ response, status: result.statusCode,
headers: result.headers, body: Buffer.concat(chunks) }));
});
upstream.on('error', () => response.destroy());
request.pipe(upstream);
});
const wsServer = new WebSocketServer({ noServer: true });
relay.on('upgrade', (request, socket, head) => {
if (state.blockUpgrades) { socket.destroy(); return; }
wsServer.handleUpgrade(request, socket, head, client => {
sockets.add(client);
const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`);
const pending = [];
upstream.on('open', () => pending.splice(0).forEach(data => upstream.send(data)));
client.on('message', data => {
try { evidence.ws.push({ direction: 'client', type: JSON.parse(String(data)).type }); } catch {}
if (state.dropWs) return;
if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else pending.push(data);
});
upstream.on('message', data => {
try {
const msg = JSON.parse(String(data));
evidence.ws.push({ direction: 'server', type: msg.type, historyChanged: msg.historyChanged });
} catch {}
if (!state.dropWs && client.readyState === WebSocket.OPEN) client.send(data, { binary: false });
});
client.on('close', () => { sockets.delete(client); upstream.terminate(); });
upstream.on('close', () => { if (client.readyState === WebSocket.OPEN) client.close(); });
client.on('error', () => upstream.terminate());
upstream.on('error', () => client.terminate());
});
});
await new Promise(resolve => relay.listen(port, '127.0.0.1', resolve));
return {
state,
disconnect() { for (const socket of sockets) socket.terminate(); },
release() {
state.holdId = '';
for (const item of state.held.splice(0)) {
if (item.response.destroyed) continue;
item.response.writeHead(item.status, item.headers); item.response.end(item.body);
}
},
async close() {
this.release(); this.disconnect(); wsServer.close(); relay.closeAllConnections();
await new Promise(resolve => relay.close(resolve));
},
};
}
async function runBrowser(options) {
const { port, fixture, root, query, A, B, PASSWORD, STREAM, waitFor, freePort, stopChild, sleep } = options;
const evidence = { http: [], ws: [], stages: {} };
if (CDN_FIXTURE_DIR) for (const name of CDN_FILES) assert(fs.existsSync(path.join(CDN_FIXTURE_DIR, name)), '缺少浏览器静态依赖 ' + name);
const relayPort = await freePort();
const driverPort = await freePort();
const relay = await startRelay(relayPort, port, evidence);
const gecko = spawn(GECKODRIVER, ['--port', String(driverPort)], { stdio: ['ignore', 'pipe', 'pipe'] });
const log = fs.createWriteStream(path.join(root, 'geckodriver.log'));
gecko.stdout.pipe(log); gecko.stderr.pipe(log);
const driver = new FirefoxDriver(driverPort);
const snapshot = () => driver.execute(`return {
title:document.querySelector('#chat-title').textContent,
loading:document.body.classList.contains('session-loading-active'),
ids:[...document.querySelectorAll('#messages > [data-session-message="true"]')].map(node=>node.dataset.messageId),
streaming:document.querySelector('#streaming-msg')?.textContent || '',
text:document.querySelector('#messages').textContent,
};`);
const click = id => driver.execute(`const node=document.querySelector('.session-item[data-id="'+arguments[0]+'"]');
if(!node) throw new Error('找不到会话 '+arguments[0]); node.click();`, [id]);
async function loaded(id, count, stream = false) {
return waitFor(async () => {
const value = await snapshot();
return value.title === id && !value.loading && value.ids.length === count
&& (!stream || value.streaming.includes(STREAM)) ? value : null;
}, '浏览器未显示 ' + id + ' 的 ' + count + ' 条消息');
}
try {
await waitFor(async () => (await fetch(`http://127.0.0.1:${driverPort}/status`)).ok, 'geckodriver 启动失败');
evidence.browser = await driver.start();
await driver.navigate(`http://127.0.0.1:${relayPort}/`);
await waitFor(async () => {
const ready = await driver.execute(`return !document.querySelector('#app').hidden
&& !!document.querySelector('.session-item[data-id="'+arguments[0]+'"]');`, [A]);
if (ready) return true;
await driver.execute(`document.querySelector('#login-password').value=arguments[0];
document.querySelector('#login-form').requestSubmit();`, [PASSWORD]);
return false;
}, '浏览器登录未就绪');
relay.state.dropWs = true;
let started = Date.now();
await click(A);
await loaded(A, 12, true);
evidence.stages.fakeOpen = { durationMs: Date.now() - started, count: 12, runtimeVisible: true };
await click(B);
await loaded(B, 12);
for (const count of [36, 58]) {
await driver.execute(`document.querySelector('.history-load-more-button').click();`);
await loaded(B, count);
}
const full = await snapshot();
assert.equal(new Set(full.ids).size, 58);
assert.deepEqual(full.ids.slice(0, 57), Array.from({ length: 57 }, (_, i) => B + '-' + i));
evidence.stages.pagination = { count: full.ids.length, unique: true, ordered: true };
relay.state.holdId = A;
await click(A);
await waitFor(() => relay.state.held.length, 'A 的延迟响应未到达转发层');
await click(B);
relay.release();
await sleep(150);
await loaded(B, 58);
evidence.stages.lateResponse = { title: B, count: 58 };
relay.state.holdId = A;
started = Date.now();
await click(A);
await waitFor(() => relay.state.held.length, '断连前的 HTTP 响应未到达');
relay.state.blockUpgrades = true;
relay.disconnect();
await sleep(100);
relay.release();
await loaded(A, 12, true);
evidence.stages.disconnected = { durationMs: Date.now() - started, runtimeVisible: true, inFlightPreserved: true };
await click(B);
await click(A);
await loaded(A, 12, true);
// 旧 HTTP 快照已读出后任务完成;恢复连接再释放旧响应,必须通过修订对账补齐终态。
await click(B);
relay.state.holdId = A;
await click(A);
await waitFor(() => relay.state.held.length, '完成间隙的 HTTP 快照未到达');
assert.equal(JSON.parse(relay.state.held[0].body).isRunning, true);
fs.writeFileSync(fixture.completeFlag, 'complete');
await waitFor(async () => {
const result = (await query(A)).payload;
return !result.isRunning && result.messages.some(message => message.content.includes('Codex App mock handled:'));
}, 'mock 任务未保存完成消息');
relay.state.dropWs = false;
relay.state.blockUpgrades = false;
relay.release();
const completed = await waitFor(async () => {
const value = await snapshot();
return value.title === A && !value.loading && !value.streaming
&& value.text.includes('Codex App mock handled:') ? value : null;
}, '读取与订阅之间完成的消息未补齐', 12000);
assert(evidence.ws.some(msg => msg.type === 'session_subscribed' && msg.historyChanged));
evidence.stages.completionGap = { count: completed.ids.length, streaming: false, finalVisible: true };
await click('http-corrupt-chunk');
await waitFor(async () => {
const value = await snapshot();
return value.title === A && !value.loading && value.text.includes('会话历史读取失败');
}, '历史读取失败时页面未结束加载并保留当前会话');
evidence.stages.readFailure = { previousViewPreserved: true, loading: false };
assert(!evidence.ws.some(msg => msg.direction === 'client' && ['load_session', 'load_history_page', 'resume_session'].includes(msg.type)));
fs.writeFileSync(path.join(root, 'browser-evidence.json'), JSON.stringify(evidence, null, 2));
console.log('Firefox 通过:假 OPEN/断 WS 首屏与运行输出、HTTP 分页顺序、迟到响应隔离、切换间隙完成对账;浏览器未通过 WS 查询历史');
} catch (error) {
evidence.failure = error.message;
evidence.state = await snapshot().catch(() => null);
fs.writeFileSync(path.join(root, 'browser-evidence.json'), JSON.stringify(evidence, null, 2));
throw error;
} finally {
await driver.close().catch(() => {});
await stopChild(gecko);
await relay.close();
log.end();
}
}
module.exports = { runBrowser };

View File

@@ -0,0 +1,169 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const source = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
function extractFunction(name) {
const match = new RegExp(`(?:async )?function ${name}\\(`).exec(source);
assert(match, '缺少生产函数 ' + name);
let parentheses = 0;
let signatureEnd;
for (let index = source.indexOf('(', match.index); index < source.length; index++) {
if (source[index] === '(') parentheses++;
if (source[index] === ')' && --parentheses === 0) { signatureEnd = index; break; }
}
let depth = 0;
for (let index = source.indexOf('{', signatureEnd); index < source.length; index++) {
if (source[index] === '{') depth++;
if (source[index] === '}' && --depth === 0) return source.slice(match.index, index + 1);
}
throw new Error('函数边界异常 ' + name);
}
function createHarness() {
const network = [];
const api = new Function('network', 'AbortController', 'URLSearchParams', `
let currentSessionId = 'original';
let currentSessionViewRequestId = 'view-original';
let currentSessionHistoryRevision = 'r0';
let currentSessionSubscriptionRequestId = '';
let sessionLoadAbortController = null;
let activeSessionLoad = null;
let activeHistoryPageRequest = null;
let activeOutlineHistoryRequest = null;
let activeOutlineTargetRequest = null;
let currentHistoryState = {};
let currentOutlineHistoryState = {};
let pendingSessionResumeRequest = null;
let ws = null;
let wsAuthenticated = false;
const authToken = 'test-http-token';
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45000;
const sessionCompletionVersions = new Map();
const events = [], errors = [], sent = [], timers = new Map();
let sequence = 0;
function setTimeout(callback, ms) { const id = ++sequence; timers.set(id, {callback, ms}); return id; }
function clearTimeout(id) { timers.delete(id); }
function createSessionSwitchRequestId(id) { return 'query-' + ++sequence + '-' + id; }
function clearSessionLoadRequestTimer() {}
function renderHistoryLoadControl() {}
function setSessionLoading(id, options) {
sessionLoadAbortController?.abort(); sessionLoadAbortController = null;
activeSessionLoad = id ? { sessionId:id, ...options } : null;
}
function clearSessionLoading() { setSessionLoading(null, {}); }
function handleServerMessage(payload) {
if (payload.throwRender) throw new Error('夹具渲染失败');
events.push(payload);
if (payload.type === 'session_info') { currentSessionId = payload.sessionId; activeSessionLoad = null; }
if (payload.type === 'session_history_chunk') activeHistoryPageRequest = null;
}
function appendError(message) { errors.push(message); }
function showToast(message) { errors.push(message); }
function finishGenerating(id) { events.push({ type:'finished', sessionId:id }); }
function requestOutlineMessageTarget(index) { events.push({ type:'target', index }); }
function connect() {}
function fetch(url, options) { return new Promise((resolve, reject) => network.push({url,options,resolve,reject})); }
${['requestSessionLoad','fetchSessionQuery','fetchHistoryPage','requestSessionResume','flushPendingSessionResume'].map(extractFunction).join('\n')}
return {
requestSessionLoad, fetchSessionQuery, events, errors, sent, timers,
state: () => ({ currentSessionId, currentSessionViewRequestId, activeSessionLoad, sessionLoadAbortController }),
complete: id => sessionCompletionVersions.set(id, (sessionCompletionVersions.get(id) || 0) + 1),
page: (id, requestId) => { activeHistoryPageRequest = {sessionId:id, requestId};
fetchHistoryPage(activeHistoryPageRequest, 24); return activeHistoryPageRequest; },
socket: open => { wsAuthenticated = open; ws = open ? {readyState:1,send:data=>sent.push(JSON.parse(data))} : null; },
};
`)(network, AbortController, URLSearchParams);
return { ...api, network };
}
function respond(api, index, overrides = {}, status = 200) {
const request = api.network[index];
const url = new URL(request.url, 'http://localhost');
const isPage = url.pathname.endsWith('/history');
const payload = { type: isPage ? 'session_history_chunk' : 'session_info',
requestId: url.searchParams.get('requestId'), sessionId: url.pathname.split('/')[3],
messages: [], historyRevision: 'r1', ...overrides };
request.resolve({ ok: status === 200, json: async () => payload });
}
const flush = () => new Promise(resolve => setImmediate(resolve));
async function runFrontendUnit() {
{
const api = createHarness();
api.requestSessionLoad('A');
assert.equal(api.network.length, 1, '没有 WebSocket 也必须立即查询 HTTP');
assert.equal(api.network[0].options.headers.Authorization, 'Bearer test-http-token');
api.requestSessionLoad('B', { requestId: 'new-B' });
assert(api.network[0].options.signal.aborted, '连续切换必须取消旧读取');
respond(api, 1);
await flush();
respond(api, 0, { runtime: { type: 'resume_generating', sessionId: 'A', text: '迟到输出' } });
await flush();
assert.equal(api.state().currentSessionId, 'B');
assert.deepEqual(api.events.map(event => event.type), ['session_info'], '忽略取消后仍然抵达的旧响应和旧运行输出');
assert.equal(api.sent.length, 0);
}
{
const api = createHarness();
api.requestSessionLoad('A', { requestId: 'old-A' });
api.requestSessionLoad('A', { requestId: 'new-A' });
respond(api, 0); await flush();
assert.equal(api.events.length, 0, '同一会话也必须隔离请求代次');
respond(api, 1, { runtime: { type: 'resume_generating', sessionId: 'A', text: '完整输出' } });
await flush();
assert.equal(api.events[1].text, '完整输出');
assert(!api.events.some(event => event.type === 'target'), '未指定定位不能被 null 转换成第0条');
const page = api.page('A', 'older');
api.requestSessionLoad('B', { requestId: 'B' });
assert(page.controller.signal.aborted);
respond(api, 2); await flush();
assert(!api.events.some(event => event.type === 'session_history_chunk'), '新视图必须拒绝旧分页');
respond(api, 3); await flush();
}
{
const api = createHarness();
api.socket(true);
api.requestSessionLoad('A', { requestId: 'before-done' });
api.complete('A');
respond(api, 0, { isRunning: true }); await flush();
assert.equal(api.events.length, 0, '已收到完成事件后不能应用较早的运行快照');
assert.equal(api.network.length, 2, '完成事件与 HTTP 交错时应重新读取持久化终态');
respond(api, 1, { isRunning: false }); await flush();
assert.equal(api.events[0].isRunning, false);
assert.deepEqual(api.sent.map(msg => msg.type), ['subscribe_session']);
}
{
const api = createHarness();
for (const [overrides, message] of [
[{ requestId: 'unmatched' }, /响应与请求不匹配/],
[{ type: 'invalid_payload' }, /响应格式无效/],
[{ throwRender: true }, /夹具渲染失败/],
]) {
const index = api.network.length;
api.requestSessionLoad('invalid');
respond(api, index, overrides); await flush();
assert.match(api.errors.at(-1), message);
assert.equal(api.state().activeSessionLoad, null, '响应或渲染异常不能留下永久加载');
}
}
{
const api = createHarness();
api.requestSessionLoad('broken');
respond(api, 0, { message: '夹具历史读取失败' }, 500); await flush();
assert.equal(api.errors[0], '夹具历史读取失败');
assert.equal(api.state().activeSessionLoad, null);
assert.equal(api.state().currentSessionId, 'original', '失败不能清空当前视图');
api.requestSessionLoad('timeout');
for (const timer of api.timers.values()) timer.callback();
assert(api.network[1].options.signal.aborted);
api.network[1].reject(new Error('aborted')); await flush();
assert.match(api.errors.at(-1), /HTTP 请求超时/);
assert.equal(api.timers.size, 0, '成功、失败和超时均须清理请求计时器');
}
console.log('HTTP 前端单测通过:无 WS 读取、取消、跨会话/同会话迟到隔离、分页取消、运行快照、完成交错、500及超时恢复');
}
module.exports = { runFrontendUnit };
if (require.main === module) runFrontendUnit().catch(error => { console.error(error); process.exitCode = 1; });

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env node
'use strict';
// 真实服务隔离回归,不读取或修改生产会话。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { WebSocket } = require('ws');
const { createSessionHistoryStore } = require('../lib/session-history-store');
const REPO = path.resolve(__dirname, '..');
const PASSWORD = 'SessionHttpFixture!234';
const STREAM = 'HTTP 夹具正在运行的完整输出';
const A = 'http-switch-a';
const B = 'http-switch-b';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function waitFor(fn, label, timeout = 10000) {
const started = Date.now();
let lastError;
while (Date.now() - started < timeout) {
try { const result = await fn(); if (result) return result; } catch (error) { lastError = error; }
await sleep(40);
}
throw new Error(label + (lastError ? ':' + lastError.message : ''));
}
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode) return;
const exited = new Promise(resolve => child.once('exit', resolve));
child.kill('SIGTERM');
await Promise.race([exited, sleep(500)]);
if (child.exitCode === null && !child.signalCode) { child.kill('SIGKILL'); await exited; }
}
async function connect(port) {
const ws = new WebSocket('ws://127.0.0.1:' + port + '/ws');
const messages = [];
ws.on('message', data => messages.push(JSON.parse(String(data))));
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
ws.send(JSON.stringify({ type: 'auth', password: PASSWORD }));
const auth = await waitFor(() => messages.find(msg => msg.type === 'auth_result'), '隔离登录超时');
assert(auth.success);
return { ws, messages, token: auth.token };
}
function createFixture(root) {
const sessionsDir = path.join(root, 'sessions');
const homeDir = path.join(root, 'home');
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(homeDir, { recursive: true });
const store = createSessionHistoryStore({ sessionsDir, chunkMaxMessages: 8 });
function seed(id, count = 57) {
const now = new Date().toISOString();
const session = { id, agent: 'codexapp', title: id, cwd: homeDir, created: now, updated: now,
permissionMode: 'yolo', hasUnread: false,
messages: Array.from({ length: count }, (_, index) => ({ id: id + '-' + index,
role: index % 2 ? 'assistant' : 'user', content: id + ' 保存消息 ' + index, timestamp: now })) };
store.save(session);
fs.writeFileSync(path.join(sessionsDir, id + '.json'), JSON.stringify(session));
return session;
}
seed(A); seed(B); seed('http-corrupt-chunk'); seed('http-corrupt-manifest');
const corruptPath = path.join(sessionsDir, '_history', 'http-corrupt-chunk');
const manifest = JSON.parse(fs.readFileSync(path.join(corruptPath, 'manifest.json'), 'utf8'));
fs.writeFileSync(path.join(corruptPath, 'chunks', manifest.entries.at(-1).file), '{破损块');
fs.writeFileSync(path.join(sessionsDir, '_history', 'http-corrupt-manifest', 'manifest.json'), '{破损清单');
const legacy = { ...seed('http-legacy'), id: 'http-legacy-only' };
fs.writeFileSync(path.join(sessionsDir, legacy.id + '.json'), JSON.stringify(legacy));
// 原生目录也隔离,不覆写 HOME 等系统变量。
const preload = path.join(root, 'isolated-home.cjs');
fs.writeFileSync(preload, [
"const fs = require('node:fs'); const Module = require('node:module');",
"require('node:os').homedir = () => " + JSON.stringify(homeDir) + ';',
// worker 也继承 preload;独立锁路径避免等待生产 app-server 的锁。
'const locks = require(' + JSON.stringify(path.join(REPO, 'lib/codex-home-lock')) + ');',
'const acquire = locks.acquireCodexHomeLock;',
'locks.acquireCodexHomeLock = (options = {}) => acquire({ ...options, lockPath: ' + JSON.stringify(path.join(root, 'codex-fixture.lock')) + ' });',
"const originalLoader = Module._extensions['.js'];",
"Module._extensions['.js'] = (module, filename) => {",
'if (filename !== ' + JSON.stringify(path.join(REPO, 'server.js')) + ') return originalLoader(module, filename);',
"const source = fs.readFileSync(filename, 'utf8');",
"const original = " + JSON.stringify("const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');") + ';',
"if (!source.includes(original)) throw new Error('测试隔离目录注入点已变更');",
'module._compile(source.replace(original, ' + JSON.stringify('const CODEX_SESSIONS_DIR = ' + JSON.stringify(path.join(homeDir, '.codex', 'sessions')) + ';') + '), filename); };',
].join('\n'));
const completeFlag = path.join(root, 'complete-turn');
const mockPath = path.join(root, 'mock-codex.js');
const mock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8');
const timer = 'thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);';
assert(mock.includes(timer));
fs.writeFileSync(mockPath, mock.replace(timer, [
"send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'agent-msg',delta:" + JSON.stringify(STREAM) + '}});',
"thread.timer = setInterval(() => { if (!require('fs').existsSync(" + JSON.stringify(completeFlag) + ')) return;',
'clearInterval(thread.timer); completeTurn(thread, turnId, text); }, 50);',
].join('\n')));
fs.chmodSync(mockPath, 0o700);
return { sessionsDir, homeDir, store, preload, completeFlag, mockPath };
}
async function runApi(port, fixture) {
let connection = await connect(port);
try {
const token = connection.token;
const closed = new Promise(resolve => connection.ws.once('close', resolve));
connection.ws.close();
await closed;
let sequence = 0;
async function query(id, suffix = '', authenticated = true) {
const requestId = 'api-' + ++sequence;
const separator = suffix.includes('?') ? '&' : '?';
const response = await fetch('http://127.0.0.1:' + port + '/api/sessions/' + id + suffix + separator + 'requestId=' + requestId, {
headers: authenticated ? { Authorization: 'Bearer ' + token } : {},
});
const payload = await response.json();
assert.equal(payload.requestId, requestId);
return { response, payload };
}
const first = await query(B);
assert.equal(first.response.status, 200);
assert.equal(first.payload.messages.length, 12);
assert.equal(first.payload.historyCursor, 45);
assert(first.response.headers.get('server-timing'));
const ids = first.payload.messages.map(message => message.id);
let cursor = first.payload.historyCursor;
while (cursor > 0) {
const page = await query(B, '/history?before=' + cursor);
assert.equal(page.response.status, 200);
assert(page.payload.messages.length <= 24);
ids.unshift(...page.payload.messages.map(message => message.id));
cursor = page.payload.historyCursor;
}
assert.deepEqual(ids, Array.from({ length: 57 }, (_, index) => B + '-' + index));
assert.equal((await query(B, '', false)).response.status, 401);
assert.equal((await query('does-not-exist')).response.status, 404);
assert.equal((await query('bad%2Fid')).response.status, 400);
assert.equal((await query('http_switch-a')).response.status, 400, '非法 ID 不能经 sanitizeId 别名到另一会话');
assert.equal((await query('%ZZ')).response.status, 400);
assert.equal((await query(B, '/history?before=-1')).response.status, 400);
assert.equal((await query(B, '/history?before=1.5')).response.status, 400);
for (const id of ['http-corrupt-chunk', 'http-corrupt-manifest']) {
const result = await query(id);
assert.equal(result.response.status, 500);
assert.equal(result.payload.code, 'history_read_failed');
assert.equal(result.payload.sessionId, id);
}
assert.equal((await query('http-legacy-only')).payload.messages.length, 12);
connection = await connect(port);
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: B,
requestId: 'subscribe-before-commit', historyRevision: first.payload.historyRevision }));
const unchanged = await waitFor(() => connection.messages.find(msg => msg.requestId === 'subscribe-before-commit'), '订阅结果缺失');
assert.equal(unchanged.type, 'session_subscribed');
assert.equal(unchanged.historyChanged, false);
assert.equal(unchanged.messages, undefined, '实时订阅不能重发历史');
const changed = fixture.store.loadMetadata(B);
changed.messages.push({ id: 'http-new-between-query-and-subscribe', role: 'assistant', content: '订阅间隙的新消息' });
fixture.store.save(changed);
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: B,
requestId: 'subscribe-after-commit', historyRevision: first.payload.historyRevision }));
const advanced = await waitFor(() => connection.messages.find(msg => msg.requestId === 'subscribe-after-commit'), '新修订订阅结果缺失');
assert.equal(advanced.historyChanged, true);
assert.equal(advanced.messages, undefined);
assert.equal((await query(B)).payload.messages.at(-1).id, 'http-new-between-query-and-subscribe');
connection.ws.send(JSON.stringify({ type: 'message', sessionId: A, agent: 'codexapp', mode: 'yolo', text: 'HTTP 会话切换运行夹具' }));
const running = await waitFor(async () => {
const result = await query(A);
return result.payload.isRunning && result.payload.runtime?.text.includes(STREAM) ? result.payload : null;
}, 'HTTP 运行态快照缺失');
assert.equal(running.historyPending, false);
assert.equal(running.messages.length, 12);
assert.equal(running.runtime.type, 'resume_generating');
assert(!connection.messages.some(msg => msg.type === 'session_history_chunk'));
return { connection, token, query };
} catch (error) {
connection.ws.terminate();
throw error;
}
}
async function main() {
await require('./session-http-load-frontend-unit').runFrontendUnit();
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-session-http-'));
const fixture = createFixture(root);
const port = await freePort();
const server = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], {
cwd: REPO, env: { ...process.env, PORT: String(port), CC_WEB_PASSWORD: PASSWORD,
CC_WEB_INTERNAL_MCP_TOKEN: 'HttpFixtureMcp!234', CC_WEB_TASK_BOARD_ENABLED: '0',
CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_LOGS_DIR: path.join(root, 'logs'),
CC_WEB_SESSIONS_DIR: fixture.sessionsDir, CODEX_PATH: fixture.mockPath,
CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'),
}, stdio: ['ignore', 'pipe', 'pipe'],
});
const log = fs.createWriteStream(path.join(root, 'server.log'));
server.stdout.pipe(log); server.stderr.pipe(log);
let api;
try {
await waitFor(async () => (await fetch('http://127.0.0.1:' + port + '/')).ok, '隔离服务启动失败');
api = await runApi(port, fixture);
console.log('HTTP 接口通过:WS 关闭后首屏12条、分页24条、顺序、鉴权、请求级错误、独立订阅、修订及运行快照');
if (process.argv.includes('--browser')) {
await require('./session-http-load-browser').runBrowser({ port, fixture, root, query: api.query,
A, B, PASSWORD, STREAM, waitFor, freePort, stopChild, sleep });
}
const processLog = fs.readFileSync(path.join(root, 'logs', 'process.log'), 'utf8');
assert(processLog.includes('session_http_query'));
assert(processLog.includes('session_http_query_failed'));
assert(!processLog.includes(STREAM), '诊断日志不得记录消息正文');
console.log('HTTP 耗时诊断通过;隔离证据:' + root);
} finally {
if (api) api.connection.ws.terminate();
await stopChild(server);
log.end();
}
}
main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });

151
server.js
View File

@@ -4828,6 +4828,18 @@ function resolveSessionHistory(session, options = {}) {
if (historyState?.source === 'archive' && historyState.complete) {
return { messages: normalizeArchivedHistory(persistedMessages), source: 'archive', available: true, recoverable: false };
}
// 页面查询只读取自身已保存记录,不在首屏请求里扫描原生日志或触发迁移。
if (options.persistedOnly === true) {
const total = persistedMessages.length;
const end = Number.isFinite(options.before) ? Math.max(0, Math.min(total, options.before)) : total;
const start = Math.max(0, end - options.count);
return {
messages: persistedMessages.slice(start, end).map(normalizeHistoryMessage),
total, start, end, source: 'snapshot',
available: !notice && historyState?.complete !== false,
recoverable: false,
};
}
if (!notice && historyState?.complete !== false) {
return {
messages: mergeHistorySegments([], persistedMessages.map(normalizeHistoryMessage), session),
@@ -6256,7 +6268,10 @@ function loadSession(id, options = {}) {
if (!session) {
if (!fs.existsSync(filePath)) return null;
session = safeReadSessionJson(filePath, SESSION_LOAD_MAX_BYTES, { sessionId: normalizedId });
if (!session) return null;
if (!session) {
if (options.throwOnError) throw new Error('会话历史文件读取失败');
return null;
}
sessionHistoryStore.markLegacy(session);
}
normalizeSession(session);
@@ -6268,6 +6283,7 @@ function loadSession(id, options = {}) {
sessionId: normalizedId.slice(0, 8),
error: err?.message || String(err || ''),
});
if (options.throwOnError) throw err;
return null;
}
}
@@ -10034,6 +10050,11 @@ const server = http.createServer((req, res) => {
return handleReloadMcpApi(req, res, decodeURIComponent(reloadMcpMatch[1] || ''));
}
const sessionQueryMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)(\/history)?$/);
if (req.method === 'GET' && sessionQueryMatch) {
return handleSessionQueryApi(req, res, url, sessionQueryMatch[1], !!sessionQueryMatch[2]);
}
if (req.method === 'POST' && url.pathname === '/api/attachments') {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
@@ -10331,6 +10352,9 @@ wss.on('connection', (ws, req) => {
case 'resume_session':
handleResumeSession(ws, msg);
break;
case 'subscribe_session':
handleSubscribeSession(ws, msg);
break;
case 'load_history_page':
handleLoadHistoryPage(ws, msg);
break;
@@ -11805,6 +11829,131 @@ function attachClientRequestId(payload, source = {}) {
return requestId ? { ...payload, requestId } : payload;
}
function buildSessionRuntimeSnapshot(sessionId) {
const classic = activeProcesses.get(sessionId);
if (classic) {
return { type: 'resume_generating', sessionId, text: classic.fullText || '',
toolCalls: sanitizeToolCallsForPersist(classic.toolCalls || []) };
}
const active = activeCodexAppTurns.get(sessionId);
if (active) return buildCodexAppResumePayload(sessionId, active);
const retained = retainedCodexAppTurns.get(sessionId);
if (retained) return buildCodexAppResumePayload(sessionId, retained, {
isRunning: false, historySaved: retained.historySaved === true,
});
const retry = pendingCodexCapacityRetries.get(sessionId);
if (retry?.entry) return buildCodexAppResumePayload(sessionId, retry.entry, { retryPending: true });
return null;
}
function handleSessionQueryApi(req, res, url, encodedId, historyPage = false) {
const startedAt = Date.now();
const requestId = String(url.searchParams.get('requestId') || '').slice(0, 160);
let sessionId = '';
let phase = 'authenticate';
let status = 200;
let history;
const respondError = (statusCode, code, message) => {
status = statusCode;
return jsonResponse(res, statusCode, { type: 'error', code, message, sessionId, requestId });
};
try {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) return respondError(401, 'not_authenticated', '登录状态已失效,请重新登录');
phase = 'validate';
try { sessionId = decodeURIComponent(encodedId); }
catch { return respondError(400, 'history_invalid_id', '会话 ID 无效'); }
if (!/^[A-Za-z0-9-]+$/.test(sessionId)) return respondError(400, 'history_invalid_id', '会话 ID 无效');
const beforeValue = url.searchParams.get('before');
const before = beforeValue === null ? undefined : Number(beforeValue);
if (beforeValue !== null && (!/^\d+$/.test(beforeValue) || !Number.isSafeInteger(before))) {
return respondError(400, 'history_invalid_cursor', '历史游标无效');
}
phase = 'metadata';
const session = loadSession(sessionId, { metadata: true, throwOnError: true });
if (!session) return respondError(404, 'session_not_found', '会话不存在');
phase = 'history';
history = resolveSessionHistory(session, {
count: historyPage ? HISTORY_CHUNK_SIZE : INITIAL_HISTORY_COUNT,
before: historyPage ? before : undefined,
persistedOnly: true,
});
const messages = sanitizeMessagesForTransport(history.messages || []);
const total = history.total ?? messages.length;
const start = history.start || 0;
const common = {
sessionId, requestId, messages, historyTotal: total,
historyCursor: start, historyBaseIndex: start,
historyTruncated: start > 0, historyAvailable: history.available !== false,
historySource: history.source || 'snapshot',
historyRevision: sessionHistoryStore.state(session)?.revision ?? null,
};
phase = 'response';
res.setHeader('Server-Timing', `history;dur=${Date.now() - startedAt}`);
if (historyPage) return jsonResponse(res, 200, { ...common, type: 'session_history_chunk', remaining: 0 });
const waitState = crossConversationWaitState(sessionId);
const taskTracking = taskTrackingSnapshotForSession(session);
return jsonResponse(res, 200, {
...common, type: 'session_info', historyBuffered: messages.length, historyPending: false,
title: session.title, titleHistory: session.titleHistory, pinnedAt: session.pinnedAt || null,
...publicTitleMetadata(session),
mode: session.permissionMode || 'yolo', model: sessionModelLabel(session), agent: getSessionAgent(session),
cwd: session.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null,
totalCost: session.totalCost || 0, totalUsage: session.totalUsage || null,
updated: session.updated, hasUnread: !!session.hasUnread, isRunning: isSessionRunning(sessionId),
...waitState, taskTracking, taskTrackingEnabled: taskTracking?.enabled === true,
runtime: buildSessionRuntimeSnapshot(sessionId),
});
} catch (error) {
plog('WARN', 'session_http_query_failed', {
sessionId: sessionId.slice(0, 8), requestId: requestId.slice(0, 80),
phase, code: error?.code || 'history_read_failed', durationMs: Date.now() - startedAt,
});
return respondError(500, 'history_read_failed', '会话历史读取失败,请重试');
} finally {
plog('INFO', 'session_http_query', {
sessionId: sessionId.slice(0, 8), requestId: requestId.slice(0, 80),
kind: historyPage ? 'page' : 'snapshot', phase, status,
durationMs: Date.now() - startedAt, source: history?.source || null,
messageCount: history?.messages?.length || 0,
});
}
}
function handleSubscribeSession(ws, msg = {}) {
const sessionId = String(msg.sessionId || '');
if (!/^[A-Za-z0-9-]+$/.test(sessionId)) return wsSend(ws, attachClientRequestId({
type: 'error', code: 'history_invalid_id', sessionId, message: '会话 ID 无效',
}, msg));
try {
const session = loadSession(sessionId, { metadata: true, throwOnError: true });
if (!session) return wsSend(ws, attachClientRequestId({
type: 'error', code: 'session_not_found', sessionId, message: '会话不存在',
}, msg));
// 订阅不读取历史;两次动作间新增的提交由浏览器通过 HTTP 对账。
detachWsFromActiveRuntimes(ws);
wsSessionMap.set(ws, sessionId);
flushPendingCrossConversationReplies(sessionId);
const refreshed = loadSession(sessionId, { metadata: true, throwOnError: true }) || session;
let historyRevision = sessionHistoryStore.state(refreshed)?.revision ?? null;
const historyChanged = msg.historyRevision !== undefined && msg.historyRevision !== historyRevision;
if (refreshed.hasUnread) {
refreshed.hasUnread = false;
if (saveSession(refreshed)) historyRevision = sessionHistoryStore.state(refreshed)?.revision ?? historyRevision;
}
wsSend(ws, attachClientRequestId({
type: 'session_subscribed', sessionId, isRunning: isSessionRunning(sessionId),
historyRevision, historyChanged,
}, msg));
attachActiveRuntimeToWs(ws, sessionId, msg);
} catch (error) {
plog('WARN', 'session_subscribe_failed', { sessionId: sessionId.slice(0, 8), code: error?.code || 'session_subscribe_failed' });
wsSend(ws, attachClientRequestId({
type: 'error', sessionId, code: 'session_subscribe_failed', message: '实时消息订阅失败,历史仍可查看',
}, msg));
}
}
function handleNewSession(ws, msg) {
const request = msg || {};
const branch = resolveBranchSource(request);