Files
dsh_ai1net_server/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

111 lines
10 KiB
Markdown
Raw 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.
# 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=""`。**
## 七、回滚
```bash
# 未 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 分钟)——交付时不在活跃用户身上做,留到维护窗口。