# 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 命令执行」:读 `/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-/`(源自 `git HEAD`,含 6 个被改文件);也可 `git checkout HEAD -- `。还原后 `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 `,**不要**用 `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 修复根因)。