Cancellation
Cancellation 是对正在执行的 Run 发出的显式业务取消命令;它必须与网络断线和前端 Abort 分离,并形成可审计的服务端状态转换、执行传播和 durable terminal fact。
[!info] related notes
- 所属 MOC: Agent Runtime MOC
- 相关: Cancellation Propagation, Abort Generation, Timeout Control
- 浏览器边界: AbortController 与异步取消
- Durable recovery: Durable SSE Recovery with after_seq
- Runtime 关系: Agent Runtime 中 Command、Event、Replay、Resume 与状态层的关系
Cancellation
一句话定义
Cancellation 是用户或系统明确要求停止某个 Run 的业务命令与状态转换;它不是“HTTP/SSE 连接断了”的同义词。
最重要的边界:
Transport disconnect
≠ Business cancellation
AbortController.abort()
≠ run.cancelled
为什么必须区分 Transport 与 Run
浏览器可能因为这些原因失去连接:
Wi-Fi 切换
proxy timeout
tab suspend
浏览器刷新
临时网络故障
这些现象只能证明:
客户端失去了当前 transport。
不能证明:
用户希望取消服务器上的 Agent Run。
如果把连接断开自动解释成业务取消,会把本来可以继续执行并恢复的任务误杀。
因此 production runtime 通常要把生命周期拆开:
Transport lifetime
= 当前 HTTP/SSE/WebSocket 连接活多久
Run lifetime
= Agent execution 在服务端活多久
显式取消的正确链路
用户真正点击“停止生成”时,应该形成一个正式 Command:
User clicks Stop
↓
CancelRun(run_id)
↓
Server authorization / current-state check / idempotency
↓
Durable run transition: running|waiting_user → cancelled
↓
Propagate cancellation to active execution/resources where possible
↓
Persist/emit run.cancelled terminal fact
↓
Frontend observes and converges
前端随后可以关闭本地 reader/transport,但那只是 UI/资源清理的一部分,不是业务取消本身。
为什么只 AbortController.abort() 不够
controller.abort();
它首先表达的是:
stop waiting for / reading this browser-side async operation
它不能自动保证:
LLM stopped
Tool execution stopped
Run state became cancelled
Partial durable state was reconciled
Other clients learned the cancellation
如果服务端执行是 detached/durable 的,浏览器断开后 Run 完全可以继续。
所以真正的“停止生成”通常要有独立的 server command/API,而不是依赖 socket close 来猜用户意图。
Cancellation Propagation
服务器接受 CancelRun 后,才进入取消传播:
Durable cancellation intent/state
↓
active execution context / cancellation registry
↓
LLM stream / Agent loop / cancellable tools
↓
resource cleanup
这里的原则是 cooperative cancellation:下游必须支持或检查 cancellation signal,不能假设任意正在执行的外部操作都能瞬间强制终止。
Durable Terminal Fact
客户端不应仅根据:
fetch aborted
socket EOF
reader error
把 UI 标成 cancelled。
更可靠的是根据服务端 durable state/event,例如:
run.cancelled
它回答的是“取消已经成为系统事实”。
典型 terminal states:
completed
failed
cancelled
应保证终态唯一且不会被迟到事件随意重新打开。
Disconnect 的正确处理方向
network disconnect
→ keep server Run alive if architecture allows
→ reconnect / query durable run state
→ replay/catch up missing events
而不是:
network disconnect
→ cancel Run
这和 Durable SSE Recovery with after_seq 是同一个可靠性模型的两面:
未知网络故障 → Recovery
明确用户意图 → Cancellation
Partial Output 怎么处理
取消发生时是否保留已经生成的部分内容,是产品/领域策略,不应由“socket 被 abort”隐式决定。
系统可以选择:
- 保留 partial message 并标记 cancelled/partial;
- 只保留 durable semantic events;
- 丢弃未达成业务提交条件的临时 projection。
无论选择哪种,策略应由服务端状态机和持久化契约明确表达。
BodySense 当前实现
当前 BodySense 已有显式取消路径:
POST /consultation-runs/:id/cancel
→ Runtime.CancelRun
→ RunService / RunRepository
→ active/waiting_user → cancelled
→ cancellation signal propagated to active execution
→ durable run.cancelled(reason=cancelled_by_user)
同时,普通 transport disconnect 不会自动把 durable Run 标成 cancelled;客户端会走 durable event recovery/catch-up。
主要代码边界:
apps/api/internal/consultation/runtime.go
apps/api/internal/service/run_service.go
apps/api/internal/repository/run_repository.go
apps/api/internal/handler/consultation_handler.go
apps/web/src/features/consultation/services/consultationService.ts
常见错误
- 把
request.Context().Done()/ socket close 直接当成用户 CancelRun。 - 只 abort 浏览器请求,服务器 Run 仍继续消耗资源。
- 前端先本地标
cancelled,但服务端实际已经completed。 - Cancel 与 Complete race 没有服务端 authority/ordering,出现两个终态。
- Cancel command 不做幂等,重复点击产生重复副作用。
- 认为所有工具/外部服务都能立即响应 cancellation。
面试表达
“我会把 transport disconnect 和 business cancellation 分开。网络断线只能证明客户端失去连接,不能代表用户要停止 Run;对 durable Agent execution 应优先恢复。真正点击停止时,我会发送显式 CancelRun command,由服务端做权限和状态校验,持久化 cancelled terminal state,再向实际 LLM/tool execution 传播 cancellation。前端 AbortController 只负责停止本地 transport,不作为业务终态 authority。”
最短记忆
Disconnect → recover
Explicit Stop → CancelRun command
AbortController → client transport cancellation
run.cancelled → durable business/runtime terminal fact