liuyun847/dsh-host-restart

dsh-host-restart

DSH 宿主插件:给模型一个 restart_dsh 工具,重启 dsh 后自动恢复原会话续跑 (DeepSeek Harness)

미분류dsh

dsh-host-restart

给 DSH 宿主加一个模型可调用的工具 restart_dsh:杀掉当前 dsh 后端 → 拉起新进程 → 新进程自动恢复原会话并注入「已重启」继续对话。 两种宿主各走一条路(v0.6.0 起):web 宿主 = 杀 node 后端 + 常驻看门狗拉起;Electron 桌面端 = 杀掉整棵应用进程树 + 冷启动同一个 exe。

用途:改完宿主插件代码或 cordis patch 后常常要重启 dsh 才生效,而重启会掐断正在进行的会话。 本插件把「重启」和「把会话接回来」合成一步,用户不用刷新页面、不用重新描述上下文。

2026-10-12(描述瘦身,版本号未变):restart_dsh 的模型侧描述由 2,920 字符压到约 934 字符,只留 「能做什么 + 关键约束」(注入一条固定续接消息、约 20–40 秒、本轮回复到此为止、只杀本实例、 在飞子代理与后台作业会被硬杀且结果丢失、job-only 会话不会自动恢复、仅当用户明确要求时用)。 被移出的机制细节(写标记与驱动脚本的 2 秒宽限、slHandoff.saveAll 最多等 7 秒、兜底注入约 10 秒、 桌面端整树语义等)以本 README §1 / §4 / §14 / §15 为准。行为一个字没改:131/131 全绿。 v0.8.0(2026-10-10):修正重启前 SL 工作表契约;等待刷新提交结果、显示实际会话 ID 和刷新完整度,并明确 job-only 会话不会由 SL 恢复。restart_dsh 与 /restart 的工具说明同步更新;最多等待 7 秒,服务失败仍不阻断重启。 v0.7.0(2026-10-03,用户要求):新增人工指令 /restart —— 此前重启只有模型工具一个入口, 人想重启得先说服模型去调它。现在同一套流程多了一条人能直接敲的入口(输入框里 /restart), 行为与工具完全一致:同样写标记、同样起驱动脚本、重启后同样注入固定文案「dsh已重启,继续」, 交接记录在场时同样让位给 dsh-host-sl。实现上是两条入口共用同一个执行体 performRestart (会话校验 → 会话检测 → 交接保存 → 写标记 → 起脚本,一个字节都不分叉),差别只有两处:失败文案 前缀(restart_dsh 未执行: / /restart 未执行:)与成功文案说给谁听(工具那句「请立刻结束本轮回复」 对指令没有意义)。注册走官方 commands 服务,不进 inject(与 slHandoff 同款理由:inject 是 "全有才 apply"的硬门),服务缺席/形态不符只写一行日志、工具照常。用例 115 → 131(新增 test/restart-command.test.mjs 16 条)。⚠ 模块按 URL 缓存 ⇒ 要等下一次 dsh 重启才生效, 而那次重启之后 /restart 就能用了。见 §15。 v0.6.0(2026-09-30):支持 Electron 桌面端 —— 本机 DSH 于 2026-09-30 从 web 端切到官方桌面端 0.2.0-rc.2,而 v0.5.0 的两处判据在桌面端双双失效(实测):桌面端后端是 DeepSeek Harness.exe (Electron 的 Node 模式子进程,命令行里是 --expose-internals …\app.asar\dsh\node_modules\@deepseek-ai\dsh-desktop-host\lib\index.js), 既不是 node.exe、也没有 dsh\lib\bin.js ⇒ 旧判据一个进程都杀不掉;看门狗自启已停用 (dsh-watchdog.vbs.disabled)、桌面端也没有 3080 ⇒ 旧的自起分支拼出来的命令行与真实进程无关。 桌面端只有一种可行语义:整树重启(Host 一死,Electron 主进程会走"崩溃恢复"弹原生对话框, 那条路要人点,不能当自动化用)。身份判据从 profile/端口 改成 pid + exe 路径,拉起方式从 看门狗换手 改成 杀掉整棵同 exe 进程树 + 冷启动应用;只杀同一安装路径的应用(用户 2026-09-30 选定)。判据、实测进程树、演练开关与验收判据见 §14。用例 99 → 114(新增 test/desktop-mode.test.mjs)。 ⚠ 模块按 URL 缓存 ⇒ 要等下一次应用重启才生效。 v0.5.0(2026-09-29,用户拍板):工具不再有任何参数、注入文案固定。 删掉 restart_dsh 唯一的参数 note(删参数属破坏性变更,故升 minor),工具描述里关于 note 的 表述一并删掉;buildInjectText() 保留为导出函数但忽略参数、恒返回固定文案,注入的就是这一句: dsh已重启,继续。重启前调 slHandoff.saveAll 也不再传 note —— 那一步只是为了把对方的 活跃工作表刷到最新;"续跑消息由谁注入"仍按 pendingSummary 让位,但注入内容里没有本插件的说明。 让位逻辑一个字没动:仍然只让 dsh-host-sl 说话,本插件仅在对方缺席/没接管时兜底,兜底那条 现在就是上面那句固定文案,不再拼任何别的内容。用例 98 → 99(新增"参数表为空 + 描述不提 note + 调用点不再读 args"的静态用例,并把 buildInjectText 那条改成"传了 note 也不许被拼进去")。 ⚠ 模块按 URL 缓存 ⇒ 要等下一次 dsh 重启才生效。见 §6 的交付指纹与 §13。 2026-09-29(卸载清理修复,版本号仍是 v0.4.2):把「插件卸载时的清理」从挂不上事件的 ctx.on('dispose', …) 改成唯一一处 ctx.effect(() => () => { … }) —— 本机 cordis 卸载 fiber 时 发的是 internal/plugin、没有 dispose 事件,所以旧写法那段清理一次都没跑过(tools.register 的 disposer 没被调用、「启动注入」与「兜底复查」两个定时器没被取消),现在卸载时真的回收。 新增 6 条回归用例(test/lifecycle-cleanup.test.mjs)⇒ 全套件 92 → 98 全绿。 ⚠ 模块按 URL 缓存 ⇒ 要等下一次 dsh 重启才生效。见 §4「与「已重启」注入的合并」末条与 §6。 v0.4.2(2026-09-29):把两条注入合并成一次、一轮(用户要求)。旧版一次重启注入两条消息 —— 本插件的「已重启」路径更短(约 4s)必然先跑,dsh-host-sl 的【sl 交接续跑】多一步 list() 判定 (约 6s)排进 next-turn,于是第一轮拿不到交接记录,可能空转或乱做。现在新进程读到自己的重启 标记后先判断「这次有没有 dsh-host-sl 的待续标记、且其中含本会话尚未处理的条目」:有 ⇒ 本插件 不注入,交给对方那条(它的正文开头就写着「dsh 已重启」;v0.4.2 当时靠 note 把"下一步" 写进记录正文,v0.5.0 已删掉 note 参数、注入内容里不再有本插件的说明);没有 ⇒ 照旧自己注入。 兜底(必须有):让位之后约 10s 复查一次 —— 对方让位或失败了(标记里仍有本会话条目)且本会话 status !== 'running' ⇒ 自己补注入一次。判据、取数顺序、日志与验收判据见 §4「与「已重启」注入的 合并」与 §9。 v0.4.1(2026-09-28):apply 阶段那行「重启前的交接保存:slHandoff 服务不在场(dsh-host-sl 未装载?)」 是假阴性,已经删掉 —— apply 那一刻同进程的 dsh-host-sl 还没把服务挂上(服务由它自己的 fiber provide,时机在 apply 之后),所以 ctx.get('slHandoff') 必然读不到;而同一个进程里稍后的工具调用 又看得到它(21:38、23:05 两次重启前的 saveAll 都真的存了盘)。现在改成首次 restart_dsh 工具 被调用时探测一次(那时服务一定可见),探测结论与真实调用结果不一致时如实区分。见 §5。 v0.4.0(2026-09-28):重启不再被拒绝。原先"检测到别的会话还有活在跑就拒绝"的那层闸门已删除 (用户要求:重启不该被拒绝);检测照旧跑,结果只进日志与工具返回文案 —— 点名会被打断的会话 id、原因, 以及交接记录存没存下。理由、代价(被打断的那一步工具调用救不回来)与文案形状见 §4「其它会话在飞:只告知,不拦截」。 v0.3.0(2026-09-28):发起重启前会先调可选服务 slHandoff(由同 profile 的 dsh-host-sl 提供)存一份会话交接记录 —— 保存结果写进工具返回文案;服务缺席/抛错绝不阻断重启。 语义、顺序与实测耗时见 §4「重启前的交接保存」。 历史:2026-09-28 晚改用 saveAll:调用面从 save(当前会话) 换成 saveAll({all:true, noteSessionId, session})(v0.5.0 起不再传 note)。当时服务会枚举 live agent; 此处只记录 API 沿革,不代表当前所有活动类型都会进入可恢复工作表。本插件只续接发起会话;其它会话 只有符合 turn / subagent 判据且确实写入工作表时,才由 SL 尝试唤醒(见 §4)。


