Files
dsh_ai1net_server/dsh-server-docs/04-调整方案/78-崩溃熔断冷却期与告警.md
T

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