dsh-restart-handoff
在对话里重启 DeepSeek Harness,然后自己回到工作里 —— 不需要人打字说「继续」。
中文
它做什么
模型通过一个专用工具 restart_dsh,以两步的方式重启宿主:
- 警告调用。 这一步不重启任何东西。工具返回一段警告:原因、将重放的指令、将恢复的会话,以及一个确认令牌。模型必须带着令牌回来,所以重启绝不会是一次随手调用 —— 智能体有第二次机会把计划说清楚。
- 确认调用。 令牌匹配时,工具写入交接记录(
$DSH_HOME/restart/handoff.json):重启时正在跑的会话、原因、待重放的指令(continuePrompt),以及可继续的子代理。会话日志落盘,工具等当前轮次变空闲,然后进程给自己发信号;由 Restart=always 的单元拉起新进程。
- 重放。 新进程读交接记录,然后
- 恢复重启发生时正在跑的每一个会话,并注入一条「请继续」的指令唤醒它;
- 自上而下地唤起可继续的子代理,保证子会话只在它自己的父会话活着时才被投递;
- 把
continuePrompt 交给发起重启的那个会话;
- 对齐 Agent Team 名册并唤起其中的队友。
上面是计划内重启。非计划重启 —— 崩溃、被杀、断电 —— 不会留下交接记录,所以下次启动时插件会扫描持久化的会话,把「最后一轮被打断」的那些带回来,外加「收件箱里还排着没开始过的提示」的那些。
为什么模型不会自己去执行重启
在服务内部执行 systemctl --user restart dsh.service,会死在自己要求的那个停止动作上。这样的 bash 调用还会丢掉工具结果、丢掉当前轮次、丢掉会话的活跃 Agent。因此插件:
- 把重启暴露为一个工具,并且
- 看守会执行命令的工具:看起来像「重启 Harness」的命令会被拒绝,并给出指向
restart_dsh 的提示。
围栏的完整说明见下文 为什么这样设计。
配置(cordis.patch.yml)
| 键 | 默认值 | 含义 |
|---|
serviceUnit | dsh.service | 监管该进程的 systemd user 单元。 |
handoffDir | $DSH_HOME/restart | 交接记录写入的位置。 |
autoResume | true | 下次启动时重放交接记录。 |
resumeSubagents | true | 恢复后唤起可继续的子代理。 |
resumeRunningSessions | true | 同时恢复其它正在跑或排着活的会话。 |
pendingTtlMs | 300000 | 超过这个年龄的确认令牌会被拒绝。 |
idleTimeoutMs | 30000 | 工具等轮次变空闲再发信号的最长时间。 |
handoffTtlMs | 1800000 | 超过这个年龄的记录视为陈旧,不再重放。 |
recoverInterruptedSessions | true | 恢复非计划退出留下的会话。 |
recoverMaxAgeMs | 7200000 | 被打断的会话最多能有多旧才仍会被恢复。 |
recoverMaxSessions | 50 | 一次启动最多恢复几个会话。 |
dryRun | false | 只写交接记录,绝不发信号。 |
在 cordis.patch.yml 里改任意一项即可覆盖:
- insert:
- id: restart-handoff
name: 'dsh-restart-handoff'
config:
serviceUnit: 'dsh.service'
autoResume: true
resumeSubagents: true
idleTimeoutMs: 30000
安装
用 Harness 插件 CLI 装进某个 profile。--profile 是必需参数 —— CLI 会把其余参数转发给该 profile 目录里的 pnpm:
dsh plugin --profile web add dsh-restart-handoff
从 GitHub 装(不需要 npm 账号):
dsh plugin --profile web add github:sadkga/dsh-restart-handoff
锁定某个标签:
dsh plugin --profile web add github:sadkga/dsh-restart-handoff#v0.2.1
装完重启宿主:插件的客户端元数据与加载器会被缓存,直到进程重启才生效。
插件需要 Node 24(engines.node)。
从本地检出安装
插件是一个纯宿主 bundle:一个 name、一份 inject 列表和一个 apply。要用插件管理器安装本地工作副本,把目标指向该目录:
plugin_manager → install_bundle → target: /path/to/dsh-restart-handoff
这会写入 profile 的 package.json 依赖(link: 到该目录)、把 dsh-restart-handoff 加进 dsh.profile.bundles,加载器随后应用 cordis.patch.yml,插入插件行和它的配置。
也可以手工接线:
// profiles/web/package.json
"dependencies": {
"dsh-restart-handoff": "link:/path/to/dsh-restart-handoff"
},
"dsh": { "profile": { "bundles": ["dsh-restart-handoff"] } }
然后在 profile 目录里跑 pnpm install(nodeLinker: hoisted),并重启宿主。
宿主必须由一个会重启它的单元监管 —— 插件给自己发 SIGTERM,靠 Restart=always 拉起新进程。本仓库使用的单元是 dsh.service(Restart=always、RestartSec=5、TimeoutStopUSec=1m30s)。
为什么这样设计
一个工具、两次调用,计划绑定在警告上
重启是破坏性的:它会终结进程里所有正在跑的轮次。所以它永远不是一次调用就能完成的。第一次调用只警告并交回一个令牌。
第二次调用执行的是警告里那份计划,而不是它自己参数里的计划:原因和指令都从待确认记录里读。一个在两次调用之间改写计划的模型,确认的就是一份没人见过的计划,确认也就毫无意义。令牌被确认消耗,并在 pendingTtlMs 后过期;它绝不跨过它本该授权的那次重启存续,因为新进程里没有待确认的警告可以对。
退出是给自己发信号,不是调 systemctl
在单元内部执行 systemctl --user restart dsh.service 是个坏主意:命令会被它自己要求的停止动作半路杀掉,而且它需要一个宿主未必具备的用户 D-Bus 会话。给自己发 SIGTERM 是等价的 —— CLI 会把在途工作排空并以 0 退出,而 Restart=always 会拉起新进程,所以即使重启失败也不会留下孤儿进程。
退出发生在工具已经返回结果、轮次已经变空闲之后。一个吞掉自己工具结果的重启,等于一个谁都不知道发生了什么就消失的重启。
交接记录就是全部意义
没有记录,「进程死了」和「工作继续了」就是两件互不相干的事。记录是原子写入的(临时文件 + rename),会话列表在退出前的最后一刻重新采样,回放一次后即删除。属于当前运行进程的记录,绝不会被同一个进程重放 —— 那将是一次根本没发生的重启。
恢复判据是最后一个 turn/end —— 外加排队中的工作
被打断的会话靠它最后一个轮次结束的原因、以及是否还有工作在等它来识别:
最后一个 turn/end 的 reason | 恢复? | 为什么 |
|---|
interrupted | 是 | 写入方崩溃了;会话层事后补上了轮次收尾 |
aborted + disposed | 是 | 进程在轮次中途被拆掉 |
aborted + user | 否 | 是人主动停的 |
completed | 否 | 跑完了;没有在途的东西 |
error | 否 | 失败了;没有在途的东西 |
另外,与 reason 无关地,收件箱里还排着一个从未开始的提示的会话也会被恢复:它所属的轮次或许结束了,但已被接受的工作还在等着,会随进程一起丢失。收件箱投影读作 projections.values.inbox['next-turn'] / ['next-step']。
interrupted 标记是 session-query 在给尾部轮次没有收尾的日志做配平时补上的收尾 —— 冷读和恢复时都会补。因此同一套判断同时覆盖「从没被恢复过的会话」和「恢复过、然后又死了的会话」。
turnBoundary.openTurnStartSeq 这个投影刻意不参与判定。冷读会在调用方看到之前先给日志配平,所以那个字段恒为 null;建立在它上面的判据看起来合理,却永远不触发 —— 是让整个功能「看起来活着、实际什么都没做」的死代码。这正是本规则要替换掉的那个 bug。
年龄上限用最后一个事件的时间戳,而不是会话的创建时间:合成出来的收尾复用了真实事件的时间,所以两者一致。
围栏判的是命令,不是提及
围栏是文本启发式,所以它的全部价值在于它看哪里。两个作用域,一个内核:
- 会执行的工具(shell、终端、代码执行器):它的参数被当作命令行判定 —— 内核按 shell 分隔符切分文本,剥掉包装层(
sudo、env、nohup、nice、command、VAR=value),看每一段的首个动词,再问这个动词是不是冲着这台机器来的。提交信息里提到「kill -1」不是 kill,因为首个动词是 git;
- 会存储的工具(写、编辑、读):它的参数被当作数据。一个内容里提到
systemctl --user restart dsh.service 的文件是文档,拦下它反而会阻止 Agent 写自己的测试和笔记。
一句话:围栏覆盖执行通道;纯数据载荷不当命令判。
The code-shaped self-harm signals — os.kill(os.getppid(), …),
process.kill(process.ppid, …), os.killpg, process.kill(-1, …) — are
denied in every tool, because a sentence never contains them.
内核还会递归进入解释器的载荷、任何把 -c 载荷交给程序的东西,以及执行器调用:
The kernel also recurses into the payload of an interpreter (sh -c '…'), of
anything that hands a -c payload to a program (busybox sh -c '…') and of
an executor call (execSync('pkill -f node'),
subprocess.run(['kill', '-1']), tools.bash({ command: 'pkill -f node' })),
because their argument runs exactly what a shell segment would.
最后,插件跑在宿主进程内部,所以它知道宿主从外面看是什么样子:它的 pid、它的父进程,以及它启动时的那条命令行。于是判据不再是"文本里有没有那个数字",而是"这条查找命令按它自己的匹配规则,会不会选中宿主":
| 写法 | 结果 |
|---|
kill -TERM <宿主的字面 pid> | 拦住 |
kill $PPID | 拦住 |
P=$(pgrep -f "apps/cli/src/bin.ts web") && kill -TERM $P | 拦住 |
kill $(pgrep -f "apps/cli/src/bin.ts web") | 拦住 |
pkill -f node | 拦住 |
kill 0(杀整个进程组) | 拦住 |
P=$(pgrep -f "my-unrelated-service") && kill -TERM $P | 放行 |
kill -9 12345(某个具名进程) | 放行 |
kill -- -12345(宿主以外���进程组) | 放行 |
pgrep -f "apps/cli/src/bin.ts web"(只查看,不发信号) | 放行 |
这是 shell 自己的匹配规则,不是猜测:pkill -f 拿 pattern 去匹配每个进程的完整命令行,所以凡是 pattern 出现在宿主命令行里的查找,必然会选中宿主;反之,查找一个无关进程是日常工作,必须放行。要绕过它仍然不难(base64、脚本文件、把 pid 编码进变量),所以围栏的定位始终是引导和后果说明 —— 真正的兜底是恢复机制:无论宿主是被杀、被崩还是断电,正在跑的会话、子代理和队友都会回来。
保真:会话回来时还是它自己
恢复的会话保留自己的 provider、模型、思考强度和 agent preset,全部从会话日志读,而不是用部署默认值 —— 包括「适配器默认给的思考强度不算对话的选择,因此不继承」这条规则。
恢复性能
扫描在每次启动时都会跑,所以它的成本是每个重启的人都要付的。三件事在真实存储上测过(132 个候选会话,其中一个 8 MB / 20 000 条事件):
| took(整轮扫描) | reads | readMs |
|---|
| 朴素做法:全量读每个日志 | 97 750 ms | 49 | 87 353 ms |
| + mtime 预筛 | 31 901 ms | 14 | 29 631 ms |
| + 只读日志尾部 | — | — | 14 ms |
| + 向忙碌的 Agent 投递时不空等 | 12 873 ms | 2 | 14 ms |
三个决定带来了这个结果:
- 年龄窗口在读日志之前,就用文件的 mtime 判定。 读一个日志意味着解压并解析里面每一个事件;真实存储里的大多数候选早就死了。这个预筛严格比它所预判的那条持久规则更保守 —— 整个存储都测过,日志最后一个事件时间与 mtime 的差从未超过 5.7 ms。
- 只读日志的尾部。 判据是「最后一个轮次是不是被打断」,答案就在末尾:最后一个
turn/start,以及它后面有没有跟着 turn/end。日志是多帧 zstd 文件,所以从末尾回溯帧、只解压最后几帧 —— 最大的那个会话读了 88 条事件,而不是 21 486 条。尾部判据与全量判据在整个存储上逐个会话比对过,全部一致。
- 忙碌的 Agent 不必等。
followup 是把消息排进 Agent 的下一轮;正在跑的 Agent 会在自己的轮次边界消费它。消息待在运行中 Agent 的队列里是正常状态,不是失败,所以投递不会等它变空闲。
剩下的约 12 秒,是真正把恢复出来的会话和它们的子代理启动起来的代价,而不是检查它们的代价。
两个已知成本不在这个插件的掌控范围内:
- 存储里有十个日志是单帧 zstd 文件;单帧必须从开头解压,所以它们只能回退到全量读。要让它们也快,需要宿主把日志切成更多帧。
readSession 这条路把大部分时间花在 CPU 上(解压、解析、structuredClone、重放校验),而不是 I/O —— 这正是「读尾部」收益如此大的原因,也是「并行读日志」收益甚微的原因。
已知边界
- 围栏是引导,不是隔离。 它匹配命令文本,所以原则上仍能被混淆绕过(
base64 -d | sh、把 pid 编码进变量、藏进脚本文件)。它的价值在于把模型引导到 restart_dsh 并解释后果;真正的隔离需要 PATH 白名单或独立 cgroup,那属于宿主改动。真正的兜底是:即便宿主被杀,工作也会回来 —— 非计划恢复那条路不在乎进程是怎么死的。
- 它认得出"指向宿主",但认不出"藏起来的宿主"。 围栏跑在宿主内部,因此知道宿主的 pid 和命令行;凡是按 shell 自己的匹配规则会选中宿主的查找 + 信号,都会被拦下。但把 pid 藏进变量或脚本之后,它就看不见了 —— 那种情况下靠的是恢复机制,不是围栏。
- 名字里没有「执行」二字的工具一律当数据工具。 它的载荷不当命令判,所以第三方插件里那些「把收到的字符串当命令执行」的工具在围栏够不着的地方。shell、终端和代码执行器这些通道 —— 模型真正用来重启宿主的那些 —— 是覆盖到的。
- 子代理是因为它的日志被打断才被唤醒,不是因为它的模式。
continuable 子会话和 one-shot 子会话一视同仁:如果它自己的日志显示有未闭合的轮次,就被唤醒并被告知接着干。跑完的子会话没有活可接,继续睡。
- 人主动停下的会话不会回来。
aborted + user 被明确排除,completed 和 error 也是 —— 除非收件箱里还排着一个从未开始的提示。
- 恢复是有上限的。 一次启动最多恢复
recoverMaxSessions 个会话,且只恢复比 recoverMaxAgeMs 新的,最新的优先。年龄窗口在读日志之前用文件 mtime 判定,它严格比它所预判的规则更保守:刚刚被打断的会话绝不会被它滤掉。
- 浏览器那一侧不是插件该管的事。 宿主重启,页面短暂断连,前端会重新挂上。插件恢复的是会话和工作,不是页面的连接。
serviceUnit 只是描述性的。 重启是自我发信号,所以这个插件从不调用那个单元;这个名字只写进宣告退出的那行日志。真正把新进程拉起来的是单元自己的 Restart=always。
- 恢复后的第一轮,会话的工具集可能比平时少几个。 会话被恢复时拿到的是那一刻注册好的工具;有些插件的工具是注册在各自的 scope 里的(
ctx.inject(['schedule'], …) 这种),要到这个 agent 的 scope 在第一轮装配完才补齐。实测:一个被恢复的会话第一轮带 123 个工具,下一轮就是完整的 153 个 —— 差的那 30 个是一个客户端插件、一个 MCP server 和一组提醒工具。
这不是本插件造成的(恢复路径与扫描路径都一样),也改不了:插件只能恢复会话,装配工具集是宿主的事。宿主会把它记成一条「工具已更新 · 移除 N 个」,下一轮就补上了。
本插件能做的是等全局注册表安静下来再恢复(resumeSettleMs),因为有些插件是在启动之后才注册自己的工具的;这消除了全局层面的竞争,但管不到 scope 层面那部分。
测试
npm test # node --test
| 文件 | 覆盖什么 |
|---|
test/restart-handoff.test.mjs | 两步确认、令牌有效期、确认的边界、会话收集、模型路由、磁盘上的记录。 |
test/guard-vectors.test.mjs | 围栏的向量套件:一条命令一条断言。 |
test/recovery-criterion.test.mjs | 恢复矩阵:哪个 turn/end reason 意味着「该回来」,以及排队提示的情形。 |
test/handoff-replay.test.mjs | 重放路径:恢复、交接记录、子代理、队友。 |
test/plugin-lifecycle.test.mjs | apply 注册工具、围栏与提示段,并在注册被拒时依然健在。 |
test/session-collection.test.mjs | 一次重启必须带回哪些会话。 |
probe-guard.mjs 是一条命令的验收探针:它用真实的围栏跑遍所有已知向量,一旦有不匹配就以非零退出。
English
Click to expand the English version
dsh-restart-handoff
Restart the DeepSeek Harness from inside the conversation and come back to the
work — without a human typing "continue".
What it does
The model restarts the Host through one dedicated tool, restart_dsh, in
two steps:
- Warning call. The tool records nothing that restarts anything. It
returns a warning: the reason, the instruction that will be replayed, the
sessions that will be resumed, and a confirmation token. The model must
come back with that token, so a restart is never a single stray call — the
agent gets a second chance to state the plan.
- Confirmation call. With the matching token, the tool writes the handoff
record (
$DSH_HOME/restart/handoff.json): the sessions that were running,
the reason, the pending instruction (continuePrompt), and the continuable
subagents. The session log is flushed, the tool waits for the current turn
to go idle, and the process signals itself. The unit's Restart=always
brings a fresh process up.
- Replay. The fresh process reads the handoff record and
- resumes every session that was running when the restart started, and
wakes it with a message that tells it to continue,
- wakes the continuable subagents again, top-down, so a child is only
delivered to while its own parent is live,
- hands the
continuePrompt to the session that asked for the restart,
- reconciles an Agent Team roster and wakes its teammates.
A planned restart is the case above. An unplanned one — a crash, a
kill, a power cut — leaves no handoff record, so on the next start the plugin
scans the persisted sessions and brings back the ones whose last turn was
interrupted, plus the ones whose inbox still holds a prompt that never
started.
Why the model never runs the restart itself
systemctl --user restart dsh.service from inside the service dies with the
very stop it asks for. A bash call like that also loses the tool result, the
turn, and the session's live agent. The plugin therefore:
- exposes the restart as a tool, and
- guards the tools that execute: a command that looks like a Harness
restart is denied with a message naming
restart_dsh.
The guard is described in full under Why it is built this way below.