1. 时序

1.0 两种宿主的差别(v0.6.0)

web 宿主(原路径)Electron 桌面端(v0.6.0 新增)
后端进程node.exe + dsh\lib\bin.js <profile> --port 3080DeepSeek Harness.exe --expose-internals …\dsh-desktop-host\lib\index.js(Electron 主进程的子进程)
身份判据pid + profile + 端口pid + exe 路径(没有 profile;端口只用于就绪判定)
杀谁命中身份的那一个 dsh 进程同一 exe 的全部进程(主进程 + GPU/渲染/网络/后端 Host/runner),只限这一个安装路径
怎么起看门狗换手 / 自行拉起 node bin.js等旧进程退干净(Electron 单实例锁)→ Start-Process 冷启动同一个 exe
就绪判据端口可应答 + 抓到带 token 的 dsh web: 行端口可应答(默认 19387)+ 主进程在
脚本参数-ProfileName / -Port-Mode desktop / -DesktopExe <exe> / -Port
额外产物dsh-last-url.txt(带 token URL)无(桌面端没有 token URL)

会话续跑那一半两边完全一样:标记文件 + sessionController.resolveAgent + 注入,一个字没改。

模型调用 restart_dsh()(**无参数**,v0.5.0)
   │
   ├─⓪ 检测:其它会话还有活在跑 ⇒ **只告知、不拦截**(v0.4.0,2026-09-28)——
   │      口径三条:自己在本轮运行 / 名下子代理在跑 / 名下后台作业未结算(见 §4);
   │      结果写进日志与工具返回文案(点名会话 id + 原因 + 交接存没存下),然后**照常往下走**
   ├─⓪.5 **重启前刷新 SL 工作表**(可选服务 slHandoff,由 dsh-host-sl 提供):
   │      `saveAll({all:true, noteSessionId, session})` ⇒ 等待最终表并报告实际会话 ID / refreshed;
   │      服务最多刷新 5s,本插件最多等待 7s;job-only 不入表、不承诺恢复;
   │      服务缺席、失败或超时 ⇒ 只写一行日志,**照常往下走**(见 §4)
   ├─① 插件写标记 $DSH_HOME/storages/dsh-restart/pending.json
   │      { sessionId, text:"dsh已重启,继续", createdAt, waitSeconds, pidBefore }
   ├─② 插件用 WMI(Win32_Process.Create) 启动驱动脚本 —— 脱离 dsh 进程树,
   │      并把**本实例身份**(自己的 pid + profile 名 + 端口)随命令行传给脚本
   ├─③ 插件确认脚本真的起来了(日志首行出现本次 SessionId)后才返回
   └─④ 工具结果要求模型立刻结束本轮输出
                 │
   [dsh-restart.ps1](独立进程,父级是 WmiPrvSE.exe)
     a. 单实例保护 → 解析本实例身份(-DshPid/-ProfileName/-Port) → 等 WaitSeconds(固定 2s,只够本次工具结果落盘)
     b. 只杀**本实例**的 dsh:精确 pid(命令行已校验)优先 → 退按 profile 名 + 端口扫描 →
        仍确定不了 ⇒ 退出码 4,一个进程都不杀(fail closed)
        · dsh 没被杀掉 ⇒ 放弃,退出码 2(避免拉起后 EADDRINUSE 假崩溃)
     c. **先探测看门狗进程在不在**(判据:node 后紧跟 dsh-watchdog.js;2026-09-24 起重启不再依赖看门狗):
        · 在 ⇒ 换手(杀旧看门狗 → 用当前 DSH_ENTRY 起新实例)→ 轮询本实例端口等它回门户
               (它判定端口空闲需约 15s)→ 发一次 HTTP 请求触发它拉起 dsh → 转 e
        · 不在 ⇒ **不再盲等 25s 门户**,直接转 d
     d. 自行拉起 dsh(看门狗不在时的**主路径**,不再是"降级"):
        绝对路径 node + Start-Process 重定向 stdout/stderr 到 dsh-selflaunch-out/-err.log
        → 就绪判据=端口可应答 **且** 输出文件里用 `dsh web:\s*(\S+)` 抓到**带 token** 的 URL
        → 失败最多重试 3 次(每次写清原因:进程已退出 / 无 URL 行 / URL 不带 token / 端口未应答)
        → 仍失败 ⇒ 退出码 2;成功后把带 token 的完整 URL 覆盖写入 dsh-last-url.txt
        → 再尽力起回一个看门狗接管这个 dsh(失败只记日志,不影响"重启已成功")
     e. 轮询管理端口 3081 /dsh-url 直到新 dsh 就绪(最长 60s);走 d 的路径在 d 内已确认就绪
                 │
   [新 dsh 进程] 插件 apply → 延迟 4s → 读标记
     → 等 sessionController 就绪 → resolveAgent(sessionId)(必要时 resume + 重新挂载会话记录的 preset)
     → **v0.4.2:先看交接记录会不会接手这次注入**(`slHandoff.pendingSummary()`,旧版/缺席则退回
        读 <DSH_HOME>\storages\sl-handoff\pending.json):
         · 待续标记里有本会话的未处理条目(`sessionId` 相等且 `done !== true`)⇒ **本插件不注入**,
           删掉自己的标记,排一个 10s 复查定时器 —— 这一次注入由 dsh-host-sl 的【sl 交接续跑】承担,
           于是**整轮只有一条消息**(对方的正文开头写着「dsh 已重启」;v0.5.0 起注入内容里
           没有本插件的说明)
         · 没有 / 服务缺席 / 本会话不在标记里 / 读不到 / 形态不符 ⇒ `agent.followup("dsh已重启,继续")`
           → 删标记(与 v0.4.1 逐字相同,日志里写清是哪种原因)
     → 10s 复查(**只有让位那条路会排它**):标记里**仍有**本会话的未处理条目(= 对方让位或失败了)
       **且**本会话 `status !== 'running'` ⇒ 自己补注入一次(`buildInjectText()` 的固定文案,与照旧注入同一条);
       标记里已无本会话条目(对方接手了)/ 会话已在 running / 插件已卸载 ⇒ 什么都不做

