# 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 内所有进程一起被终止**。带对照组的实测结论(三步验证,详见「验证记录」): | 派生方式 | 是否继承 Job | Job 关闭后 | | --- | --- | --- | | 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. `/profiles/web/package.json` → `"dsh-quick-restart": "link:<插件目录绝对路径>"` (发布到 npm / GitHub 后也可写成版本号或 `github:zhuifengqug/dsh-quick-restart`) 2. `/profiles/web/cordis.patch.yml` → `- insert: { id: dsh-quick-restart, name: dsh-quick-restart }` (即仓库根目录的 `cordis.patch.yml`) 3. `/profiles/web/node_modules/dsh-quick-restart` → 指向插件目录(junction/symlink) 装完需要**手动重启一次** DSH 才会加载(这正是本插件要解决的问题,所以第一次得手动一次)。 之后就可以一直用 GUI 里的按钮。 > ⚠️ 不要用 `dsh web --patch <外部文件>` 的方式挂载本插件:client 模块扫描以补丁文件所在目录为 > resolution base,插件会加载到宿主半体但**客户端半体不会进 boot graph**(实测 404)。 ## 四、构建与测试 ```bash npm run typecheck # tsc --noEmit(仓库自带 tsc:node /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。 状态文件:`/quick-restart/state/-.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 存活)。