Files
cc-web/.trellis/spec/backend/session-history.md

120 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 完整历史保存与分页契约
## 1. 范围与触发
会话保存、读取、原始日志补齐、导入、运行结束及重启恢复涉及本契约。成功保存的正文与工具数据必须能从 cc-web 自身读取;预览大小限制只用于快照缓存和实时工具预览,不能限制完整历史及完成消息的阅读。
## 2. 函数签名
```js
createSessionHistoryStore({ sessionsDir, fs? })
store.load(id) // 完整会话或 null;注册可信加载基线
store.loadTail(id, count = 180, { maxBytes? }) // 只加载可修改尾部,保留全局索引
store.loadMetadata(id) // 只加载元数据,保存时保留全部消息
store.loadWindow(id, start, end) // 可修改中间窗口,保存时保留前缀和后缀
store.loadHistoryPage(id, before, count = 24) // { messages, total, start, end, complete, revision }
store.findMessage(id, predicate) // 按块查找,内存只保留少量块
store.forEachMessage(id, callback) // 按块遍历,用于附件清理等全历史操作
store.messageIndex(id, messageId) // 从轻量清单查询全局消息索引
store.countMessages(id, role) // 从清单计数,不加载正文
store.save(session, { taskBoardMetadata? }) // { revision, complete, session, rebased, taskTrackingPreserved };失败抛错
store.markLegacy(session) // 注册旧快照来源与完整性状态
store.markComplete(session, { reset? }) // 确认可靠补齐或主动清空;返回状态还原函数
store.state(session) // 来源、修订与完整性;不暴露消息基线
store.listIds()
store.revision(id)
store.remove(id)
store.saveTurn(id, state) // 完整 Codex App 运行态先原子提交
store.loadTurn(id) // 优先于有界运行预览
store.listTurnIds()
store.removeTurn(id) // 完整消息已提交或主动清空后才清理
saveSession(session) // 完整档案提交成功为 true,否则 false
loadSession(id, options?) // 默认尾部最多180条、2MiB;单条超大消息完整保留
// options 支持 metadata、count、window、messageId、promptId;显式 full 才全量加载
resolveSessionHistory(session, { count?, before? }) // 有界出口另含 total/start/end
```
## 3. 存储与响应契约
- `${SESSIONS_DIR}/_history/<id>/manifest.json` 是唯一持久提交点,格式为 `{ version: 2, revision, complete, resetRevision, session, entries }`。`session` 只有可序列化元数据,排除运行态句柄;`entries` 只保留稳定消息 ID、角色、摘要、块引用、块内索引和字节数。
- 完整消息保存在 `chunks/<sha256>.json` 不可变块中,默认每块最多 2 MiB 或 64 条;单条超大消息允许独占大块,正文、工具、数组和深度不使用预览限额。保存复用内容未变的块,只写修改的消息,不序列化或复制整份历史。
- 旧 `_history/<id>.json` 首次读取时使用 `lib/json-persistence.js` 按 64 KiB 扫描流式迁移,每次构造一个字段或消息;块全部落盘后才提交新清单。旧文件保留,迁移失败不破坏原文件。
- `<id>.json` 是有界快照缓存。`historyArchiveRevision` 位于顶层首字段,用来判断缓存是否过期;不得从嵌套同名键推断修订。
- `CC_WEB_SESSION_PERSIST_MAX_MESSAGES`、`CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS` 等预览配置不能限制完整档案;真实完成和恢复路径也不得先裁剪消息再提交。
- 可信来源保存在存储实例的 `WeakMap`,不能凭 JSON 的修订字段伪造。基线只引用清单,不保留整份正文字符串。当前修订精确保存窗口内删除和插入,未加载前后缀原样保留;跨未知前缀的重复 ID 保留已提交原文。旧修订按单消息三方合并,保持活跃消息及工具对象引用。主动清空须明确 `markComplete(session, { reset: true })`。
- 归档提交顺序为修改块写入、文件 `fsync`、原子 `rename`,完成协调所需的块读取,再原子提交清单、推进基线、更新有界快照。清单失败不得推进基线或更新快照;已提交而快照失败仍返回成功。每32次提交回收无引用块,跨存储实例的活跃旧基线通过弱引用保护,清理失败不改变提交结果。
- 可信同修订保存仅读取最多256字节的修订头并复用不可变清单;修订已变化才读取当前清单。任务状态版本保护使用同一次提交的清单,不为单个字段重复加载历史或清单。普通保存保留更高任务版本,任务看板通过 `taskBoardMetadata=true` 显式提交其负责的更新或删除;受保护状态仅在提交成功后同步到活跃对象。克隆只复制JSON容器,比较使用规范JSON值的结构比较,不为正文生成序列化副本。
- 列表枚举快照与档案 ID 的并集,排除 `_turns` 保留目录及同名旧文件。首次快照失败后,孤立档案仍须可读、可列出;缓存继续失败时使用内存有界预览。
- Codex App 运行态写入 `_history/_turns/<id>.json`,包含完整正文、工具及映射,保留原 `version:1` 格式。读取直接构造对象,写入按小段转义正文并聚合写出,单次读写最多64KiB字节;不生成整份JSON原文或写出缓冲。运行态读取结果仍为完整对象,不能声称总内存与正文大小无关。有界运行态文件只作预览。重启枚举完整运行态,恢复消息提交成功后才清理,异常档案保留证据。
- 用户提问必须保存成功后才启动 runtime 或接受 steer;完成、重启恢复及跨会话返回必须检查保存结果,失败保留可重试内容。CLI 恢复标识须区分回合,同一已提交回合不可因清理失败再次追加。
- 确认原始边界后的旧前缀必须先进入自身档案。`complete=false` 保留旧缺口状态,不能因创建档案就宣称旧历史完整。
- 首屏和重连只读取最近12条;向前分页每次读取24条,目标消息预取受最大页数限制。表单和撤回使用单消息窗口,历史查重和附件枚举按块执行;任务看板只读取和保存元数据。
- 响应的 `historyCursor` 是尚未展示的已有消息数;正数允许分页。`historyAvailable=false` 只说明旧来源待核实,不能阻断正文或用户目录。完整档案响应 `historySource='archive'`、`historyAvailable=true`。
## 4. 校验与错误矩阵
| 条件 | 行为 |
|---|---|
| 非字母、数字、下划线或连字符 ID | `history_invalid_id`,不写入 |
| 已有档案但传入未可信加载的短预览 | `history_untrusted_source`,保留已有档案 |
| 档案 JSON 或结构异常 | 读取失败,禁止回退短快照覆盖档案 |
| 旧修订缺可靠消息 ID | `history_stale_unidentified`,不覆盖新记录 |
| 旧清空请求遇到后来追加 | `history_stale_clear`,要求重新加载 |
| 清空已提交后旧对象再保存 | 允许无冲突元数据更新;禁止旧历史及线程复活 |
| 会话已删除或档案已移除 | `history_removed`,旧对象不能重新创建 |
| 完整档案写入失败 | `saveSession=false`,保留上次提交;调用方不得宣称保存成功并清理唯一未提交输出 |
| 完整档案成功、快照失败 | `saveSession=true`,记录缓存错误,重开从档案读取 |
| 原始日志缺失或边界未确认 | 仅返回自身已有消息,保留待核实状态,不猜测补入 |
## 5. 正常、基本与失败场景
- 正常:650 条及长正文、125 条工具调用保存后,快照仍有界;存储重开和原始日志移开后,完整内容相等。
- 基本:短会话重复保存后正文、顺序和稳定 ID 不变;主动删除、清空、撤回不会复活旧消息。
- 失败:磁盘写入失败只保留上次提交及可重试的未提交内容;旧短预览不能作为新完整历史覆盖已有档案。
## 6. 必需回归与断言
- `scripts/session-history-store-unit.js`:实际生产保存和读取入口、超限内容、存储重开、两类写失败、孤立档案、并发及活跃引用、删除/清空/撤回、确认前缀先归档。
- 同一脚本还须覆盖默认尾部、中间窗口及元数据保存保留未加载历史,不同加载范围并发合并、清空防复活、失败后重试和未加载前缀 ID 去重。
- 同一脚本还须覆盖同修订保存只读修订头、更高任务版本整体保护、任务看板显式删除以及清单失败后任务状态和可信基线不提前推进。
- `scripts/json-persistence-unit.js`:编码与读取对照原生JSON及项目bigint/Map/Set转换,覆盖toJSON单次求值、对象键、UTF-8和代理对跨段、短读短写、非法JSON及编码中途异常的原子失败保护。
- `scripts/runtime-state-memory-unit.js`:独立子进程在256MiB堆参数下读写80MiB旧v1完整运行态,正文与工具映射摘要逐项一致;检查单次64KiB字节读写及write/fsync/rename失败保留旧文件、无临时文件残留。合成峰值不能代表生产或任意并发量。
- `scripts/session-history-memory-unit.js`:独立子进程在256MiB堆上限下流式迁移70.5MiB旧归档,执行尾部/元数据保存和完整分页;逐条摘要校验所有历史,监测RSS、旧文件读取块大小和块复用。合成夹具的结果不能当作生产进程测量。
- 真实完成与恢复路径使用超限正文和工具,断言归档原样读回;不能仅绕过生产入库函数直接构造消息数组。
- 真实 runtime 事件覆盖纯工具、纯文本与结构块混合、Claude user/tool_result;恢复覆盖图片内容块、提交失败保留以及重复恢复幂等性。任务看板的克隆适配器需重新可信加载并只更新元数据。
- `scripts/session-history-unit.js` 与 `scripts/history-frontend-unit.js`:57 条、首屏 12 条、剩余 45 条在来源待核实状态下仍全部可读,顺序正确、无重复,用户目录完成索引。
- 隔离浏览器:确认旧前缀后移开夹具原始日志,重启测试服务、重新登录,再从 `archive` 分页到最早消息;继续运行、刷新及重连保持内容。
- 单测设置 60 秒上限;真实会话和生产服务不能代替隔离夹具。
## 7. 错误与正确示例
```js
// 错误:把预览当成唯一提交,完整正文在写档案前已经裁剪。
session.messages.push({ content: truncateTextValue(entry.fullText, previewLimit) });
writeFileAtomicSync(sessionPath(session.id), buildSessionJsonForPersist(session).json);
// 正确:保存完整值,归档先提交,再生成有界预览缓存。
session.messages.push({ content: entry.fullText });
const committed = store.save(session);
try {
const tail = store.loadTail(session.id, 180, { maxBytes: 2 * 1024 * 1024 });
const preview = buildSessionJsonForPersist({ ...tail, historyArchiveRevision: committed.revision });
writeFileAtomicSync(sessionPath(session.id), preview.json);
} catch (error) {
// 完整档案已提交,缓存失败不改成历史保存失败。
plog('WARN', 'session_snapshot_cache_save_failed', { error: error.message });
}
```
实际代码还须处理稳定 ID、可信加载、运行态排除和快照写失败;这些保护由统一保存入口承担。
## 8. 同一任务的自动续跑与运行中重连
- 自动延续重试交接完整 `fullText`、全部工具、消息及工具去重映射、稳定助手消息ID和逻辑回合标识。模型提示可只保留末尾7000字,气泡与归档不能使用该提示替代完整输出。
- 清理中断轮前先提交完整运行态;重试等待期仍须被认定为运行中,并可通过 `resume_generating` 读回完整状态。完整保存失败须保留唯一输出,不能继续破坏性交接。
- 重试使用独立 `runtimeItemScope`,原生item ID跨轮复用不能覆盖较早输出;高频长正文增量可分片发送,拼接结果必须等于原始增量。
- 正常新用户任务从空正文开始。耗尽、取消、启动失败及最终保存失败均须保留已产生正文,并防止旧回合晚到通知结束新回合。
- `turn/start`已发送且原生回合ID未知时取消,旧轮须继续持有线程门禁。通知先到也归旧轮;确认身份并精确中断后才能放行新任务,RPC超时不能凭空释放身份。
- 完整运行态、`done.assistantMessage`和历史分页应提供全部正文及工具输入/结果;不能让有界工具预览在完成或刷新时覆盖完整气泡。
- 必需回归:`codexapp-retry-history-unit.js`覆盖A/B/C三次续跑、等待期重连、全部工具、存储重开、耗尽、取消、启动交错、保存失败、重启恢复及正常新轮隔离;`history-runtime-unit.js`同时检查完整读取与有界实时预览。