整轮耗时:看门狗在场约 26 秒,不在场约 11 秒。依据是 2026-09-17 实测的一次工具发起的重启 (当时 wait_seconds=15,全程 39 秒 = 15s 静默 + 1s 杀进程 + 15s 看门狗回门户 + 2s 新 dsh 启动

  • 4s 注入延迟 + 2s 注入)。其中那 15s 是看门狗判定"端口空闲"的固定开销,与本插件无关; 静默自 2026-09-27 起固定 2s(旧版默认 6s、可由模型传 2~60),上面前两项随之各减 4 秒。 看门狗不在场时更快:那条 15s 固定开销不再发生(脚本探不到看门狗进程就直接自行拉起), 代价是少了看门狗的 token 自动跳转(见 §4「自行拉起路径的 token」)。 ⓪.5 的交接保存给这条时间线加几毫秒(实测见 §4),不影响上面任何一项。 v0.4.2 起注入只剩一条消息、一轮:让位给交接记录时,那次注入发生在 dsh-host-sl 的路径上 (约 6s 那一轮),本插件那条不再单独发生 —— 时间线本身不变,变的是"只跑一轮"。

2. 文件与配置

下面写的 <工具目录> 指驱动脚本与日志所在的目录:默认是 <DSH_HOME>\tools(DSH_HOME 未设时用 ~\.dsh),可用配置项 restartScript / logFile 覆盖。

