dsh-autorestart 0.8.1 → 0.8.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.3 — 2026-10-29
4
+
5
+ **重启阶段搬到界面正中:不再挤在输入框那一行被截断,靠数字说话,也不是常驻。**
6
+
7
+ ### 现场(真机截图)
8
+
9
+ 阶段那行字挤在输入框工具行的小胶囊里(22px 高、一行宽)。窗口不宽时「正在等待所有会话空闲…(还有 1 个会话在忙)」被省略号吃掉,用户看不出卡在哪、还要不要等。另一次截图里浮层上的「等待会话空闲」挂了十分钟不消失——那笔重启只是在**排队等空闲**,其实什么动作都没在跑。
10
+
11
+ ### 改
12
+
13
+ - **阶段改为主界面居中浮层**(`shell.overlay` 槽位,id `autorestart-restart-overlay`):320px 固定宽、水平垂直居中、圆角、半透明,`pointer-events: none`——点哪里都照样点到下面的界面,不挡输入。
14
+ - `snapshotting`:「DSH 重启前备份中」+ **真实进度条** + 「12.3 / 40 MB」+ 百分比(数字来自拷贝循环的实测字节,没测到就一个数字都不写);
15
+ - `restarting`:「正在重启…」,一直挂着,直到宿主真的退出;
16
+ - `back`:「已回来」,**1.5 秒**后自动消失;
17
+ - `waiting`:**浮层不出现**——只是排队等空闲,没有任何动作在跑,会话数交给胶囊去说;
18
+ - state.json 里残留的旧阶段(`pendingRestart === false`)和页面的第一帧,都**不画**。
19
+ - **窄窗按字号缩放,不截断**:浮层里没有 `text-overflow`、没有省略号、不换行;窗口不够宽时用 `clamp()` 缩字号,数字永远完整可读。
20
+ - **底部胶囊恢复短句**(≤12 字、单行):等空闲时「待重启 · 空闲后自动重启」/「待重启 · 等待 N 个会话」;备份中 / 重启中 / 回来时整行换成「重启前备份中」/「正在重启…」/「已回来」。长句只留 tooltip,细节全部交给浮层。
21
+ - **胶囊与面板的数字同源**:修掉「胶囊说还有 1 个会话、点开面板却说等 0 个任务」。两处现在读同一个 `blocking` 数字——Host 在 `restartPhase.blocking` 里单独报出它,不再只藏在句子中。
22
+ - **设置修改有回执**:改完设置,面板里给回执——成功是绿色「已生效」+ 时间;失败是红色并保留原因、不自动消失。
23
+ - **回执贴在设置面板内部顶部**(`position:sticky` + `top:0`,往下滚动设置内容也一直可见;条高 30px,覆盖不挤压表单),不再浮到窗口层——`shell.overlay` 上那条注册已删,提示只出现在本插件的设置页里,关掉设置页即清空。
24
+ - **口径统一**:悬停提示里的「任务」改成「会话」(英文 session(s)),和胶囊、面板保持一致。
25
+ - **体积按大小自动选单位**:一份 92 KB 的 profile(Web/WSL)如果按 MB 印就是「0 MB」甚至「0.0 MB」,等于把一次真的备份说成什么都没干。现在 `92 KB` / `156 MB` / `12.3 MB` / `0 B` 各按大小选单位,进度行两侧也各带单位(`12.3 MB / 40 MB`)。浮层**没有任何保底显示时长**:备份快就一闪而过,慢就一直在,出现与消失完全跟随真实阶段(用户拍板:「浮层不用保底时间,短就算了,不用硬加长」)。Host 侧 `restartPhase.snapshotBytes` 仍跨阶段记录真实体积,供界面与诊断使用。
26
+ - **说清是什么备份**:光看「正在备份」看不出备份的是什么,所以浮层改成「DSH 重启前备份中」、胶囊改成「重启前备份中」(英文 "Backing up before DSH restart" / "Backing up…"),进度条与 MB 数字不变。设置页与 tooltip 那句长句也一起改成同一个说法:「DSH 重启前备份中(预计 X 秒,已完成 Y MB)…」(英文 "Backing up before DSH restart (about Xs, Y MB done)…"),「慢就明说」那条也把主语补成「DSH 重启前备份比预期慢…」,全产品不再出现孤立的「正在备份」。
27
+
28
+ ### 验证
29
+
30
+ - 单元测试 **760 → 801 项全绿**(新增 `test/restart-overlay.test.mjs` 19 条、`test/settings-save-notice.test.mjs`、`test/pill-count-source.test.mjs`)。
31
+ - 两个演练仍是 exit 0:`node scripts/drill-watchdog.mjs`、`powershell -File scripts/drill-rescue.ps1`。
32
+ - 客户端 bundle 仍是整文件一个 IIFE(`test/client-bundle-isolation.test.mjs` 守着)。
33
+
34
+ ## 0.8.2 — 2026-10-29
35
+
36
+ **线上缺陷修复:宿主起来之后没有撤销看门狗,会把好环境回滚掉。**
37
+
38
+ ### 事故现场(真机,19:19–19:21)
39
+
40
+ 触发重启 → `watchdog-pending.json` 写入、`dsh-autorestart-watchdog` 计划任务注册成功、报告写 `armed`;新宿主 19:20:20 起来,health 连打 3 次都是 200;**但 `watchdog-ready.json` 从头到尾没出现**,`watchdog-pending.json` 还在、计划任务还在 `Ready`。当时是人工 `Unregister-ScheduledTask` 拆掉的——没人管的话,90 秒后它会还原快照并再拉起一个实例,**把一个健康的环境回滚掉**。
41
+
42
+ ### 根因(已复现)
43
+
44
+ health 路由里那段"写 marker + 撤看门狗"的代码读的是 `stateDirOf()`——那是 runtime 工厂内部的闭包变量,**模块级函数根本看不见它**。所以每次请求都在第一行就抛 `ReferenceError: stateDirOf is not defined`,外面那个 `catch {}` 把异常吞了:路由照样回 200,marker 没写、pending 没清、计划任务没删,任何地方都没有痕迹。
45
+
46
+ - 复现方式:用测试上下文调真实的 health 路由,断言 marker 出现——旧代码失败(`ready written: false`)。
47
+ - 同源的第二个坑:`clearPending` / `readPending` 当时也没被 import,`armWatchdog` 注册失败的回滚分支同样会抛 `ReferenceError`。
48
+
49
+ ### 修
50
+
51
+ - **health 路由不再靠闭包**:状态目录改由 `runtime.stateDir` 提供(`runtime` 上新增 `stateDir` / `pluginVersion`)。这类"路由看不见工厂内部变量"的错误从此没机会重演。
52
+ - **撤销拆成独立模块**(`lib/watchdog-disarm.js`),三件事分开做、都留痕:
53
+ 1. **先写 ready marker**(同步、read-back 校验)——它是唯一能阻止回滚的东西,绝不再依赖计划任务是否删成功;
54
+ 2. 清 `watchdog-pending.json`;
55
+ 3. 按 pending 自己写的 tier/taskName 删调度项(不再硬编码名字),删完**回查**(`schtasks /Query`)确认真的没了。
56
+ - **失败必须重试并留痕**:默认 3 次、退避 120ms/240ms;每次失败都带 `error.name` + `message`,写进 `watchdog-disarm.json`、health 响应、诊断文本("报告一个问题"),不再静默。
57
+ - **开机就撤销,不等任何人来问**:`apply()` 里注册完路由立刻跑一次 disarm。即使一次 health 都没人打,宿主起来就会把看门狗撤掉;health 路由仍是第二道保险(后台清理,不拖慢响应)。
58
+ - **兜底(最要紧的一条):看门狗回滚前必须自己问一次 health**。`runWatchdogFlow` 新增 3b 步:直接 HTTP 问 `127.0.0.1:<port>`(端口依次取实例记录 `access.json`、pending 里的 port、旧 `healthUrl`),**只要有一台在应答就绝不回滚**(`result: not-needed`);一个端口都问不到时,额外再等一轮、复查 ready marker 与 pending,仍然没动静才允许回滚。
59
+ - **顺手修掉的版本号 bug**:runtime 里那份 `pluginVersion()` 读的是 `../package.json`(插件目录的上一层),永远返回 `unknown`;health 于是把 `unknown` / 硬编码常量当版本报出来。现在统一走模块级读取,`health.version` 是真正加载的版本。
60
+
61
+ ### 演练
62
+
63
+ `node scripts/drill-watchdog.mjs` 从三个场景扩到**四个**,新增:
64
+
65
+ - **场景 ④「宿主起来了但没写 ready marker」**(就是这次事故):假宿主只应答 health、绝不写 marker,并且**真实注册一个 Windows 计划任务**;断言 watchdog `result: not-needed`、`healthCheck: healthy`、**一次还原都没有**、pending 已消失、**计划任务真的被删掉**(`schtasks /Query` 查不到)。
66
+
67
+ ### 重启阶段化 UI(同版发布:点下去 1 秒内就有反馈,备份有真实进度)
68
+
69
+ 用户反馈:"点『0 秒后重启』之后十几秒什么都不动,其实在备份 156MB,我会以为按钮没反应 / 0 秒是假的 / 有 bug。"
70
+
71
+ - **四个阶段立刻可见**:`waiting`「正在等待所有会话空闲…」→ `snapshotting`「正在备份配置和插件(预计 X 秒,已完成 Y MB)…」(0.8.3 起改为「DSH 重启前备份中(预计 X 秒,已完成 Y MB)…」)→ `restarting`「正在重启,稍后自动继续…」→ `back`「已回来,接着干」。阶段写在 checkpoint(`restartPhase`)里,点击的**同一次调用**就把 `waiting` 落盘并 persist,界面下一次轮询(2 秒一次,且点击后立即再拉一次)就能看到。
72
+ - **备份进度是真的**:`createBackup` 改成**文件级拷贝循环**(原来是 `fs.cpSync` 一次性拷完,既没进度也不能取消),边拷边报 `{ files, bytes, total }`,`total` 是拷之前量出来的真实总量;拷贝过程**每 120ms / 每 40 个文件让出一次事件循环**,所以取消请求能在拷贝途中被服务,进度也能在拷贝途中到界面。
73
+ - **备份阶段可取消**:取消后拷贝在下一个文件处停下,**半成品快照目录被删掉**(不留一个看着能还原的假备份),请求被撤回、宿主不重启;**进入 `restarting` 后取消被拒绝**(HTTP 409 + `reason: not-cancellable` + 原因),界面按钮置灰并说明"正在重启,已经不能取消了"。
74
+ - **两处都能读到**:`/__dsh_autorestart/health?state=1` 与 `/health` 都带 `restartPhase`(阶段、双语文字、可取消性、`slow`)和 `progress`(files/bytes/total/percent/ageMs)。
75
+ - **慢就明说**:备份超过 300 秒,或字节数 60 秒没动 → 显示「备份比预期慢(可能磁盘很忙),还在继续…」,绝不假装正常进度。
76
+ - **界面**:composer 那颗 pill 多一行阶段文字(含进度),设置页「重启设置」组标题下常驻一行同样的状态(pill 只在有待办重启时出现,设置页那行一直都在)。
77
+
78
+ ### 验证
79
+
80
+ - 单元测试 **740 → 753 项全绿**(新增 `test/watchdog-disarm.test.mjs`:事故复现、开机自动撤销、marker 读写与 read-back、失败/重试/留痕、按 pending 选调度项、诊断文本、流程里"健康就不回滚"、问不到端口多等一轮)。
81
+ - 阶段化 UI 再加 **`test/restart-phase.test.mjs`(6 项,累计 760 项全绿)**:①点击后 1 秒内可读到 `phase='waiting'`(含 state 文件与 state 路由两处)②备份中 `progress.bytes` 单调增长且等于最终写入量 ③备份中取消 → 半成品目录消失、宿主没重启、请求被撤回 ④`restarting` 阶段取消被 409 拒绝并给出原因 ⑤慢拷贝两种文案 ⑥里程碑:健康拷贝报真实 MB 与预计秒数、没有测量前不报任何数字。
82
+ - 演练四场景 + 三档调度真机触发全部 exit 0。
83
+
3
84
  ## 0.8.1 — 2026-10-29
