zhuifengqug/dsh-quick-restart

dsh-quick-restart

为 DeepSeek Harness 提供快速重启命令的插件

待分类dshrestart

dsh-quick-restart

在 DSH Web GUI 里点一下,就重启整个 DSH 运行进程——让新安装 / 更新 / 改过配置的插件与配置立刻生效。 不是刷新浏览器、不是只重载本插件、也不新建第二个实例。

  • 入口:侧栏「设置」旁边的 快速重启 按钮(另有设置页左侧导航里的 快速重启 DSH 整页)
  • 重启期间 Edge 窗口保持打开,服务恢复后由 DSH 自己的连接控制器自动重连
  • 有任务在跑时先等任务结束,超时只提示;强制重启必须由用户明确确认

一、为什么必须有一个「外部助手」

插件跑在 DSH 进程内部,一旦 DSH 退出就什么都不剩了,没有组件还能启动新实例。 所以本插件只负责妥善收尾并把接力棒交出去:一个不依赖 DSH 存活的 detached 助手 负责「等旧实例退出 → 等端口释放 → 起新实例 → 健康检查 → 写回结果 → 自删退出」。

关键约束:Job Object

本机 DSH 由 D:\dsh\dshstart\Start-DSH-GUI.ps1 托管,该脚本用 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE(0x2000) 创建 Job Object,脚本结束时关闭 Job 句柄, Job 内所有进程一起被终止。带对照组的实测结论(三步验证,详见「验证记录」):

派生方式是否继承 JobJob 关闭后
Node spawn({detached:true})是被杀
Start-Process(同控制台)是被杀
explorer.exe / ShellExecute否存活
WMI Win32_Process.Create否(父进程 = WmiPrvSE.exe)存活

因此助手只用 WMI 创建,并且创建后硬核验父进程必须是 WmiPrvSE.exe: 核验不过就中止重启(DSH 保持原样),绝不会出现「DSH 停了但没人拉起」。 CreateFlags 必须是合法的 DETACHED_PROCESS(0x08)(助手不分配控制台 ⇒ 不弹黑窗); CREATE_NO_WINDOW(0x08000000) 不在 Win32_ProcessStartup.CreateFlags 的合法取值里, WMI 会直接返回 InvalidParameter(21)。

环境变量怎么传

WMI 创建的进程继承的是 WmiPrvSE 的环境,不是 DSH 的。所以宿主机把 DSH 环境按变量名过滤后随 spec 传给助手:名字看起来像凭据的(*_KEY / *TOKEN* / *SECRET* / PASSWORD / COOKIE / AUTHORIZATION …)一律不传,只把变量名报告给用户,值永不进入文件、命令行或日志。


二、怎么停止自己:ctx.appExit,不是 process.exit

