Article
TUI 不该拥有任务:我如何重构一个可恢复的 AI Agent Runtime
界面已经结束,任务却不一定结束
这次重构 Orca,从一个很短的时序窗口开始。
用户在 TUI 里取消了一轮任务。runtime 已经提交了 cancelled terminal,界面也收到了 SessionCompleted(cancelled)。按用户的理解,这轮已经结束,下一次输入应该启动一个全新的任务。
但当时的 TUI controller 还保留着旧的 active surface。如果这时再来一次晚到的 Interrupt,它仍可能命中上一轮留下的控制对象;紧接着提交下一轮,又要依赖 TUI 自己把旧状态清干净。
单看界面,这像一次普通的清理顺序问题:把 active = false 提前一点就行。
继续往下查,我发现同类问题散落在很多地方。TUI 记着当前 operation,runtime 也记着;TUI 保存 pending approval,runtime worker 里还有真正等待响应的 waiter;界面收到 completion 时,持久化 terminal 和 finalization 未必已经全部成立;当前进程里能继续的任务,重启后又要靠另一套 history 和内存状态猜应该恢复什么。
每一处单独看都能补。可我把这些状态放在一起对照时,发现 TUI 和 runtime 各自保留了一套任务真相。
Orca 的执行早已迁进 RuntimeHost。但执行代码在 runtime,不等于任务已经由 runtime 拥有。只要 TUI 还可以定义“现在跑的是谁”“这个权限是否仍有效”“取消是否完成”“重启后继续哪一代任务”,它就不只是客户端。
Orca v0.2.51 沿着这条线改了普通 TUI turn:提交、持久化、交互、取消、terminal 和 restart recovery 开始共用同一份 runtime 事实。
用户看不到一个对应的新按钮,大多数时候甚至感觉不到 surface 的存在。变化只在故障时出现:网络断了,工具执行到一半,权限窗口迟迟没有回应,用户按下 Esc,或者程序被直接杀掉。以前这些情况靠多个模块互相猜测;现在 runtime 必须留下足够的事实,让下一步动作能够被验证。
我原本以为只是事件映射太乱
最初分析 TUI 和 runtime 的关系时,我的判断没有这么重。
当时的执行主干已经是:
TUI
-> RuntimeHost
-> ThreadActor
-> provider / tool / persistence
看起来只需要把 TUI 里残留的 raw event 解析换成 typed event,再删掉几个直接调用 runtime handle 的入口。执行归 runtime,展示归 TUI,依赖方向干净一些,事情似乎就结束了。
动手迁移以后,我发现 TUI 手里拿着的远不止展示状态:
- 它镜像当前 operation 和 cancellation;
- 它决定什么时候把 terminal event 发给界面;
- 它保存 approval、permission、user input 的 pending response;
- 它直接读取 session history,也直接修改 settings、memory 和 pinned context;
- 它自己持有 MCP registry,负责 mention 展开和 catalog discovery;
- 恢复会话时,它先读 transcript,再把结果交给 runtime;
- Goal、workflow 和 background task 又各有一部分独立 owner。
这些代码不是“写错了”。大部分都是功能自然长出来的。
TUI 最早需要显示当前任务,于是保存一个 active operation;后来要支持 Esc 取消,就再保存一份 cancellation handle;再后来要做权限弹窗、历史恢复、后台任务和 Goal,最短的实现路径都是把对应对象传进 TUI。
每增加一份本地状态,客户端就多获得一点解释系统事实的权力。等功能足够多,TUI 便形成了自己的控制面。
拆到这里,我才想清楚“脱离”具体指什么。TUI 与 runtime 的边界不取决于 crate 有没有互相 import,也不取决于消息是不是 enum。两边出现冲突时,谁说了算,才是边界。

