改用 HTTP 加载会话历史并更新 CentOS 7 发布包
This commit is contained in:
@@ -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 历史、运行快照、独立订阅与竞态隔离 | 已记录 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
100
.trellis/spec/backend/session-query.md
Normal file
100
.trellis/spec/backend/session-query.md
Normal 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);
|
||||
```
|
||||
|
||||
生产函数还必须执行控制器身份、请求代次和完成事件版本校验,不能仅复制此示意代码。
|
||||
@@ -1,5 +1,9 @@
|
||||
# State Management
|
||||
|
||||
会话视图的 HTTP 读取、实时订阅、请求代次和完成事件交错规则,见
|
||||
[会话查询与实时订阅契约](../backend/session-query.md)。历史读取不能依赖 WebSocket
|
||||
连接、鉴权或重连;长连接断开不能取消正在进行的 HTTP 请求。
|
||||
|
||||
> How state is managed in this project.
|
||||
|
||||
---
|
||||
|
||||
3
.trellis/tasks/10-14-session-switch-timeout/check.jsonl
Normal file
3
.trellis/tasks/10-14-session-switch-timeout/check.jsonl
Normal 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":"验证加载错误与运行态历史"}
|
||||
16
.trellis/tasks/10-14-session-switch-timeout/findings.md
Normal file
16
.trellis/tasks/10-14-session-switch-timeout/findings.md
Normal 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 处理未整体异步化;旧版存储自身迁移仍由存储层负责,不能把通道拆分描述为所有服务端阻塞都已解决。
|
||||
@@ -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":"已证实故障与交付约束"}
|
||||
9
.trellis/tasks/10-14-session-switch-timeout/info.md
Normal file
9
.trellis/tasks/10-14-session-switch-timeout/info.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# HTTP 历史查询与实时订阅拆分
|
||||
|
||||
- HTTP 使用现有 Bearer 鉴权,响应保留 requestId/sessionId;首次12条,向前24条,不等待 WebSocket。
|
||||
- 所有 HTTP 请求均可取消;新会话选择、同会话新代次以及退出视图取消旧请求。响应进入现有请求代次校验后渲染。
|
||||
- HTTP 首屏同时提供完整运行态快照,让断开 WebSocket 时仍可查看正在产生的输出。
|
||||
- WebSocket subscribe_session 仅校验元数据、绑定运行态并发送最新运行快照;不读取历史。历史修订已变更时通知前端通过 HTTP 对账,覆盖读取与订阅之间刚好完成的任务。
|
||||
- 完整归档直接读取有界块。旧快照仅返回自身已有消息,不因打开页面触发原生日志扫描或历史迁移。
|
||||
- HTTP 错误显式返回状态码和请求标识;诊断只含脱敏标识、阶段、消息数量及耗时。
|
||||
- 保留既有服务端 WebSocket 协议供已存在调用方使用;本次浏览器读取链路全部迁到 HTTP。
|
||||
14
.trellis/tasks/10-14-session-switch-timeout/prd.md
Normal file
14
.trellis/tasks/10-14-session-switch-timeout/prd.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 修复会话切换加载超时
|
||||
|
||||
## 症状
|
||||
用户多次反馈:切换到运行中的会话时偶发一直加载并超时,刷新页面后会话立即显示。
|
||||
|
||||
## 交付要求
|
||||
- 找出代码层面可证实且可复现的故障,明确区分未证实的现场推测。
|
||||
- 首屏、历史分页和目录查询走带鉴权的 HTTP;WebSocket 断开或假 OPEN 时仍能查看历史。
|
||||
- WebSocket 只接入实时会话状态和增量,重新订阅不得重读或重发历史。
|
||||
- 运行中会话的历史和运行态快照能通过 HTTP 显示,订阅接入时对齐最新运行状态。
|
||||
- 保留快速连续切换的请求标识隔离,过期响应不得覆盖最新选择。
|
||||
- 增加足够诊断以定位同类超时,不记录消息正文或敏感内容。
|
||||
- 用隔离测试验证运行中会话、断线恢复、请求失败和乱序响应。
|
||||
- 测试通过后检查其他运行中对话,满足项目重启条件才重启服务。
|
||||
23
.trellis/tasks/10-14-session-switch-timeout/progress.md
Normal file
23
.trellis/tasks/10-14-session-switch-timeout/progress.md
Normal 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,任务跟踪已收尾。
|
||||
118
.trellis/tasks/10-14-session-switch-timeout/research/frontend.md
Normal file
118
.trellis/tasks/10-14-session-switch-timeout/research/frontend.md
Normal 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、服务端处理慢和前端渲染异常。
|
||||
|
||||
@@ -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 事件回调内全量同步解析;首屏只读取
|
||||
有界尾部,完整历史继续分页。
|
||||
|
||||
26
.trellis/tasks/10-14-session-switch-timeout/task.json
Normal file
26
.trellis/tasks/10-14-session-switch-timeout/task.json
Normal 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": {}
|
||||
}
|
||||
19
.trellis/tasks/10-14-session-switch-timeout/task_plan.md
Normal file
19
.trellis/tasks/10-14-session-switch-timeout/task_plan.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 修复会话切换加载超时
|
||||
|
||||
## 目标
|
||||
将会话首屏、历史分页改为独立 HTTP 查询,消除对 WebSocket 鉴权、重连和运行态绑定的依赖;实时消息由独立订阅接入,用隔离测试验证。
|
||||
|
||||
## 阶段
|
||||
1. [complete] 确认 HTTP 加载与实时订阅的边界及并发契约。
|
||||
2. [complete] 实现 HTTP 会话快照、历史分页与独立实时订阅。
|
||||
3. [complete] 验证断开 WebSocket、运行中切换、连续切换和历史异常。
|
||||
4. [complete] 检查生效条件并交付根因、验证结果与限制。
|
||||
|
||||
## 约束
|
||||
- 不把刷新恢复直接归因于网络;区分连接、请求、服务端和渲染。
|
||||
- 不删除或修改用户会话历史,不中断其他运行中对话。
|
||||
- 单次测试设置 60 秒超时,使用隔离测试数据。
|
||||
- 用户明确禁止子代理,全部工作由主代理直接完成。
|
||||
|
||||
## 已遇到的问题
|
||||
- 上一轮运行 session-switch-race 60 秒无输出;先检查测试入口再选择隔离用例,避免原样重复。
|
||||
46
.trellis/tasks/10-14-session-switch-timeout/validation.json
Normal file
46
.trellis/tasks/10-14-session-switch-timeout/validation.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user