Fold 与快照
fold 是把一个 Task 的 EventLog 还原为 Task 状态的函数。状态只可能由它产生——旁边没有另一条"加载"路径。恢复一个崩溃的 Task、在 UI 里渲染一个 Task、一个月后审计一个 Task,都是同一次调用。
它的输入被刻意保持得极小:fold(event_log, content_store, task_id),别无其他。没有时钟、没有随机性、没有网络,也不会重新调用一个模型 provider。这种贫瘠正是重点——它意味着同一条日志在任何机器的任何进程里 fold,都得到字节级相同的状态。
常见情形:一台机器在步骤中途死掉
上面这张图就是完整的恢复故事,而它里面没有任何恢复代码。Worker A 正在推进一个 Task,然后被杀掉。它的租约不再有心跳,于是过期。过期清扫把这个 Task 送回就绪队列。Worker B 租用它、fold 日志——这本来就是每个租约步骤要做的第一件事——追加一个标记来封存那次被中断的尝试,然后继续。
崩溃之前没有任何东西需要被"保存",因为每一个持久事实本来就已经是一个事件。没有半写的状态文件需要对账,也没有"副本与日志不同步"这一整类 bug 需要修,因为根本没有副本。
两条路径,一个结果
从顶部重放一条长日志会越来越慢,因此 fold 保留了一条快速路径:
- 从顶路径 —— 从
TaskCreated创世事件引导出空状态,然后重放它之后的一切。 - 基线路径 —— 从最新的基线事件恢复状态,然后只重放它之后的尾部。
一个基线是一个携带指向 ContentStore 的 state_ref 的普通事件。有四种类型符合资格:
| 基线事件 | 它为什么存在 |
|---|---|
TaskSnapshot | 纯粹的加速——别的什么都不改变 |
TaskRewound | 对话被回退到了更早的一轮 |
StepAttemptAbandoned | 一次被中断的尝试被封存为死历史 |
TaskForked | 一个新 Task 从这个状态分叉出去 |
TaskSnapshot 会在每个终止事件之前、每次挂起之前,以及当连续工具调用轮次越过 CONSECUTIVE_TOOL_CALLS_SNAPSHOT_THRESHOLD(20)时在循环中途各写一个——因此一个从不让出的 Policy 仍然会留下可用的恢复点。另外三种是有意义的重定基(re-base)标记,fold 在两条路径上都会应用它们:一个被回退的对话、一次被放弃的尝试、一个被派生(fork)的 Task,都是命名一个新基线,而不是编辑此前发生的内容。
铁律:两条路径 fold 出字节相等的结果
fold(..., ignore_snapshots=True) 会强制完整重放,测试套件用它来把两条路径相互交叉校验。
这条规则把 TaskSnapshot 的地位永久钉死:它是一个性能加速器,永远不是第二个事实来源。把系统里的每个快照都删掉,行为不变,只是变慢。
同样的优先级也管辖 fold 无法信任的状态。一个缺少 fold 所依赖字段的基线正文会被丢弃,转而做一次完整重放,等到一个新的基线被写下,快速路径重新激活。宁可慢,不可错。
规范化渲染
"字节相等"需要一个后盾,这一层就是 canonical(规范化):把任何类型化的值渲染为一种稳定的字节形式——键排序、分隔符紧凑、全程 UTF-8 的 JSON。等价的对象因此渲染为完全相同的字节,同一内容的哈希在任何机器、任何时间都相同。
有两样东西直接依赖它。内容寻址用它来去重;基线正文往返穿过它,好让带标签的值类型(ContentRef、唤醒条件、子任务结果、类型化消息块)在跨越边界时保持自己的身份。整个事件溯源设计的可复现性就建立在这一薄层之上。(随着字段的增删,记录如何保持可 fold,见状态与写入者。)
fold 何时运行
每次唤醒、每次检查,以及每一个租约步骤的开始。
一次时间点读取——"这个任务在 seq N 时是什么样子?"——是通过对同一批事件的一个有界只读视图来 fold,而不是给 fold 教一个上限。fold 本身只向前走,流上也没有任何东西会被改写。