位置作用
~/.dsh/profiles/desktop/plugins/dsh-host-restart/lib/index.js插件本体:注册工具 + 启动时消费标记并注入
~/.dsh/profiles/desktop/plugins/dsh-host-restart/cordis.patch.yml本包自己的注册行 id: restart-dsh(包层 patch:本包是组合包,由 profile 的 dsh.profile.bundles 加载;改它不需要重启,但不会自己触发重组合,见 §7 末段)
<工具目录>\dsh-restart.ps1驱动脚本:解析本实例身份 → 只杀本实例 dsh → 探看门狗(在就换手+触发,不在就自行拉起)→ 等就绪(退出码 4 = 身份确定不了,一个都不杀)
<工具目录>\dsh-restart.log插件与脚本共用的流程日志(超过 1MB 截断保留尾部)
~/.dsh/storages/dsh-restart/pending.json重启标记;成功注入即删,陈旧/失败改名归档为 pending.<原因>-<时间>.json
<工具目录>\dsh-selflaunch-out.log / dsh-selflaunch-err.log脚本自行拉起 dsh 时的 stdout/stderr(每轮覆盖写,不是追加)。默认与 -LogFile 同目录,可用 -SelfLaunchLogDir 覆盖
<工具目录>\dsh-last-url.txt最近一次自起 dsh 的带 token 完整 URL(覆盖写、只保留最近一次)。用途:清 cookie / 换浏览器时取一次;重启日志里只记"已取得带 token 的 URL",不留 token 明文
%USERPROFILE%\Desktop\kill_dsh.bat桌面人工用的"杀 web 实例"脚本(判据是命令行含 dsh\lib\bin.js 且含 web,跨实例)。驱动脚本不再调用它,只按本实例身份杀进程
<工具目录>\dsh-restart.lock驱动脚本的锁文件(单实例保护)。退出后故意不删:判据是"里面记的 pid 是否还活着",所以残留无害、也不会阻塞下次重启
~/.dsh/storages/sl-handoff/不是本插件的目录:重启前存下的交接记录落在这里(dsh-host-sl 管),见 §4「重启前的交接保存」

⚠ 源码在 plugins\ 下,但真正被加载的是 node_modules\ 里的那份拷贝(含包内 cordis.patch.yml)。 本包 4 个共享文件实测全部是独立拷贝(node_modules\dsh-host-restart 是独立目录,非 junction)—— 拷贝文件在源目录内容变了时它仍可能报 Already up to date 直接跳过同步 —— 必须 dshpm remove 再 add(硬链接形态不需要这步:原地改即两侧生效)。 完整流程见 §7,已因此踩过一次坑(§8 第 3 条)。

启停:Web 侧栏「插件」页 →「已安装」区里本卡的总开关(写 dsh.profile.bundles); 点开卡片后每一行还有行级开关(向 profile 的 cordis.patch.yml 写 disabled 覆盖 —— profile 层在包层之后应用,所以覆写优先)。

插件配置(包内 cordis.patch.yml 的 config 段,全部可选;profile 层可按 id 覆写): bootDelayMs(4000) / staleMs(600000) / controllerWaitMs(30000) / restartScript / logFile / pendingDir / psExe / wmiExec(仅测试注入的启动原语,见 §6)/ handoffPendingFile(v0.4.2:交接记录那条退路要读的文件,默认 <home>\storages\sl-handoff\pending.json, 与 dsh-host-sl 的默认落点一致;只读,测试把它指到临时目录)/ deferRecheckMs(v0.4.2:让位之后的兜底复查延迟,默认 10000)。 非法值会 fail loud(抛错、不注册工具)。 等待不是配置项:waitSeconds 自 2026-09-27 起固定为常量 WAIT_SECONDS = 2,配置里写它不再生效 (理由见 §4「为什么等待仍固定成 2s」)。

工具参数:无(v0.5.0 起清零)。两个参数先后被删:wait_seconds 于 2026-09-27 删除(等待固定成 WAIT_SECONDS = 2),note 于 2026-09-29 删除(注入文案固定成 dsh已重启,继续)。 parameters 在源码里写成空对象 —— defineTool 不接受省略该字段。

3. 为什么这样启动驱动脚本(本机实测结论)

dsh 的 pwsh 工具走 dsh-subprocess-local,Windows 上 detached:false 且带 taskkill 树级清理 ⇒ 任何由 dsh 直接/间接拉起的进程都不保证能在 dsh 被杀后继续运行。所以驱动脚本必须由一个 与 dsh 进程树无关的载体启动。逐项实测结果(判据=WMI Create 后子进程能否写出文件):

通道结果说明
conhost.exe --headless "pwsh.exe" …✅ 0.4s 起效、无窗口首选
"pwsh.exe" -WindowStyle Hidden …✅ 4.5s 起效、无窗口回退通道(不依赖 conhost)
"pwsh.exe" …(不带 WindowStyle)✅ 0.9s,但会分配控制台窗口不用
wscript.exe + vbs 中转❌ 完全不执行WMI 进程没有可用的交互式 window station,GUI 子系统宿主起不来
mshta.exe / cscript.exe 中转❌ 完全不执行同上
Start-Process / 普通 spawn❌ 仍在 dsh 进程树内会被一起清理

⚠ -WindowStyle Hidden 只是冷启动慢(约 4.5 秒),不是失败 —— 启动确认窗口必须 ≥9s, 否则会把回退通道误判为不可用,进而重复启动驱动(脚本因此带单实例保护,见下)。

插件对每次启动都做启动确认:WMI Create 返回 0 只说明进程被创建,脚本解析/启动失败在 CreateProcess 层看不到;因此记录日志偏移,等驱动脚本写下含本次 SessionId 的首行日志才算成功, 失败则换下一通道重试,全失败就撤销标记并如实报错(不会出现"以为重启了其实什么都没发生")。

启动命令行里同时带着本实例身份:-DshPid(插件宿主自己的 pid)、-ProfileName(profile 名)、 -Port(端口)。三者都由插件从自己的 argv 读出 ⇒ 驱动脚本在目标进程的命令行里必然能找到 同样的 token,"杀哪一个 dsh"因此有确定答案(判据与 fail-closed 语义见 §4)。

