dsh-autorestart 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +457 -41
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/assets/screenshot-3.png +0 -0
- package/assets/screenshot-4.png +0 -0
- package/assets/screenshot-5.png +0 -0
- package/client.js +5973 -3222
- package/cordis.patch.yml +55 -9
- package/docs/README.zh.md +305 -27
- package/index.js +2522 -127
- package/lib/access.js +212 -0
- package/lib/activity.js +124 -0
- package/lib/autostart-advice.js +198 -0
- package/lib/backup.js +131 -4
- package/lib/blockers.js +228 -0
- package/lib/checkpoint.js +147 -0
- package/lib/diagnostics.js +180 -0
- package/lib/health.js +264 -0
- package/lib/host-kind.js +145 -0
- package/lib/lifecycle.js +17 -4
- package/lib/node-runtime.js +144 -0
- package/lib/queued.js +146 -0
- package/lib/restart-verdict.js +327 -0
- package/lib/restart.js +823 -56
- package/lib/sleep.js +46 -49
- package/lib/startup-mode.js +491 -0
- package/lib/supervisor-config.js +735 -0
- package/lib/supervisor-install.js +557 -0
- package/lib/supervisor.js +1276 -0
- package/locale/en.json +247 -60
- package/locale/zh.json +247 -60
- package/package.json +7 -2
- package/screenshots.json +6 -4
- package/scripts/emergency-disable.cmd +27 -0
- package/scripts/emergency-toggle.ps1 +302 -0
- package/scripts/run-tests.ps1 +46 -0
- package/supervisor/supervisor.js +884 -0
package/cordis.patch.yml
CHANGED
|
@@ -25,14 +25,24 @@
|
|
|
25
25
|
relaunchDelaySeconds: 2
|
|
26
26
|
# quiet window (ms) after the last conversation activity before a restart may fire
|
|
27
27
|
settleMs: 2000
|
|
28
|
-
#
|
|
29
|
-
# before the host is really restarted
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
|
|
28
|
+
# RESTART reserve (ms): the extra wait after the LAST conversation
|
|
29
|
+
# finished, before the host is really restarted. A restart is a daily
|
|
30
|
+
# operation, so the default is 0 (no extra wait). A DROPDOWN in the
|
|
31
|
+
# settings page: 0 / 5 / 10 / 20 / 30 / 60 seconds.
|
|
32
|
+
# This plugin can only see the DSH agents — other plugins (a memory
|
|
33
|
+
# plugin that writes its notes a few seconds later, a summary, an MCP
|
|
34
|
+
# callback) queue their work with a timer of their own, and that work
|
|
35
|
+
# is invisible here.
|
|
36
|
+
restartGraceMs: 0
|
|
37
|
+
# The graceful shutdown has NO reserve of its own: the visible
|
|
38
|
+
# countdown (sleepCountdownSeconds) IS the wait before the machine
|
|
39
|
+
# goes off.
|
|
40
|
+
# Upper bound (ms) of a QUEUED inbox message: a message that nobody
|
|
41
|
+
# claims within this window is stuck, so it stops blocking the
|
|
42
|
+
# restart (and the session that holds it is named in the log).
|
|
43
|
+
# Without it a single unclaimed message kept the restart pending for
|
|
44
|
+
# ever. `0` switches the protection off (the old behaviour).
|
|
45
|
+
queuedGraceMs: 60000
|
|
36
46
|
# dsh web: reload the page when the host boot token changes
|
|
37
47
|
autoReloadPage: true
|
|
38
48
|
# hard-block shell commands that would kill/relaunch DSH by hand
|
|
@@ -51,8 +61,44 @@
|
|
|
51
61
|
# Keep at most this many snapshots; older ones are deleted automatically.
|
|
52
62
|
maxBackups: 2
|
|
53
63
|
# How long the detached watcher waits for the relaunched host (ms) before
|
|
54
|
-
# it restores the snapshot
|
|
64
|
+
# it restores the snapshot. The watcher NEVER launches a second copy of
|
|
65
|
+
# the host: it restores the files and writes the report.
|
|
55
66
|
rollbackTimeoutMs: 90000
|
|
67
|
+
# How long the restart command may run (ms) before the plugin stops
|
|
68
|
+
# waiting for it. A command that starts the host in the foreground never
|
|
69
|
+
# returns — that is not a failure, but it is reported as "still running"
|
|
70
|
+
# instead of "restarted".
|
|
71
|
+
restartCommandTimeoutMs: 30000
|
|
72
|
+
# How long ONE restore step may take (ms): the configuration files, then
|
|
73
|
+
# the installed plugin tree. A restore that does not finish in time is
|
|
74
|
+
# given up and the reason is written into the rollback report.
|
|
75
|
+
rollbackCopyTimeoutMs: 120000
|
|
76
|
+
# The self-starting supervisor: who starts this host.
|
|
77
|
+
# auto = nobody decided yet, installed = the supervisor of this plugin,
|
|
78
|
+
# external = your own way (docker, systemd, your own script),
|
|
79
|
+
# off = no automatic start at all.
|
|
80
|
+
supervisorMode: auto
|
|
81
|
+
# Which form of the self-start record was installed. Empty = the
|
|
82
|
+
# default of this platform (a scheduled task on Windows, a systemd user
|
|
83
|
+
# unit on Linux).
|
|
84
|
+
supervisorKind: ''
|
|
85
|
+
# How long the supervisor waits before it starts the host again after it
|
|
86
|
+
# died. The delay DOUBLES with every failed start.
|
|
87
|
+
supervisorRestartDelayMs: 3000
|
|
88
|
+
# The upper bound of that delay: the supervisor never waits longer.
|
|
89
|
+
supervisorRestartDelayMaxMs: 30000
|
|
90
|
+
# How long the supervisor waits for the host to answer its health route
|
|
91
|
+
# before it restores the snapshot.
|
|
92
|
+
supervisorStartTimeoutMs: 90000
|
|
93
|
+
# How long the supervisor waits between asking the host to stop and
|
|
94
|
+
# killing it.
|
|
95
|
+
supervisorKillGraceMs: 15000
|
|
96
|
+
# The port the supervisor waits for to become free before it starts the
|
|
97
|
+
# host (0 = the port of the current instance).
|
|
98
|
+
supervisorPort: 0
|
|
99
|
+
# Whether the one-time question may install the supervisor without
|
|
100
|
+
# asking again.
|
|
101
|
+
supervisorAutoInstall: false
|
|
56
102
|
# 收工关机:Show the graceful-shutdown button at the foot of the left
|
|
57
103
|
# sidebar, next to the Settings row (root scope: it is there even when
|
|
58
104
|
# no conversation is open).
|
package/docs/README.zh.md
CHANGED
|
@@ -30,6 +30,75 @@
|
|
|
30
30
|
| Agent 有一个持久 goal(`create_goal`)跨重启 | goal 的 activation 是进程内的,重启后变回 `disarmed`,自动续跑停止 | 启动时重新 `goals.resume()`,goal round driver 立刻开始下一轮 |
|
|
31
31
|
| Agent 想装/升级插件但"重启必打断" | 只能等或赌 | 调 `continuum_restart`:等所有 agent idle(当前这一轮跑完)再重启,回来自己接着做 |
|
|
32
32
|
|
|
33
|
+
## 三分钟看懂(普通用户读这一节就够)
|
|
34
|
+
|
|
35
|
+
### 重启为什么不会丢活
|
|
36
|
+
|
|
37
|
+
插件把"重启"做成一件**可恢复**的事,顺序是固定的:
|
|
38
|
+
|
|
39
|
+
1. **先把会话状态落盘** —— 每个会话在做什么、哪些 goal 还带着,都先写进磁盘上的检查点;
|
|
40
|
+
2. **排队等所有会话空闲** —— 谁还在跑就等谁,等齐了才动手;
|
|
41
|
+
3. **换新进程** —— 老进程退出,新进程起来(桌面版由应用自己拉起自己;Web 托管端见下一节);
|
|
42
|
+
4. **新进程自检** —— 起来之后核对四项:端口通了没、路由齐了没、插件加载了没、会话恢复完没;
|
|
43
|
+
5. **没起来就回滚** —— 自检没过,就把重启前的快照(配置 + 已装插件)放回去,并且把这次重启的结论如实写在状态里。
|
|
44
|
+
|
|
45
|
+
所以"重启丢活"变成了"重启后接着干":你不用再补一句"继续"。
|
|
46
|
+
|
|
47
|
+
### 两种运行形态,各管各的
|
|
48
|
+
|
|
49
|
+
| | 桌面客户端 | Web 托管端(`dsh web`) |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| 谁负责把它拉起来 | **应用自己**(自己关自己、自己开自己) | 先看"是谁拉起的" |
|
|
52
|
+
| 要不要装系统服务 | **不要**。装了反而会双开:登录时再多开一个 DSH | 只在"没人管它"的时候才可能装 |
|
|
53
|
+
| 已经有人管着它(守护脚本 / systemd / 容器 / pm2 / Windows 计划任务…) | 不适用 | **只优雅退出**,交给那个守护去拉起它(不自己再起一个) |
|
|
54
|
+
| 认不出是谁拉起的 | 不适用 | **什么都不做,也不猜命令** —— 宁可说"我认不出",也不在第一个旁边再开一个 |
|
|
55
|
+
|
|
56
|
+
### 开机自启:插件只给建议,不替你改系统
|
|
57
|
+
|
|
58
|
+
插件**不会**改你的系统配置、**不会**替你注册任何开机项。它只在设置页把"这台机器上怎么做开机自启"讲清楚,要用的命令可以直接复制,要不要用由你决定。
|
|
59
|
+
|
|
60
|
+
| 你的情况 | 推荐做法 | 注意 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| Windows 上的 `dsh web` | 登录时自启:优先用"计划任务"(不需要管理员);这台机器不允许的话,就用"启动文件夹"里的一个 .cmd | 记录里跑的是**真正的 node + 守护脚本**,不是 DSH 客户端本体(否则每次登录会再开一个 DSH) |
|
|
63
|
+
| Linux(不是 WSL) | systemd 用户服务 | 这是**登录后自启**;要**开机自启**(不必先登录)得另外开一次 linger |
|
|
64
|
+
| WSL 里有 systemd | 同上:systemd 用户服务 + linger | 同上 |
|
|
65
|
+
| WSL 里没有 systemd | 这里没有"登录时自启"这回事。两条路:① 在 WSL 内部开启 systemd(改发行版的配置 + 重启子系统);② 让 Windows 侧用计划任务/启动文件夹把 WSL 拉起来(更接近真正的开机自启) | 插件**不会**替你改发行版配置,也不会在 WSL 里推荐容器编排那套做法(WSL 的"编排器"是 Windows 本身) |
|
|
66
|
+
| 容器 / k8s | 由编排器负责(compose 的 `restart: always` 那一类) | 容器里没有"登录时自启" |
|
|
67
|
+
|
|
68
|
+
**"登录后自启"和"开机自启"不是一回事**:前者等你登录了才生效,后者是机器一开就生效(要额外开一次 linger)。插件把这句话和对应的命令一起写在设置页里。
|
|
69
|
+
|
|
70
|
+
### DSH 起不来了
|
|
71
|
+
|
|
72
|
+
双击插件目录里的 `scripts/emergency-disable.cmd` —— 它会把这个插件安全地摘掉并留一份备份;然后重新打开 DSH 就行(细节见下面的「起不来怎么办」)。
|
|
73
|
+
|
|
74
|
+
### 实测状态表(哪些真机验过,哪些只有单元测试)
|
|
75
|
+
|
|
76
|
+
| 启动方式 | 真机验过? | 依据 |
|
|
77
|
+
| --- | --- | --- |
|
|
78
|
+
| 由 shell 守护脚本拉起(`supervise-*.sh` 之类) | **是** | 真机识别为 external,识别依据指到那个脚本进程,并且**拒绝再装第二个守护** |
|
|
79
|
+
| 裸跑:手起(前台) | **部分** | 真机确认它把自己认成"没人管"(mode `bare`,依据是父进程是 bash)并给出了安装计划;**真正的"装 → 跑 → 卸"是在"已有守护"的实例上真机跑通的**,裸跑实例上没做完整装卸 |
|
|
80
|
+
| 裸跑:后台(`nohup` / `setsid`) | **部分** | 真机确认能被认成裸跑(后台启动);**"重拉命令脱离原 shell"这一条只有单元测试**(当时机器上返回的并不是脱离标记) |
|
|
81
|
+
| WSL(有 systemd) | **是** | 真装真卸 |
|
|
82
|
+
| Windows 登录任务 | **部分** | 真机用系统原生方式建 → 查(State=Ready、登录触发器)→ 删(无残留)成功,证明确实不需要管理员;**"被拒后降级到启动文件夹"只有单元测试** |
|
|
83
|
+
| 桌面客户端 | **是** | 真机确认能自己重启回来(有快照与自检记录、没有触发回滚、不会双开);这是客户端自己的事,不在托管端的矩阵里 |
|
|
84
|
+
| pm2 / forever | 否,只有单元测试 | 识别与行为由单元测试钉住 |
|
|
85
|
+
| 容器 / compose | 否,只有单元测试 | 同上 |
|
|
86
|
+
| `npx` / `pnpm dlx` 包装 | 否,只有单元测试 | 同上 |
|
|
87
|
+
| 由别的 DSH 插件拉起 | 否,只有单元测试 | 同上 |
|
|
88
|
+
| tmux / screen / SSH 会话 | 否,只有单元测试 | 同上 |
|
|
89
|
+
| WSL 由 Windows 侧拉起 | 否,只有单元测试 | 同上(认成"认不出",什么都不做) |
|
|
90
|
+
|
|
91
|
+
**未实测 ≠ 不支持**:这些路径的行为由单元测试钉住;「没做完整装卸」「只有单元测试」都不等于不支持。若你的环境被认错,请把状态页里的「启动方式 + 识别依据」贴给我们。
|
|
92
|
+
|
|
93
|
+
### 报告问题
|
|
94
|
+
|
|
95
|
+
设置页最下面有一组「**报告问题**」:
|
|
96
|
+
|
|
97
|
+
- 主按钮「**复制诊断信息并去 GitHub 提问题**」:点一下,诊断信息进剪贴板,同时打开一个**已经填好标题和提纲**的新 issue 页 —— 把剪贴板里的诊断信息粘贴进去就行。
|
|
98
|
+
- 诊断信息里**没有**你的访问令牌,也**没有**任何密钥:它只有插件版本、平台、是不是桌面客户端、启动方式(连同判断依据)、监管器、重启模式,以及上一次重启的结果(失败时还有失败的那几项自检)。
|
|
99
|
+
- 浏览器不让你用剪贴板时(不是安全上下文、或没给权限),插件会把诊断信息显示在一个**可以全选**的文本框里,并提示你手动复制 —— 报告不会因为浏览器说"不"就没了。
|
|
100
|
+
- 没有 GitHub 账号?同一组里有一行小字给出邮箱(252170229@qq.com),外加一个「复制诊断信息」按钮和对应的邮件链接(主题已填好,正文提示粘贴)。
|
|
101
|
+
|
|
33
102
|
## 适合谁 / 不适合谁
|
|
34
103
|
|
|
35
104
|
适合:经常因为装插件、升级、应用崩溃而丢掉正在进行的长任务,希望重启后自动接续的人。
|
|
@@ -61,16 +130,96 @@ session resume on boot, durable-goal re-arm, and an agent-callable idle-aware gr
|
|
|
61
130
|
| 系统提示词 | 一段「重启政策」说明,告诉 agent 怎么请求重启、插件会做什么 |
|
|
62
131
|
| 宿主事件 | `agent/status`(跟踪哪些会话在忙)、`tools/pre-execute`(拦下"手动杀/重启 DSH"的 shell 命令)、启动时的恢复流程 |
|
|
63
132
|
| profile 行 | 一条 bundle 行:`dsh-autorestart` |
|
|
64
|
-
| 写文件 | 只在 `<DSH_HOME>/storages/dsh-autorestart/` 下写:`state.json`(检查点)、`backups/`(重启前快照)、`restart-<代次>.cmd` / `restart-<代次>-watch.ps1`(脱离进程的重启 helper)、`last-rollback.json
|
|
133
|
+
| 写文件 | 只在 `<DSH_HOME>/storages/dsh-autorestart/` 下写:`state.json`(检查点)、`backups/`(重启前快照)、`restart-<代次>.cmd` / `restart-<代次>-watch.ps1`(脱离进程的重启 helper)、`last-rollback.json`(回退报告)、`restart-outcome.json`(插件自己判定的那次重启的结论)、`access.json`(当前实例的带 token 访问链接,权限 `0600`)。唯一会写进 profile 的时机是**回退**:把快照里的 `cordis.patch.yml`、`package.json`、`pnpm-lock.yaml` 和 `node_modules` 拷回去 |
|
|
65
134
|
|
|
66
135
|
## 重启前备份与自动回退
|
|
67
136
|
|
|
68
137
|
- **每次重启前**先给 profile 拍快照:`cordis.patch.yml`、`package.json`、`pnpm-lock.yaml`,以及(默认开启)已安装插件目录 `node_modules`。本机实测:**2.76 秒 / 117.6 MB / 5,562 个文件**。
|
|
69
138
|
- 快照放在 `storages/dsh-autorestart/backups/<时间戳>/`,带 `manifest.json`;**只保留最新 2 份**,写新快照时自动删旧的。
|
|
70
|
-
-
|
|
71
|
-
- 宿主**一直不回应**时:把快照拷回去 → 写 `last-rollback.json`(含"本次重启之后"的崩溃日志尾部,旧日志不会被误当证据)→
|
|
72
|
-
-
|
|
73
|
-
-
|
|
139
|
+
- 看护脚本跑在**宿主进程之外**(所以宿主被杀它也活着),等**新实例**最多 **90 秒**(`rollbackTimeoutMs`):新实例把自己的端口和 boot id 写进 `access.json`,看护脚本据此找到**新实例**的健康端点(桌面版每次 `--port 0` 端口随机,旧端口没有意义)。正常回答就静默退出,**什么都不动**。
|
|
140
|
+
- 宿主**一直不回应**时:把快照拷回去 → 写 `last-rollback.json`(含"本次重启之后"的崩溃日志尾部,旧日志不会被误当证据)→ **不会再拉起第二个宿主**(已经起来的那一个由它自己继续,回退分支绝不 `Start-Process` 第二个 app)。
|
|
141
|
+
- 拷回本身也有**时间上限**(`rollbackCopyTimeoutMs`,默认 120 秒/步):拷不完就放弃,并把原因写进报告。
|
|
142
|
+
- **托管模式(`restartMode: command`)也有同一张安全网**:重启命令通常会把插件自己所在的进程一起杀掉,所以看护脚本在命令**之前**就启动了,由它来确认新实例有没有起来。命令起不来或以非零码退出时,插件**如实报错**(带退出码和输出)并把快照拷回去,绝不静默当成功。
|
|
143
|
+
- 下次启动时 `continuum_status` 会报 `lastRollback`(时间、等了多久、是否回滚、**失败原因**、快照路径、崩溃日志名与尾部摘要)。
|
|
144
|
+
- 开关:`backupOnRestart`(默认 `true`)、`backupIncludePlugins`(默认 `true`,关掉就只备配置文件)、`maxBackups`(默认 `2`)、`rollbackTimeoutMs`(默认 `90000`)、`restartCommandTimeoutMs`(默认 `30000`)、`rollbackCopyTimeoutMs`(默认 `120000`)。
|
|
145
|
+
|
|
146
|
+
### 重启后的访问链接(token 是一次性的)
|
|
147
|
+
|
|
148
|
+
`dsh web` 的 boot token **每个进程都不一样**,重启后浏览器手里那条旧链接会 401,用户就被挡在门外。所以插件把当前实例的访问链接写进 `storages/dsh-autorestart/access.json`(权限 `0600`,里面是密钥),并在 `continuum_status` 与日志里报出来;前端发现手里那条链接过期时,会自己跟到新链接上。
|
|
149
|
+
|
|
150
|
+
### 开机自启:插件自带的守护
|
|
151
|
+
|
|
152
|
+
手动启动的托管实例(`dsh web`)挂掉之后不会自己回来——没人拉起来的重启,就等于要用户自己再启动一次。插件可以装一个**守护**:一个独立的小进程,负责拉起宿主、看住它、它掉了再拉起。
|
|
153
|
+
|
|
154
|
+
- **它是独立进程**:插件活在宿主里,宿主一死插件也没了,所以插件不可能是"把宿主拉回来"的那个东西。守护是随包发布的零依赖 Node 脚本(`supervisor/supervisor.js`),安装时被复制到 `storages/dsh-autorestart/supervisor/`。
|
|
155
|
+
- **它做什么**:用 `supervisor.json` 里的命令拉起宿主 → 等它退出 → 退避(1 秒起,每次翻倍,30 秒封顶;**只有真正起来过一次才清零**)→ 再拉起。宿主完全起不来时,把上次重启的快照恢复回去。
|
|
156
|
+
- **怎么登记自启(都不需要管理员)**:
|
|
157
|
+
- **Windows**:计划任务 `dsh-autorestart-web`(`/SC ONLOGON /RL LIMITED`)。若该机策略不允许普通用户建计划任务,会自动降级到「启动文件夹」里的一个 `.cmd`(同样零权限、同样是"登录时")。
|
|
158
|
+
- **Linux / WSL**:systemd **用户**单元(`~/.config/systemd/user/dsh-autorestart-web.service`)。没有系统服务管理器的机器(容器、没开 systemd 的 WSL)根本没有"开机自启"这回事——插件会明说,并把启动命令交给用户。
|
|
159
|
+
- **每个平台各有一份「开机自启怎么做」的引导**(设置页会显示,连带可复制的命令):
|
|
160
|
+
|
|
161
|
+
| 托管平台 | 插件怎么做 |
|
|
162
|
+
|---|---|
|
|
163
|
+
| **Windows** | 计划任务(`/SC ONLOGON /RL LIMITED`,不要管理员);这台机器拒绝时降级为「启动文件夹」里的 `.cmd`。注册的命令是**真 node** + `supervisor.js`,绝不是 DSH 客户端本体。 |
|
|
164
|
+
| **Linux(非 WSL)** | systemd **用户**服务;并提示 `loginctl enable-linger <user>`——**不开 linger 就只是"登录后自启",不是"开机自启"**。 |
|
|
165
|
+
| **WSL(有 systemd)** | 同样的用户单元 + linger 提示。 |
|
|
166
|
+
| **WSL(无 systemd)** | 这里根本没有"登录时自启":两条路 —— ① 在 WSL 内部开启 systemd(`/etc/wsl.conf` → `[boot] systemd=true` + `wsl --shutdown`;**这是系统配置改动**);② 在 Windows 侧用计划任务/启动文件夹拉起 WSL(`wsl.exe -d <distro> -e <命令>`;**需要 Windows 侧操作,但更接近真正的开机自启**)。WSL 里**永不**建议容器编排那套做法 —— WSL 的编排器是 Windows 本身。 |
|
|
167
|
+
| **容器 / k8s** | 不承诺开机自启:由编排器负责(compose 的 `restart: always`)。 |
|
|
168
|
+
| **认不出** | 不装任何东西、不猜命令:说明原因,让用户自己选。 |
|
|
169
|
+
|
|
170
|
+
只**猜成** WSL(仅凭挂载的 Windows 盘)时,插件**什么都不替你做**:只提示"没把握,建议手动选一条"。
|
|
171
|
+
- **装多少次都只有一个**:任务/单元名是固定的,计划任务用 `/F` 覆盖,守护自己还持一把独占锁——两个守护不可能并存。
|
|
172
|
+
- **装到一半失败不会留残骸**:某一步失败时,已经写下的 unit(或计划任务)会被撤掉,回滚过程也报在步骤列表里——半装的守护会在下次登录拉起一个过期的。
|
|
173
|
+
- **命令名不会被拼两次**:一步里程序名放在 `file`、参数放在 `args`,所以跑的是 `systemctl --user enable --now …`,而不是 `systemctl systemctl …`(那会让 systemd 回一句 "Unknown command verb systemctl")。
|
|
174
|
+
- **随时可以还原**:安装前先把原来的 `restartMode` / `restartCommand` 存下来,卸载时写回去。设置页有「开机自启 → 卸载」一个按钮,手动收尾的命令也印在卡片和日志里;卸载哪一步没做完,会把待办**写下来**(`UNINSTALL-PENDING.json`,放在守护目录**旁边**的 `<stateDir>/` 下,所以写它不会把守护目录又拽回来),而且**只有真的还有残留时**才写。
|
|
175
|
+
- **第一次会问一次**:托管实例第一次出现时问一句"要不要装守护"。卡片写清**会改什么 / 需要什么权限 / 怎么卸**。选「保持我现在的启动方式」什么都不改——插件只记录"你已经自己有守护了"(于是永远不会装第二个)。
|
|
176
|
+
|
|
177
|
+
桌面版**不问**:它自己就能把自己拉起来(`restartMode: app`),问了是噪声。桌面版**也装不了**守护:登录自启记录会去跑 Electron 客户端本体,那等于**每次登录再开一个 DSH**(双开)。所以桌面版上 `plan` / `install` 直接回 `ok: false`、`error: "desktop-manages-itself"`、步骤列表为空;设置页显示 `mode: "app"`(`restartCapable: true`),不会再吓唬用户"重启能力不可用"。
|
|
178
|
+
|
|
179
|
+
守护的启动命令**必须指向一个真正的 Node**:进程本身就是 node 时用它,否则去安装目录里找 `resources/runtime/primary-runtime/dependencies/node/bin/node.exe`,再退到 PATH 里的 `node`。**绝不拿应用 exe 冒充 node**;一个都找不到就拒绝安装并说清原因。
|
|
180
|
+
|
|
181
|
+
### 这个宿主是怎么被拉起来的(启动方式矩阵)
|
|
182
|
+
|
|
183
|
+
Web 端的启动方式有十几种,插件必须**每一种都适配对**。分类器是一个**纯函数**(`lib/startup-mode.js`):输入是父进程链 / 环境标记 / 容器标记 / 平台,输出是 `{mode, kind, evidence, selfReplay, canInstallSupervisor, suggestion}`。
|
|
184
|
+
|
|
185
|
+
| 启动方式 | `mode` | 插件怎么做 |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| 裸终端 `dsh web`(前台) | `bare` | **可以**装自家守护;不装则给出推导命令 + 确认 |
|
|
188
|
+
| `nohup … &` / `setsid` 后台 | `bare` | 同上,但重拉命令**脱离shell**(`setsid nohup …`),不随启动它的 shell 退出而死 |
|
|
189
|
+
| shell 守护脚本(`supervise-*.sh`) | `external` | 只优雅退出(`selfReplay: refused`)——脚本会把它拉回来 |
|
|
190
|
+
| systemd(user / system 单元) | `external` | 只优雅退出(依据:`INVOCATION_ID` 或父进程链) |
|
|
191
|
+
| pm2 / forever | `external` | 只优雅退出 |
|
|
192
|
+
| docker / compose / k8s | `container` | **不承诺开机自启**:给 compose `restart: always` 片段;容器里不装 systemd |
|
|
193
|
+
| Windows 计划任务 / NSSM 服务 | `external` | 只优雅退出 |
|
|
194
|
+
| WSL 里由 Windows 侧拉起 | `unknown` | **什么都不写**:父进程在 WSL 内看不见——建议选「裸终端装自家守护」,或者自己写一条启动命令(可选) |
|
|
195
|
+
| `npx` / `pnpm dlx` 包装 | `bare`(可用)或 `unknown` | `argv[1]` 是解析后的脚本路径就能用;否则拒绝,绝不猜 |
|
|
196
|
+
| 别的 DSH 插件拉起 | `external` | 只优雅退出——**绝不双开** |
|
|
197
|
+
| tmux / screen / SSH 会话 | `bare` | 走「命令重放」分支,且命令**脱离会话**(优雅退出会断开会话) |
|
|
198
|
+
|
|
199
|
+
**比表格更重要的两条规矩**:只有 `bare` 的宿主才允许写自重放命令、才允许装自家守护;`unknown` 一律 `selfReplay: 'unknown'` 并且**什么都不装**——插件宁可说"我认不出",也不在第一个宿主旁边再开一个。
|
|
200
|
+
|
|
201
|
+
设置页会把判定和**依据**一起显示出来(「当前由 … — ancestor:1234: systemd」),让人看得懂插件为什么这么说。
|
|
202
|
+
|
|
203
|
+
### 「保持我现在的启动方式」之后:推导重启命令(E)与干跑确认(B)
|
|
204
|
+
|
|
205
|
+
用户选择保持现状时,插件仍然可以写下一条"能把它拉起来"的命令——但**只能靠推导,绝不猜**。猜错的命令比没有命令更糟:它看起来像重启成功了,实际留下一个起不来的宿主。
|
|
206
|
+
|
|
207
|
+
候选命令来自进程自己知道的事实:`[process.execPath, ...process.execArgv, ...process.argv.slice(1)]` 加上工作目录。**优先级**决定接下来怎么办:
|
|
208
|
+
|
|
209
|
+
| 级 | 情形 | 写什么 |
|
|
210
|
+
|---|---|---|
|
|
211
|
+
| **E0** | 是插件自己的守护把这个实例拉起来的 | **什么都不写**——重启直接问守护(插件先写回滚请求,让守护去杀进程、重新拉起)。 |
|
|
212
|
+
| **E1** | 有**外部**监管器(systemd / 用户自己的脚本 / pm2) | **不写**自重放命令:`restartCommand` 变成**优雅退出**(`kill -TERM <pid>` / `taskkill /PID <pid>`),因为是那个监管器负责再拉起我们。装第二个守护会抢同一个端口。 |
|
|
213
|
+
| **E2** | 没有守护、也没有 init(容器) | 给出命令 + 一段 compose 片段(`restart: always`),**不承诺**开机自启。 |
|
|
214
|
+
| **E3** | 没有守护、有 init(Windows / systemd 可用) | 推导出的命令——但**要用户确认干跑之后**才写。 |
|
|
215
|
+
|
|
216
|
+
**拒绝**的情形(每种都有代码,也都指出路):桌面版(它自己会重启)、没有 web 服务端的宿主、读不回命令行、已有外部监管器、没有 init 的容器、以及**端口每次都变**(浏览器的 cookie 会跟着 authority 一起死——端口必须固定)。
|
|
217
|
+
|
|
218
|
+
**干跑(B)是一个承诺**:命令、工作目录、端口、健康检查 URL 在**写任何东西之前**原样展示。只有明确的确认才会把命令写进 profile 配置;写之前的原值被记下来,设置页一键就能改回去。
|
|
219
|
+
|
|
220
|
+
- 干跑:`POST /__dsh_autorestart/supervisor` 带 `{ "action": "dry-run" }`(或工具 `continuum_supervisor` 的 `action: "dry-run"`)——**什么都不写**。
|
|
221
|
+
- 确认:`{ "action": "apply", "confirmed": true }`——没有 `confirmed` 宿主回 `400 confirmation-required`,同样什么都不写。
|
|
222
|
+
- 撤销:`{ "action": "revert", "confirmed": true }`——把原来的 `restartMode` / `restartCommand` 写回去。
|
|
74
223
|
|
|
75
224
|
### 首次安装只问一次:备份要不要连已装插件一起备
|
|
76
225
|
|
|
@@ -137,7 +286,7 @@ agent/status(running) ──► 写 checkpoint(<DSH_HOME>\storages\dsh-autores
|
|
|
137
286
|
|
|
138
287
|
## 安装
|
|
139
288
|
|
|
140
|
-
当前版本:**0.
|
|
289
|
+
当前版本:**0.7.0**。
|
|
141
290
|
|
|
142
291
|
```powershell
|
|
143
292
|
# 在 dsh 里(或对 Agent 说):把工作区里的这个包安装为 profile bundle
|
|
@@ -148,8 +297,23 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
|
|
|
148
297
|
|
|
149
298
|
安装后行 id 为 `dsh-autorestart`。本机已经装好并处于 `active`。
|
|
150
299
|
|
|
300
|
+
## 起不来怎么办(装了插件之后 DSH 打不开)
|
|
301
|
+
|
|
302
|
+
**不用等 DSH 跑起来**就能把这个插件摘掉 —— 插件目录里带了一个一键开关:
|
|
303
|
+
|
|
304
|
+
1. **双击 `scripts\emergency-disable.cmd`** —— 弹出的黑窗口里会写清楚做了什么,按任意键关掉;
|
|
305
|
+
2. **重启 DSH**(关掉再打开)——这次没有这个插件,DSH 就起得来;
|
|
306
|
+
3. 以后想再试它:在同一个目录里按住 Shift 右键 →「在终端中打开」,运行 `powershell -NoProfile -ExecutionPolicy Bypass -File .\emergency-toggle.ps1 -Action enable`,然后再重启 DSH。
|
|
307
|
+
|
|
308
|
+
它只动**一个文件**里**一个数组**:profile 的 `package.json` 里的 `dsh.profile.bundles`(就是「插件启用列表」那几行)。写之前先备份成 `package.json.bak-emergency-<时间戳>`,写完重新解析一遍 JSON 校验;校验不过就用备份回滚,并如实报错。**重复运行没有副作用**:本来就没启用时只会告诉你「已经禁用」,不会失败。
|
|
309
|
+
|
|
151
310
|
## 配置
|
|
152
311
|
|
|
312
|
+
设置页里关于「备份」和「启动方式」的两块,先说清楚它们看的是什么:
|
|
313
|
+
|
|
314
|
+
- **现在备份了什么**(在两个备份勾选的下面):宿主真的去读 `<DSH_HOME>/storages/dsh-autorestart/backups/`,所以份数、每份的**本地时间**、大小(MB)、有没有含已装插件、以及存放路径都是真的。只有目录元信息被读,备份里的内容**不遍历**(一份就可能 150 MB)。没有备份时写的是「还没有备份;每次重启前会自动生成一份」。
|
|
315
|
+
- **启动方式**(Web 托管端才有):默认只给**一行大白话**(例如「我记下的启动方式:由你的脚本拉起(重启交给它,我不插手)」)加两个小动作(重新检测 / 撤销);命令原文收在**「技术细节」**折叠里,默认收起,展开才出现。**桌面客户端这一整块不渲染** —— 它自己拉起自己,也不该写 `restartCommand`。宿主说不清是哪一端时,宁可整块不显示。
|
|
316
|
+
|
|
153
317
|
所有键都有默认值,写在 `cordis.patch.yml` 的 insert 行里;要覆盖就在 profile 的
|
|
154
318
|
`~/.dsh/profiles/<profile>/cordis.patch.yml` 里加同名 `- id: dsh-autorestart` 行(**整段替换 config**)。
|
|
155
319
|
插件同时导出真实的 `Config` schema(schemastery),所以 Plugin Manager / 配置编辑器里能看到键名、类型与默认值
|
|
@@ -172,7 +336,8 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
|
|
|
172
336
|
| `restartCommand` | `''` | 自定义重启命令,支持 `{pid}` `{ppid}` `{app}` 占位符 |
|
|
173
337
|
| `relaunchDelaySeconds` | `2` | 脱离的 helper 在杀掉应用**之前**等几秒(`ping -n 3`,约 2 秒) |
|
|
174
338
|
| `settleMs` | `2000` | 静默窗口(ms):最后一次会话活动之后,宿主要安静这么久,重启才允许开始 |
|
|
175
|
-
| `restartGraceMs` | `
|
|
339
|
+
| `restartGraceMs` | `0` | **重启前等待**(ms):最后一个会话收工后,再等这么久才真正重启——**下拉框:0 / 5 / 10 / 20 / 30 / 60 秒**,默认 0(不额外等)。重启是日常操作,所以默认不预留 |
|
|
340
|
+
| `queuedGraceMs` | `60000` | **排队消息的上限**(ms):一条没人认领的 inbox 消息最多挡多久的重启;超过就算卡住,不再挡路(并在日志里点名那个会话)。`0` = 没有上限(0.6.2 之前的行为)。**正在跑的会话在任何设置下都照样挡路** |
|
|
176
341
|
| `autoReloadPage` | `true` | `dsh web`:host 重启(bootId 变化)后让页面自动 reload |
|
|
177
342
|
| `blockManualRestart` | `true` | 硬拦截 agent 用 shell 命令手动杀掉/重启 DSH,并引导到 `continuum_restart` |
|
|
178
343
|
| `healthPath` | `/__dsh_autorestart/health` | 健康检查路由(返回 bootId/pid/phase/自检标志) |
|
|
@@ -180,22 +345,75 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
|
|
|
180
345
|
| `backupOnRestart` | `true` | 每次重启前给 profile 拍快照;重启后起不来时能自动回退 |
|
|
181
346
|
| `backupIncludePlugins` | `true` | 快照里是否带上已安装插件目录(本机约 117 MB、多约 3 秒);关掉就只备配置文件 |
|
|
182
347
|
| `maxBackups` | `2` | 保留几份快照;写新的时自动删旧的 |
|
|
183
|
-
| `rollbackTimeoutMs` | `90000` |
|
|
348
|
+
| `rollbackTimeoutMs` | `90000` | 看护脚本等新宿主多久(毫秒),超时就回退快照(**不会再拉起第二个宿主**) |
|
|
349
|
+
| `restartCommandTimeoutMs` | `30000` | 重启命令最多让插件等多久(毫秒)。前台跑着宿主的命令永远不返回——那会报「还在运行」,不算失败 |
|
|
350
|
+
| `rollbackCopyTimeoutMs` | `120000` | 拷回快照的**每一步**最多花多久(毫秒):拷不完就放弃,并把原因写进回退报告 |
|
|
184
351
|
| `showSleepButton` | `true` | 收工关机的月亮按钮是否显示在左侧栏最底部、设置按钮旁边 |
|
|
185
|
-
| `sleepCountdownSeconds` | `20` |
|
|
352
|
+
| `sleepCountdownSeconds` | `20` | **关机倒数秒数**:收工关机在真正关机前倒数几秒——**下拉框:0 / 10 / 20 / 30 / 60 秒**,默认 20;`0` 表示**不倒数、立即关机**(仍会先等所有会话收工)。**这段看得见的倒数就是关机前的那段等待** —— 关机没有第二个看不见的预留 |
|
|
186
353
|
| `sleepCommand` | `''` | 收工关机的自定义关机命令;留空 = 自动识别宿主(Windows 用 `cmd.exe /c …`,其它平台用 `/bin/sh -c …`) |
|
|
187
354
|
|
|
188
355
|
### 为什么收工后还要多等几秒
|
|
189
356
|
|
|
190
357
|
**我们只看得到 DSH 的会话。** 这个插件知道的是:某个会话还在不在跑、有没有排队的 inbox 消息、最后一个是什么时候闲下来的。它**看不到别的插件用定时器排队的活** —— 比如记忆插件在会话结束后过几秒才把这次对话写进记忆、比如一次总结、一次 MCP 回调、一次日志落盘。对我们来说这些活是不存在的,所以「所有 agent 都闲了」并不等于「现在真的没有事情在发生」。
|
|
191
358
|
|
|
192
|
-
|
|
359
|
+
**只有一个预留窗口,给重启。** 重启没有倒数环节:会话一收工就该赶紧重启。`restartGraceMs` 是留给别的插件延迟落盘的那一小段等待 —— 也是**唯一**的一个:
|
|
193
360
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
361
|
+
| 配置 | 管什么 | 默认 | 档位 |
|
|
362
|
+
| --- | --- | --- | --- |
|
|
363
|
+
| `restartGraceMs` | **重启**前等待 | `0`(不额外等) | 0 / 5 / 10 / 20 / 30 / 60 秒 |
|
|
364
|
+
| `sleepCountdownSeconds` | **关机**(看得见的)倒数 | `20`(20 秒) | 0 / 10 / 20 / 30 / 60 秒 |
|
|
365
|
+
|
|
366
|
+
- **重启闸门只听 `restartGraceMs`**:它等的是 `max(settleMs, restartGraceMs)` 的安静。默认(0)就是只等静默窗口本身 —— 会话一收工就重启。
|
|
367
|
+
- **收工关机没有看不见的预留**:关机前的那段等待**就是屏幕上的那段倒数** —— 秒数看得见,用户可以盯着它、也可以点「不关了」取消。再加一个看不见的窗口只是把同一件事说两遍,所以没有了:所有会话一收工,倒数立刻开始。
|
|
368
|
+
- **等待期间又有活,重新计时**:预留永远跟着「最后一次活动」走 —— 会话又开始干活,就从那一刻重新等。
|
|
369
|
+
- **在哪改**:设置 → 自动重启与关机,分成两个带标题的框 ——「重启相关设置」(重启前等待 + 两个备份开关)在前,「关机相关设置」(关机倒数秒数)在后。
|
|
370
|
+
- **怎么排障**:状态接口都带着它们 —— `GET /health?state=1`、`GET /health?gate=1`、`continuum_status` 报 `graceMs`(重启预留)和 `effectiveSettleMs`(闸门真正在等的窗口);`GET /sleep` 报 `restartGraceMs` 和 `countdownSeconds`(正在走的倒数);都还带 `quietForMs`(已经安静了多久)。
|
|
371
|
+
|
|
372
|
+
### 为什么「排队的消息」不能永远挡着重启
|
|
373
|
+
|
|
374
|
+
**死锁是怎么来的。** 一个会话的 inbox 里躺着一条**没人认领**的消息时,它在重启闸门眼里就算「忙」。消息马上要被领走时这是对的 —— agent 一两秒就来领了;但**没人会领**的时候就是死锁。真实现场就是:UI 里新开的一个**空会话**(一个轮次都没跑过),inbox 里有条没人认领的消息 → 闸门永远关着 → 硬期限到了也只是记一条告警 → **重启永远不触发**。
|
|
375
|
+
|
|
376
|
+
**上限就是为这件事留的。** `queuedGraceMs`(默认 **60000** 毫秒)是一条没人认领的消息最多能挡多久的重启:
|
|
377
|
+
|
|
378
|
+
- **窗口内被认领 → 照样挡**:正常情况完全没变,agent 把消息领走,重启继续等它。
|
|
379
|
+
- **超过窗口还没人领 → 卡住了**:它**不再挡路**,日志里点名那个占着队列的会话(`the queued message of session <id> has been waiting 61s and nobody claimed it; it no longer blocks the restart`),重启照常往下走。
|
|
380
|
+
- **正在跑的会话在任何设置下都照样挡**:这个上限只管**排队**的活,**在跑的轮次永远不放宽**,也没有任何配置能让插件去杀掉一个正在跑的会话。
|
|
381
|
+
- **计时跟着活走**:会话开始干活 → 排队计时清掉;之后再排队 → 从零重新计。
|
|
382
|
+
- **`0` 就是「和以前一样」**:`queuedGraceMs: 0` 时,没人认领的消息永远挡着重启,和 0.6.2 之前完全一致。
|
|
383
|
+
- **在哪看**:`GET /health?state=1`、`GET /health?gate=1`、`continuum_status` 都会报 `queuedGraceMs`。
|
|
384
|
+
|
|
385
|
+
### 长期等待的时候,用户看到什么
|
|
386
|
+
|
|
387
|
+
硬期限(`deferHardMs`)到了、还有会话在跑的时候,插件**不再装作「一切正常,只是在等一会儿」**,但也**不会放弃**:
|
|
388
|
+
|
|
389
|
+
- **请求留着**:待重启仍然是待重启,请求方的会话仍然记在检查点里,闸门一 quiet 就照常重启。**不会在用户背后把请求悄悄丢掉**(不 cancel、不 releaseRequesters)。
|
|
390
|
+
- **等待变得看得见**:日志每分钟最多一条(不是每 3 秒刷屏)——「已等待 17 分钟,XXX 一直占着;可以取消或等它收工」;`GET /health?state=1` 里多一个 `longWait` 对象(`since` / `reason` / `blockedBy`)。
|
|
391
|
+
- **气泡里也说一句**:待重启的胶囊多显示一行「已等 17 分:XXX 一直占着」(英文:`waiting for 17m: XXX keeps holding it`)。
|
|
392
|
+
|
|
393
|
+
**不会**为了重启去强杀任何会话:`forceRestartWhenBusy` 默认仍然是关的,这个插件也不会去打开它。
|
|
394
|
+
|
|
395
|
+
### 安静窗口到底在量什么
|
|
396
|
+
|
|
397
|
+
重启闸门有两层:**没有会话在忙** + **一段安静窗口**(`max(settleMs, restartGraceMs)`)。
|
|
398
|
+
|
|
399
|
+
安静窗口只回答一个问题:**距上一次「真的有活」过了多久。** 它从下面两个标记里**较晚的那个**开始算:
|
|
400
|
+
|
|
401
|
+
- **最后一次真正的活动信号** —— 一轮开始或结束(`agent/status`)、一条消息被排队(`agent/inbox/inserted`)、或者一个说明「确实在干活」的会话事件(消息、工具调用、轮次);
|
|
402
|
+
- **最后一次看见会话在忙** —— 正在跑的轮次,或者排队的消息(`busyAgents()`)。
|
|
403
|
+
|
|
404
|
+
**例行的会话事件不算活动。** 宿主在界面轮询/状态同步时会持续产生会话事件(标题、投影、请求上下文、已结算的任务),实测每 2~3 秒一个。以前这些都算活动,于是安静窗口每两三秒就被清零 —— **只要窗口比事件间隔长,它就永远凑不满,重启永远不会触发**(这就是 0.6.0 把宽限期从 2 秒抬到 10 秒之后「昨晚正常、今天不动」的原因)。
|
|
405
|
+
|
|
406
|
+
这条规则是**单边**的:**读不出类型的事件一律不算活动** —— 拿不准的时候宁可早重启,也不要永远不重启。
|
|
407
|
+
|
|
408
|
+
**正在跑的会话永远挡路。** 安静窗口只决定「什么时候可以重启」;「有没有会话在忙」是硬保证,任何配置都放松不了它。
|
|
409
|
+
|
|
410
|
+
**重启不动的时候去哪儿看。** 状态接口会把闸门的原因和阻塞者一起报出来(`GET /health?state=1` 里的 `reason`:`conversation-active` = 有会话在忙,`settling` = 只是安静窗口还没凑满,`quiet` = 可以走了);待重启期间日志里每分钟会写一条诊断行:
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
restart gate: action=wait reason=agents-busy-within-soft-window busyCount=1 quietForMs=1432 effectiveSettleMs=10000
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
`action` 是这次决策做了什么(`wait` / `restart` / `long-wait` …),`reason` 是为什么,`busyCount` 是几个会话在挡,`quietForMs` 是已经安静了多久,`effectiveSettleMs` 是要凑满的窗口。
|
|
199
417
|
|
|
200
418
|
## Agent 工具与"它怎么知道该用插件"
|
|
201
419
|
|
|
@@ -206,7 +424,7 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
|
|
|
206
424
|
| `continuum_recover_now` | **立刻重跑恢复**(不用等下一次重启):重试卡住的会话,返回每个会话的动作 + 原因。参数 `sessionId`(省略 = 所有待恢复会话) |
|
|
207
425
|
| `continuum_handoff` | **会话接力**:把一个已经超出模型 context window 的会话交给一个**全新的会话**继续(带去 goal 目标、检查点里记录的接手原因和一段有界的对话尾部),旧会话保留成存档、goal 被解除。参数 `sessionId`(省略 = 调用方自己的会话)/ `maxChars`(默认 `20000`) |
|
|
208
426
|
| `continuum_status` | 只读:phase、generation、是否有待重启、检查点路径、识别的重启环境、被记录的会话、每个阻塞会话的阻塞原因、被停泊的 goal |
|
|
209
|
-
| `continuum_cancel_restart` |
|
|
427
|
+
| `continuum_cancel_restart` | 撤回待重启请求。默认**只撤调用方自己那一份**(`scope: "mine"`),别的会话排队的重启不受影响;`scope: "all"` 才是"所有人都别重启了"。参数 `scope`(`mine`(默认)/ `all`)/ `reason`(写进日志) |
|
|
210
428
|
|
|
211
429
|
除了工具本身(模型在工具表里能看到名字/描述),插件还有两层保证:
|
|
212
430
|
|
|
@@ -232,6 +450,46 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
|
|
|
232
450
|
所以**不需要改 AGENTS.md**——这是 Host 插件钩子(工具注册 + system prompt section + tools.guard),对 profile 内所有 agent 生效;
|
|
233
451
|
你当然也可以额外在 AGENTS.md 里写一条团队规范,但它只是重复。
|
|
234
452
|
|
|
453
|
+
### 取消重启:只撤自己 vs 全撤
|
|
454
|
+
|
|
455
|
+
重启请求是**合并**的:好几个会话可以同时请求同一次重启,插件把每个请求者都记在 `restart.requesters` 里,
|
|
456
|
+
重启成功后**一起**被唤醒。所以"取消"必须说清楚**撤的是谁的请求**:
|
|
457
|
+
|
|
458
|
+
| scope | 会发生什么 |
|
|
459
|
+
| --- | --- |
|
|
460
|
+
| `mine`(默认) | 只撤掉**调用方自己**那一份。别的会话还在等这次重启 → 重启请求**保留**,`restart.bySessionId` 改指向剩下的第一个请求者;调用方是**最后一个**请求者时,重启才真的消失:phase 回到 `idle`,这个会话被释放。 |
|
|
461
|
+
| `all` | 为**所有人**取消:所有请求者都被释放,待重启请求消失。也就是"所有人都别重启了"。 |
|
|
462
|
+
|
|
463
|
+
有两种调用会被**拒绝**,而且**不改动任何状态**——"顺手全撤"永远不是兜底行为:
|
|
464
|
+
|
|
465
|
+
- 不知道调用方是哪个会话(调用时没有会话)→ 拒绝;
|
|
466
|
+
- 调用方**并没有**请求这次重启(只是在别人的会话名下)→ 拒绝。
|
|
467
|
+
|
|
468
|
+
工具会回报结果:`cancelled`(重启请求本身是否真的没了)、`remainingRequesters`(还有几个会话在等)、
|
|
469
|
+
`scope`、`phase`。例如只撤自己、别人的请求还在时:
|
|
470
|
+
|
|
471
|
+
```json
|
|
472
|
+
{ "ok": true, "cancelled": false, "scope": "mine", "remainingRequesters": 1, "sessionId": "sess-a", "phase": "deferred" }
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### 小胶囊:点它看详情,也从这儿自己撤掉
|
|
476
|
+
|
|
477
|
+
输入框那排的小胶囊只有 22px 高、一行宽,所以它**只放数字**(`待重启 · 已等 16 分钟 · 3 项任务在跑`)——
|
|
478
|
+
不放名字,也不放任何"双击可以取消"之类的提示(没地方放)。**单击**它(或聚焦后按 Enter / 空格)会在上方
|
|
479
|
+
弹出一个小面板:
|
|
480
|
+
|
|
481
|
+
- 这次重启还在等几个任务结束;
|
|
482
|
+
- 还有几个**别的**会话也在等同一次重启;
|
|
483
|
+
- 一个按钮:「取消我的这次重启」。
|
|
484
|
+
|
|
485
|
+
按钮按下去弹出的是**确认框**(没有时限,也不倒计时):「取消你的这次重启?」,并说清现在取消意味着什么
|
|
486
|
+
(只有你,还是你退出、别人照样重启)。**只有确认框里的「确认取消」才真的发请求**;按 Esc、点面板外面、
|
|
487
|
+
或者按「再想想」都只是关掉,什么都不做。
|
|
488
|
+
|
|
489
|
+
这个请求只撤**本会话自己**那一份(`POST /__dsh_autorestart/cancel`,body 是
|
|
490
|
+
`{ "sessionId": "…", "scope": "mine" }`):别的会话照样重启。面板里**没有**"全撤"——那种操作只留给
|
|
491
|
+
工具显式调用(`continuum_cancel_restart` 带 `scope: "all"`)。
|
|
492
|
+
|
|
235
493
|
## 收工关机(Graceful shutdown)
|
|
236
494
|
|
|
237
495
|
**它干什么。** 左侧栏最底部一个月亮按钮:点一下 → 等**所有**会话都收工 → 屏幕上倒数(一个巨大的数字 + 一只蓝色小鲸鱼,每秒换一句中文)→ **关掉整台电脑**。倒数期间随时可以取消:卡片上有按钮,月亮按钮本身在关机期间也变成了"再点一下 = 取消"。倒数期间要是有会话又开始干活,倒计时会退回"等待",**绝不关机**。
|
|
@@ -240,7 +498,7 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
|
|
|
240
498
|
|
|
241
499
|
**倒计时不遮界面。** 倒数是角落里的一张**浮动卡片**,不是全屏遮罩:它不压暗页面、不锁滚动、也不吃掉任何点击——卡片外面照常能读正文、能滚动、能点侧栏切会话,卡片自己**只有那个「算了,别关」按钮能点**。切换会话或切换页面也**不会打断或重置**倒数:秒数来自宿主的轮询,不是来自当前打开的那个视图。
|
|
242
500
|
|
|
243
|
-
**倒数多长由设置分区决定。** `sleepCountdownSeconds` 是一个**下拉框**:0 / 10 / 20 / 30 / 60 秒,默认 20 秒。`0` = **不倒数、立即关机**:所有会话一收工、闸门一开就直接关机(忙着的会话照样挡住它),屏幕上**不会出现倒计时卡片**。配置里还留着下拉框之外的老数值(15、45…)时,会自动吸附到最近的一档(15→20、45→60
|
|
501
|
+
**倒数多长由设置分区决定。** `sleepCountdownSeconds` 是一个**下拉框**:0 / 10 / 20 / 30 / 60 秒,默认 20 秒。`0` = **不倒数、立即关机**:所有会话一收工、闸门一开就直接关机(忙着的会话照样挡住它),屏幕上**不会出现倒计时卡片**。配置里还留着下拉框之外的老数值(15、45…)时,会自动吸附到最近的一档(15→20、45→60),不会把插件弄坏。这个下拉框在**设置 → 自动重启与关机 →「关机相关设置」**里。**这段看得见的倒数就是关机前的那段等待** —— 关机没有第二个看不见的预留。
|
|
244
502
|
|
|
245
503
|
**点一下就够了。** 月亮按钮按下就武装:宿主等所有忙会话收工,在角落里倒数 **20 秒**(浮动卡片,不遮挡界面),然后关掉整台机器。**再点一下**(或点卡片上的「算了,别关」)就取消。除此之外什么都不用用户做:没有确认框、不用点第二次。
|
|
246
504
|
|
|
@@ -255,7 +513,7 @@ dsh plugin --profile desktop add C:\path\to\dsh-autorestart
|
|
|
255
513
|
|
|
256
514
|
**意图存在哪。** 检查点文件(`<DSH_HOME>/storages/dsh-autorestart/state.json`)里的 `sleep: { armed, armedAt, countdownSeconds }`。机器关掉、关机命令没跑起来、或者你点了「不关了」,它都会被清掉。**记录坏掉就直接丢弃** —— 宁可让你再点一次,也不能因为半截文件让机器永远自己关自己。
|
|
257
515
|
|
|
258
|
-
**排障用。** `GET /sleep` 会报 `armedAt`(什么时候点的关机),旁边还有 `phase`、`blockers`、`
|
|
516
|
+
**排障用。** `GET /sleep` 会报 `armedAt`(什么时候点的关机),旁边还有 `phase`、`blockers`、`countdownSeconds`(正在走的倒数)和 `restartGraceMs`(重启前等待)。
|
|
259
517
|
|
|
260
518
|
### 故障排查:点了没关机
|
|
261
519
|
|
|
@@ -278,7 +536,7 @@ GET /__dsh_autorestart/sleep
|
|
|
278
536
|
|
|
279
537
|
1. **还有任务在跑。** `blockers[]` 列的就是它们,看 `kind`:`session` 是会话,`subagent` 是某个会话派出去的子智能体。**子智能体也算在干活,这是故意的** —— 否则重启会把它掐断。**怎么办:** 等它干完,或者把那个会话停掉;列表会自己变短。
|
|
280
538
|
2. **有待执行的重启。** `kind` 是 `restart-pending`(`label`/`name` 都是 `待执行的重启`)。这是正常排队,不是故障:先重启,会话跟着回来,完事之后倒数自动接着走。**怎么办:** 不用管,等重启完即可(也可以用 `continuum_cancel_restart` 把这次重启取消掉)。
|
|
281
|
-
3.
|
|
539
|
+
3. **刚收工,倒数还没开始。** 关机**没有看不见的预留**:所有会话一收工,倒数立刻开始。**你会看到:** `blockers[]` 空了、`phase` 还是 `waiting`,说明会话刚刚才闲下来,这一轮 tick 还没把机器切到 `countdown`。**怎么办:** 不用管,下一拍就进倒数了;真要有落盘的活要等,把「关机倒数秒数」调大就行。(重启有它自己的窗口 —— `restartGraceMs`,默认不预留 —— 两个互不影响。)
|
|
282
540
|
4. **倒数期间又有会话开始干活。** 倒数退回「等待」,`remainingMs` 变回满值。**你会看到:** 消息里说又有谁开始干活了,`blockers[]` 列着是谁。**怎么办:** 等他们干完。
|
|
283
541
|
5. **想看「到底等谁」。** `continuum_status` 的 `blockedBy[]` 和 `GET /sleep` 的 `blockers[]` 是**同一份数据**(前者给 agent 看,后者给脚本看)。每条都有 `kind`(它是什么)、`label`(会话标题)、`name`(完整称呼,比如「写代码的会话的子智能体」)。标题解析不到时 `label` 是 `null`、`name` 是空字符串 —— 这时候等待那句话就只说「等 3 个任务收工」,不带名字列表。
|
|
284
542
|
6. **彻底不想关了。** 等待卡片上的取消按钮(或者再点一次月亮按钮,或者 `POST /sleep {"action":"cancel"}`)会把这个意图彻底清掉:`armed` 变回 `false`,`armedAt` 变回 `null`。
|
|
@@ -311,17 +569,25 @@ WSL 的判定依据:`WSL_DISTRO_NAME`、`WSL_INTEROP`、`/proc/version` 里出
|
|
|
311
569
|
| 键 | 默认 | 说明 |
|
|
312
570
|
| --- | --- | --- |
|
|
313
571
|
| `showSleepButton` | `true` | 左侧栏最底部(设置按钮旁边)的月亮按钮是否显示 |
|
|
314
|
-
| `sleepCountdownSeconds` | `20` | 真正关机前倒数几秒——**下拉框:0 / 10 / 20 / 30 / 60 秒**,默认 20;`0` =
|
|
572
|
+
| `sleepCountdownSeconds` | `20` | 真正关机前倒数几秒——**下拉框:0 / 10 / 20 / 30 / 60 秒**,默认 20;`0` = 不倒数、立即关机(仍会先等会话收工)。**这段看得见的倒数就是关机前的那段等待** |
|
|
315
573
|
| `sleepCommand` | `''` | 自定义关机命令;留空 = 自动识别宿主,填了就用这条命令代替(Windows 走 `cmd.exe /c …`,其它平台走 `/bin/sh -c …`) |
|
|
316
574
|
|
|
317
|
-
**设置分区(设置 →
|
|
575
|
+
**设置分区(设置 → 自动重启与关机)分成两个带标题的框。** 这个分区是设置页侧栏里的**独立一栏**(排在「Agent预设」下面),点进去直接就是这两个框——不用先去插件详情页,也不用再展开任何一层。**「重启相关设置」在前,「关机相关设置」在后。**
|
|
576
|
+
|
|
577
|
+
**「重启相关设置」**
|
|
318
578
|
|
|
319
579
|
| 控件 | 配置键 | 说明 |
|
|
320
580
|
| --- | --- | --- |
|
|
321
|
-
|
|
|
581
|
+
| 重启前等待(下拉) | `restartGraceMs` | 0 / 5 / 10 / 20 / 30 / 60 秒,默认 0(不额外等) |
|
|
322
582
|
| 重启前备份(开关) | `backupOnRestart` | 关掉就完全不做重启前快照 |
|
|
323
583
|
| 备份包含已安装插件(开关) | `backupIncludePlugins` | 关掉后只备份 DSH 自身状态,不含 profile 里的插件目录;**「重启前备份」关掉时它置灰** |
|
|
324
584
|
|
|
585
|
+
**「关机相关设置」**
|
|
586
|
+
|
|
587
|
+
| 控件 | 配置键 | 说明 |
|
|
588
|
+
| --- | --- | --- |
|
|
589
|
+
| 关机倒数秒数(下拉) | `sleepCountdownSeconds` | 0 / 10 / 20 / 30 / 60 秒;`0` = 不倒数、立即关机 |
|
|
590
|
+
|
|
325
591
|
改完立刻写进 profile 配置(走插件自己的 choice 路由,用官方 `configEditor` Service),并且**从宿主回读**,所以分区里永远不会出现宿主没接受的值。**非法值一律 400、一个字节都不写。**
|
|
326
592
|
|
|
327
593
|
**首次安装会问一次「按钮要不要留着」。** 答完写进 profile 配置的 `showSleepButton` 键(用官方 `configEditor` Service,和备份范围那次问询同一条路),所以设置分区和问询看到的永远是同一个值。
|
|
@@ -330,6 +596,10 @@ WSL 的判定依据:`WSL_DISTRO_NAME`、`WSL_INTEROP`、`/proc/version` 里出
|
|
|
330
596
|
|
|
331
597
|
**给代码看的状态字段。** `GET <healthPath 前缀>/sleep`(默认 `/__dsh_autorestart/sleep`)会回答阶段、倒数和"要关的是哪台机器"。其中几个字段是给**程序**看的、不是给人看的:`host.kind` 是机器类别(`windows` / `wsl` / `linux` / `macos` / `unsupported`;如果平台没认出来、但你配了 `sleepCommand`,则是 `custom`);`host.suspect` 为 `true` 表示这台机器**只凭挂着 Windows 盘**才被认成 WSL(是佐证,不是铁证);`lastRun.dryRun` 表示上一次是**试运行**,什么都没真关;`backupOnRestart` / `backupIncludePlugins` 报告重启前备份的当前范围。
|
|
332
598
|
|
|
599
|
+
**上一次重启的记录。** `GET <healthPath>?state=1` 和 `continuum_status` 工具都会报 `lastRestart`:重启前拍的那份快照(`snapshot.dir`、`snapshot.stamp`、`snapshot.atLocal`——**本地时间**,因为目录名是 UTC——、`snapshot.durationMs`、`snapshot.pruned`),以及**起来那个新实例**的判定(`result` 是 `pending` / `ok` / `failed`,外加 `verifiedAt`、`durationMs` 和四条开机检查 `listener` / `routes` / `plugin` / `recovery`)。新实例始终没起来时,记录停在 `pending`——这同样看得见。记录落在 `<stateDir>/last-restart-verdict.json`。
|
|
600
|
+
|
|
601
|
+
**这个宿主是谁拉起来的。** 同一个返回里的 `supervisor` 块说 `mode`(`app` / `installed` / `external` / `unknown` / `off`)、`restartCapable`,以及——因为**第二个守护会抢同一个端口**——`selfReplay`(`allowed` / `refused` / `unknown` / `n/a`);别人在管时还有 `external` 的证据(`kind`、`evidence`、`commandLine`)。Linux 上会读宿主的**父进程链**(`/proc/<pid>/cmdline`),所以用户自己的 `supervise-*.sh` 脚本能被认出来。**认不出人时是 `unknown`,绝不会默默当成"裸跑"**:这时插件拒绝自己重放自己的命令。**桌面版不会是 `unknown`**:它自己会把自己拉起来,所以报 `mode: "app"`、`restartCapable: true`、`selfReplay: "n/a"`。
|
|
602
|
+
|
|
333
603
|
**关机命令起不来时的退路。** 宿主会等命令的**真实结果**(进程有没有真的起来)。命令起不来(找不到程序、权限不够)时,关机**不会假装成功**:状态回到 `idle`,并给出原因("关机命令没跑起来(…),电脑还开着,可以再点一次月亮按钮。"),电脑保持开机。界面上那个「正在关机」的卡片也**不会把界面锁死**:最多停留 8 秒就会给出一个"关掉这个提示"的按钮。
|
|
334
604
|
|
|
335
605
|
## 需要 goal 吗?
|
|
@@ -519,7 +789,7 @@ lib/lifecycle.js 纯决策:bootPlan / restartDecision / selectGoalDisarms
|
|
|
519
789
|
lib/restart.js Windows 脱离重启(WMI 主、schtasks 兜底、dryRun、自定义命令)
|
|
520
790
|
lib/sleep.js 收工关机:识别真宿主机(含 WSL)、关机命令、等待→倒数→关机的状态机
|
|
521
791
|
lib/semver.js 版本号比较(公告去重用:只看"记录版本是否低于当前功能版本")
|
|
522
|
-
locale/{en,zh}.json
|
|
792
|
+
locale/{en,zh}.json 界面文案的镜像(与 client.js 里注册给 DSH 的两份字典同键同词;键集由一条用例钉住)
|
|
523
793
|
icon.svg 插件图标
|
|
524
794
|
LICENSE MIT 许可(发布到插件市场需要)
|
|
525
795
|
test/ node:test 用例(状态机 / 检查点 / 重启命令 / goal 解除 / 集成恢复 / recover_now)
|
|
@@ -532,12 +802,20 @@ A restart is never scheduled while another DSH conversation is still working.
|
|
|
532
802
|
|
|
533
803
|
1. Conversation gate: busyAgents() inspects every live agent (subagents and
|
|
534
804
|
teammates included). An agent blocks the restart when its status is 'running'
|
|
535
|
-
or when it still has queued inbox work
|
|
536
|
-
inbox.hasPending)
|
|
805
|
+
(ALWAYS, under every setting) or when it still has queued inbox work
|
|
806
|
+
(inbox.nextTurn / inbox.nextStep, or inbox.hasPending) — the queued work only
|
|
807
|
+
blocks until queuedGraceMs (default 60000 ms) is over; after that it is
|
|
808
|
+
treated as stuck, it stops blocking and the session that holds it is named in
|
|
809
|
+
the log. The verdict is { allow:false, reason:'conversation-active',
|
|
537
810
|
blockedBy:[...] }.
|
|
538
811
|
2. Settle gate: even with no busy conversation, the host must have been quiet for
|
|
539
|
-
settleMs (default 2000 ms) since the last
|
|
540
|
-
|
|
812
|
+
settleMs (default 2000 ms) since the last REAL activity — a turn that started
|
|
813
|
+
or ended (agent/status), a message that was queued (agent/inbox/inserted), a
|
|
814
|
+
session event that says work happened (a message, a tool, a turn), or the last
|
|
815
|
+
moment a conversation was seen working. A ROUTINE session event (a title, a
|
|
816
|
+
projection, a context request) is not activity: the host emits those every
|
|
817
|
+
two to three seconds, so counting them kept the window from ever filling and
|
|
818
|
+
the restart never fired. Otherwise the verdict is
|
|
541
819
|
{ allow:false, reason:'settling' }. This closes the gap between the decision
|
|
542
820
|
and the detached helper's ping delay, where a new turn could otherwise start
|
|
543
821
|
and be killed.
|