Skip to content
dsh.fish
Bundle

dsh-safety-restart

DSH 的安全无感重启:agent 可以自己重启宿主并接着干,但重启前必须过四道闸(重启循环熔断、有别的会话在跑就拒绝、profile bundle 解析不了就拒绝、拿不到可靠的拉起手段就拒绝),重启后原会话自动接续。桌面端走 Electron app.relaunch(),web/CLI 端走 detached helper。

Source
Dayi-Z
License
MIT
Updated
Updated yesterday

Readme

# dsh-safety-restart

**English TL;DR** — A safe, seamless restart plugin for DeepSeek Harness. The agent can restart the host itself (host-half code changes only take effect after a full process restart) and gets woken up afterwards to continue. Before restarting it must pass four gates: restart-loop breaker, other sessions mid-turn, unresolvable profile bundles, and (on desktop) an unavailable `app.relaunch()`. Any gate that fails means **refuse with a reason**, never force it through. Desktop uses Electron `app.relaunch()`; web/CLI uses a detached helper.

---

DSH 的**安全无感重启**插件。

## 它解决什么

改完**宿主半**(`index.js` / `lib/*.js`)必须**整进程重启**才生效。这不是洁癖,是实测:

> 回收渲染器进程只会重载外壳。插件确实会重新 `apply`、日志也照打,**但服务请求的那张路由表仍然是更早那次 apply 的** —— 新加的路由一律 404,而返回的是插件自己那句 `not found`,看起来像"路由写错了",其实是"根本没走到新 handler"。

而在此之前 agent 没有重启的手段:只能让用户去点。这个插件把它变成一次工具调用。

## 四道闸("安全"具体是什么)

每一条都对应一次真实踩坑,不是设想出来的风险。

| 闸 | 拒绝条件 | 为什么 |
|---|---|---|
| **重启循环熔断** | 窗口内已重启 ≥ `loopMax`(默认 3 次 / 10 分钟) | 一个起不来的配置会让每次启动都失败,而 agent 看到失败只会想再重启 —— 那是重启风暴,代价是机器一直在 boot |
| **有别的会话在跑一轮** | 除调用方外还有会话处于 `turn/start` 之后未 `turn/end`(除非 `force: true`) | 盲着重启会把别人正在做的事**从中间切断**,而且切断后那一轮的工具调用结果未知 |
| **profile 的 bundle 表解析不了** | 某个 bundle 在 `node_modules` 里不存在、或入口文件不存在;**或者根本找不到 profile 根目录 / 读不到它的 bundle 表**(`checked:false` 一律拒绝) | 重启进一个坏掉的 profile,得到的是**起不来的 app** —— 连报错界面都没有;而"没查到"不等于"检查通过"—— 未知状态应当拒绝,不是绿灯 |
| **拿不到可靠拉起手段** | 桌面端 `electron.app.relaunch` 不可达 | **绝不退化成"杀进程"**:那会把 app 关掉而不拉起来,把可恢复的状态变成不可恢复的 |

任一不过就是**拒绝重启并说明原因**,返回里带 `gate` 与 `why`。拒绝不是失败 —— 那道闸就是为了不让你把环境弄成不好收拾的样子。

## 无感在哪

1. 重启前写**续作标记**(会话 id),重启后把"你已经重启过、请继续"投回**原来那个会话**。
   顺序是刻意的:**先写标记,再拉起**。反过来会在进程先退出时丢掉标记。
2. 续作提示里明确要求:重启前**结果未知**的工具调用不要盲目重试,先核查实际影响。
3. 标记消费掉就删;标记文件损坏时返回空数组而不是抛 —— 重启后的启动不能因为读标记而挂。

## 两条重启路径

| 环境 | 机制 | 为什么不能只用一种 |
|---|---|---|
| **桌面端**(Electron 内嵌宿主) | `app.relaunch()` + 延迟 `app.quit()` | 桌面端宿主的 `webServer` 是 IPC 载体(`app://`),**不开端口** —— 任何"起 helper 等健康检查通过"的做法在这里都不成立 |
| **web / CLI** | 写一个 `detached` helper(`detached:true` + `stdio:'ignore'` + `unref()`),老进程随后 `process.exit(0)` | helper 必须在**父进程死掉之后**继续活着;它等旧 pid 退出 → 用**同一份 `execPath` / `execArgv` / `argv`** 拉起新实例 → 等它活过 10 秒 → **若命令行里推得出 web 端口(`--port` 或默认 3080)再等端口恢复(健康检查)** → 把结果写进审计。健康检查来自 dsh-guardian 编排器(90s 健康检查)与 start-harness.ps1(端口探测 + HTTP HEAD)的同类实践 |

## 装

