Files
dsh_shenxian/dsh-server-docs/04-调整方案/78-崩溃熔断冷却期与告警.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

10 KiB
Raw Blame History

78 · 崩溃熔断:冷却期 + 告警(防「崩溃循环可无限重来」)

  • 日期:2026-09-13
  • 状态:✅ 已部署并验证(2026-09-13 08:02 重启载入,PID 410287→413817;/api/dsh/status 已返回新字段 breaker)
  • 触发:档案 77 §八 遗留 1 —— 「崩溃熔断可无限重来:熔断后 mains.delete + resetCrashState(),而用户下次 launch()/enter 又 resetCrashState() ⇒ 计数清零、重新给足预算;且熔断只写 stderr、无告警」
  • 结论一句话:原实现的熔断 = 「清空预算 + 一行 stderr」,等于给崩溃循环发了永久通行证。本次把熔断态做成跨轮存活 + 指数冷却,冷却期内拒绝隐式启动,并把熔断变成有痕迹的告警。
  • 关联:档案 20(指数退避 + 窗口熔断,本次补它的缺口)/档案 25(插件把实例搞崩)/34·35(启用探活 + 逐插件隔离)/档案 77(回到页面自愈会把 enter 打进来 ⇒ 正是无限重来的放大器)

TL;DR|① 熔断态放进独立 breaker Map,不被 resetCrashState() 清;② 熔断后冷却 10 min × 2^(n-1),封顶 6 h,冷却期内 launch() 抛 CrashBreakerOpenError → HTTP 503 instance_circuit_open;③ 冷却过后只给一次干净预算;④ 熔断同时写 stderr([crash-breaker])+ /var/log/dsh-crash-breaker.log,并把 breaker 暴露到 /api/dsh/status;⑤ 平台自身显式操作(插件启用/隔离)用 force 绕开冷却,避免「想自动禁用肇事插件却被自己的冷却挡住」。


一、根因(代码取证)

# 缺陷 证据
主 熔断即清空预算:circuit-open 分支里 resetCrashState(userId) 把 crashHistory / crashStreak 一并删掉,注释自己写着「计数一并清空 = 下一轮重新给足预算」 src/supervisor/orchestrator.ts 原 855–871 行
次 launch() 每次显式启动也 resetCrashState() ⇒ 任何重试路径都是满额预算 同文件原 124–127 行
次 重试路径有多条且互相叠加:① 用户 F5/重进页面 ② 档案 77 的注入脚本自愈(页面可见即探活 → 失败即 POST /api/dsh/enter)③ ensure-*.cjs 直铺后重启 ④ 并行调试会话每改一版就重启实例 2026-09-12 admin 实例 1 小时重启 17 次实测(档案 77 §八)
次 无告警:熔断只 process.stderr.write 一行,无落盘、无计数、无观测面 原 crashLog() 实现

为什么这算缺陷:熔断的目的是"止损",而"清空预算"把它降级成了"限流一次"。极端情形(不兼容插件 + 有人不断重试)下实例可以无限崩溃重启:每轮 5 崩 + 退避到 30 s,循环往复,且除一行 stderr 没有任何痕迹。

二、方案取舍

方案 做法 判定
A 现状(熔断即清预算) 标记 failed,计数清零 ❌ 本次要修的:等于没有熔断
B 只加告警、不设冷却 熔断时写日志 + 观测面 ❌ 不闭环:痕迹有了,循环还在
C 跨轮熔断态 + 指数冷却 + 告警(采用) 独立 breaker Map;冷却期内拒绝隐式启动;冷却过后一次干净预算;双通道告警 ✅ 循环有界、痕迹可 grep、不引入人工清障依赖
D 熔断后彻底禁止,必须人工解冻 硬闸 ❌ 太重:把"用户自己重进一下就好"的常见情形也变成工单;本平台无通知渠道

为什么冷却用指数:同一人反复熔断 = 病根没除(多半是插件不兼容)。冷却 10 min 逐步长到 6 h —— 把"越反复越要等人"编码进机制;opens 计数给"该找人了"提供判据。

三、实现(代码侧 6 个文件)

文件 改动
src/supervisor/crash-policy.ts 新增纯函数 breakerCooldownMs / breakerActive / breakerUntil / openBreaker + 类型 BreakerState / BreakerPolicy(便于单测)
src/supervisor/spawner.ts 新增 CrashBreakerOpenError(带 retryAt / opens / retryAfterMs);Spawner 接口加可选 breakerInfo?();launch() 加可选 opts.force
src/supervisor/orchestrator.ts ① breaker Map(刻意不被 resetCrashState() 清)② assertBreakerClosed() / clearBreaker() / breakerPolicy() / breakerInfo() ③ launch() 冷却门(force 可绕)④ circuit-open 记熔断态并告警 ⑤ alertBreaker() 双通道告警
src/config.ts 新增 crashBreakerCooldownMs(默认 600000)+ crashBreakerMaxCooldownMs(默认 21600000);env:DSHS_CRASH_BREAKER_COOLDOWN / ..._MAX_COOLDOWN
src/web/routes/dsh.ts /api/dsh/enter、/api/dsh/launch 把该错误映射成 503 instance_circuit_open(带 retryAfterMs / opens);/api/dsh/status 增加 breaker 观测面
web/wake.html 过渡页对该错误给出可读文案(原来只显示裸 HTTP 503)—— 静态文件,免重启