中间我还试过把问题收窄成事件类型:既然两边状态不一致,先让它们使用同一组 enum。
这只能统一字段。TUI 完全可以构造一个结构正确的 SessionCompleted,但它无法单凭这个类型证明 tool 已经终结、interaction waiter 已经清理、terminal batch 已经落盘。event 长得一样,并没有消除两个发布者。
普通事件总线也有类似限制。它擅长把“发生了什么”广播出去,却通常不回答“广播前是否持久化”“订阅者断线以后从哪里补”“两个客户端同时响应时谁赢”“发布者崩溃后这条消息算不算成立”。这些问题不补上,event 越整齐,两套真相反而越难被看见。
两套真相是怎么形成的
我把这些状态分成四类重新排查。
任务身份
一次普通对话并不只有一个“正在运行”的布尔值。它至少有 operation identity、当前 generation、来源、控制意图和最终结算状态。
任务可能在 provider 启动前被取消,也可能运行中断后产生新的 recovery generation;还可能已经写入 terminal,但客户端尚未收到投影。如果 TUI 只保存一个 current operation id,它看到的是某一时刻的内存指针,不是完整生命周期。
更麻烦的是恢复。进程重启以后,内存 handle 已经没有了。系统必须知道上一次 operation 停在哪个 generation、哪些副作用已经发生、terminal 是否已经提交,才能决定允许 resume、cancel,还是只能 fail closed。
权限与交互
approval、permission、user input 和 MCP elicitation 表面上都是“弹一个框,等用户回答”。
这个请求实际绑定了某个 operation 的某一代 generation。只有 scope、request id、revision 和 authority 都对得上的第一份响应,才能解除等待。
如果 TUI 自己维护 pending map,runtime worker 自己维护 waiter,两者中间只传一个 true / false,就会出现几个问题:迟到响应可能命中新请求;重复响应可能唤醒两次;客户端关闭后 waiter 仍然活着;更危险的是,副作用已经被允许,允许记录却没有持久化。
v0.2.51 把 interaction 收进 request-scoped runtime state。响应先提交成 durable fact,再唤醒对应的 waiter。持久化失败,waiter 继续等。
终结顺序
过去客户端很容易把“收到了 provider completion”当成“任务结束”。
但一个 Agent turn 的终结还包括 tool terminal、usage、context、interaction cleanup、operation finalization 和持久化 cursor。任何一项失败,都可能让界面看起来结束,重启后却无法证明这轮到底完成没有。
从这里开始,terminal 被当成一种需要证据的状态,不能再等同于普通 UI event。只有 terminal batch 已持久化、必要清理与 finalization 已经闭合,runtime 才向客户端发布终态。
前面提到的取消竞态就是按这个顺序修的:先拿到 durable terminal receipt,清掉 controller 的 active operation,再把 SessionCompleted 发给 TUI。用户看到“已取消”时,旧任务已经不能继续影响下一轮。
重启恢复
一个进程不退出时,很多 ownership 问题都可以被 handle、channel 和 callback 暂时掩盖。重启一次,系统就必须回答几个硬问题:
最后一个 durable cursor 在哪里?
哪一个 operation 仍未终结?
当前 generation 是什么?
上一次允许的副作用有没有发生?
客户端应该补收哪些事件?
如果答案要从 transcript、UI message 和几个零散 JSON 文件里重新推断,恢复就成了猜测,谈不上继续执行。
我以前把重启恢复想得太接近“重新打开上一段对话”。对话文本只说明模型和用户说过什么,证明不了工具副作用、权限收据、operation generation 和 terminal settlement。上下文能帮助模型继续思考,系统能不能继续行动,还得看 durable state。两者混在一起,旧权限就可能跟着上下文一起回来。
把事实收回 RuntimeHost
实现上,我加了一层 runtime-owned typed surface。
它没有试图包住所有 runtime API,只限定一件事:客户端只能看到 runtime 已经确认的事实,也只能提交 runtime 能验证的命令。
我先把一笔 surface change 的提交顺序固定下来:
typed command
-> 完整 batch 预检
-> durable append
-> reducer 更新 snapshot
-> cursor 前进
-> 发布 typed event
-> 唤醒 interaction / terminal waiter

