Esc 之后发生了什么:取消、崩溃恢复与重入
每轮一个取消信号,中断也要把账记平,kill -9 之后重启还能接着跑,已落盘的劳动成果一条不丢。
turn/end 回日志;以及进程被 kill -9 之后,重启的 DSH 靠什么把半截会话救回来、又保住了哪些东西。
下面是一条正在干活的轮次:模型流式输出中,还叫了一个长工具。左边是会话日志,右边是运行状态。三个情景按钮对应三种事故:按 Esc、kill -9 后重启、以及一个假想的截断式恢复做对照。点播放,字幕会告诉你每一步日志里多了什么、少了什么。
packages/core/agent-loop/src/agent.ts(取消与 turn/end 落日志)与 packages/core/session/src/repair.ts(崩溃恢复合成 closer)。情景 C 的截断式恢复是教学假设,DSH 未实现该行为,用来对照数据损失。它解决什么问题
想象取消做成一个全局开关:agent 身上挂一个布尔标志位,谁都能设,各处代码自己抽空看一眼。翻车迟早发生:上一轮注册的某个超时回调半夜苏醒,顺手把正在跑的新一轮取消了;或者取消用 Promise.race 实现,race 输掉的那个工具调用没人善后,还在后台偷偷改文件、写状态,成了僵尸工作。
取消这件事,难点是停得干净、只停该停的。这两样,全局开关都给不了。
思路是什么
DSH 的取消是一根显式传递的线,一头拴在轮次上,另一头拴在每个正在干活的边界上。驱动器每次醒来干活,新建一个 AbortController(取消控制器);一轮跑完、队列里还有活,就再换一个新的。任何时刻最多只有一个控制器有效,取消权的生命周期和轮次一样长。
Esc 只是拉了一下这根线。界面把按键翻译成 agent.cancel({ kind: 'user' }),子 agent 被父级打断则是 { kind: 'parent' },取消自带身份。cancel 的入口小到可以背下来,就两个动作:默认先把 inbox 清空,排队没跑的消息全部作废,想保住排队工作就传 keepInbox,只中断当前活动;然后对当前控制器 abort(cause)。空闲时调 cancel 是空操作,不会给未来的工作预埋取消状态。
同一个 signal 显式地发给 pre-step、提示词组装、模型请求、流式读取、工具执行、审批这些环节,连 bash 工具都能顺着它杀掉整个进程组。传递是协作式的:循环在每个 await 边界前后检查中断,不用 Promise.race 半路丢弃一个还在跑的 Promise,所以不会有僵尸工作偷偷改状态。
最后是权力交接。循环在发布 turn/end 之前就清掉本轮的取消持有者,之后哪怕持久化刷新还没结算完,谁也取消不了已经完成的轮次工作;下一轮拿到的是全新的 signal,旧回调想越权,连把手都摸不到。
出处:每轮新建 AbortController 在 packages/core/agent-loop/src/agent.ts 第 187 行,跑完换新在第 325 行,cancel 入口(可选清 inbox,再 abort 带类型化 cause)在第 134 至 140 行;取消权不跨轮泄漏的设计记录见项目 Agent Note 2026-07-16。
为什么长期成立
显式令牌加作用域绑定,是结构化并发的通则:Go 的 context、.NET 的 CancellationToken 走的都是这条路。令牌由创建者负责收回,活不过自己的作用域,越权自然无从谈起。只要系统里同时有流式 IO 和外部进程要停,换个语言重写,这根显式的线还是得有。
它解决什么问题
假设被取消的轮次不写终态:日志停在半截,回放的人不知道这轮怎么结束的;UI 没法如实告诉用户哪些排队的活被扔了;下游拿到日志,分不清这轮是被人有序停掉的,还是意外死掉的。中断是正常业务,不记账的中断才是事故。
思路是什么
轮次的主体逻辑包在 try 里。catch 分支发现 signal.aborted,就把结局定为 aborted;finally 里无论如何写一条 turn/end 回日志。已经落盘的流式 chunk、工具输出一个都不删。
于是日志里的死法只有两种,各有专属签名。aborted 由循环亲手写下:有人调了 cancel,轮次有序收尾。interrupted 循环从来不发,它只在崩溃恢复时由持久化后端合成,是唯一一个非循环出品的结局。看一眼 reason,就知道这轮的结束方式。
两个配套细节。日志只存粗粒度的 aborted,不存是谁按的,user 还是 parent 属于运行时信息,回放不需要也不该知道。被清掉的排队消息没有任何 turn/end 描述它,foldConsumedWork 单遍扫日志,靠 inbox 记录里的 outcome: 'canceled' 算出 droppedUnrun,UI 才能如实说有活被扔了、没跑。
出处:catch 定结局在 packages/core/agent-loop/src/agent.ts 第 302 至 305 行,finally 写 turn/end 在第 316 至 323 行;droppedUnrun 的折叠在 consumed-work.ts 第 87 行。
为什么长期成立
所有退出路径都写终态,是一切拿日志当权威状态的系统的底线,数据库事务日志的 commit 和 abort 记录同理。异常路径和正常路径出同样的账,重建现场的人才不用猜。模型换代、语言重写,这条纪律都不变。
它解决什么问题
取消好歹有 finally 善后,kill -9 连善后的机会都没有。进程死掉的瞬间,日志停在半截:turn/start 开着,某个工具调用记了 tool/call,却永远等不到 tool/result。重启后冷加载这份日志,摆在面前的是一道选择题:把没写完的轮次删掉,还是补齐?
删掉看似干净,代价大得多。长周期任务的单个轮次可能非常庞大,几十个步骤、大量工具输出,这些在崩溃前都已经持久追加。截断等于把用户的劳动成果陪葬,演示的情景 C 算的就是这笔账。
思路是什么
DSH 选补齐。恢复逻辑生成几条确定性的合成事件把尾巴关上:先给每个悬空的工具调用补一条错误占位的 tool/result,再关掉开着的 step,最后合成 turn/end,结局标 interrupted。顺序有讲究:step 还开着就写 turn/end 违反日志不变式,所以先补 step 的边界,再补 turn 的。
给悬空工具调用补的那条占位结果,措辞分两种情况。工具记录了启动但结果没落盘的,占位文本告诉模型结果未知,只有只读或幂等操作才可以重试,有副作用的要先核实外部状态或问用户,原文强调 「Do not retry blindly」。工具压根没启动的,直接说需要就重试。
「它不会截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 turn/end { reason: { kind: 'interrupted' } } 关闭这个遗留轮次,在不改变其前后任何独立事件的情况下配平被中断的执行。」
还有一条容易忽略的边界:这套修复只对冷会话生效。会话还活着的时候,load 会等权威内存快照落盘、只在日志配平时返回,活跃轮次没闭合就直接拒绝,绝不给一个正在跑的轮次插入合成边界。写盘那头也有讲究:持久化插件批量落盘,循环在领取下一轮之前用 session/flush 做检查点,把顺序和写盘错误都看在眼里。
出处:占位结果的两种措辞在 packages/core/session/src/repair.ts 第 91 至 124 行,closer 的合成顺序(先 step 后 turn)在第 126 至 132 行;只修冷会话在 docs/subsystems/persistence.zh.md 第 17 行,flush 检查点见同文档对应小节。
为什么长期成立
追加式日志加读取时修复,就是数据库 WAL(预写日志)恢复的思路:崩溃后不改写历史,只补足让状态机能继续走的最小事件。只要权威状态放在事件日志里,恢复逻辑重写多少遍都长这样。反过来说,敢截断日志的系统,等于默认单轮工作便宜到可以随便扔,这个假设在长任务时代不成立。
Grok Build
进程级 · 专门的崩溃 crateGrok 有一个专职 crate xai-crash-handler:用 sigaction 接住 SIGSEGV 和 SIGBUS,崩溃现场只用信号安全操作把二进制快照写进 last-crash.bin,还顺手用预计算的转义序列把终端恢复原状;下次启动再解析符号、生成崩溃报告,保留最近 5 份(crate README 与 handler.rs)。
它回答的问题是进程怎么死的、终端别留烂摊子。DSH 的 repair.ts 回答的是会话日志怎么活下来。两家各占一层,正面对比下 DSH 的独特点在于把恢复语义做进了持久化契约:load 的接口注释直接承诺补齐中断尾部、不改写已提交事件。在已核对的 Grok Build 材料中未见等价的会话日志配平机制,这一条基于已公开源码。
Claude Code
任务级 · AbortController 挂在 task 上后台 agent 的取消走 killAsyncAgent:从任务状态里取出 abortController 调 abort,把任务标成 killed(restored-src 的 LocalAgentTask.tsx 第 283 至 298 行,书稿第 6 章引用)。控制器的生命周期跟着任务走,一个任务一个。
DSH 把粒度再切细一档:控制器跟着轮次走,同一个 agent 的下一轮自动拿新信号,旧回调想越权都拿不到把手。另外 DSH 明确不持久化取消原因,durable 日志只留 aborted;Claude Code 的会话恢复与中断记录细节未在已核对的书稿章节展开,这里保留未知项。
推演两个时刻的日志差异
同一个轮次,取消发生在两个不同时刻:a)模型流式输出到一半;b)bash 工具正在执行。
问题一:分别写出日志从 step/start 到 turn/end 之间会出现哪些事件,turn/end 的 reason 是什么。
问题二:把这两种情况的事故换成 kill -9,重启后 repair 分别要合成几条 closer?提示:流式中断时没有悬空的 tool/call;工具执行中断时有一条记录了启动的 tool/call,对应结果未知的占位文本。