修复完整历史与运行态内存放大并重建发布包

This commit is contained in:
shiyue
2026-10-14 18:13:02 +08:00
parent 69b6db0f5b
commit 2b1c91488d
28 changed files with 2344 additions and 159 deletions

View File

@@ -45,6 +45,8 @@ Questions to answer:
- 完整档案先原子提交,随后更新有界快照;完整档案失败时不得用裁剪快照覆盖上次状态。档案提交成功而快照失败时,读取和会话列表仍须能找到已提交档案。
- 可信完整读取后的修改应保留清空、撤回和插入顺序;短预览及旧修订不能覆盖完整消息或丢掉后来追加的记录,正常并发保存不得静默漏记。
- 旧版截断历史只有在确认原始记录对应边界后才能补入完整档案;新建档案本身不能证明旧历史已完整。
- 大型运行态JSON须使用分段编码和直接构造对象的流式读取,禁止完整原文、全量写出缓冲及序列化克隆同时驻留;不能把单次读写有界解释为返回对象总内存有界。
- 任务状态版本保护在同一次归档提交内处理,失败不得提前推进可信基线或活跃任务状态;JSON读写需对照原生语义并覆盖跨段编码、短读短写及原子失败保护。
---

View File

@@ -9,7 +9,15 @@
```js
createSessionHistoryStore({ sessionsDir, fs? })
store.load(id) // 完整会话或 null;注册可信加载基线
store.save(session) // { revision, complete, session, rebased };失败抛错
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) // 来源、修订与完整性;不暴露消息基线
@@ -21,21 +29,26 @@ store.loadTurn(id) // 优先于有界运行预览
store.listTurnIds()
store.removeTurn(id) // 完整消息已提交或主动清空后才清理
saveSession(session) // 完整档案提交成功为 true,否则 false
loadSession(id) // 优先完整档案;无档案才读取旧快照
resolveSessionHistory(session) // { messages, source, available, recoverable }
loadSession(id, options?) // 默认尾部最多180条、2MiB;单条超大消息完整保留
// options 支持 metadata、count、window、messageId、promptId;显式 full 才全量加载
resolveSessionHistory(session, { count?, before? }) // 有界出口另含 total/start/end
```
## 3. 存储与响应契约
- `${SESSIONS_DIR}/_history/<id>.json` 是持久提交点,格式为 `{ version: 1, revision, complete, resetRevision, session }`。`session` 包含完整可序列化字段,排除运行态句柄;正文、工具、数组和深度不使用预览限额。
- `${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 的修订字段伪造。当前修订精确保存删除、清空和插入;旧修订根据加载基线合并互不冲突的修改,保持活跃消息及工具对象引用。
- 归档提交顺序为临时文件写入、文件 `fsync`、原子 `rename`、推进基线,再更新快照。档案失败不得更新快照;档案已提交而快照失败仍返回成功。
- 列表枚举快照与档案 ID 的并集。首次快照失败后,孤立档案仍须可读、可列出;缓存继续失败时使用内存有界预览。
- Codex App 运行态写入 `_history/_turns/<id>.json`,包含完整正文、工具及映射;有界运行态文件只作预览。重启枚举完整运行态,恢复消息提交成功后才清理,异常档案保留证据。
- 可信来源保存在存储实例的 `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. 校验与错误矩阵
@@ -62,6 +75,11 @@ resolveSessionHistory(session) // { messages, source, available, recoverable
## 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 条在来源待核实状态下仍全部可读,顺序正确、无重复,用户目录完成索引。
@@ -79,7 +97,8 @@ writeFileAtomicSync(sessionPath(session.id), buildSessionJsonForPersist(session)
session.messages.push({ content: entry.fullText });
const committed = store.save(session);
try {
const preview = buildSessionJsonForPersist({ ...committed.session, historyArchiveRevision: committed.revision });
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) {
// 完整档案已提交,缓存失败不改成历史保存失败。