# AI Agent 的停止按钮为什么不好做：取消信号、工具进程、会话历史与执行状态

**URL:** <https://www.sunai.net/t/topic/1517>\
**Category:** IT\
**Tags:** ai, 编程\
**Created:** [2026 年10 月 7 日 00:25 UTC](https://www.sunai.net/t/topic/1517 "2026-10-07T00:25:35Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

作者： ![Logos](https://www.sunai.net/user_avatar/www.sunai.net/logos/32/2077_2.png) [@Logos](https://www.sunai.net/u/Logos)\
发布日期： [2026 年10 月 7 日 00:25 UTC](https://www.sunai.net/t/topic/1517/1 "2026-10-07T00:25:35Z")

</div>

用户点击 AI Agent 的“停止”按钮后，界面不再输出文字，任务就真的停了吗？

如果 Agent 只是生成回答，问题相对简单。可一旦它开始执行命令、修改文件、调用远程工具，停止就涉及另一件事：已经启动的工作由谁负责收尾，下一轮对话又该怎样理解这些工作的结果。

本文介绍取消机制的基本原理，并给出一套工程设计建议。涉及 API 的具体规则会标明来源；状态名称、界面文案和测试清单属于设计示例，不是某个框架的统一标准。

## 1. 先分清用户究竟想停什么

设计停止按钮之前，建议把下面几种意图区分开：

| 用户操作 | 系统应该做什么 |
| --- | --- |
| 停止展示输出 | 界面停止更新，但不能据此宣称后台任务已停止 |
| 取消本轮任务 | 停止调度新工作，处理已经启动的工作，并保存执行状态 |
| 拒绝一次操作 | 不执行尚未获批的工具调用 |
| 补充要求 | 在约定的安全边界注入新消息，调整后续方向 |
| 撤销已完成操作 | 执行另外一项回滚或补偿操作，需要单独判断是否可行 |

这些是不同的产品行为，不宜全部用一个“停止”状态表示。特别是取消与撤销：停止接下来的执行，不应该让用户误以为已经写入的文件、已经发送的消息也被恢复了。

可以用一个假设场景理解：Agent 正在修改代码，前两个文件已经保存，第三个文件尚未处理。用户取消任务后，合理的结果是停止后续工作，记录哪些文件已改、哪些未改；如果要恢复前两个文件，还需要有备份、版本记录或明确的撤销步骤。

## 2. 取消信号是通知，不是强制终止

取消通常采用协作式机制。上层发出信号，下层收到后停止等待或工作，再释放资源。

JavaScript 的 AbortController / AbortSignal、Go 的 context.Context 都是这类机制的例子。Go 官方文档明确说明，CancelFunc 不会等待工作停止。因此，“调用了 cancel”与“所有工作已经退出”是两个不同的时刻。[1][2]

在 Agent 系统里，建议让一轮任务拥有自己的取消作用域，把信号传给模型请求、工具执行和重试等待。不要只在聊天页面保存一个全局布尔值，然后期待所有后台操作自动停下。

用 Go 的思路理解：调用链一路接收 ctx，并不意味着执行中的函数已经支持取消。如果下层从来不检查 ctx，也不把它传给可取消的 I/O，信号就没有真正影响工作。

建议重点检查这些位置：

- 发起下一次模型请求前。
- 接收流式输出的过程中。
- 将工具调用放入执行队列前。
- 工具真正启动前。
- 重试的退避等待过程中。
- 准备执行有副作用的步骤前。

其中“工具启动前再检查一次”很容易漏掉。排队时任务还正常，轮到执行时用户可能已经取消。

但检查也不是万能的。检查通过与外部操作开始之间仍可能发生取消；越接近有副作用的步骤，越要配合执行记录与结果核实，而不是只依赖一个判断。

### Claude Code 与 Codex 如何暴露取消能力

Claude Code 的交互文档明确说明：按 Esc 可以中断本轮响应或工具调用，已经完成的工作会保留；如果已有消息排队，后续仍可能发送这些消息。在授权提示里按 Esc 则是拒绝该操作。同一个按键在不同场景中承担不同语义，不能把中断等同于撤销或清空队列。[11]

程序接入时，Claude Agent SDK 提供了明确的取消接口。官方发布的 TypeScript SDK 0.3.185 类型定义包含 `Options.abortController`，用于取消 query 并清理资源；流式输入/输出模式下的 `Query.interrupt()` 则是中断当前执行、交还控制权的控制请求。[12] Agent SDK 官方概述说明，它提供了驱动 Claude Code 的工具、Agent Loop 和上下文管理能力，因此这些接口比普通模型客户端的断开连接更贴近 Agent 执行生命周期。[13]

两者不应被当成完全相同的操作：需要结束 query 时使用对应的取消能力，需要在持续会话中中断当前执行时使用受支持的控制接口，再按具体版本处理后续输入。

Codex 的公开 Rust 源码则能看到取消如何进入工具层：`tools/router.rs` 的分发函数接收 `CancellationToken`，并把它放入 `ToolInvocation` 后交给工具注册表执行。[14] 这直接支持了“取消信号应沿调用链向下传递”的实现思路，但不代表每个外部工具都一定响应取消。

本文 Codex 源码引用固定到 2026 年 10 月 7 日的提交 `ed59a6c1cdf5e6fc96351fd46dfc1ef8a16db385`，便于对照具体代码，不把变化中的 main 分支当成永久不变的行为规范。

## 3. 停止调度和清理已有工作，要分开做

推荐把取消处理分成两个阶段。

第一阶段关上入口：本轮任务不再派发新的模型请求和工具调用，不再继续普通业务重试。排队但没开始的工作标记为未执行。

第二阶段收尾：通知正在运行的工具退出，等待有界的清理时间，保存已知输出，核实执行状态。

下面是建议的流程，不是某个 SDK 的固定实现：

```mermaid
flowchart TD
    A[收到取消请求] --> B[禁止本轮新增工作]
    B --> C[向运行中的工作传递取消]
    C --> D[限时等待与必要的强制终止]
    D --> E[记录结果和未确认事项]
    E --> F[补齐会话并结束本轮]

```

清理本身也需要时间预算。否则用户取消任务后，系统可能无限等待一个永远不退出的工具，停止按钮依然不好用。

建议为清理使用独立、短时限的执行作用域，而不是拿已经取消的业务作用域完成所有落盘和核实。它只允许保存结果、释放资源和核实状态，不能趁机继续原来的业务任务。这是基于取消传播特性的工程安排。[1]

### Codex 把“请求中断”与“回合结束”分开表示

Codex App Server 的官方文档提供了 `turn/interrupt`：客户端用 `threadId` 和 `turnId` 指定要中断的回合，成功应答为 `{}`，回合最终以 `interrupted` 状态结束；回合生命周期通过 `turn/completed` 通知呈现。[15]

从这个接口设计可以得出一个实用的接入建议：收到中断请求的成功应答后，继续处理结束通知和已有工具记录，不要马上销毁所有会话状态。中断针对某个回合，也不必通过杀掉整个 App Server 来完成。

需要注意，回合的 `interrupted` 是执行生命周期状态，不是“所有业务修改均已撤销”的声明。远端副作用仍应按工具的实际结果核实。

Claude Agent SDK 的更新记录也体现了队列清理需要单独处理：0.3.219 为 interrupt 控制请求增加了可选的 `cancel_queued`，需要相应能力支持，用于同时取消排队及待派发消息。[16] 因而实现停止按钮时，必须明确是只停当前回合，还是连尚未应用的新要求一起取消，不能默认两者总是同时发生。

## 4. 杀掉 shell，不代表命令的所有工作都结束了

命令工具经常经过 shell 启动其他程序。例如执行一个构建命令，实际运行的可能是 shell、包管理器、Node 进程以及多个工作进程。

Node.js 官方文档明确提醒：终止父进程不一定会终止子进程。成功发送 kill 信号，也不能直接当作进程已经退出。[3]

Linux/macOS 上，一个常见做法是为工具建立独立进程组，再对这个组执行终止。通常先请求优雅退出，等待一段时间；仍未结束时，再强制终止。Agent 自己不能混在准备终止的进程组里。

进程组也有边界。Node 的 detached 选项在非 Windows 平台上可以让子进程成为新会话和新进程组的首进程。也就是说，进程树的后代与当前进程组的成员并不是同一个集合，不能把“杀进程组”写成“保证杀掉所有后代”。[3]

Windows 上应采用适合该平台的管理机制，例如 Job Object。它可以把进程作为一个整体管理和终止，但仍要考虑进程加入方式、breakaway 配置和嵌套作业等边界。[4]

工程上建议抽象出“工具运行单元”：统一管理启动、取消、等待、输出回收与残留核查，内部再按操作系统实现。不要让每个工具各写一段随意的 kill 逻辑。

### Codex 源码中的进程组清理

Codex 的 `utils/pty/src/process_group.rs` 将这部分做成了公共辅助模块。Unix 路径中，`set_process_group` 用于建立独立进程组；`terminate_process_group` 向指定组发送 SIGTERM，`kill_process_group` 使用 SIGKILL；`kill_process_group_by_pid` 则先查询 PGID，再面向整个组发信号。[17]

模块还包含平台差异处理，例如 macOS 在组信号被拒绝时尝试对组内成员发送信号。这些实现说明，“按运行单元管理相关进程”确实是实际 Agent 工程中的工作，而不只是抽象建议。

不过，这些函数采用 best-effort 语义，该文件中的非 Unix 实现有空操作分支。不能仅凭这份源码宣称 Codex 在所有平台、所有执行路径上都必定采用同一套退出顺序，更不能把发送信号当成所有后代已经退出。本文建议的“限时等待、必要时升级终止、确认结果”仍需由执行器结合实际路径完成。

## 5. 进程退出了，输出管道也可能还没结束

进程管理还有一个不太显眼的坑：某个后代继承了 stdout / stderr 的管道。即使直接启动的进程已经退出，管道读取仍可能等不到 EOF。

Go 的 os/exec 文档就说明了这类等待问题。WaitDelay 可以限制取消后的退出等待，以及进程退出后 I/O 管道仍未关闭的等待；默认值为零时，不会施加这个限制。[5]

所以工具执行器应分别管理：进程是否退出、输出是否读完、文件描述符是否释放。不要把“stdout 还没读完”直接当成“程序还在执行”，也不要把“主进程已经退出”当成“一切已经清理完”。

这里还要区分进程退出码与业务结果。命令被终止只能说明执行结束了，不能说明它没有修改任何文件。

## 6. 远程工具的取消，是另一层问题

本地 Agent 停止等待远程响应，不能单凭这一点断言远端工作已经停止。远端需要接收取消，并将它继续传到自己的请求、进程或后台任务。

以 MCP 为例，2025-06-18 版规范定义了 notifications/cancelled：用请求 ID 指明希望取消哪次调用。规范也允许接收方在请求已经完成、无法取消等情况下忽略通知，并要求双方处理取消与完成之间的竞态。[6]

MCP Go SDK 文档同样区分了“通知已发送”和“服务器已经观察到通知”，前者不保证后者。[7]

这意味着，远程工具执行器最好能提供任务 ID、状态查询和取消能力。如果接口只会返回“连接断开”，Agent 就应保留结果未知的可能，而不是自动补成“未执行”。

MCP 的取消规则与传输行为存在版本差异，接入时应核对协商使用的协议版本。不要把某一版规范的细节当成所有 MCP 服务的共同实现。

## 7. 会话历史要补齐，但不能编造结果

工具调用已经进入历史后，突然中止执行可能留下一个没有结果的调用。后续模型请求是否接受这种历史，取决于具体 API。

以 Claude 的客户端工具为例，tool\_use 与 tool\_result 通过 ID 对应；官方要求工具结果紧接对应的工具调用消息，不能在中间插入其他消息。并行调用多个工具时，也要分别匹配结果。[8]

但补齐历史不等于统一写一句“用户拒绝了操作”。建议按真实状态描述：

| 已知情况 | 建议记录 |
| --- | --- |
| 用户拒绝授权 | 未获批准，工具没有启动 |
| 排队期间取消 | 未执行，不应声称运行失败 |
| 启动后被终止 | 运行中取消，记录已知输出与终止情况 |
| 工具已经完成 | 保存真实完成结果，即使本轮随后取消 |
| 无法确认远端结果 | 结果未知，继续前先核实 |

这些文案应映射成目标 API 接受的工具结果格式。对于平台托管、由服务端执行的工具，也不能擅自伪造客户端结果；Claude 文档明确区分了客户端与服务端工具的处理方式。[8]

还有一种情况：流式响应只到了一半，工具参数尚未形成有效调用。建议将它保留在调试记录中，而不是为了“补齐历史”强行解析并执行。一个完整的执行记录和一份可继续发送给模型的历史，可以是两个不同的数据视图。

### Codex 会修补缺失的工具输出，但这不是业务结果核实

Codex 的 `context_manager/normalize.rs` 有一个明确的修补函数：`ensure_call_outputs_present`。在本文引用的提交中，它检查调用与输出的对应关系，为缺少结果的 FunctionCall、CustomToolCall 等类型构造内容为 `aborted` 的输出，并插入到对应调用之后。[18]

这是“工具结果不能无故缺失”的直接源码例子。不过，这属于准备模型上下文时的兜底处理；该文件的注释也说明，合成输出可能只用于提示词归一化而不被持久化。不能把它描述成“每次取消都已完整保存真实执行结果”。

`aborted` 能补上协议结构，却不能说明文件到底改了几行、邮件是否已经提交。实际工具记录仍应保存已知输出、结果未知的原因和恢复步骤。

Claude 这边，Messages API 的调用与结果规则见本节前面的官方说明；Agent SDK 0.3.216 的更新记录还增加了 `tool_result_meta`，让接入方能够区分拒绝、中断、取消等情况，而不必只匹配结果文本。[8][16] 这支持了“不要把所有非成功结果统一写成用户拒绝”的状态设计，但不是对 Claude Code 全部内部补齐路径的源码证明。

## 8. 任务取消了，某个工具仍然可以是成功的

建议把任务状态与工具状态分开保存。

假设本轮依次执行三个步骤：读取项目、写入配置、部署服务。用户在部署之前取消，整轮任务可以记为取消，但读取和写入已经完成，不应被一起改成取消。

设计上可以让任务拥有 running、cancelling、cancelled 等生命周期；工具则单独记录未启动、执行中、成功、失败、运行中取消或结果未知。名称可以调整，信息不能丢。

对于有副作用的工具，还建议另外保存“副作用是否已核实”。例如，进程已终止，但配置文件是否写完仍待确认。单一 status 字段往往不足以表达这种状态。

取消与成功同时到达时，应按可核实的执行事实保存结果。即使某个传输协议要求客户端忽略晚到的响应，也不应因此推断业务副作用没有发生。[6]

## 9. 取消不是回滚，重试也不是天然安全

考虑一个假设的发送邮件工具：请求到达服务器，邮件已经提交，但客户端没收到响应就取消了。此时直接重试，存在重复发送的风险。

建议恢复顺序是：先用操作 ID 或业务记录查询状态，再决定是否重试。支持幂等的接口可以降低重复执行风险。

Stripe 的官方文档提供了一个具体例子：请求携带幂等键，同一个键的后续请求可以返回此前保存的结果，避免重复创建或更新。但键有保存期限，参数也需要一致，不能把它理解成永久有效的通用去重保证。[9]

对 Agent 工具而言，建议把“同一次业务操作”对应的幂等键保存下来。恢复任务时如果每次重新生成一个键，服务端就可能将它们当成新的操作。

文件修改则需要自己的保护措施。可选做法包括执行前记录版本、保留差异、在隔离工作区修改、执行后核查。回滚前还要确认文件没有被其他人继续改动，避免恢复旧内容时覆盖新的工作。

这是恢复与补偿的设计，不是取消机制自动提供的能力。

### Claude Code 的 rewind 也有明确边界

Claude Code 将中断与回退区分为不同操作。交互文档说明，输入框为空时双按 Esc 可以打开 rewind 菜单，恢复或整理先前的代码与会话状态；这与单次 Esc 的中断行为不同。[11]

官方 checkpointing 文档同时写明：通过 Bash 命令修改的文件不在这套检查点追踪范围内，不能靠 rewind 恢复；检查点追踪的是 Claude 文件编辑工具直接做出的修改。[19]

这给“取消不等于回滚”提供了具体产品例子：连专门的回退功能都有范围限制，普通停止按钮更不应该承诺任意操作都能恢复。调用外部服务产生的影响，则需要服务自身提供撤销、查询或补偿能力。

## 10. Steering 用来改方向，不用来替代取消

用户说“后面再加一节说明”，通常是在补充要求；用户说“不要发送了”，则需要停止相关操作。产品上应明确区分。

一种应用侧实现是把新消息放入队列，在工具结果归集后、下一次调用模型前注入。这种边界容易管理，但不是所有系统都必须等整批工具完成。

OpenAI 的 Mid-turn steering 文档区分了消息被接受、排队与实际应用；同时说明 Steering 不会改写已经输出的内容、撤销早先动作，或取消已经启动的工具。[10]

因此建议界面展示“补充要求已排队”和“已用于后续执行”两个状态，不要收到消息就立刻显示“要求已生效”。如果用户要求影响尚未执行的删除、发送、部署，应先阻止这些操作继续启动，再决定如何调整计划。

### Codex 明确区分 Queue、Steer 与 Interrupt

OpenAI 的 Codex 官方使用文章区分了 Queue 与 Steer：Queue 等当前响应完成后，把新输入作为下一轮发送；Steer 向进行中的工作注入指导。[20] 它们也不应与 Interrupt 混用。

Codex App Server 则把这种区分做成了接口：`turn/steer` 向活跃回合追加用户输入，不启动新的回合；`expectedTurnId` 必须匹配当前回合，没有活跃回合时请求会失败。要请求取消，则使用前面介绍的 `turn/interrupt`。[15]

这说明 Steering 不只是聊天输入框里“再发一条消息”。系统要知道消息属于哪个回合、是否被接受，以及应该作为新任务排队还是影响当前工作。

Claude Agent SDK 的 Streaming Input 文档也描述了长生命周期会话、排队消息、中断与跨轮上下文保留等能力。[21] 但两家的具体处理时机并不因此完全一致，尤其不能将 Codex 的 `turn/steer` 接口、OpenAI Responses API 的 Mid-turn steering，以及 Claude 的输入队列写成同一套协议。

## 11. 一套可落地的取消设计

综合以上边界，可以把实现要求整理成以下清单。这部分是工程建议：

1. 每轮任务有独立 ID 与取消作用域，避免一个会话的停止影响另一个会话。
2. 发出取消后，先禁止新增工作，再收尾正在运行的工作。
3. 队列、模型请求、重试等待和工具执行都支持取消。
4. 取消可以重复调用，清理与结果写入需要避免重复执行。
5. 本地命令由统一运行单元管理，按平台处理进程组或 Job Object。
6. 清理有时限，并记录哪些资源未能确认释放。
7. 工具启动前留下执行记录，结束后保存实际结果；记录写入失败不能继续对外宣称状态已保存。
8. 会话适配层按 API 规则补齐结果，不把取消、拒绝和失败混成一个状态。
9. 结果未知的写操作先核实，再恢复；支持幂等的工具复用原操作键。
10. Steering 使用单独的消息队列和生效状态，不复用取消按钮的语义。

界面也应反映真实进度。比如“正在停止”“本地进程已退出”“远端执行状态待确认”，比统一弹出“已取消”更准确。尚未核实的工作可以继续保存在恢复记录中，不必为了结束本轮等待而伪造一个确定结果。

一个停止按钮是否可靠，最终要看取消之后还剩下什么：有没有残留工作，有没有未确认的修改，会话能否继续，以及下一次恢复会不会重复执行。

## 参考资料

[1] [Go：context 包](https://pkg.go.dev/context)

[2] [AbortController 与 AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortController)

[3] [Node.js：child\_process](https://nodejs.org/api/child_process.html)

[4] [Microsoft：Job Objects](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects)

[5] [Go：os/exec 包](https://pkg.go.dev/os/exec)

[6] [MCP 2025-06-18：Cancellation](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/cancellation)

[7] [MCP Go SDK：LifeCycle](https://go.sdk.modelcontextprotocol.io/protocol/)

[8] [Claude：Handle tool calls](https://platform.claude.com/docs/en/agents-and-tools/tool-use/handle-tool-calls)

[9] [Stripe：Idempotent requests](https://docs.stripe.com/api/idempotent_requests)

[10] [OpenAI：Mid-turn steering](https://developers.openai.com/api/docs/guides/steering)

### Claude Code、Claude Agent SDK 与 Codex 的直接材料

以下材料分别对应正文中的产品行为、SDK 接口和具体源码。文档与源码用于说明已有实现，不代表所有版本与第三方工具都具备同样保证。

[11] [Claude Code：Interactive mode，Esc 中断、授权拒绝与 rewind](https://code.claude.com/docs/en/interactive-mode)

[12] [Anthropic 官方发布包：Claude Agent SDK 0.3.185 类型定义，Options.abortController 与 Query.interrupt](https://app.unpkg.com/@anthropic-ai/claude-agent-sdk@0.3.185/files/sdk.d.ts)

[13] [Claude Agent SDK：Overview，与 Claude Code 的运行能力关系](https://code.claude.com/docs/en/agent-sdk/overview)

[14] [OpenAI Codex 源码：tools/router.rs，CancellationToken 传递](https://github.com/openai/codex/blob/ed59a6c1cdf5e6fc96351fd46dfc1ef8a16db385/codex-rs/core/src/tools/router.rs)

[15] [OpenAI：Codex App Server，turn/interrupt、turn/steer 与回合生命周期](https://developers.openai.com/codex/app-server/)

[16] [Anthropic：Claude Agent SDK TypeScript 更新记录，0.3.219 的 cancel\_queued 与 0.3.216 的 tool\_result\_meta](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md)

[17] [OpenAI Codex 源码：process\_group.rs，进程组信号与平台差异](https://github.com/openai/codex/blob/ed59a6c1cdf5e6fc96351fd46dfc1ef8a16db385/codex-rs/utils/pty/src/process_group.rs)

[18] [OpenAI Codex 源码：context\_manager/normalize.rs，缺失工具结果修补](https://github.com/openai/codex/blob/ed59a6c1cdf5e6fc96351fd46dfc1ef8a16db385/codex-rs/core/src/context_manager/normalize.rs)

[19] [Claude Code：Checkpointing，回退能力与 Bash 修改限制](https://code.claude.com/docs/en/checkpointing)

[20] [OpenAI：Mastering remote engineering work from your phone，Queue 与 Steer 的区别](https://developers.openai.com/blog/mastering-codex-remote-for-engineering)

[21] [Claude Agent SDK：Streaming Input，持续会话、消息队列和中断](https://code.claude.com/docs/en/agent-sdk/streaming-vs-single-mode)

---

<div class="post-metadata">

作者： ![Logos](https://www.sunai.net/user_avatar/www.sunai.net/logos/32/2077_2.png) [@Logos](https://www.sunai.net/u/Logos)\
发布日期： [2026 年10 月 7 日 00:26 UTC](https://www.sunai.net/t/topic/1517/2 "2026-10-07T00:26:55Z")

</div>

## 补充：术语、执行记录与取消测试清单

下面补几项实现细节。数据字段与测试标准是设计示例，可按自己的系统调整；API 行为另附官方来源。

### 1. 几个容易混淆的术语

| 术语 | 在本文中的含义 |
| --- | --- |
| 协作式取消 | 发出停止请求，由正在执行的代码观察信号并退出；不会自动强制终止任意代码 |
| 强制终止 | 使用操作系统机制结束进程，不能依赖程序执行完自己的业务清理 |
| 超时 | 时间预算耗尽，通常触发取消；仍需处理已启动工作和结果未知的情况 |
| 竞态 | 取消、完成、派发等事件时间上重叠，最终结果取决于实际发生顺序和同步设计 |
| 幂等 | 对同一操作重复执行，预期业务效果不被重复叠加；具体接口的保证范围需要查文档 |
| 补偿 | 用新的业务操作处理先前操作的影响，不等于原操作从未发生 |
| 安全边界 | 系统选定的可检查取消、接收新要求或持久化进度的位置；需要明确它能保护哪些步骤 |

AbortController 与 AbortSignal 是一组控制器和信号：前者发出取消，后者供下层观察。Go 的 Context 还携带截止时间等信息。.NET 则使用 CancellationTokenSource 发出通知，由 CancellationToken 的接收方协作处理。它们都是机制，不应直接等同于某个 Agent 产品的完整停止实现。[1][2][3]

### 2. Go 的 CommandContext 不是完整的进程树管理器

`exec.CommandContext` 的默认取消行为是调用直接启动进程的 Kill；它不会自动提供“先 SIGTERM、再 SIGKILL、覆盖所有后代”的完整流程。官方允许在启动前定制 `Cmd.Cancel` 和 `Cmd.WaitDelay`。[4]

`WaitDelay` 可以限制文档列出的进程退出和管道关闭等待，但不会把脱离管理的后代自动归入自己的终止范围。它也不是所有阻塞的通用超时器：官方文档提醒，阻塞的输入 Reader 或输出 Writer 仍可能让 Wait 等待，因此日志接收端与 I/O 生命周期也要单独设计。[4]

建议命令工具先明确三个问题：直接启动的是业务程序还是 shell？谁负责管理后代？输出的读取和关闭由谁负责？解决这些问题之后，再封装跨平台执行器。

### 3. 执行记录最好同时保存“结果”和“确定程度”

考虑一个示例：Agent 调用部署工具，远端返回前本地取消。建议保存的内部记录可以包括：

| 字段 | 示例 |
| --- | --- |
| run\_id | 本轮任务 ID |
| tool\_call\_id | 模型工具调用 ID |
| operation\_id | 远端业务操作 ID |
| execution\_state | 结果未知 |
| cancel\_requested | 是 |
| remote\_cancel\_state | 已请求，未确认 |
| side\_effect\_state | 待核实 |
| last\_observation | 请求已提交，未获得最终状态 |
| recovery\_action | 查询原部署任务，不直接再次部署 |

这不是任何模型 API 的请求格式。内部记录应先保存事实，再由适配层转换成目标 API 接受的工具结果。

建议保留 run\_id、工具调用 ID 和业务操作 ID 的映射，不要把三者混为一个 ID：一次模型调用可以触发多项工具执行；同一业务操作也可能跨越重试和恢复。

对输出还应记录是否截断、最后观测时间和来源。只拿到一段日志时，可以记录“已知输出”，不能据此声称掌握完整执行结果。

### 4. “检查一下取消状态”还有一个时间缝隙

下面是一种假设时序：

1. 调度器检查：任务还未取消。
2. 用户点击停止，任务进入 cancelling。
3. 调度器按照刚才的检查结果启动工具。

建议让“关闭本轮入口”和“为工具取得启动许可”通过同一个受控调度机制协调，比如串行调度器、锁或具备条件更新的状态存储。启动许可要能对应执行记录，不能只是读一个布尔值。

已经获得许可、正在启动的工具，则应归入运行中工作，由取消流程继续管理。

这里的设计目标是明确工作归属、减少竞态，并不意味着可以把本地状态更新与任意远端副作用变成一个原子操作。外部请求已经发出而记录尚未完成时，仍可能需要状态查询和幂等保护。

### 5. 保存了执行日志，也不代表解决了所有恢复问题

建议在执行前写下操作意图，执行后保存结果。这样发生崩溃时至少能知道哪些操作需要核实。

但要区分两种假设情况：

- 记录了“准备发送”，随后崩溃，实际还没有发送。
- 已经发送成功，保存成功结果之前崩溃。

如果本地只留下相同的“准备发送”记录，恢复程序就无法单靠这条记录判定实际结果。不能把所有未结束记录都自动重试，也不能全部改成成功。

建议给有副作用的工具定义恢复策略：能否查询原操作、能否复用幂等键、是否需要人工确认、是否有补偿动作。工具接口如果缺少这些能力，应如实暴露限制。

### 6. 取消机制的测试不要只测 sleep

下面是一份建议的故障注入清单。测试应观察进程、队列、执行记录、模型历史与业务结果，不能只看界面是否停止输出。

| 场景 | 建议验收点 |
| --- | --- |
| 工具尚在排队时取消 | 没有启动；历史描述为未执行 |
| 重试退避时取消 | 等待及时结束，没有下一次普通重试 |
| 模型流只返回半截工具参数 | 不执行不完整调用，不生成虚假的完成结果 |
| shell 派生长时间运行的子进程 | 确认管理范围内的工作全部退出 |
| 后代建立独立会话 | 能发现残留或明确报告管理边界 |
| 主进程退出，后代保持输出管道 | 读取不无限等待，保留输出截断情况 |
| 工具忽略优雅退出请求 | 在设定时限后升级终止，并确认实际状态 |
| 并行工具中有的成功、有的取消 | 每个调用单独保存结果与对应 ID |
| 工具成功与取消同时发生 | 不覆盖可核实的成功结果，也不继续派发后续工作 |
| 远端完成，但响应丢失 | 状态保留为未知或经查询核实，不直接重复写操作 |
| 连点停止按钮 | 不重复补结果、不重复执行补偿 |
| Agent 在保存结果前崩溃 | 重启后能识别未确认操作并按恢复策略处理 |
| 取消后立即开始新一轮 | 新旧运行隔离，旧回调不会串改新一轮状态 |
| Steering 后紧接着取消 | 不把待应用的新要求作为继续执行的理由 |

最后一项可以再扩展：应用侧 Steering 队列中的消息如果未被模型使用就发生取消，建议保留“未应用”标记，由用户决定是否用于下一轮，而不是默默丢掉或自动执行。

### 参考资料

[1] [WHATWG DOM：Aborting ongoing activities](https://dom.spec.whatwg.org/#aborting-ongoing-activities)

[2] [Go：context](https://pkg.go.dev/context)

[3] [Microsoft：Cancellation in Managed Threads](https://learn.microsoft.com/en-us/dotnet/standard/threading/cancellation-in-managed-threads)

[4] [Go：os/exec](https://pkg.go.dev/os/exec)
