跳转到内容

13. 取消、进度流和持久化后台任务

阶段 13 · 让长任务有正确的生命期

上一章的结论:一次远程调用被两侧各裁决一遍,结果绑定 Hand 和连接 generation。

本章要动的那一块:上一章走读的是「读一个文件」——几十毫秒就结束。如果换成「构建整个项目」,要跑十分钟,前面那套模型立刻不够用了。

读文件时不存在的问题,现在全都出现:

  • 用户盯着一个没有任何输出的界面,不知道它是在跑还是卡死了
  • 跑到第 8 分钟用户想取消——但命令可能已经改了一半的文件
  • 第 5 分钟网络断了 30 秒,这期间的输出丢了;任务本身应该继续吗?
  • Face 关掉了,用户想明天再看结果

这些需求指向三种生命期完全不同的东西,混在一起建模必然出错。

前台 run进度流后台 task
用途一次调用的权威状态让用户看见正在发生什么断线后仍要跑完的工作
丢了会怎样不能丢,模型要靠它继续少看几行输出,无影响不能丢,用户明天要查
谁是权威Mind Registry没有权威,纯观察Hand
终态唯一无终态概念唯一
生命期绑定调用方还在等连接独立于任何连接

把进度当状态用是最常见的错误。「最后一条进度显示 100%」不等于成功——进度是允许丢弃的观察,而成功是需要终态裁决的事实。

进度流:有界,且明确允许丢弃

Section titled “进度流:有界,且明确允许丢弃”

进度的设计目标是「尽量让用户看到」,不是「保证送达」。一个跑十分钟的构建可能产生几十 MB 输出;如果全量转发并持久化,一次调用就能把连接和数据库打满。

所以有三个硬上限:

MaxRPCProgressChunkBytes = 4 << 10 // 单条进度消息 4 KiB
MaxRPCProgressBytes = 1 << 20 // 单次 run 总量 1 MiB
MaxRPCProgressEvents = 256 // 单次 run 事件数

超出之后丢弃后续进度,但不影响执行——命令继续跑,终态照常产生。这是 第 7 章 那条「可丢通道 fail open」的直接应用。

如果用户确实需要完整输出呢?那说明这个工作应该是后台 task——它有自己的日志文件,不受进度上限约束。进度是给人看的,日志是留存的,两者刻意分开。

走读一次竞争:取消和结果同时到达

Section titled “走读一次竞争:取消和结果同时到达”

这是本章的核心场景。

时间线:用户在第 8 分钟点了取消。取消请求发往 Hand 的同时,Hand 的命令刚好执行完,结果正在回程。两条消息在网络上交错。

Mind 此刻收到两个都合法的输入:一个「已取消」,一个「已成功」。终态该是哪个?

取消优先

用户的意图最新,听用户的。但命令确实已经跑完了——文件已经改了。报告「已取消」是在说谎。

制造假事实

结果优先

如实反映发生了什么。但如果取消其实先到、Hand 也确实中止了,硬报成功同样是错的。

同样可能说谎

先到者赢,且唯一

由 Registry 仲裁:第一个合法终态胜出,之后所有终态被拒绝。哪个先到取决于实际时序,而实际时序就是事实。

Half-Pi 的选择

配套的界面语义很重要:用户点取消后,Face 只能显示「取消已请求」,不能显示「已取消」。真正的状态要等 Authority 仲裁完成。这听起来体验变差了,但另一种做法是在界面上撒谎——显示已取消,而那个命令实际跑完并改了文件。

stateDiagram-v2
    [*] --> queued
    queued --> running
    queued --> cancelled
    running --> succeeded
    running --> failed
    running --> cancelled
    running --> timed_out
    running --> lost: Hand 重启 / 断线
    succeeded --> [*]
    failed --> [*]
    cancelled --> [*]
    timed_out --> [*]
    lost --> [*]

后台任务:两条状态机,一个关联 ID

Section titled “后台任务:两条状态机,一个关联 ID”

需要断线后继续的工作,显式声明 background = true。这时会发生一件容易搞混的事:

启动这个任务的 run,和这个任务本身,是两条独立的状态机。

  • 启动 run:在 Hand 完成持久化登记后就成功结束了。它的成功只意味着「任务已被受理」
  • 后台 task:独立继续执行,可能几分钟后才有结果

两者用 task_id == start_run_id 关联,但这只是关联,不是同一个状态。看到启动 run 成功就报告任务完成,是这里最典型的错误。

权威在 Hand 侧:它用自己的 SQLite 存元数据、用受限的日志文件存输出。Mind 存的是脱敏快照——「我最后一次听说它是什么样」。

情况Hand 侧Mind 侧
正常运行权威状态快照跟随更新
连接断开任务继续跑快照标 stale
Hand 重启非终态任务标 lost重连后对账

Hand 重启后发现有几个任务处于 queued/running。自动重跑显然对用户更友好——为什么不做?

因为系统无法判断哪个动作是幂等的。重跑 read_file 无害,重跑 git push、重跑一次部署、重跑一次数据迁移则是重复副作用。而 Hand 手里只有一个工具名和一份参数,它没有任何依据来区分这两类。

标记 lost 把「我不知道这个任务是否完成」如实交给用户判断。这比替用户赌一次幂等要好——赌错的代价是不可逆的。

同样的理由,第 8 章 里 pending 审批恢复为 cancelled、第 12 章 里旧 run 标 lost,都是同一条原则。

检查点:最后一条进度显示「Build succeeded, 100%」,能把任务标成 succeeded 吗?

不能。进度是尽力投递、允许丢弃的观察通道,它甚至可能是命令输出里的一句普通文本。只有合法的终态结果能推进状态机。反过来也成立:没收到 100% 的进度不代表任务失败——进度可能已经超出上限被丢弃了。

检查点:Hand 重启后自动重跑 queued 任务,用户体验不是更好吗?

对非幂等操作风险太高,而系统无法自动判断幂等性。Half-Pi 标记 lost,由用户根据实际外部状态(文件是否已改、提交是否已推)决定要不要重新发起。「让用户多做一步」优于「可能执行两次不可逆操作」。

检查点:启动后台任务的那个 run 返回成功了,能告诉用户任务完成了吗?

不能。启动 run 成功只证明任务已被 Hand 持久化受理,它此刻很可能刚开始跑。两条状态机共用一个 ID 容易让人混淆,但它们的终态是分开的——要查任务结果必须查 task 状态,不是查启动 run。

三种生命期各自建模、加上 Hand 与 Mind 的对账机制,是本章引入的主要复杂度。换来的是系统不会把「已启动」误报成「已完成」,也不会把「没看到进度」误判成「失败」。

到这里跨设备执行完整了。但有个前提一直没兑现:Mind 得一直在线,才能持有这些 run 和 task。如果 Mind 本来只是个跟着终端一起启动退出的交互程序呢?下一章把它变成常驻服务。

证据证明什么
protocol/rpc.go进度的三个上限与消息校验
taskmanager/manager_test.go去重、取消竞争、重启 lost 与配额
remoteexec/registry_cancel_test.goresult / cancel / disconnect 的唯一终态仲裁