Files
dsh_shenxian/dsh-server-docs/04-调整方案/20-崩溃自愈现状核实与加固方案.md
T
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

13 KiB
Raw Blame History

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 修复根因)。