05 · 事件溯源编排内核
单写入队列、纯 decider、事务投影、幂等回执与提交后发布如何共同保证一致性。
核心问题:并发意图如何形成一个事实顺序
同一时刻可能发生:用户发送新消息、Provider 返回 delta、手机批准工具、桌面取消 turn、checkpoint 完成。 如果每个回调直接改一组 mutable objects,最终状态会取决于调度时机,重启后也无法解释“为什么变成这样”。
T3 Code 将所有领域变化压成 OrchestrationCommand,交给
OrchestrationEngine.ts
中的单一 command queue 和 worker。队列使命令进入一个全序写路径;决策与持久化再把顺序固定为事件 sequence。
CommandEnvelope:命令之外还需要什么
一个 envelope 不只包含业务命令,还携带命令 ID/幂等键、调用关联和等待结果的 Deferred。
dispatch 将 envelope 放入队列,然后等待 worker 完成 Deferred;调用方无需知道 worker fiber,却能收到 accepted/rejected 结果。
使用 unbounded queue 的含义不是“系统可以无限承压”,而是避免在编排核心形成第二种背压错误语义。 真正的负载控制要通过 RPC 限流、输入上限、Provider 流聚合和监控实现;如果队列持续增长,仍是健康问题。
纯 decider:从当前状态和意图得到事件
decider.ts 接收当前 read model 与命令,
返回事件列表或 invariant rejection。它不调用 SQLite、不启动 Provider、不读 Git。
这种结构把业务规则变成确定函数:
(current state, command) -> events | rejection例如 turn start 命令会检查 thread/session 生命周期,并产生用户消息、thread.turn-start-requested 以及需要的生命周期重置事件。
“用户消息已记录”和“Provider 已收到 turn”是不同事件阶段,后者由 reactor 执行后回流。
纯函数带来三项收益:
- 相同状态和命令必有相同结果,测试无需外部资源;
- invariant 集中,不会在 Web、Mobile 和 Provider adapter 各写一份;
- 历史事件可以用同一个 projector 重建,而不重复副作用。
事务里同时做四件事
对被接受的命令,引擎在一个 SQL 事务中:
- 追加领域事件;
- 用事件推进候选内存 read model;
- 将相关 relational projections 持久化;
- 写 accepted command receipt。
只有提交成功后,候选 read model 才替换进程内权威引用,并向 PubSub 发布事件。这一顺序阻止两类幽灵状态:
- UI 看见一个还没提交、随后回滚的事件;
- receipt 说命令成功,但事件/投影实际没有落盘。
为什么事务里既更新 event log 又更新 projection?因为 Server 的高频读取不想每次重放全量事件,而投影又必须与日志在 commit 边界一致。它选择“写时同步投影”,用更复杂的事务换取稳定读性能和明确一致性。
幂等回执
移动网络会制造“请求其实成功,但响应丢了”的场景。客户端重连后重发相同命令,如果 Server 没有幂等层, 就可能出现两条用户消息、两个 turn 或两次审批。
引擎在决策前查询 command receipt:
- 已有 accepted receipt:返回之前结果,不再追加事件;
- 已有 rejected receipt:返回相同 rejection;
- 不存在:执行决策,并把结果与事件放进同一事务。
幂等键必须标识同一个用户意图,不能在每次网络重试时重新生成。否则 Server 无法区分重试和新命令。
Projector:事件如何变成 UI 可读状态
projector.ts
是另一个纯核心。它按事件更新 projects、threads、messages、activities、sessions、turns、pending approvals 等视图。
几个显式容量策略揭示了产品取舍:
- 单个 read model 保留最多约 2,000 条消息;
- checkpoint 视图保留最多约 500 个;
- 流式文本 delta 尽量追加到已有 assistant segment,而不是每个 token 新建消息;
- session 离开 running 时,会结算最新 turn,避免 UI 永久处于忙碌。
这些不是数据库删除策略,而是进程内/传输视图的边界。历史仍可由持久层分页读取;限制避免一个超长线程持续放大内存与快照。
失败后的内存对账
事务或投影步骤可能在复杂边界中失败。引擎记录处理开始时的 sequence;失败路径会检查持久化事件是否已经前进, 必要时从 store 重放新增事件来 reconciliate 内存 read model。这个补偿防止进程内视图停在旧版本,而数据库已经提交。
理想数据库事务应让“提交结果未知”极少发生,但进程中断、driver error 和取消边界使对账仍有价值。
提交后才发布
为什么不能先 PubSub.publish 再写数据库?订阅者可能立刻发起读取,得到旧投影;如果随后事务失败,客户端还见过一个
永远不存在的事实。T3 Code 的顺序是:
append + project + receipt → COMMIT → swap read model → publish这给予进程内订阅者“看到事件时,可读取状态至少已包含该事件”的保证。跨进程客户端仍要用 sequence 检测 gap。
Reactor 与引擎的闭环
编排引擎只拥有确定性状态变化;reactor 监听已提交事件并执行不可回放的外部副作用:
ProviderCommandReactor:确保/恢复 session、发送 turn、审批、用户输入、interrupt、stop;ProviderRuntimeIngestion:把 Provider 回调变成新的 orchestration commands;CheckpointReactor:捕获 turn 前后 Git checkpoint、diff 和 revert 状态;- 删除等其他 reactor:处理进程或资源清理。
Reactor 失败不能修改旧事件;它必须发出新的失败事实,例如 activity 和 session error。这保留了因果历史。
这个设计不是什么
- 不是多节点共识:单 Server 的单 worker 形成顺序,不解决多个 Server 同时写同一数据库。
- 不是纯粹 CQRS 异步投影:关系投影在写事务内同步更新。
- 不是无限事件保留即无限 UI 重放:网络 gap 和内存投影都有边界。
- 不是“event bus 代替函数调用”:只有需要持久因果、异步副作用或订阅的变化才进入领域日志。
测试应该保护的性质
OrchestrationEngine.test.ts
及 decider/projector 测试应覆盖:
- 并发 dispatch 仍按队列顺序提交;
- 同一 idempotency key 不产生重复事件;
- rejection 被稳定回放;
- 事务失败不泄漏候选 read model;
- publish 发生在 commit 后;
- event replay 与增量 projector 得到相同视图;
- 消息/checkpoint 截断保持最新且不破坏生命周期。
可迁移经验
- 用单写入者换取简单、可证明的领域顺序;先测清吞吐,再决定是否分区。
- 幂等回执必须和状态变化在同一事务。
- 将业务规则写成
(state, command) -> events,把副作用交给 commit 后 reactor。 - 发布事件前先提交并替换可读状态,给予订阅者明确的 read-after-event 保证。
- 事件日志与关系投影可以共存:一个提供因果与恢复,一个提供产品查询性能。