4. 边界与失败语义

  • 只对主会话开放:session.header.origin === 'subagent' 或拿不到会话 id → 直接拒绝,不写标记、不重启。 原因:子代理会话不能作为"重启后要恢复的那个会话"(sessionController 会拒绝 subagent-owned 会话)。 v0.4.0 后只剩这两条拒绝路径(就是本行前半句的两种情形:拿不到会话 id、子代理发起)。
  • 看门狗未运行(2026-09-24 改):脚本先探测看门狗进程在不在,不在就不再盲等 25 秒门户, 立即自行拉起 dsh —— 绝对路径 node + Start-Process 把 stdout/stderr 重定向到 dsh-selflaunch-out.log(禁止走管道:父进程退出后子进程会 EPIPE 崩,实测 ~0.5s), 就绪判据=端口可应答 且 输出文件里抓到带 token 的 dsh web: URL(两者缺一不算就绪), 失败最多重试 3 次(每次在日志里写清原因),仍失败才退出码 2。注入照常完成。
  • 为什么不用 WMI 直起 node(最容易被"简化"改回去的老路,实测否决):Win32_Process.Create 起 node 会弹出可见的终端窗口(实测 WindowsTerminal,确认在屏幕上可见 on-screen),且无法重定向 stdout/stderr —— 输出全丢,dsh 启动时打印的一次性 token URL 拿不到(浏览器无法自动取得带 token 的地址)。 所以自行拉起固定用 Start-Process + 文件重定向 + -WindowStyle Hidden,node 用绝对路径 C:\Program Files\nodejs\node.exe(裸 node 在 WMI 宿主里虽能解析、但依赖服务环境变量不可靠);实测子进程在父脚本退出后仍存活 110s+、输出完整落盘、无可见窗口。
  • 自行拉起路径的 token:看门狗不在场时没人解析 dsh 的 stdout,脚本自己抓到的带 token URL 会 覆盖写入 <工具目录>\dsh-last-url.txt(只留最近一次,日志里不留 token 明文)。 浏览器通常已有长效 cookie,直接访问 3080 即可;清过 cookie / 换浏览器时从该文件取一次 URL 访问。
  • 看门狗回归(尽力而为):自行拉起成功且 dsh 已就绪后,脚本会尝试起回一个看门狗去接管这个 dsh。 顺序是"先 dsh 后看门狗"——看门狗启动时探测端口,见是 dsh 就接管;此时 dsh 已绑定端口, 不存在门户与新 dsh 抢端口的问题(反过来才会有)。回归失败只记日志,不影响"重启已成功"。
  • 自行拉起需要 profile 名:profile 是 dsh 的位置参数(缺了它会 error: --profile <name> is required 退出), 所以身份里没有 profile 时脚本不做"猜一个"的尝试,直接前置失败(退出码 1)。
  • dsh 没被杀掉:脚本放弃拉起并记日志(避免双实例抢 3080)。
  • 重复调用:标记后写覆盖前写,只有最后一次生效。
  • 陈旧标记:超过 10 分钟(或时间戳在未来)→ 归档不注入,避免下次启动凭空插一条"已重启"。
  • 注入失败(sessionController 不可用 / resume 报错):标记归档为 pending.failed-*.json, 只记日志、不阻断宿主启动、不无限重试。
  • 本轮输出被截断:进程被杀时尚未落盘的输出会丢;前端"保留被打断回答"的补丁会保住已产出的部分。 静默固定 2s 后,能确定落盘的只有本次工具结果,模型随后写的收尾正文基本都会被截断 —— 这是为堵住"判定之后、杀之前"那段无人复查的窗口所付的代价。
  • 为什么等待仍固定成 2s(2026-09-27 定,2026-09-28 保留):判定与杀之间隔着这段等待,窗口里随时可能 有别的会话开始跑(另一个标签页发消息、子代理结束唤醒父会话、定时提醒到点),而那时没有任何机制复查。 旧版把等待交给模型(wait_seconds 2~60s,默认 6)只会让被打断的活更多。 现在只留"本次工具结果落盘"必需的 2s,工具参数与配置项一并删除。

其它会话在飞:只告知,不拦截(v0.4.0 起;恢复口径 v0.8.0)

  • 重启不因活跃会话而拒绝:检测结果用于告知,不阻断写标记和启动驱动脚本。

  • 检测与恢复是两套口径:重启插件会列出本轮、名下子代理和未结算作业;dsh-host-sl 工作表只接续符合 turn / subagent 条件的会话。后台作业会被硬杀、结果丢失;仅因作业被检测到的会话不会自动恢复。

  • 逐项核对,不承诺全体恢复:重启前等待 slHandoff.saveAll() 返回已提交的会话 ID,再与被打断名单比对。文案区分已列入、未列入和刷新不完整;子代理按 parentSessionId 归属。本插件自己的固定续接消息只恢复发起会话。

  • 刷新状态如实报告:refreshed:true 表示 live 会话枚举与 turn / subagent 重算完成;false 表示只提交最近已知状态;旧版服务未提供该字段时标为未知。工作表的写盘成功不等于刷新完整。

    重启前 dsh-host-sl(SL) 工作表已完成刷新并提交,含 2 个会话条目(...);
    本次重启会打断 2 个其它会话(`session-other-a`(会话在跑)、`session-job`(后台作业在跑))、
    本会话自己名下还有 1 个子代理在跑:它们与本会话共用同一个 dsh 宿主,会被一起硬杀;
    正在跑的那一步工具调用直接丢。被打断的其它会话中 1/2 个已列入工作表,SL 会尝试唤醒;
    1 个未列入工作表,重启后不会由 SL 自动唤醒,其中 1 个仅因后台作业被检测到;
    本会话名下子代理有 1/1 个列入工作表,后台作业本身不会恢复、未结算结果会丢失。
    
  • 检测口径:① 顶层会话 status === 'running';② 名下任意深度子代理在跑;③ 名下作业为 running / stopping。发起者名下的子代理与作业单独计入 own,不算其它会话。

  • 可见范围:只看本进程的 agents / jobs 服务;其它 dsh 进程不可见。标签页空闲且无子代理、作业时不列入告警名单,但重启仍会终止该进程。

  • 检测失败不当作“没有活动”:agents / jobs 缺席、形态不符或枚举抛错时照常重启;文案说明无法确认状态,日志记录失败原因。

  • 检测到杀进程之间有窗口:静默 2 秒加驱动脚本身份解析期间新开始的活动不会进入本次告警或已保存快照;目前不做复检与取消。