4
85
 
5
86
  **修复打包遗漏**:0.8.0 的 npm 包按 `package.json` 的 `files` 白名单打包,而那份白名单只收了 `scripts/*.ps1` 与 `scripts/*.cmd`——**三个 POSIX 自救脚本(`restore-profile.sh` / `emergency-disable.sh` / `drill-rescue.sh`)、`scripts/README.md`、`scripts/rescue-strings.zh.json` 全都没进包**。Linux / WSL 用户装完 0.8.0 后,文档让他去双击/运行 `scripts/restore-profile.sh`,那个文件却不存在。
package/README.md CHANGED
@@ -36,9 +36,10 @@ round starts, so the session goes idle and the restart can proceed. The goal is
36
36
  restart and continues.
37
37
 
38
38
  The browser shows the state: a compact "pending restart" pill on the left side of the input toolbar
39
- and a small dot next to the session title in the sidebar. The pill's text reports how many **tasks**
40
- are still in the way — a conversation, a subagent or a teammate counts as one task each, and the
41
- tooltip explains that queued-but-not-started work counts too; it does not open a details panel. The
39
+ and a small dot next to the session title in the sidebar. The pill's text reports how many
40
+ **conversations** are still in the way (`待重启 · 等待 2 个会话` / `pending restart · waiting for 2
41
+ session(s)`) — a conversation and its own subagents count as one, and the tooltip explains that
42
+ queued-but-not-started work counts too; it does not open a details panel. The
42
43
  dot breathes when the restart is about to fire.
