新 Thread 可以先存在于内存而没有 Rollout 文件,但一旦物化,第一条规范记录必须是预先构造好的 SessionMeta,后续读取以它确定身份和 History Mode。
本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:解释 Create 路径从无文件到首批记录,不讨论 Resume 打开既有文件。输入:RolloutRecorderParams::Create、SessionMeta 和首批业务项;状态所有者:RolloutWriterState 的 deferred writer 状态;成功结果:以 canonical SessionMeta 开头的 JSONL Rollout。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。
状态边界
| 问题 | 本节答案 |
|---|---|
| 输入 | RolloutRecorderParams::Create、SessionMeta 和首批业务项 |
| 状态所有者 | RolloutWriterState 的 deferred writer 状态 |
| 成功产物 | 以 canonical SessionMeta 开头的 JSONL Rollout |
| 研究范围 | 解释 Create 路径从无文件到首批记录,不讨论 Resume 打开既有文件 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 创建时计算路径与 SessionMeta。
- 不立即创建空文件。
- 首批 AddItems 进入 pending。
- Persist/Flush 触发 materialize。
- 先写 SessionMeta 再写业务项。
图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。
源码机制拆解
创建与物化分离
Recorder::new(Create) 保存目标路径和元数据,却让 writer 为 None。这样一次很快失败、没有产生任何规范内容的 Thread 不必留下空文件。
SessionMeta 是物理日志头
它携带 thread/session identity、cwd、source、provider、base instructions、dynamic tools、history mode、history_base 与多 Agent 字段。Loader 把第一条 SessionMeta 当作 canonical 元数据。
Persist 可以主动制造存在性
即使 pending 没有业务记录,persist 也要求创建文件并写入 SessionMeta。需要让 Thread 立即可被列表、Fork 或外部引用发现时,调用方必须显式跨过这条边界。
首批业务项不能抢在 Meta 前面
Writer 在第一次打开 Create 状态时把预存 SessionMeta 放到序列最前。Paginated ordinal 的起点也以该头记录和 history_base 为依据。
后续 SessionMeta 不改写头身份
复制 Fork 可能带入旧 SessionMeta 行,Loader 仍只把第一条作为当前物理 Rollout 的 canonical meta;后续行作为历史内容保留,不能覆盖当前 Thread 身份。
Python 风格伪代码
下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:
class DeferredWriter:
def __init__(self, path, session_meta):
self.path = path
self.session_meta = session_meta
self.file = None
self.pending = []
async def add(self, items):
self.pending.extend(items)
if self.file is not None:
await self.write_pending()
async def materialize(self):
if self.file is None:
self.file = await create_new_jsonl(self.path)
await self.write_line(self.session_meta)
await self.write_pending()
async def persist(self):
await self.materialize()
await self.file.flush()
阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| 创建后从未物化 | 只有内存 Thread | 进程退出后不可恢复 | 需要耐久身份时显式 persist |
| 首行不是 SessionMeta | 缺少 canonical identity/mode | Loader 拒绝或无法解释后续行 | 物化逻辑独占首行写入 |
| SessionMeta 写成一半 | 尾部 JSON 不完整 | 加载跳过坏行后找不到 meta | 创建失败,不把文件当有效 Thread |
| 复制来的旧 Meta 覆盖当前身份 | 一个文件出现多条 meta | 路径和 ThreadId 错配 | 只认首条 canonical meta |
这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。
必须保持的不变量
- 有效 Rollout 的第一条可解析记录是当前 SessionMeta
- 未物化 Thread 不能声称已经耐久
- 业务项的 ordinal 必须接在当前历史基准之后
- Create 不应留下无意义空日志
这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。
设计取舍
延迟物化减少短命 Thread 和探测操作的磁盘噪声,但上层必须理解“对象已创建”与“可从磁盘发现”是两个阶段。
源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。
Mini Codex 复刻
创建 Thread 时只分配 ID 和 Meta;第一次 append 或显式 persist 用 create-new 打开文件,原子地先写 Meta,再写 pending。
复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。
源码导航
- codex-rs/rollout/src/recorder.rs:Create/Resume 参数、deferred writer 和 materialize 路径
- codex-rs/protocol/src/protocol.rs:SessionMeta 字段及 history_base
- codex-rs/thread-store/src/local/create_thread.rs:LocalThreadStore 构造创建参数
相邻测试也很重要:
- codex-rs/rollout/src/recorder_tests.rs:延迟创建、显式 persist 与首行元数据
- codex-rs/rollout/src/metadata_tests.rs:SessionMeta 读取与兼容字段
本节结论
新 Thread 可以先存在于内存而没有 Rollout 文件,但一旦物化,第一条规范记录必须是预先构造好的 SessionMeta,后续读取以它确定身份和 History Mode。
评论
登录后即可评论