# 持久取消与安全停靠：点了取消，为什么还得等一张回执？

> 把取消请求、在途结果和安全停靠分开保存：用 SQLite、真实进程退出和并发竞争，验证先取消后提交、迟到成功、未知结果阻塞确认与不可复活的取消墓碑。

- 作者：芝士AI吃鱼
- 发布日期：2026-10-26
- 主题：Agent、AI工程、工作流、取消协议、SQLite
- HTML 正文：[https://ai-knowledgepoints.cn/blog/agent-reliability-18-durable-cancellation](https://ai-knowledgepoints.cn/blog/agent-reliability-18-durable-cancellation)
- Markdown 永久链接：[https://ai-knowledgepoints.cn/blog/agent-reliability-18-durable-cancellation/index.html.md](https://ai-knowledgepoints.cn/blog/agent-reliability-18-durable-cancellation/index.html.md)

引用本文时，请注明作者与 HTML 正文链接。Markdown 版本用于机器阅读，与 HTML 正文共享同一内容来源。

## 正文

用户按了取消，页面立刻显示“已停止”。几秒后，一封邮件还是发了出去。

如果后台只是把按钮变灰、停止等结果，这个结局并不神秘。真正麻烦的是，后来的人相信了那三个字，把“用户不想继续”理解成“刚才什么都没发生”。于是又执行了一次。

这是本文构造的故障场景，不是真实用户事故，也没有在实验中发送邮件。[第四篇](https://ai-knowledgepoints.cn/blog/agent-reliability-04-deadlines-retry-budgets)已经区分取消等待和撤销效果；[第十四篇](https://ai-knowledgepoints.cn/blog/agent-reliability-14-durable-drain-barrier)讨论整个队列的维护排空。本篇作为可靠执行系列第十八篇，处理另一项具体协议：一个任务的取消指令如何持久保存，怎样挡住迟到的提交，以及什么时候才能确认“不再继续”。

随附项目使用 Python 标准库和 SQLite。请求文本、步骤结果都是合成数据；真实运行的是事务、进程退出、锁竞争及重启读取。它不接入模型、邮件服务或真实 RPC，也不声称能够强行终止任意工具。先把这个边界说清，后面的绿色状态才不会比取消按钮更冒进。

## 取消请求和业务结果，别挤在同一个格子里

只用一个 status 字段，常常会逼实现撒谎。

任务刚完成最后一步，取消指令也到了。如果写 cancelled，就容易把实际成功藏起来；如果只写 succeeded，又丢失了用户已经提出停止的控制事实。两者的先后还会影响取消回执，不该靠刷新页面碰运气。

本例分成两条状态轴。control 保存 run、requested、acknowledged，分别表示可以申请下一步、已请求取消、协议已经确认停靠。result 保存 open、succeeded、failed，描述固定步骤清单的完成情况。open 只是清单尚未全部成功、也没有明确失败，不能单独解释为任务仍在运行。

因此 acknowledged/open 是合法组合：已经确认不再继续，但后面的步骤没有完成。acknowledged/succeeded 也可能成立：取消请求到达时最后一步已经在途，随后拿到了成功回执，才确认停靠。确认没有下一步，并不会把最后一步的成功撤销。

反过来，如果最后一步的成功先提交，之后才首次请求取消，接口返回 too_late，control 保持 run，result 仍为 succeeded。这里 run 是“未受理取消控制”的枚举值，不表示已完成的任务仍可执行；领取入口同时检查 result。生产界面最好显示“已完成，取消未生效”，而不是把内部枚举直接翻译成“运行中”。

这些不是文字游戏。恢复进程、客服查询和补偿流程都可能读取这份记录。把停止原因与已知结果分开，才能解释同一时刻看似矛盾的事实。

## 取消比提交先到，也必须留下东西

一次提交请求在路上延迟，用户已经点了取消。取消接口查不到 job，返回“任务不存在”；随后提交抵达，任务照常开始。

如果产品契约承诺撤回这次精确意图，仅仅返回不存在就不够。本例的 request_cancel 接收 job_id 和完整有序 steps 清单。若任务尚未登记，就在同一事务中写入 admitted=0、control=acknowledged 的取消墓碑，并保存该清单及取消回执。

之后同 ID、同清单的 submit 得到 blocked_cancelled，不能复活；同 ID 换清单得到 conflict，也不能借换内容绕过旧记录。墓碑不是“忽略这个名字以后出现的任何请求”，而是和一份不可变意图绑定的阻挡记录。

已登记任务同样检查精确清单。取消携带的请求内容或顺序不一致，得到 spec_conflict，原任务不被改变。这里只使用有序字符串列表，序号区分步骤，顺序变化就是意图变化。真实业务还需要把工具版本、目标、参数及租户作用域纳入身份，不能以本文这份短列表替代完整生产契约。

同样重要的是权限。代码中的取消入口接受可信本地输入，没有验证“谁有权取消谁”。一套允许任意调用者随手创建取消墓碑的公网接口，完全可能被拿来抢占别人的任务 ID。本例验证身份绑定和持久化，不提供身份认证；接入时必须先解决这层授权。

墓碑也没有自动过期。如果随手删除它，旧提交就可能再次出现。[第六篇](https://ai-knowledgepoints.cn/blog/agent-reliability-06-idempotency-retention)讨论过保留和代次封口的代价；取消记录面临同类问题。永久保留会增长存储，删除则必须有迟到请求不再有效的证据。代码没有偷偷用一个 TTL 把这个难题省略。

## 挡住下一次预留，不代表上一项已经停下

固定清单保存后，reserve 每次只预留下一项步骤。每项步骤可能是 pending、inflight、unknown、ok 或 rejected。同一任务最多一项处于 inflight 或 unknown，前面的步骤必须已经 ok，才能预留下一项。

预留记录是持久的。它表示某一步已经越过“准备执行”的协议边界，不证明请求已到达外部服务。重复 reserve 如果看到已有在途项，只返回 inflight 和原序号；这是一条恢复线索，不是授权再次调用不可幂等工具。

取消请求受理后，control 变成 requested，新的 reserve 被拒绝。但一项在取消之前已经预留的动作，仍可能随后才发送。本例没有把数据库里的控制位想象成跨网络刹车。预留进程可能刚读完数据，也可能已交给工具执行；协调者不知道它具体走到了哪里。

所以 checkpoint 看见 inflight 或 unknown 时，返回 inflight，不能确认 acknowledged。取消按钮按得再急，也不能替这项工作编造一个“确定没发生”的结果。

[gRPC 的取消说明](https://grpc.io/docs/guides/cancellation/)指出，库通常无法直接打断应用处理器，长时间运行的处理器需要主动检查取消并配合停止。本文没有运行 gRPC；引用它是为了对照“取消需要执行方合作”的边界，不是把这个本地示例当成 RPC 取消实现。

还有一个很不讨喜却必须保留的情况：刚预留就崩溃，实际上根本没发送。对本例的持久记录来说，它仍然未决，不会自动归还成 pending。要证明“没发送且以后也不会发”，需要额外的派发日志、接收端封口或可信对账协议。本篇不具备这些能力，宁可停在待核验，也不把一次进程重启变成再次发送的许可。

![取消请求阻止新预留，已有在途或unknown仍需结清；没有未结预留后确认停靠，保留已完成结果和未执行步骤](https://ai-knowledgepoints.cn/images/articles/agent-reliability-18/cancel-checkpoint.svg)

原创协议图：可信合成回执与本地事务；确认停靠不代表撤销效果、杀死进程或远端绝对停止。

[查看竖版大图](https://ai-knowledgepoints.cn/images/articles/agent-reliability-18/cancel-checkpoint-mobile.svg)

## 回执可以迟到，结论不能随手改写

report 接受三个结论：unknown、ok、rejected。unknown 保留未结预留，value 必须为空；ok 和 rejected 结清该项，并保存有界结果文本。rejected 必须来自可信的明确拒绝证据，不能用“暂时查不到”或超时推测替代。

取消请求已经持久化之后，合法的迟到回执仍然需要接收。比如第一步实际完成，第二步尚未开始，此时收到第一步的 ok，项目保存这份成功，把第一步变为终态。它不会因为用户点过取消，就把结果当垃圾扔掉。

接着 checkpoint 可以把 control 变成 acknowledged：没有未结预留，也不会再申请第二步。第一步的成功值和第二步的 pending 都保留，result 仍然 open。这比一个没有解释的 cancelled 更诚实，也为后续有授权的人工处置提供了事实基础。

如果在途的正好是最后一步，迟到 ok 会把 result 变成 succeeded；随后确认取消，得到 acknowledged/succeeded。取消成功的是“阻止继续申请工作、到达协议安全点”这项控制，不是把已经完成的业务变回未发生。若产品不接受这种表达，应当改产品状态展示，而不是删掉成功记录。

如果迟到的是 rejected，result 会变成 failed。已有取消请求仍可在没有在途项后确认；失败也保留下来。示例没有自动执行补偿，更没有授权根据“取消”重新发起一项撤回操作。[第十篇](https://ai-knowledgepoints.cn/blog/agent-reliability-10-compensation-state-machine)的补偿是另一个有对象、有证据、有授权边界的流程。

消息身份同样不能糊弄。取消和结果回执共用一个全库 event_id 空间；事件 ID 绑定命令类型、任务、清单或结果内容。相同 ID、相同内容重放原解释，同 ID 改内容返回 event_conflict。

这意味着第一次 unknown 后，必须用新的事件报告后续核验的 ok；原 unknown 事件重试仍返回 unresolved。第一次取消回执是 requested，即使后来 checkpoint 已确认，重放那条旧事件仍然得到 requested。原回执回答“那一次指令怎样被处理”，inspect 才回答“现在状态是什么”。让旧回执跟着现状改口，会破坏重试的可解释性。

未登记任务的回执、尚未预留步骤的回执，也会被明确拒收并记住。同一个拒收事件后来重试，不能悄悄被接受。换 ID 是否允许重新提交，是上层的明确决定，不是基础库自动修正业务事实的捷径。

## 让取消、最后完成和重启共用一个提交边界

核心操作在一个 SQLite 文件里用 BEGIN IMMEDIATE 事务完成。取消控制位和取消回执一起提交；步骤终态、整体结果和报告回执一起提交；停靠确认则在检查没有在途项后，同事务修改控制位。

[SQLite 官方事务文档](https://www.sqlite.org/lang_transaction.html)说明，一个数据库同时只能有一个写事务，IMMEDIATE 会立即尝试开始写事务，也可能遇到 SQLITE_BUSY。这里用的是单库写入的串行边界，不是跨服务事务；连接等待 30 秒只是小规模演示的锁等待配置，不承诺任意负载下都能完成。

取消与最后完成之间因此会形成明确次序。如果成功事务先提交，取消得到 too_late；如果取消先提交，最后的合法回执仍能结清在途项，之后再确认停靠。两条路径都保留成功，不靠“谁最后写 status 就覆盖谁”来解决竞争。

崩溃也不能切开这些关系。步骤更新后、回执保存前退出，整个事务回滚；提交后但响应返回前退出，状态和回执一起留下，原事件重试重放既有解释。早取消墓碑的任务行、全部步骤和取消回执也同成同败，不留下半份清单。

不过，数据库串行化只约束经过这些入口的调用。管理员直接改表、旁路工具、多个数据库里重复使用同一业务意图、旧执行尝试伪造结果，都不在本例的保证内。项目没有 lease、fencing 或执行代次，不能靠“第一个到达的回执”判断生产中哪个 worker 有资格代表这项工作。

同样，acknowledged 不是进程表。它只确认本协议内没有未结预留，并且该任务不再产生新预留；没有证明操作系统进程已经退出，也没有独立验证外部工具以后不会产生迟到副作用。能否相信终态报告，仍取决于接收端契约和上层证据。

## 故障实验：不只把状态改成红色

36 项单元测试覆盖早取消墓碑、清单冲突、跨命令事件冲突、重复回执、未预留拒收、unknown 保留、成功和拒绝的迟到结果、顺序预留、取消确认与 UTF-8 字节边界。每个测试结束都检查跨表不变量。

run_experiment.py 真正运行 12 组场景，其中包含 24 个进程退出边界。实验分别在提交、早取消墓碑、已提交任务的取消、预留、结果报告及停靠确认中调用 os._exit(73)，父进程核对实际退出码。提交前必须完整回滚，提交后必须完整保留；随后重新连接并重试，检查状态与无故障路径一致。

四组并发实验各启动八个进程，所有进程先报告 READY，测试驱动再统一发 GO。它们分别竞争提交与取消、预留与取消、最后回执与取消，以及同一任务的停靠确认。实验不冻结哪一方赢锁：有时取消先到，没有在途项；有时预留先到，必须结清后才能确认。冻结的是两种合法次序都必须遵守的约束。

最能说明边界的一组实验使用两个临时 SQLite 文件。协调库先预留第一步，另一个真实子进程在接收端库写入一条合成 effect，提交后立即退出，尚未向协调库回报。此时请求取消，checkpoint 必须继续返回 inflight。

测试驱动重新读取接收端效果，拿到可信的合成核验结果后才报告 ok，再确认停靠。取消前后那条 effect 都仍然存在，下一步没有被预留。这里的查询是实验仪器的特权观测，不代表真实网络里任何一次“查不到”都能证明未执行，更不提供跨库原子性。

另附独立 audit_invariants.py，不直接依赖实现的状态转换来计算预期。实际执行 48 个种子、每个 300 次操作，共 14,400 次参考模型比较，包含 2,881 次显式重复尝试；再运行 32 组各四个门控进程，共 128 个进程。每组把返回值和最终数据库与至多 24 种串行排列对照，核验这批实际并发历史能被合法次序解释。

这些是有限实验，不是形式化证明、生产压力测试或可靠率统计。结果文件不记录谁赢了多少次，也不把运行耗时包装成取消延迟。测试中的等待上限只防止子进程挂死，与业务 deadline 无关。

## 下载运行，先看两个状态，再谈接入真实工具

把以下六个文件放在同一目录，使用 Python 3.10 或更新版本运行，无需第三方 Python 包、网络或密钥。不要使用 python -O，因为实验断言本身就是检查的一部分。

- [cancellation.py：取消墓碑、在途结清与停靠协议](https://ai-knowledgepoints.cn/examples/agent-reliability-18/cancellation.py)
- [test_cancellation.py：36 项边界测试](https://ai-knowledgepoints.cn/examples/agent-reliability-18/test_cancellation.py)
- [run_experiment.py：12 组真实事务与进程场景](https://ai-knowledgepoints.cn/examples/agent-reliability-18/run_experiment.py)
- [audit_invariants.py：独立参考模型和并发历史审计](https://ai-knowledgepoints.cn/examples/agent-reliability-18/audit_invariants.py)
- [results.json：实际运行后保存的稳定结果](https://ai-knowledgepoints.cn/examples/agent-reliability-18/results.json)
- [README.md：复现方法与适用边界](https://ai-knowledgepoints.cn/examples/agent-reliability-18/README.md)

```bash
python3 test_cancellation.py
python3 run_experiment.py --check
python3 audit_invariants.py
```

第一条应报告 Ran 36 tests 和 OK；第二条应报告 PASS: 12 scenario groups match results.json。--check 会重新建立临时数据库、启动子进程执行，再与冻结文件对照；不是读取一份 JSON 后打印“通过”。不带 --check 会有意重建结果文件，不应用来掩盖没有解释的差异。

下面这段最小例子保留第一步的成功，但不执行第二步：

```python
from pathlib import Path
from tempfile import TemporaryDirectory
import cancellation as c

with TemporaryDirectory() as folder:
    db = Path(folder) / 'cancel.db'
    steps = ['合成步骤A', '合成步骤B']
    c.init(db)
    c.submit(db, 'job-a', steps)
    c.reserve(db, 'job-a')
    c.request_cancel(db, 'cancel-1', 'job-a', steps)
    assert c.checkpoint(db, 'job-a') == 'inflight'
    c.report(db, 'result-1', 'job-a', 0, 'ok', '合成成功结果')
    assert c.checkpoint(db, 'job-a') == 'acknowledged'
    assert c.reserve(db, 'job-a')['status'] == 'cancelled'
    assert c.check_invariants(db)
```

真实接入还有明确缺口：谁持续发现取消请求并运行 checkpoint，谁核验在途结果，如何验证调用者身份和执行代次，如何限制未决任务及墓碑的存储增长。这个项目没有后台扫描器、自动超时、重试调度、生产背压或清理协议。一个在途结果永远无法结清，取消确认就可能永远停在 requested；没有自动恢复能力的地方，不应写成“最终一定会停止”。

回到开头的取消按钮。可以立刻告诉用户“取消请求已接收，正在核验已发出的步骤”。等证据足够，再显示已经停止继续处理，同时保留哪些步骤实际完成、哪些未被本协议预留。按钮没有变慢，只是终于没有替后台抢答。

可继续阅读[第十七篇的持久并行汇合](https://ai-knowledgepoints.cn/blog/agent-reliability-17-durable-fanout-join)，讨论多个分支的未决结果如何影响整体完成。进一步交流见[知识星球](https://ai-knowledgepoints.cn/planet)与[公众号入口](https://ai-knowledgepoints.cn/about#wechat)。

本文由 AI 辅助制作。代码、SQLite 事务及进程实验已经实际运行；业务请求、回执和效果为合成样例，未调用真实模型、RPC 或外部业务工具。配图按白底技术信息图文字要求原创绘制，原始参考图尚待补充核对。官方资料核对日期：2026 年 10 月 26 日。