重启前刷新 SL 工作表(slHandoff.saveAll,v0.8.0)

  • 调用顺序:检测 → await saveAll({all:true, noteSessionId, session}) → 写本插件标记 → 启动驱动脚本。dsh-host-sl 的 saveAll 等待活动重算,最多 5 秒;本插件额外设 7 秒上限。任一服务缺席、抛错、返回失败或超时都不阻断重启。
  • 返回语义:ok:true 表示工作表已写盘;refreshed:true 表示完整枚举与重算成功。若 refreshed:false,服务仍会提交最近已知表,并把不完整原因返回;restart 插件只把实际写盘成功的 items 计入会话 ID 清单。记录文件路径去重后显示。
  • 服务边界:工作表只覆盖被判定为 turn / subagent 的条目,不包含 job / schedule / goal-only 活动。发起会话仍由本插件的重启标记续接;其它会话只有出现在已提交条目清单中时,才会由 dsh-host-sl 尝试唤醒。
  • 写入与恢复不是断点执行:被打断的工具调用本身无法恢复;会话只会带着原有上下文重新开始一轮。作业结果属于宿主内存态,进程终止后不可恢复。
  • v0.8.0 之前的描述仅为历史:早期版本把全部活跃会话保存描述成可自动恢复;当前实现以 saveAll 实际返回的条目与 refreshed 状态为准。
  • 与「已重启」注入的合并(v0.4.2,2026-09-29 用户拍板):一次重启只注入一条消息、只跑一轮。
    • 旧版为什么是两条:两条注入各自独立 —— 本插件路径更短(apply 后 4s + resolveAgent,约 4s) 必然先跑;dsh-host-sl 多一步 list() 判定(约 6s)排进 next-turn ⇒ 第一轮拿不到交接记录, 那一轮只能凭「已重启 + note」空转或乱做,交接记录要到第二轮才到(v0.5.0 起 note 已删, 这一段的"note"是当时的历史形态)。
    • 现在的判据:新进程读到自己的重启标记、并且已经 resolveAgent 拿到 agent 之后,先问一句 「这次有没有 dsh-host-sl 的待续标记、且其中含本会话尚未处理的条目」—— 条目判据只有两条:sessionId 相等、done !== true。active 不参与:空闲条目 (active:false,保存那一刻没有在飞的活)同样会被对方处理掉并归档,所以它一样意味着"有人接手"。
    • 取数顺序(两条来源,都要 fail-soft):① ctx.get('slHandoff') 上的只读方法 pendingSummary()(dsh-host-sl v0.6.0 起提供;旧版没有这个方法 ⇒ typeof !== 'function' 就往下走);② 退回只读默认路径 <DSH_HOME>\storages\sl-handoff\pending.json (handoffPendingFile 配置项可覆盖;v2 列表与 v1 单会话标记都读得进);③ 两条都拿不到 ⇒ 视为"没人接手"⇒ 照旧自己注入。服务抛错、返回值形态不符、文件损坏一律退到下一条来源, 日志里写清是哪一种。
    • 有 ⇒ 本插件不注入:交给对方的【sl 交接续跑】。v0.4.2 当时靠 note 把"下一步"写进记录正文, v0.5.0 已删掉 note 参数 —— 现在注入的就是对方那条(正文开头写着「dsh 已重启」), 本插件不再往里塞任何说明。 日志:交接记录将接手注入,本次不再单独注入「已重启」:…(来源=service|file,kind=…,active=…)。
    • 没有 ⇒ 照旧自己注入,日志写清原因(服务缺席 / 没有标记 / 本会话不在标记里 / 读不到 / 形态不符): 交接记录不会接手本次注入(<原因>),照旧自己注入「已重启」。
    • 兜底(必须有,否则对方一让位就没人叫醒会话):决定让位后延迟约 10s(deferRecheckMs) 复查一次,两个条件都成立才自己补注入一次(buildInjectText() 的固定文案,与照旧注入同一条消息): ① 标记里仍有本会话的未处理条目(= 对方让位了 —— 闸门① 别的顶层会话正在跑一轮 —— 或失败了); ② 本会话 status !== 'running'(对方已经把它排进下一轮 ⇒ 别再插一条)。 复查时标记里已无本会话条目(对方接手了,条目被移除或整份归档)或会话已在 running ⇒ 什么都不做。 日志:交接记录没接手(<原因>),自己兜底注入「已重启」 / …兜底注入跳过。
    • 卸载清理只有一处登记,走 ctx.effect(2026-09-29 修):ctx.effect(() => () => { … }) —— cordis 卸载时真的会跑,一次回收四样:disposed 标记、cancelBoot「启动注入」定时器、 disposeTool(tools.register 返回的 disposer)与 cancelRecheck 兜底复查定时器。 不许用 ctx.on('dispose', …):本机 cordis(cordis/lib/index.js:969-970)在 fiber 卸载时 发的是 internal/plugin,根本没有 dispose 事件 —— 本包历史那处挂的就是它,那段清理一次都 没跑过(已修:原先只处理 disposed/cancelRecheck 的那段 effect 已并进来)。 ⚠ 生效条件:模块按 URL 缓存 ⇒ 装载中的旧代码仍带缺陷,要等下一次 dsh 重启才换成新代码。 test/lifecycle-cleanup.test.mjs(6 条)钉住四种回收与可重复调用,test/boot-inject.test.mjs 在假 ctx 上把"卸载 ⇒ 定时器被取消 ⇒ 到期也不注入"钉死了。
    • 已知残留:让位判据不看标记的新鲜度(dsh-host-sl 的 staleMs 默认 24h)。若那次 saveAll 失败、磁盘上留着一份超过 24h 的旧标记且里面有本会话条目,对方启动时会把整份标记 判为陈旧并归档(不注入),而本插件在 10s 复查时看到"标记里已没有本会话条目"⇒ 也不补注入 ⇒ 这一轮没人叫醒会话。触发条件是"交接保存失败 + 旧标记超期",概率很低;要消掉它得让两边共用 同一份新鲜度判据(本次不做,如实记在这里)。
  • 失败也不影响重启,但有副作用残留:若交接存下来了、而重启本身失败(驱动脚本起不来), 本插件会撤销自己的重启标记,那份交接记录与它的待续标记留在 sl-handoff 里 —— 下次任意一次 dsh 启动时 dsh-host-sl 会把它注入回来(内容仍是"重启前的状态"), 不想要就手工删 ~\.dsh\storages\sl-handoff\pending.json。
  • 只杀本实例:驱动脚本按"本实例身份"选进程 —— 精确 pid(插件注入的宿主 pid,命令行已校验) → 退按 profile 名 + 端口扫描 → 仍确定不了 ⇒ 退出码 4,一个进程都不杀(fail closed)。 为什么必须这样:profile 名是 dsh 的位置参数(dsh desktop … 等价 dsh --profile desktop), 所以"命令行含 web"这种判据跨实例 —— 2026-09-21 试跑实测,从 teamlab 实例(--port 3090) 发起重启会杀掉 web 实例(3080)。桌面 kill_dsh.bat 仍是那个跨实例判据,只作人工工具, 驱动脚本已不再调用它。旧版插件(不传身份参数)启动新脚本时,脚本会从重启标记的 pidBefore 兜底取宿主 pid(要求标记的 sessionId 与本次一致、且写于 5 分钟内), 所以"改完插件之后的第一次重启"不会空转。

