Files
dsh_shenxian/dsh-server-docs/04-调整方案/20-崩溃自愈现状核实与加固方案.md
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

155 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 20 · 崩溃自愈现状核实与加固方案
- 日期:2026-09-10
- 触发:档案 19 §C4 判定「崩溃自修复实际未启用」,用户裁定「**需要**」;本轮**代码级复核**发现该判定**有误**,本档案做订正并给出真实加固方案。
- 结论一句话:**崩溃自愈本来就有**(`child.on('exit')` → `scheduleRestart()` 1 s 退避重启 main,与 watchdog 无关);真正缺的是 **①无重试上限(1 s 无限重试 → 坏 bundle 可致 spawn 风暴)②无观测/告警 ③handoff 语义悬空**。**不建议**为此启用 `enablePatch`。
- 状态:**方案 A + handoff 停写 已实施**(代码 / 构建 / 测试 / 服务重启 完成,见 §九);待 **live 自愈与熔断实测**
> **TL;DR**|**结论**:**订正档案 19 §C4**:崩溃自愈**本来就有**(`exit` → 1 s 退避重启 main,与 watchdog 无关)。
> **关键**:真缺口 = ① 无重试上限(坏 bundle 可致无限重启)② 无观测 ③ handoff 悬空 → 方案 A:指数退避 + 窗口熔断 + 观测。
> **状态**:✅ 加固已上线;熔断 live 实测见本档附录
---
## 一、核实结论(代码证据)
| 问题 | 核实结果 |
|---|---|
| 崩溃后会自动重启吗? | **会**。`orchestrator.ts` 的 `child.on('exit')`(376-409 行):非 `stopped`、非"干净退出的 watchdog" → 置 `status='crashed'` + 记录 `lastError`(stderr 尾 500B)→ `spawnWatchdog()`(enablePatch=false 时直接返回)→ **`scheduleRestart(userId, instance)`** → `restartBackoffMs` 后 `spawnInstance(...)` 重新拉起 main |
| 退避多久? | `DEFAULT_RESTART_BACKOFF_MS = 1000`(1 s),可用 `DSHS_RESTART_BACKOFF` 覆盖 |
| watchdog 是必需的吗? | **不是**。`spawnWatchdog()` 首行 `if (!this.config.enablePatch) return undefined`,线上 `DEFAULT_ENABLE_PATCH=false` 且 env 未设 → watchdog 从不启动,但**自愈照常发生** |
| watchdog 独有能力? | 「agent 级 handoff 命令执行」:读 `<userRoot>/handoff.json` 并在重启后于实例内执行命令 |
| 谁在用 handoff? | `POST /api/dsh/restart {command}` 会写 handoff;但该端点**全站无前端调用**(`web/*.html` grep 无引用)→ **handoff 事实上从未被消费** |
| `ensure-role-profile-patch.cjs --restart` 的注释准确吗? | **不准确**。注释写"kill main → 由 watchdog 拉起新实例",实际 watchdog 不启动,实例会**原地停住**,直到用户重新 `enter` 才由 launch 拉起 |
## 二、对档案 19 的两处订正
| 档案 19 原判 | 订正 |
|---|---|
| C4「崩溃自修复实际未启用」 | ❌ → ✅ **自愈已实现**(`scheduleRestart`);缺口改为"无上限/无观测/handoff 悬空"三项 |
| C5「死代码含 crash-repair 子系统」 | 范围收窄:`scheduleRestart` 是**活代码**,不在清理范围;`runtime.ts` 是否可删取决于 handoff 去留(见 §四) |
## 三、真实缺口(本轮新判)
| # | 级别 | 缺口 | 证据 / 影响 |
|---|---|---|---|
| **G1** | **P2** | **重试无上限**:`scheduleRestart` 无 attempt 计数与熔断 | 坏 profile bundle / 端口冲突 / 依赖缺失会让实例"起来就崩",1 s 一次**无限重试** → 持续的 spawn/kill 风暴与 journald 刷屏(实测 `restartBackoffMs=1000`,无 `attempts` 字段) |
| **G2** | P2 | **崩溃无观测**:`status='crashed'` 与 `lastError` 只在**内存**,`GET /api/dsh/status` 不返回重启次数;无告警 | 运维看不到"某用户实例在反复崩",只能靠用户报障 |
| **G3** | P3 | **handoff 语义悬空**:`/api/dsh/restart` 写 `handoff.json`,但唯一消费者(watchdog)永不启动 | 接口暗示"重启后执行命令"但实际不执行 → 误导;且 `ensure-role-profile-patch.cjs --restart` 依赖同一误解 |
## 四、加固方案
### 方案 A(**推荐**):加固自愈 + 补观测 + 明确 handoff 去留
| 项 | 设计 |
|---|---|
| **A1 重试上限与熔断** | `scheduleRestart` 引入 per-user 计数窗口:**指数退避**(1 s → 2 s → 4 s … 上限 30 s)+ **窗口上限**(如 10 分钟内 5 次)→ 超限置 `status='failed'` 并**停止自动重启**(等待用户重新 `enter` 或 admin 介入),日志打 `crash-loop circuit-open` |
| **A2 观测** | ① `Instance` 增 `restarts` 计数与 `lastCrashedAt`;② `GET /api/dsh/status` 返回二者;③ 每次自愈写一条结构化日志(`event=instance-restart`);④ 可选:写 DB `dsh_instances`(沿用现有表,不新增) |
| **A3 handoff 去留(二选一)** | **(a) 停写**(推荐先做):`/api/dsh/restart` 不再写无人消费的 `handoff.json`,返回体注明能力未启用;同步订正 `ensure-role-profile-patch.cjs` 注释。**(b) 实现**:若确实需要"重启后执行命令",则必须启用 watchdog(见方案 B),或改由 main 自执行 |
| 改动面 | 仅 `src/supervisor/orchestrator.ts`(+ 少量 `src/web/routes/dsh.ts`、`src/config.ts`)→ `npm run build` → **重启 dshs 服务** |
| 风险 | 低;熔断是"减少动作",不会让原本能自愈的场景变差 |
### 方案 B(**不推荐**,仅记录):启用官方 watchdog 路径
- 做法:`DSHS_ENABLE_PATCH=true` + 让 `dshs/runtime` 在子 dsh 内可解析(**必须把该包复制进每个 profile 的 node_modules**,因为包在 700 的 `/opt/dshs`,实例 uid 读不到)+ 重启服务。
- 代价:① 所有实例启动路径改走 `--patch`(新增失败点);② 每 profile 多一份平台包副本(版本漂移风险);③ `renderPatch` 会带上已废弃的 `folder_plugins` 勾选逻辑;④ 只为 handoff 能力,收益与成本不匹配。
- 结论:**除非确定要 handoff 能力,否则不做**。
## 五、实施窗口与前置(重要)
改 `orchestrator.ts` 需 **`npm run build` + 重启 `dshs` 服务**。服务重启会**丢掉内存态**(`mains` / `watchdogs` / `restartTimers`),而实例进程由 `systemd-run` scope 管理、可能**存活下来成为"孤儿"**(服务端不知道它们在跑,`/api/dsh/status` 会报未运行,用户再次 `enter` 可能触发重复启动/端口冲突)。
**因此建议的执行顺序(与档案 18 装包合并到同一个窗口)**:
```
1) 先经编排器 API 停掉所有实例(POST /api/dsh/stop 或 supervisor.stop)→ 确认无 dsh 子进程
2) 打补丁:档案 20(自愈加固)+ 档案 18(装 picker bundle)一并 build
3) systemctl restart dshs → 确认服务健康
4) 验证:档案 20 §六 + 档案 18 §七
5) 用户重新登录/enter(实例按新代码与 bundle 拉起)
```
## 六、验证清单
- [ ] 崩溃自愈:对**测试账号**实例的 dsh 子进程 `kill -9` → 观察 ~1 s 后自动拉起;`GET /api/dsh/status` 的 `restarts` +1、`status='running'`
- [ ] 指数退避:连续 kill 3 次 → 观察间隔递增(1s→2s→4s)
- [ ] 熔断:构造持续失败(如临时破坏 profile 的 patch)→ 达窗口上限后置 `failed` 并**停止**重启;日志出现 `crash-loop circuit-open`
- [ ] 恢复语义:熔断后用户 `enter` 能正常重新 launch(且计数重置)
- [ ] 回归:正常 stop / restart / idle-reap / last-wins 行为不变
- [ ] 观测:结构化日志可被 journald 检索(`event=instance-restart`)
## 七、回滚
| 项 | 回滚 |
|---|---|
| 代码 | **回滚副本**:`/opt/dshs/bak-档案20-rollback-<TS>/`(源自 `git HEAD`,含 6 个被改文件);也可 `git checkout HEAD -- <file>`。还原后 `npm run build` + `systemctl restart dshs` |
| 熔断状态 | 计数只在内存,重启即清;无数据迁移 |
| handoff 停写 | 恢复 `writeHandoff` 调用即可(无外部依赖) |
| 新增文件 | `src/supervisor/crash-policy.ts`、`test/crash-policy.test.mjs`(删除即回退到旧行为;注意 package.json 的 test 脚本) |
## 八、红线遵守
- **R1**:不触发 dsh 升级。
- **R2**:改动限于编排器自身代码(`src/supervisor` + 少量路由),**不碰官方 dsh 主程序与缓存**;方案 B 即便采用也只走官方 `--patch` 机制。
- **R4**:验证用**测试账号**;crash 测试不得在真实用户会话上进行(会打断会话)。
---
## 九、实施记录(2026-09-10 22:2x,方案 A + handoff 停写)
**已执行**(用户裁定「按照你的建议执行」):
| 项 | 结果 |
|---|---|
| 新增 `src/supervisor/crash-policy.ts` | 纯策略函数:`decideCrashAction` / `backoffDelayMs` / `pruneHistory`(可单测,不 spawn 进程) |
| `orchestrator.ts` | 新增 `crashHistory` / `crashStreak` / `stableTimers` 三个状态表;`scheduleCrashRestart` 取代 `scheduleRestart`(指数退避 base→30 s + 窗口熔断 5 次/10 min → 置 `failed` 且停止自动重启并清计数);`launch`/`restartMain`/`stop` 重置崩溃状态;main 连续运行 60 s → `instance-stable` 日志并重置退避步数;结构化日志 `[crash-restart] {event=instance-restart\|crash-loop-circuit-open\|instance-stable}` |
| `spawner.ts` | `InstanceStatus` 增 `failed`;`Instance` 增 `restarts` / `lastCrashedAt` |
| `config.ts` | 新增 4 项(均可 env 覆盖):`restartBackoffMaxMs=30000`、`crashMaxRestarts=5`、`crashWindowMs=600000`、`crashStableMs=60000` |
| `routes/dsh.ts` | `alive()` 把 `failed` 视为不可复用;`/api/dsh/status` 暴露 `restarts` + `lastCrashedAt`;**`/api/dsh/restart` 停写 handoff**,返回 `handoff:{accepted:false,reason:'watchdog_disabled'}`,命令非空时记 `[handoff-disabled]` 日志 |
| `ensure-role-profile-patch.cjs` | 订正与实不符的注释(watchdog → orchestrator 崩溃自愈 `scheduleRestart`) |
| `package.json` | `npm test` 纳入新单测 |
**验证结果**:
| 检查 | 结果 |
|---|---|
| `npm run typecheck` | ✅ 无错误 |
| `npm test`(全量) | ✅ 39 tests / 38 pass / 1 skipped / **0 fail** |
| 新单测 `test/crash-policy.test.mjs` | ✅ **7/7 通过**(退避封顶、窗口裁剪、熔断阈值、窗口外不计数、稳定后重置、第 5 次放行第 6 次熔断) |
| 构建产物生效 | ✅ `resolveConfig` 读出 `{1000, 30000, 5, 600000, 60000}`;`lib/web/routes/dsh.js` 含 `handoff-disabled`/`watchdog_disabled`;`lib/supervisor/orchestrator.js` 含 `crash-loop-circuit-open` 与 `restarts` |
| 维护窗口 | ✅ 编排器已停 → 清残留实例 → 启动:`active`/`enabled`,**0 残留 dsh 进程**,门户 `HTTP 200` |
| live 自愈 / 熔断 | ⏳ **待实测**(需用户重新进入会话产生实例后,kill 一次看 `[crash-restart] delayMs=1000`;熔断需连续 6 次) |
**踩坑(重要,已固化)**:
1. **`pkill -f "dsh --profile"` 会杀掉执行它的 ssh 会话**——该命令串自身含同样文本,`pkill -f` 会匹配到自己。**后果**:本次窗口脚本在第 2 步自杀,导致「服务停了但后续步骤没跑」。**正确做法**:用 `ps -eo pid,user,args | grep "[d]sh --profile"` 定位后按 pid 处理,或用 `systemctl stop <scope>`,**不要**用 `pkill -f` 匹配包含自身命令行文本的模式。(本次已在 3 分钟内 `systemctl start` 恢复,无残留、门户 200)
2. **仓库文件是 CRLF**:本地改写文件若按 LF 回写,会让 `git diff` 变成"全文件重写"(432 行全变)。**正确做法**:读/写保留原行尾(`newline=''`),或改完后按原文件的 CRLF 归一;本次已修正(diff 收敛为真实改动 ≈189 行)。
3. **drain 与重启顺序**:`systemctl stop dshs` → 清残留 scope/进程 → `systemctl start`。若只重启服务而不清残留,旧实例会变成"孤儿"(编排器内存态已丢,用户重新进入会重复启动)。
---
## 附:熔断 live 实测记录(2026-09-11 · 已通过)
**来源**:真实用户 `071d678a…`(档案 52 的新用户)触发崩溃循环时的实测日志,非人造测试。
**① 指数退避(实测序列)**
| attempt | delayMs | restartsInWindow | restarts |
|---|---|---|---|
| 1 | **1000** | 1 | 1 |
| 2 | **2000** | 2 | 1 |
| 3 | **4000** | 3 | 1 |
| 4 | **8000** | 4 | 1 |
→ 退避按 base×2 递增 ✓;`restartsInWindow` 逐次累加 ✓。
**② 窗口熔断(实测)**
```
[crash-restart] {"event":"crash-loop-circuit-open","windowMs":600000,
"restartsInWindow":5,"maxRestartsInWindow":5,"restarts":1,...}
```
→ 10 分钟窗口内第 **5** 次触发开断(与配置一致),当日共记录 **2** 次开断(两轮崩溃各一次)✓
**③ 结论**:**live 实测通过**(退避 + 计数 + 开断 + 结构化日志四要素齐全)。此前"需连续 6 次 kill 做实测"的待办**据此关闭** —— 真实故障已经完成了该验证,无需再打断用户会话来复现。
**④ 附带的正面效果**:熔断开断后实例被标 `failed` 并停止自动重启,避免了无限重启风暴(该用户当时的问题已由档案 52 修复根因)。