Skip to content

Fold 与快照

fold 是把一个 Task 的 EventLog 还原为 Task 状态的函数。状态只可能由它产生——旁边没有另一条"加载"路径。恢复一个崩溃的 Task、在 UI 里渲染一个 Task、一个月后审计一个 Task,都是同一次调用。

它的输入被刻意保持得极小:fold(event_log, content_store, task_id),别无其他。没有时钟、没有随机性、没有网络,也不会重新调用一个模型 provider。这种贫瘠正是重点——它意味着同一条日志在任何机器的任何进程里 fold,都得到字节级相同的状态

崩溃恢复——一个 worker 在步骤中途死掉,它的租约过期,另一个 worker 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 本身只向前走,流上也没有任何东西会被改写。

下一步

  • 唤醒与恢复 —— 让上面这套恢复成为恰好一次的那个投递保证。
  • 事件溯源 —— fold 读取的那条日志。
  • 部署 Worker —— 在生产中运行执行这套恢复的排空循环。
  • 已知限制 —— 恢复保证的边界在哪里。

Released under the Apache License 2.0.