青雲的博客
从 TUI 到 ACP:我如何把 Orca 的任务真相收回 Runtime

Article

从 TUI 到 ACP:我如何把 Orca 的任务真相收回 Runtime

· 14 分钟阅读

Orca v0.2.55 的二进制已经在四个平台上构建完成,GitHub Release 里的附件也传完了,npm 上能查到 @blade-ai/[email protected]。从大多数发布步骤看,这个版本已经成功。

发布流程末端的远程验证却失败了:

Release tag target d6f98b0ac...
does not match main 136f1855d...

原因不是 npm 传播延迟,也不是二进制损坏。v0.2.55 tag 先推到了远程,main 还没有 fast-forward 到同一个 commit。我把 main 推到 d6f98b0ac 后,只重跑失败的发布验证,这次才拿到完整的通过结果。

这个小故障恰好是这次重构的缩影。一件事在局部看起来已经结束,不代表系统拥有足够证据宣布它结束。发布要让 commit、tag、main、GitHub Release 和 npm 指向同一份产物。Agent runtime 也一样:界面显示完成、provider 返回完成、tool 停下来,都不能单独证明一轮任务已经终结。

我最初把它看成了事件映射问题

Orca 的执行主干在 v0.2.30 已经进入 RuntimeHost。TUI 提交 prompt,runtime 运行 provider 和 tool,再把 event 发回界面。当时我的第一个判断是:执行层已经统一,只是 TUI 和 ACP 的事件投影没有跟上 runtime。

这个判断只对了一半。

TUI 虽然不再运行 agent loop,却还在保存 active operation、cancellation handle、pending approval 和 Goal 状态。ACP 另外维护 current operation,自己把 raw EventEnvelope 翻译成协议 update。JSONL server 也有自己的 session、permission route 和 terminal 处理。

于是一轮任务同时有几个解释者:

RuntimeHost: 我在执行这个 operation
TUI:         我手里的 handle 表示它还活着
ACP:         我的 current_op 表示它可以被 cancel
JSONL:       我的 route 表示这个权限请求还有人等

这些状态在正常路径上大多保持一致。只要加入取消、断线、迟到响应或进程重启,它们就会露出差异。真正的问题不在于 event enum 够不够整齐,而在于冲突发生时谁有权说任务已完成、权限已生效、副作用可以继续。

TUI 与 Runtime 从双重状态收束为 RuntimeHost 单一事实

这一段普通 TUI turn 的拆解写在上一篇《TUI 不该拥有任务》里。那篇文章停在 v0.2.51,当时 Goal、workflow、background、ACP 和 JSONL 都还没有迁完。

三个参考实现没有给我一个现成答案

开始改代码前,我对照看了 Codex、Claude Code 和 Grok Build 的源码。

Codex 把 runtime 和 app-server 之间收成了清晰的 typed protocol。它证明了独立 surface contract 的价值,但我当时检查的实现没有 Orca 需要的 durable live cursor 和完整 snapshot/replay barrier。

Claude Code 的 QueryEngine 和 permission callback 更像一组成熟的行为契约:会话何时持久化,permission 如何 resolve-once,abort 如何传递。但我手上的代码是从 sourcemap 还原的快照,证据权重要低一些,也不适合拿来复制架构。

Grok Build 让同进程 TUI 也经过 ACP channel 与 session actor 通信。这条路径的协议边界非常真实,也暴露了代价:当 replay、terminal 和 pager projection 留在 ACP 之上,上层又会长成一套调和逻辑。

我没有让 Orca TUI 反过来走 ACP。ACP 是对外协议,它的命令集、能力协商和恢复语义不等于 Orca runtime 的全部能力。TUI 使用进程内 RuntimeSurfaceHandle,ACP 和 JSONL 做 adapter,三者都围绕同一个 runtime owner。

契约文档的作用,是逼我们提前回答故障

