T3 Code 源码拆解
核心运行时

05 · 事件溯源编排内核

单写入队列、纯 decider、事务投影、幂等回执与提交后发布如何共同保证一致性。

核心问题:并发意图如何形成一个事实顺序

同一时刻可能发生:用户发送新消息、Provider 返回 delta、手机批准工具、桌面取消 turn、checkpoint 完成。 如果每个回调直接改一组 mutable objects,最终状态会取决于调度时机,重启后也无法解释“为什么变成这样”。

T3 Code 将所有领域变化压成 OrchestrationCommand,交给 OrchestrationEngine.ts 中的单一 command queue 和 worker。队列使命令进入一个全序写路径;决策与持久化再把顺序固定为事件 sequence。

编排引擎的原子写路径
yes no rejected events dispatch CommandEnvelope Unbounded Command Queue Single Worker Idempotency receipt exists? Return stored result Pure decideOrchestrationCommand Persist rejected receipt Single SQL transaction Append event log Project in memory Persist relational projections Persist accepted receipt Commit Swap authoritative read model Publish events to PubSub

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 事务中:

  1. 追加领域事件;
  2. 用事件推进候选内存 read model;
  3. 将相关 relational projections 持久化;
  4. 写 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:处理进程或资源清理。
事件驱动副作用闭环
Committed intent event Reactor Provider / Git / Process External result or runtime event New orchestration command Engine Committed result event Projection + clients

Reactor 失败不能修改旧事件;它必须发出新的失败事实,例如 activity 和 session error。这保留了因果历史。

这个设计不是什么

  • 不是多节点共识:单 Server 的单 worker 形成顺序,不解决多个 Server 同时写同一数据库。
  • 不是纯粹 CQRS 异步投影:关系投影在写事务内同步更新。
  • 不是无限事件保留即无限 UI 重放:网络 gap 和内存投影都有边界。
  • 不是“event bus 代替函数调用”:只有需要持久因果、异步副作用或订阅的变化才进入领域日志。

测试应该保护的性质

OrchestrationEngine.test.ts 及 decider/projector 测试应覆盖:

  1. 并发 dispatch 仍按队列顺序提交;
  2. 同一 idempotency key 不产生重复事件;
  3. rejection 被稳定回放;
  4. 事务失败不泄漏候选 read model;
  5. publish 发生在 commit 后;
  6. event replay 与增量 projector 得到相同视图;
  7. 消息/checkpoint 截断保持最新且不破坏生命周期。

可迁移经验

  • 用单写入者换取简单、可证明的领域顺序;先测清吞吐,再决定是否分区。
  • 幂等回执必须和状态变化在同一事务。
  • 将业务规则写成 (state, command) -> events,把副作用交给 commit 后 reactor。
  • 发布事件前先提交并替换可读状态,给予订阅者明确的 read-after-event 保证。
  • 事件日志与关系投影可以共存:一个提供因果与恢复,一个提供产品查询性能。

On this page