```jsonc
// profiles/<name>/package.json
{
  "dependencies": { "dsh-safety-restart": "link:/path/to/dsh-safety-restart" },
  "dsh": { "profile": { "bundles": [ "...", "dsh-safety-restart" ] } }
}
```

装完**必须重启一次** app —— 插件自己也要先被加载才谈得上重启。

## 用

Agent 侧(工具):

```
safety_restart({ reason: "改了宿主半的 index.js,让新路由生效" })
safety_restart({ reason: "...", force: true })   // 即使有别的会话在跑一轮也重启
```

人侧:设置 → 插件 → 「dsh-safety-restart」卡片(运行环境、熔断计数、最近一次审计、立即重启)。

HTTP(web 端;**只接受本机请求**,反代/远程访问会被 403):

```
GET  /safety-restart/status
POST /safety-restart/restart   { "reason": "ui" }
```

## 审计与状态文件

都在 `$DSH_HOME`(默认 `~/.dsh`):

| 文件 | 内容 |
|---|---|
| `safety-restart-audit.jsonl` | 只追加。**一次重启算一次**的判定是 `kind:"restart" && phase:"scheduled"`;其余(`helper` / `exit` / `quit` / `refused` / `resume`)都是诊断信息 |
| `safety-restart-resume.json` | 续作标记(待叫醒的会话 id),消费后删除 |
| `safety-restart-helper.mjs` | web 端生成的 helper(每次重写) |
| `safety-restart.json` | 可选配置:`loopWindowMs` / `loopMax` / `delayMs` / `busyStaleMs` |

## 配置

```json
{
  "loopWindowMs": 600000,
  "loopMax": 3,
  "delayMs": 1500,
  "busyStaleMs": 1800000
}
```

`delayMs` 是"发起重启"到"真的退出"之间的等待,留出时间把这次工具结果回传给模型。设成 0 会让结果大概率丢掉。

## 已知限制(如实写,不粉饰)

- **`busy` 只覆盖本进程启动之后的轮次。** 它是按 `session/event` 维护的,更早就在跑的会话不在里面。所以第二道闸是"尽力而为",不是权威快照。超过 `busyStaleMs` 的记录会被当作过期清掉(防止一条漏掉的 `turn/end` 把闸门永久焊死)。
- **bundle 体检是轻量的**:它只证明"包在、入口文件在",**不证明它能被 import 成功**,更不证明客户端半在 slot 契约上是对的。要那一层用 [dsh-guardian](https://github.com/) 的 bundle scan。
- **web 健康检查是"端口恢复",不是"HTTP 200"。** helper 只做 TCP 连接探测(127.0.0.1 上的端口能连上就算恢复)。它证明的是 *web 在监听*,不是 *web 健康* —— 后者需要服务的业务路由(dsh-guardian 探 `/guardian/report` 就是这样)。端口怎么推:`--port <n>` / `--port=<n>` 优先,否则 argv 里有 `web` 子命令默认 3080,推不出来就跳过健康检查退回 10 秒存活检查。**宁可不探测也不探测错误的端口**:新实例其实活着,却因为探错端口误报失败,比不探测更糟。
- **web 端退出是 `process.exit(0)`(非优雅)。** 会话日志是只追加的 jsonl,写到哪算哪;工具结果在 `delayMs` 之前已回传;尾巴真被截了也有续作提示兜。不退就永远重启不成,那才是真的坏。
- **客户端半按 rc 契约写**:`settings.plugin.item` 在 rc.12 是 `list`(要 `id`),更晚的契约是 `keyed`(要 `key`)。所以**两个都传** —— 多传一个用不到的字段不会报错,少传必需的那个才会。整个 `apply` 包在 try/catch 里:契约再漂,结果也只是"没有这张卡片",不会牵连界面。

## 这里的坑都是踩过的

开发过程中端到端测出来、并已修掉的两个:

1. **web 路径最初根本没完成**:helper 起来了、等了 30 秒、然后报 `old pid still alive — giving up`。因为**没有任何人让老进程退出** —— helper 等的是"老进程死掉",而它就那样继续服务着。修法是 helper 起来后老进程自己 `process.exit(0)`。
2. **熔断计数翻倍**:一次重启会在审计里留下多条(`begin`/`scheduled`/`helper`/`quit`),全数进来会让"一次重启动"算成两次 —— 实测第三次就直接熔断了,而配置写的是 3。修法是**只数一条**。

## 自检

```bash
node scripts/run-all.mjs
```

47 条断言,都是"闸门的判定边界":熔断窗口与计数口径、`checked:false` 的诚实降级、标记损坏不抛、生成的 helper 是合法 ESM(含带健康检查端口的变体)、`--port` 推端口的解析规则、会话 id 的三种取值回退、调用方自身的排除。

## License

MIT

Install

dsh plugin --profile web add github:Dayi-Z/dsh-safety-restart

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source