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 一律写「远程服务器」。
This commit is contained in:
1 parent
c70d5d860e
commit
5ad755116e
173 files changed
+27632
No files matched your search
@@ -0,0 +1,154 @@
|
||||
# 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 修复根因)。
|
||||
Reference in new issue
Block a user