Skip to content

事件日志 ​

Noeta 从不保存 Task 的「当前状态」。Task 上发生的每件事都追加到它自己的 EventLog 里,需要状态时再从日志算出来:

状态 = fold(这个 Task 的全部事件)

日志才是正本,状态对象用完就可以扔。崩溃恢复、重放、审计,做的都是同一件事:fold。

Engine 把发生的事记下来快照只为提速,可以不要EventLog每个任务一条只追加的流水ContentStore大块内容按哈希存fold()按顺序从头重放任务当前状态对话、目标和待办、上下文、计数小事件,4 KB 以内大块内容事件里只留 ContentRef从这里开始之后的事件按哈希取下一步

保证什么 ​

  • 重放逐字节一致。 同一份日志在任何进程、任何机器上 fold 出的状态逐字节相同。fold 只读日志和内容存储,不看时钟、不用随机数、不联网、不调模型。
  • 不改旧记录。 纠错、回退、压缩都是追加新事件,原来的记录一直留在日志里。
  • 每个 Task 只有一个写入方。 每次追加都要出示 worker 的租约;租约已被收回的 worker 写不进去。
  • 快照只是加速。 把所有快照删掉,行为不变,只是慢一些。

日志里有什么 ​

一条典型的事件流,一行一条:

seq类型记录的内容
1TaskCreated目标、agent 名、父任务
2MessagesAppended用户的消息
3ContextPlanComposed指向这次给模型看的完整内容
4LLMRequestFinished模型回复和 token 用量
5ToolCallStarted比如 Read(file_path="README.md")
…循环继续
41TaskSuspendedTask 开始等待
42TaskWoken等的东西到了
58TaskCompleted最终回答

每条记录是一个 EventEnvelope,带有 seq(追加时由日志分配)、type、actor、origin(engine / llm / observer / tool / system)和一份很小的 payload。payload 上限 4 KB(EVENT_PAYLOAD_MAX_BYTES);更大的东西——完整的模型响应、大段工具输出、快照内容——放进按内容寻址的 ContentStore,事件里只存一个指向它的 ContentRef。

四块状态,只有 fold 能改 ​

Task 的状态拆成四块,只有 fold 会改它们,所以任何改动都必须先留下事件:

状态块改动从哪来内容
RuntimeStateEngine 记下的事件对话消息、每轮用量
TaskStatepolicy,只能通过决策里附带的 TaskStatePatch目标、阶段、待办、当前加载的内容
ContextStateEngine 记下的上下文组装结果上下文记录的引用、压缩摘要、内容插入位置
GovernanceState从整条事件流累计出来花费、轮数和 token 计数、子任务结果

policy 决定要改什么,但由 Engine 记成 TaskStatePatched 事件,再由 fold 写回。「决定」和「记录」是两种权限,分别在两个组件手里。

崩溃后怎么恢复 ​

事件日志迟到的写入被拒(InvalidLease)Worker A 拿着租约在跑一步A 在半路挂了租约过期,任务重新排队requeue_staleWorker B 拿到租约,从日志重建状态把做了一半的那次尝试标记作废StepAttemptAbandoned可以安全重跑需要人判断重跑这一步不需要审批停下来,等人处理连续 3 次作废 → 停下等人
  1. worker A 执行到一半被杀,心跳停了,租约过期。
  2. 过期检查把 Task 放回就绪队列。
  3. worker B 拿到租约,fold 日志——这本来就是每一步开头都要做的事。
  4. B 写一条 StepAttemptAbandoned,把中断的那次尝试标记作废。如果那次尝试里的每个工具调用都不用审批就能通过 guard,就自动重跑这一步——重跑可能把已经执行过的调用再执行一遍。只要有调用需要审批或会被拒绝、那次尝试派出过子任务,或者用到了不认识的工具,就把 Task 停住,等人来继续。连续作废三次一定会停住,崩溃循环不会无限重试。

除此之外没有别的恢复逻辑,因为从来就没有什么需要「保存」的东西。这套保证的边界见已知限制。

快照 ​

日志很长时,从头重放会变慢,所以 fold 也可以从最新的基线事件恢复,只重放它之后的部分:

基线事件什么时候写
TaskSnapshot每次挂起和结束前,以及连续 20 轮工具调用后
TaskRewound对话回退到更早的一轮
StepAttemptAbandoned把一次中断的尝试标记作废
TaskForked从这个 Task 分出一个新 Task

测试会用 ignore_snapshots=True 强制从头 fold,检查两条路径得到的字节完全一样。如果某个快照缺少当前 fold 需要的字段,就丢掉它从头重放:慢一点,但不会错。同样的规则保证半年前挂起的 Task 用今天的代码照样能 fold。

对你意味着什么 ​

  • 用 Client.events 读 Task 的完整历史,用 Client.messages 读消息;恢复时用的就是这些数据。
  • 能读到存储的进程就能恢复任何 Task,所以扩容只是换存储,不用改代码。
  • 想改变行为的钩子应该放在 policy 或 guard 里,不能放在 observer 里,见 Engine。

设计记录: event-sourced truth · single-writer invariant · step-attempt recovery

下一步 ​

  • 任务与唤醒:Task 怎么等待,又怎么只恢复一次。
  • Engine:事件是谁写的。
  • 部署:运行负责恢复的 worker。

基于 Apache License 2.0 发布。