状态、事件与产物:一句「已完成」证明不了任何事
用 revision/CAS、租约围栏、因果事件链和内容哈希,把恢复与审计从模型叙述变成可验证协议。
本页目录 · 20 节
Agent 说「任务已完成」,只能证明它生成了这句话。对一个会并发、重试、等待用户、跨进程恢复,甚至触发外部副作用的任务,系统还必须回答四个更具体的问题:当前哪一次执行有效、什么动作实际发生过、结果对应哪一版输入、交付出去的究竟是哪一组字节。
Marshall Runtime 用 State、Event 与 Artifact 三类记录分别回答这些问题。它们不是三份格式不同的进度文档,而是三种不同的一致性职责。
| 记录 | 回答的问题 | 典型读取方式 | 不能替代什么 |
|---|---|---|---|
| State | 当前有效执行在哪里 | 直接读取当前投影 | 不能解释完整历史 |
| Event | 为什么会到达当前状态 | 按因果链审计或恢复 | 不适合每次从头计算当前状态 |
| Artifact | 下游实际消费什么内容 | 按路径和 Hash 精确解析 | 不能说明动作是否被授权、是否成功 |
这套设计的关键不是「多存几份 JSON」,而是建立一个闭合证明:State 指向当前执行,Event 证明决定与动作的因果顺序,Artifact 证明输出内容没有被悄悄替换。
State:当前投影必须带并发语义
StateV3 的核心身份可以压缩成下面这组字段:
audit_id / workflow_version / stage
attempt / run_id / active_generation / resume_epoch
revision / lease.fencing_token
它们看起来都是版本号,职责却不同:
attempt区分一个 Stage 的不同尝试;run_id标识这次具体运行;active_generation选择当前有效的一组计算与产物;resume_epoch区分同一 Generation 内经 Question Gate 恢复后的时间点;revision是 State 文件自身的并发写版本;fencing_token区分先后获得执行权的 Owner。
一个 Worker 返回的结果即使内容正确,只要绑定的是旧 run_id、旧 Generation、旧 Resume Epoch 或旧 Fence,也不能进入当前投影。内容正确与执行有效是两件事。
Revision/CAS 防止覆盖更新
每次修改 State 都要带调用方读到的 expected_revision。Runtime 在锁内重新读取当前状态,只有实际 Revision 与期望值相同,才执行 Mutator、校验 StateV3 合同,将 Revision 加一并原子替换文件:
read revision = n
compute candidate
CAS(expected = n)
current == n -> validate -> write revision n + 1
current != n -> RevisionConflict
没有这一步,多个并行执行者都可能从同一 Revision 出发,各自写入一个看似完整的新 State,后写者覆盖前写者的 Worker Result、Approval 或 Delivery。CAS 不负责判断哪一个业务结论更正确,它只保证并发冲突不会静默丢失。
原子替换也有一个容易忽略的边界:系统调用可能已经完成替换,但调用方只收到清理阶段的异常。Marshall 把这类情况标成 StateCommitAmbiguous,要求精确 Readback 后再决定是否重试。直接重放写入,会把「不知道是否成功」误处理成「肯定失败」。
Lease 决定谁在工作,Fence 决定谁还能提交
State 中的 Lease 包含 Owner、Token Hash、获取时间、到期时间和单调递增的 fencing_token。Bearer Token 只返回给持有者,持久层保存 Hash;普通 State Mutation 必须证明自己仍持有当前有效 Lease。
Lease 过期并不能让已经发出的 Worker 立刻停止。旧进程可能只是网络暂停,也可能在恢复后继续提交。新 Owner 获取 Lease 时 Fence 从 n 增加到 n + 1,后续 Result、Capability 与 Action 都绑定新 Fence。这样即使旧 Owner 仍然活着,它也只是一个 Zombie Writer:持有过期 Bearer,却无法越过当前围栏。
因此 Lease 与 Fence 解决的是两个不同问题:
- Lease 用时间窗口限制当前所有权;
- Fence 用单调代次拒绝旧所有者的晚到写入。
如果下游是外部 Provider,Fence 只有在 Provider Gateway、Mutation Intent 或 Readback 协议继续传递时才有效。本地 State 里的数字,无法自动撤销已经发往外部系统的请求。
Event:记录的不是日志文本,而是因果约束
Marshall 的 memory/events.jsonl 是只追加 Ledger。每条 Event 包含:
sequence / event_id / audit_id / kind / actor
parent_event_id / decision_id / action_id / outcome_id
payload / metrics / previous_hash / event_hash
sequence 必须从 1 连续增长;previous_hash 必须等于上一条记录的 event_hash;当前 Hash 则基于规范化 JSON 计算。删除、插入、重排或修改历史记录都会破坏后续链条。
Hash Chain 提供的是篡改可见性,不是签名身份。只要攻击者能重写整条 Ledger 并替换信任根,单靠 SHA-256 不能证明是谁写的。因此它仍需依赖文件所有权、权限、锁与外部信任边界,不能被描述成不可伪造账本。
Decision → Action → Outcome
对有副作用的动作,只记录一条「执行成功」不够。EventV1 把因果拆成三步:
flowchart LR
D[Decision<br/>为什么允许做] --> A[Action<br/>实际发起什么]
A --> O[Outcome<br/>Readback 观察到什么]
Runtime 对关系做机械校验:
decision的decision_id必须等于自己的 Event ID;action必须引用已经出现的 Decision,并以它为 Parent;outcome必须引用已经出现且尚未完成的 Action,并以它为 Parent;- 一个 Action 最多只能被一个 Outcome 终结。
这比普通日志多了一层重要语义:Action 不能凭空出现,Outcome 也不能在 Action 之前出现。对于「是否已经 Push」「Provider 是否已更新」之类的问题,系统可以区分批准过、发起过和经 Readback 确认过,而不是把三件事折叠成一个布尔值。
observation 与 checkpoint 则承担非副作用观察和恢复锚点。它们可以记录输入快照、恢复证据或一致性检查,但不能冒充一条完整的外部 Mutation 因果链。
截断恢复必须保留坏尾巴
Append-only 文件最常见的故障不是中间记录被修改,而是进程在写最后一条记录时崩溃。Marshall 只对「已证明是最后一条写入中断」开放窄恢复:
- 如果最后一段能解析成完整、因果有效的 Event,只缺换行,则补换行;
- 如果最后一段无法解析,先把原始 Tail 按 Hash 保存为同一 Audit 下的不可变隔离证据,再截断到最后一个已完整验证的换行;
- 如果 Tail 能解析却违反 Schema、Hash 或因果关系,则 Fail Closed,不能把语义损坏伪装成写入中断;
- 恢复后生成 Receipt,并要求后续 Checkpoint 明确确认,未确认前禁止继续 Append。
这个策略保留了两个事实:可验证 Prefix 仍可信,坏 Tail 也没有被无痕删除。
Artifact:路径只负责定位,Hash 才定义内容
Artifact Registry 注册的不是「某个文件名」,而是一个内容绑定:
artifact_id
path
sha256
dependencies
retention_class / expires_at
status / registered_at
注册前,文件必须真实存在,实际 Fingerprint 必须与声明的 SHA-256 相同;同一个 Artifact ID 不能重新绑定不同内容。下游读取时同时核对路径与 Hash,才能避免下面这种错误:
design/final.md 仍然存在
但内容已经从 Hash A 变成 Hash B
State 仍引用旧设计 A
只校验路径会把 B 当成 A。只保存 Hash 而没有受控路径,又无法在 Audit Package 内解析实际字节。两者必须成对出现。
Artifact 还可以声明依赖与保留策略。清理时,仍被 Active Artifact 依赖的内容、legal_hold 或 never 不能进入普通 Purge。删除不是简单 unlink,而要经过候选计算、授权与 Purge Receipt;否则审计链可能引用一份已经无法读取的证据。
对于外部 Delivery,Registry 要求引用的 Artifact 已注册,而且关联的 Mutation Intent 已成功;Intent 中的 Target、Provider Locator 与 Readback Hash 必须和 Delivery 完全一致。也就是说,本地产物存在不等于外部交付完成,外部交付完成必须以 Provider 侧回读闭环。
三层怎样形成一条可恢复的因果链
以一个 Worker 产出设计报告为例:
State
当前 identity = attempt a / generation g / resume r / fence f
node = post-draft-critic in_progress
Event
Decision D: 选择安全视角评审
Action A: Dispatch worker-x,parent = D
Outcome O: Worker Report 已发布,parent = A
Artifact
worker-x-report.json
sha256:...
dependencies = [context-manifest, design-draft]
恢复时不能只看到报告文件就把 Node 标成 Completed。Runtime 还要验证:报告身份是否等于当前 State、Dispatch 是否有合法 Decision、Outcome 是否对应同一个 Action、报告路径与 Hash 是否匹配、依赖的 Context 与 Draft 是否仍是原版本。任一项不成立,报告只能进入 Stale 或 Late Result 路径。
这套映射与 W3C PROV-O 的思路相通:Artifact 类似 Entity,执行动作类似 Activity,Parent/Worker 类似 Agent,used 与 wasGeneratedBy 表达输入和输出的来源关系。Marshall 使用自己的 JSON 合同,并没有实现完整 PROV-O;这里借用的是 Provenance 的建模原则。
恢复不是「从 State 的下一行继续」
一个安全恢复过程至少包含以下检查:
- 读取并验证 StateV3 Schema、Revision 和当前执行身份;
- 验证 Event 的连续 Sequence、Hash Chain、Audit Identity 与因果引用;
- 检查是否存在未确认的 Ledger Recovery Receipt 或 Partial Commit;
- 对 State 引用的每个 Artifact 做路径、Hash、依赖与 Freshness 校验;
- 按
attempt / run / generation / resume / fence区分 Current Result、Stale Result 与 Late Result; - 如果看到 Action 没有 Outcome,先读取目标系统或 Provider 状态进行 Reconciliation,不盲目重发;
- 计算唯一安全的下一步,并通过新的 Revision CAS 投影回 State。
恢复的结果可能是继续,也可能是 Block。特别是外部动作的结果无法 Readback、Event 中存在已解析但无效的记录、或 State 与 Artifact 无法建立唯一绑定时,自动选择一个「最可能」分支会放大损坏。
State 与 Event 的双写窗口
State 和 Event 位于不同文件,它们不是天然的单一事务。写入 Event 后 State CAS 失败,或 State 已替换但 Event Append 的调用结果不确定,都可能产生双写窗口。
成熟协议必须为每一种跨存储操作规定:
- 固定写入顺序;
- 唯一 Operation ID 与 Payload Hash;
- 哪一侧是可重建投影;
- 失败后通过哪份 Readback 判定已经发生;
- 是否需要 Partial Commit Journal、Recovery Receipt 或 Checkpoint;
- 全局锁顺序,避免恢复时互相等待。
Marshall 的部分恢复路径采用 state.lock → events.lock 的固定顺序,并在 Event Snapshot 仍受锁保护时执行 State CAS;Patch Promotion 则有单独的 Partial Commit Journal。它降低特定路径的歧义,但不应被理解成文件系统上的通用分布式事务。
常见失败模式
State Lost Update
两个执行者从同一 Revision 写入,各自覆盖对方。解决点是 Revision CAS,而不是「尽量串行调用」。
Expired Lease Zombie
旧 Worker 在 Lease 过期后返回一个高质量结果。解决点是 Fence、Generation 与 Run Identity;不能因为内容看起来合理就重新接纳。
Event before Decision / Outcome without Action
日志顺序看似完整,因果却不存在。Decision/Action/Outcome 的 Parent 与 ID 约束必须由代码验证,不能靠字段命名暗示。
Truncated or Rewritten Ledger
只允许恢复最终的可证明中断;中间破坏、可解析但无效的尾记录必须停止。Hash Chain 能发现改写,但不能替代签名与访问控制。
Orphan Artifact
文件存在,却没有当前 State 引用;或 Registry 有记录,文件字节已变化。它不能自动成为当前结果,应隔离、核对 Retention 后再处理。
Late Result Re-enters Current State
晚到结果被直接写回 Active Node,会把旧输入重新引入新执行。Late Result 应保留证据,但必须与当前投影分开。
Local Success Masks External Uncertainty
本地 Outcome 写成成功,但 Provider 没有对应 Readback。外部事实只能由 Provider Locator、Revision 或内容 Hash 证明,本地自述不能替代。
适用边界与 Trade-off
State/Event/Artifact 三层适合真正存在以下性质的任务:跨进程恢复、多个 Writer、异步 Worker、不可安全重复的外部动作、长期保留的审计要求,以及必须把输出绑定到精确输入版本的流水线。
它带来的成本也很真实:Schema 迁移、锁与原子写、Hash 计算、Recovery 分支、保留策略、测试矩阵和更多磁盘 I/O。更重要的是,团队必须决定每种记录谁是 Authority,不能同时把 State、Event 和 Artifact 都当成最终真相。
Marshall 也不是纯 Event Sourcing:它保存当前 State,Event 主要承担因果、审计与恢复证据,并不承诺仅靠重放所有 Event 就能重建每个外部系统。Temporal 的 Durable Execution、W3C Provenance 和 Event Sourcing 都能提供设计参照,但它们的运行保证不能自动套到一个文件型 Runtime 上。
参考资料
- W3C PROV-O:Entity、Activity、Agent 以及输入、生成、归属关系的标准 Provenance 模型。
- Martin Fowler: Event Sourcing:当前状态与事件序列的职责、重放价值,以及外部系统交互带来的复杂度。
- Temporal Documentation:持久执行与故障恢复的设计参照;本文仅作类比,不表示 Marshall 具备 Temporal 的服务端历史与重放保证。
相关文章
从 Workflow Harness 到轻量 Prompt:Codex 5.6 之后我删掉了什么
当模型的原生流程已经够强,Harness 会从效率放大器变成上下文负担。这次重写保留了安全边界,同时大幅精简 Prompt 和运行时。
目标路由:「帮我改一下」和「帮我发个 PR」不该走同一条路
路由不是给请求贴标签,而是先确定这次授权了哪些副作用、什么证据才算完成。