Thread 列表元数据不是每次重扫全日志得到的快照;MetadataSync 从已持久化项增量观察 preview、cwd、模型、Token、Goal 和 Git,并用 generation 防止慢更新覆盖新事实。
本节解决的不是“把对象存一下”这种抽象问题,而是把问题限定在:说明 create/resume 的延迟更新、事实提取、touch 合并与并发代次。输入:canonical RolloutItem、当前 ThreadMetadata 与 Git 探测结果;状态所有者:ThreadMetadataSync;成功结果:State DB 中可查询的 Thread preview/cwd/git/recency 等字段。先把这些边界钉住,后面的顺序、失败和恢复才不会混成一句“持久化失败后重试”。
状态边界
| 问题 | 本节答案 |
|---|---|
| 输入 | canonical RolloutItem、当前 ThreadMetadata 与 Git 探测结果 |
| 状态所有者 | ThreadMetadataSync |
| 成功产物 | State DB 中可查询的 Thread preview/cwd/git/recency 等字段 |
| 研究范围 | 说明 create/resume 的延迟更新、事实提取、touch 合并与并发代次 |
正常路径:先看顺序点
这条路径可以压缩成五步:
- 只观察已持久化 items。
- 提取字段变更与 touch 信号。
- 合并 pending update 并递增 generation。
- 异步应用数据库更新。
- 仅当前 generation 清除 pending。
图中的箭头不是“可能调用”的依赖图,而是源码中决定可见性和所有权转移的先后关系。前一步没有确认时,后一步不能替它作出更强的成功承诺。
源码机制拆解
Create 延迟首个 upsert
初始化可先从 SessionMeta 和 Git 得到元数据,但在 Rollout 文件真正存在前不把数据库行当成一个已耐久 Thread。第一次持久化历史后才应用 create update。
Resume 不立即改写旧元数据
恢复时可以推导缺失字段,但写回延迟到新 append,避免仅仅打开一个 Thread 就改变 recency 或覆盖保留的显式标题。
不同 item 提供不同事实
SessionMeta 给 source/provider/cwd;TurnContext 给 model、approval、permissions;UserMessage 建 preview/first message/title 候选;TokenCount、Goal、ThreadSettings 分别更新其领域字段。
Recency 与普通 touch 分离
TurnStarted 明确推进 recency;其他仅表示活动的 touch 以 5 秒窗口合并,降低高频 item 对 SQLite writer 的竞争。
Generation 防止 ABA 覆盖
异步 DB 更新开始后,新 append 可能产生更完整 pending。完成者只在 generation 仍匹配时清空状态;旧任务不能把新变更标记为已应用。
Python 风格伪代码
下面的伪代码只保留设计职责、状态和失败顺序;它不逐行翻译 Rust,也不借 Python 语法虚构源码中不存在的事务:
async def on_append(sync, persisted_items):
delta = MetadataDelta()
for item in persisted_items:
delta.merge(extract_metadata_fact(item))
if delta.is_empty:
return
generation = sync.pending.merge_and_bump(delta)
snapshot = sync.pending.snapshot()
await state_db.apply_thread_metadata(snapshot)
async with sync.lock:
if sync.pending.generation == generation:
sync.pending.clear_applied(snapshot)
def extract_metadata_fact(item):
match item:
case SessionMeta(meta): return source_provider_cwd(meta)
case TurnContext(ctx): return model_policy_permissions(ctx)
case UserMessage(text): return preview_first_message(text)
case TurnStarted(): return advance_recency()
case TokenCount(value): return token_summary(value)
case GoalUpdated(goal): return goal_preview(goal)
case _: return maybe_coalesced_touch()
阅读时要特别看三处:哪个对象拥有可变状态,哪一个 await 是可观察屏障,以及失败后保留的是已提交前缀、未提交后缀,还是完全独立的外部副作用。
失败、取消与恢复
| 故障点 | 已留下的状态 | 可观察结果 | 恢复责任 |
|---|---|---|---|
| DB 更新慢、下一批先到 | pending generation 已增加 | 旧任务完成 | 旧 generation 不清空新 pending |
| Create 未物化就 upsert | 数据库有行但无日志 | 列表出现幽灵 Thread | 首个持久化后再创建元数据 |
| Resume 仅查看就 touch recency | 用户没有新活动 | 列表顺序被打开动作改变 | 延迟到新 append |
| 瞬时 Delta 被观察 | 大量无关事件 | preview/touch 噪声 | 只传 persistence policy 的输出 |
这里没有统一的“回滚一切”。内存状态、日志行、SQLite 投影、父子拓扑和工具造成的文件/网络变化分别有自己的提交点。恢复代码只能根据已经存在的权威证据继续,不能用较弱的投影替较强的事实背书。
必须保持的不变量
- 元数据更新不得领先 Thread 的首次耐久存在
- 显式标题不被自动 preview 覆盖
- stale generation 不清除新变化
- Paginated resume 不重复应用旧 Git/Memory 初始化副作用
这些不变量比“最终能 Resume”更严格:正常路径要成立,Writer 竞争、任务取消、坏尾行、投影落后和旧格式兼容时也必须成立。
设计取舍
增量派生比每次全扫描高效,却必须维护字段优先级、touch 限流和异步代次;落后时仍要保留 read-repair 退路。
源码可以直接证明字段、分支、调用顺序和测试期望;“为什么这样设计”的表述是基于这些事实作出的工程归纳,不把它包装成未公开的产品承诺。
Mini Codex 复刻
把 metadata reducer 与 async writer 分开;reducer 纯函数、writer 使用 generation CAS,首次 create 受 rollout_exists gate 控制。
复刻时先验证协议不变量,再补性能优化。一个能在故障注入下说明“留下了什么”的小实现,比一个只在正常路径调用 save() 的演示更接近真实 Runtime。
源码导航
- codex-rs/thread-store/src/thread_metadata_sync.rs:字段观察、5 秒 touch、create/resume 延迟与 generation
- codex-rs/thread-store/src/live_thread.rs:append 后的观察与应用顺序
- codex-rs/state/src/runtime/threads.rs:Thread metadata 的具体持久化操作
相邻测试也很重要:
- codex-rs/thread-store/src/thread_metadata_sync.rs:模块内覆盖 preview、Git、resume 与 generation 的测试
- codex-rs/rollout/src/metadata_tests.rs:从 Rollout 项提取 metadata 的兼容行为
本节结论
Thread 列表元数据不是每次重扫全日志得到的快照;MetadataSync 从已持久化项增量观察 preview、cwd、模型、Token、Goal 和 Git,并用 generation 防止慢更新覆盖新事实。
评论
登录后即可评论