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 一律写「远程服务器」。
13 KiB
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 次) |
踩坑(重要,已固化):
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)- 仓库文件是 CRLF:本地改写文件若按 LF 回写,会让
git diff变成"全文件重写"(432 行全变)。正确做法:读/写保留原行尾(newline=''),或改完后按原文件的 CRLF 归一;本次已修正(diff 收敛为真实改动 ≈189 行)。 - 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 修复根因)。