熔断态在内存(不落盘):服务重启即清。有意如此 —— 不引入需要迁移/清理的持久化状态;代价是"重启一次等于放行一次",可接受。

四、验证

项 方式 结果
类型检查 npm run build(tsc) ✅ 退出码 0
单测(新增 3 条) node --test test/crash-policy.test.mjs ✅ 10/10(冷却指数与封顶 / active·until 边界 / 熔断回归)
全量回归 npm test(build + 5 个测试文件) ✅ 45 pass / 0 fail / 1 skipped(skip 为原有用例)
未做(L1) 真把实例反复搞崩 → 冷却期内 enter 被拒 → 冷却过后放行 ⏳ 待部署后在窗口内做(必须真崩实例 = 生产操作)

口径说明:本次只覆盖纯逻辑 + 编译/回归;编排器的门与路由映射靠类型检查 + 代码走查,端到端留到部署窗口 —— 如实标注,不假装已验。

五、边界(有意不做)

  1. 不猜病因、不自动禁插件:熔断只"拒绝启动 + 记痕",不判断哪个插件坏了(那是档案 34/35 的链路)。
  2. 不给"人工解冻"加自动通道:冷却到期自然放行,平台自身显式操作走 force;不新增"重置计数/删状态"的运维入口 —— 那会变成新的绕过(同 R9 的思路:机制要能用,但不能被随手绕过)。
  3. k8s 模式无熔断:breakerInfo? 声明为可选,k8s spawner 不实现(该模式无本地 scope / crash-restart 概念)。
  4. 不做门户 UI:只在 /api/dsh/status 暴露 breaker + 日志告警;门户展示留后续(需 UI 评审)。
  5. 告警只落日志,不做"推送给谁":平台无通知渠道(短信 / IM 未接入)。

六、红线遵守

  • R2:只改本平台代码;@deepseek-ai/dsh 零改动。
  • R7:改动只落本机(6 个文件);未 scp、未 commit、未 push、未碰服务器。
  • R8:部署需重启 dshs → 会中断在线用户 ⇒ 等用户给窗口;本文只交付"代码完成 + 本地验证"。
  • 一行踩坑(如实记录):用 python read()(通用换行)改写文件会把 CRLF 静默转 LF,本次误伤 src/config.ts 等 4 个文件(git diff 一度出现 331/312 行噪声)→ 已逐文件还原行尾(只在我碰过的文件上,不是全库批量转换)。教训:读写必须显式 newline=""。

七、回滚

# 未 commit 时:六个文件整体回退
git checkout -- src/config.ts src/supervisor/crash-policy.ts src/supervisor/orchestrator.ts \
  src/supervisor/spawner.ts src/web/routes/dsh.ts test/crash-policy.test.mjs web/wake.html
# 已部署时:还原 lib/ + 重启(部署时会先备份 lib/)
  • 运行期"解冻":systemctl restart dshs 即清内存态(熔断态不落盘)。
  • 纯回退开关:把 DSHS_CRASH_BREAKER_COOLDOWN=0 ⇒ 冷却为 0 ⇒ 行为退回本次之前。

八、遗留(另行报告)

  1. 部署待窗口(R8) → ✅ 2026-09-13 08:02 已部署(见 §九)。
  2. 熔断态在内存:重启即清(有意)。
  3. 告警只有日志,无推送渠道(平台现状)。
  4. /api/dsh/status 的 breaker 字段暂无消费方(门户 UI 未做)。

九、部署记录(2026-09-13 08:02 · R8 已执行)

步骤 结果
抢锁 全局执行锁 + 服务器侧 op-lock(crash-breaker-78)
基线核验(关键) 逐文件比对本机 vs 服务器:服务器上仅有的 9 行「独有内容」全是被本次重写的 import/export/旧实现行(orchestrator.ts 9 行、spawner.ts 1 行、dsh.ts 2 行)⇒ 服务器版本 == 本次改动前基线,不存在被覆盖的他人改动
备份 /opt/dsh/backups/pre78-20260913-075910/(lib-pre78 + src/ + web/)
上传 6 个文件,全部转 LF(config.ts 本机是 CRLF → 必须先转),落地后 CR 行数 = 0
构建 服务器 npm run build rc=0;lib/ 出现标记:crash-breaker×7 / breakerActive×1 / instance_circuit_open×1
重启 systemctl restart dshs → active / 门户 200 / 孤儿 scope 0(PID 410287 → 413817)
新代码是否真在跑 临时会话(mksess.cjs,用完即删 deleted_sessions=1)打 /api/dsh/status → keys: running,instance,watchdog,breaker,url ⇒ 新字段已生效,值 null(未熔断,符合预期)
告警通道 /var/log/dsh-crash-breaker.log 已建(root 600,服务以 root 运行,可写)
清理 临时会话删除;op-lock / 全局锁释放

R8 影响:重启断在线用户 2–5 秒(当时有 guest 用户在线);实例引用由档案 72/77 的「回到页面自检 + 就地恢复」自动重建,无需用户手动刷新。

仍未做(L1):故意把实例反复搞崩以验证熔断(需要 6 次真崩 + 之后该用户被冷却 10 分钟)——交付时不在活跃用户身上做,留到维护窗口。