这次重构在生产代码前先写了 typed surface contract。文档里的问题不是“要定义多少个 type”,而是一批很具体的故障边界:

  • 一个 batch 已经 append,但还没有发给客户端,它算不算事实?
  • 客户端在 snapshot@N 与 live event 之间断线,重连后从哪里继续?
  • 权限允许已经解锁 tool,但许可记录没有落盘,重启后能不能再执行?
  • provider 已经返回,但 tool、interaction 或 finalizer 还没有收干净,能不能发 terminal?
  • 两个 surface 同时响应一个 request,哪一个拥有副作用授权?

文档后来一度越写越大。继续补穷尽状态会带来一种虚假安全感:好像所有边界都命名了,生产路径就自然正确。真正让项目向前的纠偏,是冻结 Task 1 和契约文档,只修会破坏当前所有权与恢复正确性的 Critical 和 Important,然后进生产代码。

契约从那一刻起成了 gate,不再是一个可以无限扩展的设计项目。

SurfaceCommitBatch 才是这次重构的中心

typed command 和 typed event 都很容易被看见,但它们不是最难的部分。关键是一组相关事实能不能作为完整单元持久化和发布。

Orca 把 SurfaceCommitBatch 作为 linearization 和 delivery 单元:

typed command
  -> 校验完整 batch
  -> durable append
  -> reducer 生成 snapshot
  -> cursor 前进
  -> 发布 typed event
  -> 唤醒 waiter / terminal observer

user item 和 operation started 不能各成功一半;control intent 和 interaction cancellation 不能分成两个可见瞬间;terminal 不能在 finalization receipt 之前抢先到界面。持久化失败时,客户端看不到尚未成立的事实。已经落盘却暂时无法投影时,repair authority 仍留在 runtime,不由 surface 猜测如何补齐。

attach 走同一条逻辑。客户端先拿到 snapshot@N,再消费 N 之后的事件。客户端落后到无法连续 replay 时,runtime 明确返回 SnapshotRequired。慢客户端不能阻塞 runtime,detach 也不会顺手取消任务。

interaction 还有一条更严格的规则:第一个通过 identity、generation、scope 和 authority 校验的响应获胜,resolution 先持久化,然后才唤醒 waiter。迟到的“允许”不会误中下一个请求,持久化失败也不会先放行工具。

durable commit、snapshot cursor 与重启恢复流程

这些约束比一组好看的 event type 更重要。它们决定了用户看到一个状态时,runtime 到底能不能证明它。

迁移顺序比抽象完整更重要

重构最容易失控的方式,是同时把 TUI、Goal、workflow、ACP 和 JSONL 全部拆开。那样会得到一大批不能单独验收的中间状态。

实际的路线是按可验收纵向路径推进:

  1. runtime 先建立 atomic commit、store、reducer、snapshot/cursor、operation lease 和 interaction owner。
  2. 普通 TUI turn 第一个走完 submit -> durable commit -> typed event -> permission/cancel -> terminal -> restart recovery
  3. 这条用户可见的路径以 v0.2.51 发布,而不是等 26 个任务编号全部结束。
  4. Goal、workflow 和 background task 在普通 turn 稳定后迁入同一条 command、projection 和 recovery 路径,v0.2.54 完成了生产 TUI 里程碑。
  5. ACP 排在 TUI 之后,JSONL compatibility 再排在 ACP 之后,两者都不阻塞第一个 TUI 版本。

这个顺序解决了一个很实际的问题:每迁完一个 domain,都能用真实客户端验证 runtime 是否真的接手。如果只做横向基础设施,很多 ownership 缺口会一直藏在“未来 adapter 会处理”里。

ACP 和 JSONL 暴露的问题不一样

TUI 完成后,ACP 的主要难题是 transport 生命周期。