DSH 没有「重启进程」接口,也没有全局 drain / quiesce 闸门。官方唯一的整机停止接口是 ctx.appExit(code):它接到 CLI 的 process-shutdown,dispose 整棵 fiber 树 (agent 取消 + 等空闲、jobs teardown、关监听 socket)→ 设 process.exitCode → Node 自然退出, 5 秒兜底强退。仓库 Agent Note 明确记录过:Windows 上在 Undici 请求后立刻 process.exit() 会触发 libuv 断言(nodejs/node#56645),所以本插件绝不自己调 process.exit(); 只有 ctx.appExit 缺失时才降级,并把降级如实写进日志与 GUI 状态。

有任务在跑时

DSH 没有「停止接收新任务」的全局开关。能用的官方局部闸门 + 等待语义:

  1. ctx.goals.disarm(agent) —— 撤销自动续跑授权(否则 goal 会在 turn 结束后自己再起一轮,永远等不到静止);
  2. ctx.subagents.drainContinuableDescendants(agents) —— 关闭可续跑子代理接纳;
  3. 等 agent.whenIdle() + 对账 ctx.jobs 注册表,上限 gracefulShutdownTimeoutMs;
  4. 超时 → 进入 confirming-force-restart,只提示、不自动强杀;
  5. 用户明确确认后:agent.cancel({kind:'hook'}) + ctx.jobs.kill(id, ownerAgent),再走 appExit。

活动任务检测口径

agent/status(idle⇄running,覆盖模型请求 / 流式返回 / 工具调用 / 重试恢复) + subagent/start|end + workflow/start|end + ctx.jobs 注册表。注意 jobs.list(caller) 只返回「无主 + 该 caller 的」job, 所以对账时必须对每个 agent 各取一次再并集,否则会漏掉 bash / pwsh / 子代理产生的有主后台任务。


三、安装

本插件以 profile bundle 的形式安装(package.json 里的 dsh.bundle.patch 指向仓库自带的 cordis.patch.yml,安装器据此把插件插进 profile 的补丁文件)。 手工安装就是把下面三件事做完:

  1. <DSH_HOME>/profiles/web/package.json → "dsh-quick-restart": "link:<插件目录绝对路径>" (发布到 npm / GitHub 后也可写成版本号或 github:zhuifengqug/dsh-quick-restart)
  2. <DSH_HOME>/profiles/web/cordis.patch.yml → - insert: { id: dsh-quick-restart, name: dsh-quick-restart } (即仓库根目录的 cordis.patch.yml)
  3. <DSH_HOME>/profiles/web/node_modules/dsh-quick-restart → 指向插件目录(junction/symlink)

装完需要手动重启一次 DSH 才会加载(这正是本插件要解决的问题,所以第一次得手动一次)。 之后就可以一直用 GUI 里的按钮。

⚠️ 不要用 dsh web --patch <外部文件> 的方式挂载本插件:client 模块扫描以补丁文件所在目录为 resolution base,插件会加载到宿主半体但客户端半体不会进 boot graph(实测 404)。

四、构建与测试

npm run typecheck   # tsc --noEmit(仓库自带 tsc:node <repo>/node_modules/typescript/bin/tsc)
npm run build       # node:module.stripTypeScriptTypes 剥离类型 + 拷贝客户端包与助手
npm test            # 单元测试(20 项:脱敏/配置/配方/状态机/锁/出生记录)
npm run test:launch # 助手启动路径回归测试(假新实例,不碰真 DSH;Windows)
npm run e2e         # 端到端重启(需要先起一个可重启的测试实例)

src/ 是唯一权威源码,lib/ 是可删除重建的产物。构建期守卫会拒绝不可擦除语法、 拒绝 export default apply、拒绝客户端访问未声明的服务、并强制助手只 import node: 内建模块。

启动新实例:start 直接起进程,不要写 .cmd 再 start 它

老实现把命令行写进 launch.cmd 再 cmd /c start "" launch.cmd,那等价于 cmd /K launch.cmd:cmd 是逐行读取脚本的,脚本必须活到新 DSH 退出为止。 可助手在健康检查一通过就清理了运行目录,DSH 退出时 cmd 读不到收尾的 exit, 窗口会永远停在提示符上,并且锁住工作目录(真机实测:DSH_HOME\profiles\web 被它锁死,目录改名/删除全部 EBUSY)。

现在改成直接 spawn(cmd, ['/c','start','',program,...args], { cwd }): 工作目录由 spawn 的 cwd 给(不需要 cd /d),程序退出窗口就关闭, 运行目录可以立刻清理。npm run test:launch 钉住这两条。

五、状态机与状态文件

idle → checking → waiting-for-tasks → confirming-force-restart → preparing-helper
     → stopping → starting → waiting-for-ready → succeeded | failed

迁移是表驱动的(src/state.ts 的 TRANSITIONS),非法迁移直接拒绝,不用布尔量拼状态。 旧实例只写到 stopping;starting 之后由助手续写;新实例的插件加载时读同一份记录回传 GUI。

状态文件:<DSH_HOME>/quick-restart/state/<port>-<pid>.json(多实例隔离 + 并发锁 .lock)。 内容只有路径、PID、状态、超时、健康检查结果与失败原因;写盘前统一过 sanitizeRecord, 任何字段都先经 redact()。支持过期清理、异常中断恢复(标 failed 可重试)、陈旧锁抢占 (持锁进程已死或 PID 被复用)。

六、GUI 侧

  • sidebar.footer.action:侧栏常驻按钮,窄栏只留图标但保留 aria-label / title
  • settings.section:设置页整页(同一套组件)
  • 与宿主通信走 DSH 自己认证过的 Connection RPC 通道 ctx.connection.rpc.handle('/quick-restart', …) —— 不自己注册裸 HTTP 路由,因此不会绕过 /api 的信任围栏;通道注册是调用方 fiber 的 effect, 卸载/热重载自动撤销
  • 重启期间宿主不可达,面板用 HEAD /favicon.svg(静态文件,不受鉴权影响)做只读探活; 恢复后回读真实结果。不自己实现重连:DSH 的 ConnectionController 本来就会无上限指数退避 (500ms ×2 → 10s 上限 + 抖动),并有 connection/reset 事件在重连后重新拉取设置与会话

客户端半体必须声明 inject: ['slots', 'connection']

浏览器侧 cordis 的服务守卫对未声明的服务会抛 cannot get property "connection" without inject。如果 client 半体只写了 ['slots'], ctx.connection 的访问会被这层守卫拒绝;一旦代码里用 try/catch 吞掉,症状就是 「面板能打开,但『立即重启』永远灰着」(面板上只有一行 connection-unavailable)。

三道防线(都别再退回去):

  1. client/client.js 导出的 inject 必须包含 connection;
  2. 取服务用 ctx.get('connection') 兜底 —— ctx.get() 不走守卫,注入声明被漏掉也能用;
  3. scripts/build.mjs 的 client inject guard:扫描 ctx.<名> 访问,未声明且未用 ctx.get('名') 取的一律构建失败。

新实例还会在状态目录里反查自己的「出生记录」(newInstance.pid === 自己), 所以即使浏览器整页重载,面板上依然能看到「本实例由快速重启启动(旧实例 PID …)」。 注意:这条判据不能要求记录已经是 succeeded——新实例加载时助手那侧通常还停在 waiting-for-ready,所以做成「加载时查一次 + 读状态时按 5s 节流补查」。

七、已知边界(做不到的部分)

  1. 没有全局 drain 闸门:等待任务期间 DSH 仍可能接到新任务(例如用户手动发消息)。 我们只能等,不能冻结;因此等待是有上限的。
  2. 工作流 ctx.workflowEngine 没有 list():只能靠 workflow/start|end 事件计数, 进程外无法枚举正在跑的 workflow run。
  3. schedule 定时任务没有独立可观测面;它触发时走 agent turn,已被 agent/status 覆盖。
  4. 非本机 subagent provider 的运行只能靠事件计数,无法直接查询。
  5. 环境变量按名字过滤:名字像凭据的变量不会随重启传递(只报告名字)。 依赖环境变量注入的供应商密钥请改写到 $DSH_HOME/.credentials.yaml 或设置页。
  6. 强制重启只能作用于当前实例:DSH 自身的 cancel/kill 接口;不按进程名批量杀进程。
  7. ctx.appExit 缺失的组合里会降级为 process.exit(0)(有 libuv 断言风险,已在 GUI 明示)。

八、验证记录(本机 Windows 11 26200 / DSH 0.1.5-rc.1)

  • Job Object 逃逸(合成 Job + 对照组):CONTROL-assigned 被杀;Node detached 子进程被杀; WMI 子进程存活 ✅。三次独立实验,仅 WMI 稳定存活。
  • 单元测试:node --test test/unit.test.mjs test/state.test.mjs → 17 pass / 0 fail。
  • 端到端重启(隔离实例,端口 3081,独立 DSH_HOME),用插件自己的 lib/helper.js 生产代码:
    • 助手由 WMI 创建,父进程 = WmiPrvSE.exe,escapedJob = true
    • 杀掉旧实例 → 启动器 Job 关闭 → 助手存活并接管
    • 端口释放 → 拉起新实例 → 健康检查 HTTP 401 + 端口所有者 PID 指纹匹配 → state = succeeded
    • 新实例 PID 与状态文件记录一致;助手自行退出,运行目录已清理
  • 客户端半体(无头 Edge + CDP 真实驱动 GUI):
    • 修好前(inject: ['slots']):面板能打开,但「立即重启」disabled = true, 面板上写着 connection-unavailable —— 与用户报的现象一致,已复现并定位。
    • 修好后:按钮 disabled = false,面板显示 当前状态:idle、 启动方式:直接重放当前命令行 · 服务端口 3081,设置项中文标签正常。
    • 在真实 GUI 上点「立即重启」→ 确认:preparing-helper → 助手由 WMI 启动 (父进程 = WmiPrvSE.exe)→ ctx.appExit(0) 优雅退出 → 新实例起来 → 页面短暂显示「DSH 已重新连接。」→ 新实例 state = succeeded, health.readyAt 与端口所有者 PID 都对得上。旧 PID 50460 → 新 PID 49296。
    • 新实例面板显示「本实例由「快速重启」启动(旧实例 PID 50460,… UTC)」。
  • RPC 通道:无 Cookie 时返回 401(已注册且受 DSH 鉴权保护);带 DSH 的会话 Cookie 调用 status 返回 ok:true 且 capabilities.canStart = true。
  • 客户端 bundle 热更新:改 lib/client.js 后,宿主自动重算 combo rev (三个不同的 rev 依次出现),浏览器刷新页面即生效,不需要重启 DSH。
  • 主实例(3080)全程未重启、未被影响(结束时仍 HTTP 200,PID 39244 存活)。