DeepSeek Harness · 会话与循环

Esc 之后发生了什么:取消、崩溃恢复与重入

每轮一个取消信号,中断也要把账记平,kill -9 之后重启还能接着跑,已落盘的劳动成果一条不丢。

课程目标读完你能说清三件事:按下 Esc 之后取消信号怎么从一个 AbortController 传遍模型流和工具执行,为什么取消权只管当前这一轮;被中断的轮次为什么也要写一条 turn/end 回日志;以及进程被 kill -9 之后,重启的 DSH 靠什么把半截会话救回来、又保住了哪些东西。
交互演示 · 中断演练场

下面是一条正在干活的轮次:模型流式输出中,还叫了一个长工具。左边是会话日志,右边是运行状态。三个情景按钮对应三种事故:按 Esc、kill -9 后重启、以及一个假想的截断式恢复做对照。点播放,字幕会告诉你每一步日志里多了什么、少了什么。

会话事件日志(仅追加 · 落盘即持久)
运行状态
进程运行中
本轮取消信号未创建
Inbox 排队消息0 条
日志统计会显示在这里:哪些事件保住了,哪些丢了。
选一个情景点播放,或滚动到此处自动播放情景 A。
演示为教学化模拟:事件条目做了简化,机制对应 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,所以不会有僵尸工作偷偷改状态。

一根显式的线:从 Esc 到每个正在干活的边界 Esc 按下 翻译成带身份的 cancel cancel 只做两件事 清 inbox,再 abort(cause) 本轮唯一 signal 随轮次生灭 模型流停止读取 工具与 bash 进程组 组装与审批收手 turn/end 发布之前收回取消权,下一轮换全新的 signal
信号是显式参数,不靠全局状态;每个边界在 await 前后自己检查,协作式收手。

最后是权力交接。循环在发布 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 的。

已落盘的真实事件一条不动,只在尾部补三条合成事件 kill -9 轮次开始 用户消息 模型输出 工具启动 补占位结果 补步骤收尾 补轮次终局 接着跑 崩溃前已持久化 · 全部保留 恢复时合成 · 终局标 interrupted
合成事件的时间戳复用最后一条真实事件的,绝不发明未来时间。

给悬空工具调用补的那条占位结果,措辞分两种情况。工具记录了启动但结果没落盘的,占位文本告诉模型结果未知,只有只读或幂等操作才可以重试,有副作用的要先核实外部状态或问用户,原文强调 「Do not retry blindly」。工具压根没启动的,直接说需要就重试。

「它不会截断日志:在长周期任务中,单个轮次可能非常庞大(许多步骤、大量工具输出),而这些事件在崩溃前已被持久追加。后端改为用一个合成的 turn/end { reason: { kind: 'interrupted' } } 关闭这个遗留轮次,在不改变其前后任何独立事件的情况下配平被中断的执行。」

docs/subsystems/persistence.zh.md · 崩溃恢复保留被中断的轮次

还有一条容易忽略的边界:这套修复只对冷会话生效。会话还活着的时候,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

进程级 · 专门的崩溃 crate

Grok 有一个专职 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 的会话恢复与中断记录细节未在已核对的书稿章节展开,这里保留未知项。

课堂练习
01

推演两个时刻的日志差异

同一个轮次,取消发生在两个不同时刻:a)模型流式输出到一半;b)bash 工具正在执行。

问题一:分别写出日志从 step/startturn/end 之间会出现哪些事件,turn/end 的 reason 是什么。

问题二:把这两种情况的事故换成 kill -9,重启后 repair 分别要合成几条 closer?提示:流式中断时没有悬空的 tool/call;工具执行中断时有一条记录了启动的 tool/call,对应结果未知的占位文本。

Takeaway:取消是一根显式的线:每轮一个 AbortController,cancel 清 inbox 再 abort,中断的轮次照样写 turn/end { aborted },取消权在 turn/end 发布前收回、绝不跨轮。崩溃恢复不截断:冷加载给悬空工具补错误占位、合成 turn/end { interrupted } 配平,崩溃前已落盘的每一条事件都保留。体面告别和意外身亡,日志里一眼可辨。