43
44
 
44
45
  Every blocker says **what it is**: a conversation is named by its own title, and a **subagent** is
@@ -198,7 +199,7 @@ what it adds before you install it.
198
199
 
199
200
  ## Install
200
201
 
201
- Current version: **0.8.1**.
202
+ Current version: **0.8.3**.
202
203
 
203
204
  From npm — the package is published under the name `dsh-autorestart`:
204
205
 
@@ -271,7 +272,7 @@ The capsule in the composer row is 22px high and ONE line wide, so it carries nu
271
272
  (`待重启 · 已等 16 分钟 · 3 项任务在跑`) — never a name and never a hint. Clicking it (or pressing
272
273
  Enter/Space on it) opens a small panel above it:
273
274
 
274
- - how many tasks the restart is waiting for;
275
+ - how many conversations the restart is waiting for (the same number the capsule shows);
275
276
  - how many OTHER sessions wait for the same restart;
276
277
  - one button: **Cancel my restart**.
277
278
 
@@ -407,6 +408,42 @@ hold the restart back:
407
408
  - **Where to look:** `GET /health?state=1`, `GET /health?gate=1` and `continuum_status` all report
408
409
  `queuedGraceMs`.
409
410
 
411
+ ### The four stages of a restart (what you see between the click and the comeback)
412
+
413
+ A restart is not instant, and for a while it used to look like nothing at all: the click was
414
+ accepted, then the host spent ten-odd seconds writing a pre-restart snapshot (a desktop profile
415
+ with its plugins is ~150 MB). The button looked dead. It is not — and now it says so:
416
+
417
+ | Stage | What the interface shows | Where the data comes from |
418
+ |---|---|---|
419
+ | `waiting` | "Waiting for every conversation to be idle…" (+ how many are busy) | the idle gate's blockers |
420
+ | `snapshotting` | The centred card: "Backing up before DSH restart" + a real progress bar, "12.3 MB / 40 MB" and a percentage; the settings page and the tooltip keep the long line, in the same words ("Backing up before DSH restart (about 12s, 78 MB done)…"). The card has NO minimum shown time: it lives exactly as long as the Host reports the stage (a 92 KB copy is gone in about 5 ms, a slow one stays as long as it runs); the size picks its own unit (KB or MB) so a small backup never reads "0 MB" | the REAL copy loop: files and bytes written, measured total |
421
+ | `restarting` | "Restarting; the work continues by itself in a moment…" | the host is on its way out |
422
+ | `back` | "Back — carrying on where we left off" | the new host, once it boots |
423
+
424
+ Three things make this honest rather than decorative:
425
+
426
+ 1. **The first stage is written by the click itself** and persisted at once, so the pill (which
427
+ polls the `state=1` route every 2 s, and immediately after a click) has something true to show
428
+ well inside the first second.
429
+ 2. **The progress is measured, never animated.** The copy reports the files and bytes that really
430
+ landed, against a total measured before it started; the estimate only appears once there is a
431
+ real speed to divide. When there is no measurement yet, the text claims no number at all.
432
+ 3. **A slow copy is announced** — after five minutes, or when the byte counter has not moved for a
433
+ minute, the line becomes "The backup is slower than expected (the disk may be busy); it is still
434
+ running…". That is the opposite of a progress bar that pretends.
435
+
436
+ **Cancelling.** While the host is still waiting or copying, cancelling stops the copy between two
437
+ files, deletes the half-written snapshot (no fake backup is left behind), withdraws the request and
438
+ the host keeps running. Once the stage is `restarting` a cancel is refused (HTTP 409 with the
439
+ reason) and the button is greyed out: the host is already on its way out, and a cancel there would
440
+ be a promise this plugin cannot keep.
441
+
442
+ Both `…/health` and `…/health?state=1` carry `restartPhase` (stage, bilingual text, `cancellable`,
443
+ `slow`) and `progress` (files, bytes, total, percent, age), and the settings page keeps one status
444
+ line next to the restart settings — the pill is only there while a restart is pending, the settings
445
+ line is always there.
446
+
410
447
  ### What you see while the restart waits a long time