ACP client 不只是接收几条 assistant delta。它要协商 capability,管理 session attach 和 replay,把 permission request 稳定地路由到远程客户端,处理 cancel 与在途 request 的竞态。PromptResponse 也不能在 terminal event 还留在 writer queue 时抢先返回。所以 ACP adapter 必须等 writer flush_through(terminal_cursor),再结束这次 prompt。

JSONL 的难点是兼容性和退出。v0.2.55 不能因为内部 owner 更换就修改 v0.2.50 已经对外的 wire shape。新的 JsonlSurfaceAdapter 要在保留旧协议的同时,把 session、control、permission 和 direct interaction 真正路由回 runtime surface。

更棘手的是 EOF 和连接失败。已经 committed 的响应不能因为 transport 断开就重新分类成“未发生”。未完成的 repair authority 必须移交给 runtime registry,service 也要在有界时间内 settle。收干净后才能调用唯一的 ShutdownHost 路径。

完成这两个 adapter 后,代码里的老 owner module 被直接删掉了。旧路径无法继续解释系统事实,迁移才算结束。

真实二进制比测试数量更会找问题

最终的 serial workspace test 覆盖了 1032 个 orca-runtime 测试、457 个 TUI 测试,以及 runtime host、surface interaction、ACP、JSONL 和 PTY 的独立 suite。clippyfmt、contract validator 和边界审计也都通过了。

但最后一个真正的生产 bug,是 final TUI binary gate 找到的。

测试用最终 orca 二进制走 approval recovery 时,thread_store/writer.rs 在流式脱敏路径上 panic:一个 sensitive key 恰好位于 chunk 边界,key 后面还没有 value,代码却访问了下一个字节。

问题藏在这类写法里:

condition.then_some(bytes[value_start])

then_some 会先求值参数,条件为 false 也阻止不了越界访问。修复只是换成延迟求值:

condition.then(|| bytes[value_start])

然后增加 sensitive_key_without_a_value_is_safe_during_streaming_redaction 回归测试。

这个 bug 与 typed surface 的抽象级别完全不同,却能让整条恢复路径在真实进程里崩掉。它提醒我,大量单元测试证明了很多局部性质,只有 final binary gate 能证明用户真正安装的产物经过了同一条路。

v0.2.55 交付的边界

从 v0.2.51 到 v0.2.55,Orca 把这几类事实收进了 runtime:

  • operation identity、generation、owner lease 和 control intent;
  • 完整 commit batch、snapshot/cursor 与 attach/replay;
  • approval、permission、user input 和 MCP elicitation 的 first-winner resolution;
  • tool、assistant stream、Goal、workflow 和 background task 的 typed lifecycle;
  • terminal 之前的 cleanup、finalization 和 durable cursor;
  • TUI、ACP 和 JSONL 共用的 restart recovery 事实。

TUI 于 v0.2.54 先完成生产迁移。v0.2.55 再收完 ACP 和 JSONL,并通过了 TUI approval recovery、ACP surface、JSONL server approval recovery 的真实 API gate。发布结束时,tag、main、GitHub Release 与 npm package 都指向 d6f98b0ac9eafc9228594096db5390f0d3f860e9

这也有明确的能力边界。它没有证明所有外部副作用都天然幂等,也不能代替 tool 或远程服务自己的 commit protocol。runtime 现在能够证明自己接受了什么命令、哪个权限已成立、任务停在哪个 generation,但系统外部的一次真实扣款或邮件发送,仍然需要对应系统给出可重放的收据。

代码里还有一些 legacy compatibility 和测试锚点,编译也保留了现有 warning。它们可以继续清理,但已经不能成为生产任务的第二个 owner。

我最初想修的是 TUI 与 runtime 脱节。最后做的事更具体:让每个 surface 都只能提交命令和消费已经成立的事实。以后再加新的 Goal 能力、tool 交互或外部协议,我会先问它的 identity、commit、terminal 和 recovery 归谁所有。这四个问题答不清楚,功能就还没有进入生产路径。

相关资料:

Keep Reading

相关文章

评论