5. 排障

现象查什么
工具调用后什么都没发生<工具目录>\dsh-restart.log:有没有 ===== 重启驱动启动 行;没有就是启动通道失败(插件日志里会写命中的通道与失败原因)
看门狗在场时,重启日志出现 触发请求返回异常…401 (Unauthorized)属正常现象,不需要处理 —— 浏览器页面自动重连已抢先唤起 dsh,脚本那次触发请求落在刚绑定端口的新 dsh 上(无 token 故 401);只要同一轮日志随后出现 新 dsh 已就绪,本轮重启就是成功的
dsh 重启了但会话没续日志里搜 发现重启标记 / 已向会话 … 注入;~/.dsh/storages/dsh-restart/ 下有没有 pending.failed-*.json
重启后只来了一条续跑消息(本该两条)这是 v0.4.2 的正常形态:交接记录接手了这次注入。日志搜 交接记录将接手注入,本次不再单独注入「已重启」;~\.dsh\storages\sl-handoff\sl-handoff.log 里应有对应的 恢复条目成功:…。那一轮的正文是【sl 交接续跑】(开头写着「dsh 已重启」;v0.5.0 起注入内容里没有本插件的说明)
重启后两条消息都到了 / 只到了「已重启」搜 交接记录不会接手本次注入((原因:服务缺席 / 没有标记 / 本会话不在标记里 / 读不到 / 形态不符 ⇒ 照旧自己注入)与 交接记录没接手((兜底:对方让位或失败了,本插件 10s 后自己补了一条)。两条都到说明兜底那次与对方那条撞上了 —— 只有"对方让位后又成功注入"才会这样,日志里 交接记录复查: 那行会写清当时看到的状态
交接记录那条一直没来、会话没人叫醒① ~\.dsh\storages\sl-handoff\sl-handoff.log 搜 恢复判定: / 本次恢复结束:(对方是不是让位/整份归档了);② <工具目录>\dsh-restart.log 搜 交接记录复查: —— 若写的是 待续标记里已没有本会话的未处理条目(dsh-host-sl 已接手),兜底注入跳过,说明对方把条目移除了却没注入(§4 末尾那条已知残留:超期旧标记被归档)
工具返回文案说"本次重启会打断 N 个其它会话"这是告知不是拦阻(v0.4.0 起重启不会被拒绝):文案里已点名 id、原因与交接记录存没存下;日志里搜 会话检测:(含每条原因 session / subagent / job)与 其它会话有活在跑(N 个),不再拒绝:照常重启。想避免打断就把重启推迟到它们跑完
想确认"这次打断了谁、接回来没有"对照工具返回的实际工作表 ID 和 refreshed;仅符合 turn / subagent 且列入工作表的会话才会尝试唤醒。逐项结果看 ~\.dsh\storages\sl-handoff\sl-handoff.log 的逐条 恢复条目成功:…(失败条目写原因并留在标记里等下次启动)
怀疑检测失效(说不清会打断谁)日志里搜 无法检测其它会话(:出现即说明 agents/jobs 服务形态漂移,检测已 fail-soft 跳过(这是它唯一的失效信号,重启照常但文案只说"没能读到宿主会话表")
工具返回文案说「重启前未能确认 SL 工作表写入」看同一行给的原因:slHandoff 服务不可用(dsh-host-sl 未装载?) ⇒ 对端插件没装/没加载(重启照常);不是函数 ⇒ 服务形态漂移(旧版 dsh-host-sl(≤v0.3.0)只有 save、没有 saveAll,也会走这条);抛错(...) ⇒ 对端保存时炸了。日志里搜 重启前工作表未成功提交 / 调用 slHandoff.saveAll() 有同一条记录
想确认"对端插件到底装没装"别再找 apply 那行了(v0.4.1 起 apply 阶段不探测 —— 那一刻 dsh-host-sl 还没把服务挂上,旧版那行"服务不在场(dsh-host-sl 未装载?)"是假阴性)。现在在 <工具目录>\dsh-restart.log 里搜 (首次工具调用时探测):服务在场(首次工具调用时探测) = 服务可读;服务不在场(dsh-host-sl 未装载?)…(首次工具调用时探测) = 确实没装/没加载;slHandoff.saveAll 不是函数(接口形态不符,需要 dsh-host-sl v0.4.0+) = 装的是旧版。若探测结论与真实调用结果不一致,紧跟的 交接服务探测说"…",但真实调用… 那行会说明以真实调用为准。没调用过 restart_dsh 就不会有这行(惰性探测,只探一次)
想确认这次重启到底存没存交接<工具目录>\dsh-restart.log 搜 重启前工作表已提交:(写入成功,含条目数与刷新状态)或 重启前工作表未成功提交:(失败原因);记录文件本体在 ~\.dsh\storages\sl-handoff\
重启后只有发起重启的会话被唤醒,别的会话没动静检查被打断会话的 ID 是否出现在重启结果的工作表清单,以及 refreshed 是否为 true;job-only 会话不会由 dsh-host-sl 唤醒。查 ~\.dsh\storages\sl-handoff\sl-handoff.log:应有 发现待续标记:共 N 条(顶层 a / 子代理 b) 与逐条 恢复条目成功:…;失败条目会写原因并留在标记里等下次启动
重启后页面卡"启动中"<工具目录>\dsh-watchdog.log(看门狗侧)与 dsh-watchdog-dsh.log(dsh 侧),本插件不参与端口接管
想确认是不是本插件干的~/.dsh/storages/plugins.json 里的 restart-dsh 条目 + 工具表里的 restart_dsh
从别的 profile 实例发起重启,怕误杀生产实例日志里搜 本实例身份: 与 本实例的 dsh 进程(…)(含判据来源);解析不出身份会写 fail closed(退出 4),此时一个进程都没被杀,把 -ProfileName/-Port 补进调用参数即可
重启后浏览器要 token / 提示未授权本次若走的是"自行拉起"(日志搜 自行拉起:就绪),后回归的看门狗没有 token 自动跳转 —— 从 <工具目录>\dsh-last-url.txt 取一次 URL 访问,dsh 会签发长效 cookie
自行拉起失败① dsh-restart.log 里每轮的原因(进程已退出 / 无 URL 行 / URL 不带 token / 端口未应答);② node 自身的报错在 dsh-selflaunch-err.log;③ 兜底双击桌面 dsh-web.bat(由看门狗拉起)
卸载时 pnpm remove 报 ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED本 profile 的 pnpm 带供应链策略:进 profile 目录(~\.dsh\profiles\desktop\)直接跑 pnpm remove dsh-host-restart --config.minimum-release-age=0。dshpm 的 --fast 只对 add 有效 —— pnpm remove 不接受 --minimum-release-age 这类参数

6. 测试与回滚

cd ~\.dsh\profiles\desktop\plugins\dsh-host-restart
$env:DSH_RESTART_NO_LAUNCH='1'    # 结构性熔断:不设它就别跑(放行用例会真的起进程)
node --test "test/*.test.mjs"     # 99 个用例,分布在六个文件:
                                  #   pending(8):标记判定 / 注入文案 / 启动命令行 / 配置校验(纯函数);
                                  #     v0.5.0 起那条"注入文案"断言的是**恒为固定句**:传了 note 也不许被拼进去
                                  #   inject-coverage(11):静态断言 ctx.<service> 都已声明 inject、
                                  #     代码里不再有 required:false / wait_seconds、apply 任何情况下都不抛回 loader、
                                  #     **工具参数已清零且调用点不再读 args(v0.5.0 新增)**、
                                  #     **slHandoff 不在 inject 里(可选读取)**、**保存→写标记→起脚本的顺序**、
                                  #     **apply 阶段不再写"交接服务在不在"那行(v0.4.1)**
                                  #   session-gate(41):多会话检测 —— 三条口径(本轮/子代理/作业)的
                                  #     **只告知不拦截**(在飞时照常写标记 + 经桩发起一次启动,文案点名 id 与原因)/
                                  #     无活在飞 / fail-soft(含"检测不到也不假装没有别的会话");
                                  #     **重启前的交接保存**:服务在场(含**多会话返回多路径**)/
                                  #     缺席(在飞时文案不许谎称已存)/抛错/形态不符(旧版只有 save ⇒ 也算形态不符)/
                                  #     返回 ok:false/异步实现/重启失败也报交接结果;
                                  #     **交接服务的探测时机(v0.4.1)**:首次工具调用时写一次(在场/不在场/形态不符三种)/
                                  #     同一实例只探一次/探测只读(不影响真实保存)/探测与真实结果不一致时如实区分;
                                  #     两条真拒绝(无归属会话 / 子代理发起)仍不写盘、不存交接
                                  #   instance-identity(9):本实例身份解析 + "多 profile 下只命中自己"的判据
                                  #     (与 ps1 同一条正则的 JS 镜像) + 启动命令行带身份 + 静态护栏