411
448
 
412
449
  When the hard deadline (`deferHardMs`) is over and a conversation is still running, the plugin does
@@ -568,11 +605,24 @@ plugin actually does.
568
605
 
569
606
  The flow is the same for every tier (`lib/watchdog.js`): wait for the new host
570
607
  to write the ready marker → ask again (a slow host is not a dead host) → is an
571
- instance alive? (then there is nothing to restore) → take the shared lock
572
- (`rollback.lock`, so exactly one of us restores) → restore the snapshot → start
573
- the host again → wait for the ready marker once more. At most **one** restore
574
- and **one** start; then the watchdog stops and writes what happened into
575
- `<stateDir>/last-watchdog-report.json` (which tier it used, and why).
608
+ instance alive? (then there is nothing to restore) → **ask the health route of
609
+ the host itself** (only a host that does not answer may be rolled back; when
610
+ there is no port to ask, one more round is waited and the markers are looked at
611
+ again) → take the shared lock (`rollback.lock`, so exactly one of us restores) →
612
+ restore the snapshot → start the host again → wait for the ready marker once
613
+ more. At most **one** restore and **one** start; then the watchdog stops and
614
+ writes what happened into `<stateDir>/last-watchdog-report.json` (which tier it
615
+ used, `healthCheck`, and why).
616
+
617
+ **The disarming is the host's job, and it does it at boot.** As soon as this
618
+ plugin is loaded, it writes the ready marker, removes `watchdog-pending.json` and
619
+ deletes its own scheduling item (retried with a backoff, verified afterwards by
620
+ asking the task scheduler, recorded in `<stateDir>/watchdog-disarm.json`). It
621
+ does not wait for anybody to call the health route — the watchdog must be
622
+ disarmed even when nobody opens the interface, and a disarm that fails is
623
+ visible in `health`, in the diagnostics text and in that record instead of
624
+ being silent. That is the fix of 0.8.2: an error swallowed by the health route
625
+ left an armed watchdog behind a healthy host.
576
626
 
577
627
  **Only one watchdog at a time.** There is an older watcher in this plugin as
578
628
  well (it restores the snapshot but does **not** start the host again). Both take