SurfaceCommitBatch 把一组不可拆的变化放在一起检查和持久化。user item、operation phase、tool state、interaction resolution 或 terminal 不能各自成功一半。如果 append 失败,客户端看不到尚未成立的事实;如果持久化完成但投影失败,runtime 保留 repair authority,而不是让 TUI 自己重建另一份状态。
客户端连接时拿到 snapshot@N,再消费 N 之后的 event。慢客户端不会阻塞 runtime;落后太多时,它收到 SnapshotRequired,重新从新的 snapshot 开始。detach 只表示客户端离开,不再等价于取消任务。
operation identity、generation、owner lease 和 control intent 也留在 runtime。TUI 的 /resume 和 /cancel-operation 不能只发一个“继续当前任务”的无参动作,它必须携带用户当前看到的 exact operation id。runtime 再验证这是不是仍然可恢复的同一个 operation。
后来每迁一个 TUI 动作,我都先查 runtime 有没有对应 command、需要什么 fence、成功后会提交哪些事实、重启后怎么对账。以前经常是先找到一个能工作的内部 handle,再接进 controller;这次 UI 接线被放到了最后。
直接调 handle 更快,但权限、取消和恢复会继续各写一套生命周期。改成 command 后,它们都得落回 operation、generation、commit 和 terminal。我也终于能回答两个以前说不准的问题:用户看到完成以后,系统还有没有没结算的副作用;服务重启以后,会不会把已经执行过的动作再跑一遍。
迁移以后,TUI 的责任简单了很多:
收集用户输入
提交 typed action
消费 snapshot / event
渲染当前状态
它仍然可以做复杂交互,但不再通过保存 runtime 内部 handle 获得控制权。
怎么证明它真的可以恢复
单元测试很快涨到了一个看起来足够安心的数字,但我不敢只看它。TUI 的启动顺序、终端模式和冷恢复都可能在真实进程里走出另一条路径。
v0.2.51 发布前,runtime 有 925 个单元测试,TUI 有 426 个测试,commit、attach/replay、operation、interaction、reducer 和 ACP RPC 都有独立 suite。数量只能说明覆盖面,我更看重后面两条生产证据。
我先补的是完整的 Record -> terminal -> shutdown -> Resume。
测试没有直接往 store 里塞一份伪造 snapshot。第一段通过 production typed TUI 提交并跑到 terminal,关闭 runtime;第二段创建新的 runtime,用 HistoryMode::Resume 恢复同一个 session,再通过同一条 action loop 提交下一轮。历史、cursor、operation terminal 和客户端启动顺序都不能靠进程内状态作弊。
取消也单独走了一遍:第一轮 cancelled,进程重启,下一轮仍能产生新的 operation 并提交。旧 generation 不能污染新任务。
权限与交互的测试关注另一条顺序:用户允许一个副作用时,durable resolution 必须先成功,waiter 才能醒。如果 append 失败,工具线程继续等待;runtime 重启后,丢失 live-only waiter 的请求会 fail closed,不能根据界面上曾经点过“允许”就重新授权。这里验证的是授权能否跨故障成立,不只是弹窗能不能点通。
另一条 PTY contract 直接启动 orca --provider mock,让它进入 alternate screen,提交 prompt,观察 typed assistant 输出和持久化 history,再发送两次 Ctrl+C 退出。退出后检查进程状态、alternate screen、主屏幕和 raw terminal 是否都已恢复。
这条测试很朴素,但它覆盖了普通 Rust unit test 看不到的边界:真实二进制、stdin/stdout、终端模式、渲染、退出清理,以及失败时有没有留下一个挂着的子进程。
发布前又走了一遍四个平台二进制、校验文件、GitHub Release、npm staging/publish、远端安装 verifier 和网站 changelog。经过验证的路径必须出现在用户实际拿到的产物里。
v0.2.51 只完成了第一段
这次可以明确说,普通 TUI turn 已经有了一条 runtime-owned 主线:
TUI submit
-> runtime operation
-> durable commit
-> typed assistant / tool / interaction event
-> permission / cancel
-> terminal
-> restart recovery
ACP 也已经接入 typed prompt、history replay、permission bridge 和 bounded ordered RPC,但它还不是所有 adapter 工作的终点。
Goal、workflow、background task 和完整 JSONL compatibility 仍在继续迁移。它们有自己的 receipt、lease、handoff 和恢复状态,不能因为普通 turn 已经使用同一个 surface,就自动获得同等级的正确性。
下一阶段会继续处理这些 domain 的数据库变更、typed surface 发布和崩溃恢复。它们尚未进入 v0.2.51 的完成清单,文章也不提前描述具体实现。
我原本想做的是把 TUI 和 runtime 拆开。改到后面才发现,TUI 只是最先暴露问题的客户端。到 v0.2.51 为止,普通 turn 已经可以沿着同一份 runtime 事实完成、取消和恢复;剩下几个 domain 还没有,我会继续一个个迁。
相关资料:
Keep Reading