# Peri Code Agent 取消机制复盘——五个层级、五次独立失效

> **[Peri Code](https://github.com/konghayao/peri)** — 用 Rust 写的开源 Coding Agent，兼容 Claude Code 生态。<https://github.com/KonghaYao/peri>

Peri 的 Ctrl+C 中断有过五个独立的 bug。用户按下 Ctrl+C 想中断当前操作，有时 Agent 停了但下一轮读不到上一轮的改动记录，不知道刚才改过哪些文件；有时界面卡在中断状态，输入框回不来；有时 Agent 派出去的子 Agent 还在后台跑，对 Ctrl+C 完全无反应。

五个症状看起来像同一个问题的不同表现，但调试下来发现不是。取消信号从用户按下 Ctrl+C 到 Agent 真正停下来，要穿过 Peri 的五层架构。每一层都有自己的取消处理逻辑，每一层都独立地犯了错。修一层不解决其他层，因为根因各不相同。

## 取消后对话历史被无条件清空

Agent 被用户中断后，执行结果返回最外层的服务端——ACP Server，负责管理 Agent 的执行和对话状态。服务端看到执行失败，做了一个直觉上合理的操作，把对话历史截断回这一轮开始之前的长度。

直觉是，既然这一轮被取消了，就当它没发生过。但这个直觉忽略了 Agent 在被取消之前已经做了事。Agent 在这一轮里可能调了三个工具、改了两个文件，然后用户按了 Ctrl+C。工具调用的结果已经写进了 Agent 的对话记录，改动有据可查。截断之后，这些记录全没了——下一轮用户发新消息，Agent 从截断后的历史继续，完全不知道上一轮做过什么。界面上显示一切正常，但从 Agent 的对话历史来看这些操作从没发生过。

不能简单地保留所有历史，因为 Agent 也可能因为真正的错误（比如模型报错）而返回失败，那种情况下部分写入的状态可能不完整。修复的方式是检查 Agent 在被取消前是否有有效产出——对比返回的消息数量和这一轮开始时的历史长度。如果消息数量超出了一条（用户消息本身），说明 Agent 确实在这一轮里写了新内容，这些内容保留下来，持久化到数据库。

## 中断事件被当成错误处理

Agent 执行中断后，框架发出一个事件通知界面层。这个事件的类型是「执行失败」，和 Agent 真正报错时发出的事件一模一样。界面层收到「执行失败」事件后，走了错误处理分支——显示错误信息，清理状态。

中断和错误需要完全不同的处理。错误处理只是显示一条报错，然后等用户下一步操作。中断处理要做更多——撤回用户上一条消息，让它回到输入框里方便修改后重发，同时恢复界面到可输入状态。因为中断事件走了错误处理分支，这套专门为中断设计的恢复机制从未生效。

区分两者的依据是事件里携带的消息内容。中断事件的消息固定是 `Interrupted by user`，真正的错误事件携带具体错误信息。界面层在收到执行失败事件时检查这个消息内容，走对应的处理分支。

这里还有一个互斥问题。中断或错误事件到达后，框架还会收到一个「完成」事件——因为 Agent 执行流程的正常结束就是发出完成通知。如果中断先到达并执行了恢复操作，后续的完成事件不能重复执行这些操作，否则会覆盖中断已经设好的界面状态。修复的方式是用一个标记——中断或错误处理时设为 true，完成事件到达时检查这个标记，已经处理过就跳过重建步骤，只做渲染。

## 缓存的消息位置在重建后失效

中断处理走到正确的分支后，还要找到用户上一条消息在界面对话列表里的位置，才能执行撤回。用户每次发送消息时，界面层缓存一个索引，记录这一轮对话从界面列表的第几条消息开始。中断恢复时直接读这个缓存索引去定位用户消息。

问题在于，界面在 Agent 执行过程中会不断收到状态更新，每次更新都会重建对话列表。重建之后，列表里消息的数量和顺序可能变了——新消息加进来了，或者消息的内部结构变了。缓存的索引没有跟着更新，还指向重建之前的位置。位置偏了一两条，撤回操作就找错了消息。

重建后重新缓存索引也不可靠，因为重建发生多少次、每次偏移多少都是不确定的。修复的方式是不缓存，改用实时搜索——每次中断恢复时从头遍历界面对话列表，找到最后一条用户消息。搜索基于消息类型而非位置索引来定位，不受重建影响。

## 取消令牌不传递给子 Agent

前面三层修完，Agent 单独运行时的 Ctrl+C 工作了。但 Agent 派出子 Agent 去执行任务时，Ctrl+C 又冒出了两个新问题。

Peri 的 Agent 可以派出子 Agent 去执行独立的子任务。子 Agent 有自己的执行上下文，独立运行。取消信号通过取消令牌传播——令牌是一个可以被设置和检查的信号标记，父 Agent 的令牌被触发时，所有从它派生的子令牌也会被触发。

问题出在同步子 Agent 的执行路径上。同步子 Agent 会阻塞父 Agent——父 Agent 派出子 Agent 后，等子 Agent 跑完再继续。但父 Agent 的取消令牌没有传递到子 Agent 的执行上下文。用户按 Ctrl+C 触发了父 Agent 的令牌，父 Agent 停了，子 Agent 在自己的上下文里运行，检查的是自己的令牌，父令牌被触发跟它无关。子 Agent 继续跑直到自己完成，Ctrl+C 对它完全无效。

不能用一个全局取消标记替代令牌树，因为令牌的父子关系保证了级联取消——取消父 Agent 时自动取消所有子 Agent，全局标记做不到这种层级传播。修复的方式是确保所有 Agent 执行路径共享同一棵取消令牌树。子 Agent 的令牌从父 Agent 的令牌派生，父令牌被触发时自动级联到所有子令牌。无论是同步、异步还是 Fork 模式，取消信号都沿着调用链传播。

## 中断事件被子 Agent 守卫丢弃

取消令牌的问题修好后，信号到达界面层时又被最后一道关卡挡住了。界面层有一个安全守卫，检查当前是否在子 Agent 执行期间。守卫的意图是合理的——子 Agent 自己被取消时产生的中断事件，不应该当作用户中断来处理，因为用户中断的是父 Agent 的整体任务，不是单个子 Agent。守卫发现当前在子 Agent 执行期间，就忽略中断事件，直接返回。

问题在于，父 Agent 在等待同步子 Agent 完成时被用户中断，也满足「在子 Agent 执行期间」这个条件——因为子 Agent 的界面状态是活跃的。守卫分不清两种场景，把父 Agent 的中断也一起吞了。信号从令牌传播到事件生成再到事件路由，整条链路全部正确，最后一道关卡静默丢弃了事件。用户看到的就是，按了 Ctrl+C，什么都没发生，界面卡死。

守卫不能直接删掉，因为它确实需要过滤子 Agent 自身的中断。修复的方式是移除提前返回的逻辑，让中断事件无论如何都走到后续的清理流程。清理流程能正确处理子 Agent 的中断——子 Agent 的界面状态在清理中被归档或移除，不会误触发父 Agent 的消息撤回。

---

五层修完，开头提到的三个症状——对话历史丢失、界面卡死、子 Agent 停不下来——都不会再出现。取消不是一个原子操作。在分层架构中，它是一组跨层的语义契约，每一层都要独立正确，任何一层断了，用户看到的就是 Ctrl+C 不灵了。

项目地址：[github.com/konghayao/peri](https://github.com/konghayao/peri)
