Files
dsh_ai1net_server/归档/交接单-20260924-归档/交接单_退出路径不杀实例_20260918.md
T
admin 7bd1151c67 chore(工作区): 归档交接单 27 件 + 新增 CODEBUDDY §9 工作区卫生
交接单按归属约定(正文落文档库、工作区只放指针)归档至
归档/交接单-20260924-归档/,含逐件判定 README:
- 16 件已被文档库正式版取代(T09–T21 + 覆盖网络-24/25/26)
- 4 件主题已被覆盖网络线入口汇总
- 7 件历史接续包/规划件

CODEBUDDY §9:收口清本棒 tmp、tmp 保留期 7 天、禁「待清理」中间态、
工作区入库只放文档与文件、不保留脚本副本、>60 KB 单文件须逐个判。
2026-09-24 07:58:57 +08:00

585 lines
64 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.
# 交接单 · 退出路径不杀实例(打通 E1 缺口 · 候选 `B`)
- **序号**:覆盖网络线 **序 ㉘ · 规划棒 → 单 A**(执行棒待跑)
- **立单**:2026-09-18 00:5x
- **用户拍板**:**「选 B」**(2026-09-17 23:3x,原话)—— 即三处 `teardown()` 一起改;同轮另有 **「C 登记为架构目标」**
- **上游依据**:`交接单_实例逐步拉起_20260917.md` **§8.3**(A/B/C 逐条优缺点;**倾向 B**)|同单 **§3.1**(在册文件集)|同单 **§11**(序 ㉕ 真凶取证)
- **归档号**:**129**(占号动作已执行,见 §11;⛔ 63 是空号、不补占)
- **状态**:**待执行**
> **本单与 `交接单/README.md §二` 8 段模板的对应**:§1 目标|§2 只读前置|§3 范围|§4 步骤|§5 验收(模板 6)|§6 回滚(模板 7)|**§7 决策点 + 影响面**(模板 5 + 本单要求 ④⑤)|**§8 回报格式(留空给执行棒)**|§9 回头条件|§10 未验证项|§11 指纹与状态。
---
## §0 摘要 · 口径演变留档 · 三条取证事实
### 0.1 摘要(一句话)
**把「退出进程即停掉所有在册实例」这一行为去掉** —— 退出进程把存活实例留给下一个进程,回收责任**完全**交给「启动认领(`rehydrateAdoptedScopes()`)+ TCP 探活判孤儿」这一套(序 ㉕ 已实测工作)。**主判据 = 重启 Manager / worker 后,两机 `dsh-*.scope` 计数守恒且认领报告自洽。**
### 0.2 🔴 口径演变留档(**必须如实保留**)
| 时点 | 用户答 | 落盘口径 |
|---|---|---|
| 2026-09-17 23:2x | 第 ① 问「B 和 C 哪个更符合长期主义」⇒「**选B**」;第 ② 问(实操项)⇒「**选 C(实例由独立单元托管)**」 | ①②**指向同一件事(序 ㉕ 的 E1 缺口)而答案相反** ⇒ 主代理按 ②=C 落盘,并**开纠错窗口** |
| 2026-09-17 23:3x | **行使纠错**:先「**选 B**」、再「**C 登记为架构目标**」 | **最终 = B 先做 + C 登记为架构目标**(= 23:1x 的原建议)。③ 加密档位「选 C 组密钥加密」**未变** |
⇒ 🔴 **本单按「B 先执行」出单;`C` 只能作为"架构目标"段落出现(见 §7.2),⛔ 不得按 C 出"实例解耦"的执行单。** 若读到任何旧文本仍写"按 C 执行",**一律以本段为准**。
### 0.3 🔬 本棒三条取证事实(**均为本棒现场实测,⛔ 与上游结论冲突时以本棒为准**)
1. 🔴 **处② `RemoteSpawner.teardown()` 已经就是 no-op** —— `src/supervisor/remote-spawner.ts:329-331` 与 **HEAD 版逐字相同**(`git show HEAD:…` 已核),函数体只有一行注释:`远端实例的寿命长于任何单个 Manager 副本 ⇒ 由 Manager 的归属/租约管理,不在关闭时清。` ⇒ **用户口径"三处一起改"与"处② 已符合"不矛盾**:三处**都纳入在册范围**、都加"退出不停实例"的**机器断言**,但处② **语义改动量 ≈ 0**(只补守卫/注释固化,防将来被改回去)。
2. 🔴 **两机当前活跃实例 scope 数 = `47: 0` / `106: 0`**(本棒 00:4x 实测:`ssh -p 22 bt-server` / `ssh -p 22 test106` 各 `systemctl list-units --type=scope --all | grep -c "dsh-"`)⇒ **验收必须先在真实形态夹具上做**(照序 ㉕ 的做法:真 systemd scope + 真 argv + 真监听端口),⛔ **不许拿"0 个实例"当绿**(`0 == 0` 是假绿;见 §5 P1 的反例条款)。
3. **`idle-reap` 缺省是启用的,且 TTL 很长** —— `src/config.ts:300-302`:`DEFAULT_MAX_IDLE_INSTANCES = 4`|`DEFAULT_INSTANCE_IDLE_TTL_SECONDS = 60*60*24*7`(**7 天**)|`DEFAULT_IDLE_REAP_INTERVAL_SECONDS = 60`;两机 env **均未覆盖**(只读实测:`dshs` 的 `Environment` 里只有 `DSHS_CLUSTER_LEASE_TTL_MS=30000`,无 `IDLE/REAP/TTL`)⇒ 「进程长期不重启时实例由 idle-reap / 用户访问替换来收」这一**前提成立**,但收得很慢(**7 天**)+ 有 **每 host 4 个**的常驻上限(第 5 个会淘汰 LRU)。
---
## §1 目标
**退出路径(SIGTERM → `app.onClose` / `agent.stop()`)不再停任何实例**;重启后实例**仍在册且端口可达**(TCP 判据),由启动认领接管、由 `cleanStaleScopes(uid)` 在用户访问时自然替换。
**可判定的"做完了没有"**:§5 的 **P1 + P2 + 对冲项**全绿 —— 即:重启前后 scope 数守恒(**且两侧都 ≥ 1**)+ 认领报告 `adopted + stopped === scanned` 自洽且 `scanned > 0` + 三处 `teardown()` 的"不停实例"断言全绿。
---
## §2 只读前置(执行前必须先核实,**逐条给命令与期望输出**)
> 全部为**只读**;⛔ 不改任何文件、不改任何生产值。**任一条与期望不符 ⇒ 停下报告,不许"先改了再看"。**
| # | 核实内容 | 命令 | 期望输出 |
|---|---|---|---|
| 1 | 代码仓状态 + 三处 `teardown` 现状 | `cd D:/github/dsh_shenxian && git status --porcelain src/supervisor/ && grep -n -A6 "async teardown" src/supervisor/orchestrator.ts src/supervisor/remote-spawner.ts src/supervisor/leased-spawner.ts` | `git status` = **仅** `M src/supervisor/orchestrator.ts`(序 ㉕ 留下的改动)|① `orchestrator.ts` 体 = `clearInterval(reapTimer)` + **`for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)`**|② `remote-spawner.ts` 体 = **空体(只有注释)**|③ `leased-spawner.ts` 体 = `this.stopHeartbeat()` + `await this.inner.teardown()`。🔴 **若 ① 已变成空体 ⇒ 本单已被别人做过 ⇒ 停下报告。** |
| 2 | 认领链仍在(⛔ 不许为 0) | `cd D:/github/dsh_shenxian && grep -c "rehydrateAdoptedScopes" src/supervisor/orchestrator.ts; grep -c "cleanStaleScopes" src/supervisor/orchestrator.ts` | `rehydrateAdoptedScopes` **≥ 2**(定义 + 构造函数调用)|`cleanStaleScopes` **≥ 1**。🔴 **`cleanStaleScopes` = 0 ⇒ 停下报告** —— 那是档案 30 本体(spawn 前清同 uid),⛔ 不许为了本单把它去掉。 |
| 3 | 两机活跃实例数(决定夹具规模) | `ssh -p 22 bt-server 'systemctl list-units --type=scope --all --no-legend \| grep -c "dsh-"'` + `ssh -p 22 test106 '…同上…'` | 本棒读数 = **0 / 0**。⚠️ **为 0 不是"没问题",而是"夹具必须先造"**(见 §4 S1)。 |
| 4 | 106 worker 侧 idle-reap 是否走缺省 | `ssh -p 22 test106 "systemctl show dshs-worker -p Environment \| tr ' ' '\n' \| grep -iE 'IDLE\|REAP\|TTL'"` | **无输出**(=走代码缺省:60 s 间隔 / 7 d TTL / 4 上限)。⚠️ 若读出 `=0` 的值 ⇒ 记入 §10("106 上没人收实例"缺一环)。 |
| 5 | 回滚点存在性 + 产物一致性 | `ssh -p 22 bt-server 'ls -d /opt/dsh/backups/* \| tail -3; md5sum /opt/dshs/lib/supervisor/orchestrator.js'`(106 同查 `/opt/dshs-cluster/lib/…`) | 至少一个 `seq*` 目录|两机 `orchestrator.js` md5 **同值**(序 ㉕ 铺的 `5bfb15e00b592c293548ae17b639399b`;⚠️ 若已变 ⇒ 现核取真值再写进 §5/§6 的基线,**⛔ 不写死**)。 |
| 6 | 基线三件套 + 参数表指纹 | `cd D:/github/dsh_shenxian && npm run build >/dev/null && npm.cmd test 2>&1 \| tail -5`;`node scripts/overlay-probe.cjs --table`;`sed '/^## §10 指纹/,$d' "E:/ProgramData/AI技能/aliyun-dsh-server/参数表_覆盖网络_20260917.md" \| md5sum` | `npm test` = **201 pass / 200 / 0 fail / 1 skip**(序 ㉗ 收口值)|`overlay-probe --table` = **19 PASS / 1 SKIP / 1 FAIL**(1 FAIL = `OBS-21`,**是本线已知在册缺口**、⛔ 非本单引入)|参数表指纹 = **`7fc5889341b99fb26bd05cad0313960b`**(本棒 00:4x 现核)。⛔ **判据一律用"现核值",本单写的是取数时刻的快照。** |
---
## §3 范围
### 3.1 在册文件集
| 面 | 文件 | 说明 |
|---|---|---|
| 改动 | `src/supervisor/orchestrator.ts` | `LocalSpawner.teardown()`(`:604`)—— **本单唯一语义变动处** |
| 改动(**授权扩范围**) | `src/supervisor/remote-spawner.ts` | `teardown()`(`:329`)—— 取证已是 no-op;**只补"不停实例"断言/注释固化** |
| 改动(**授权扩范围**) | `src/supervisor/leased-spawner.ts` | `teardown()`(`:252`)—— 拆开"停心跳"与"停实例"的耦合语义 |
| 改动 | `参数表_覆盖网络_20260917.md` | §6 新增观测项 `OBS-22`(⛔ 无新生产键) |
| 改动 | `scripts/overlay-probe.cjs` | 新增 `OBS-22` |
| 新增 | `test/orchestrator-teardown.test.mjs` | 三处 `teardown()` 的"不停实例"单测(⛔ **不改 `package.json` test 列表结构** —— 那是硬编码文件列表,加进去会改 `npm test` 总数;照序 ㉕ 先例刻意不进) |
### 3.2 🔑 扩范围授权依据(**必须写在单里,供第三方复核**)
- **用户原话**:「**选 B**」(**2026-09-17 23:3x**)。候选 `B` 的定义原文(`交接单_实例逐步拉起_20260917.md` **§8.3**)即 = "三处一起改:`LocalSpawner.teardown()` + `RemoteSpawner.teardown()` + `LeasedSpawner.teardown()`"。
- ⇒ `src/supervisor/remote-spawner.ts` 与 `src/supervisor/leased-spawner.ts` **不在** `交接单_实例逐步拉起_20260917.md §3.1` 在册集,**现已获准纳入**。
- ⚠️ 序 ㉕ 曾按 **R7**(超出在册集)**停下报告**、未动手;**本单解除该阻塞**。⛔ 但**"获准纳入"只覆盖这两个文件** —— 再超 ⇒ 仍按 §9-5 停下报告。
### 3.3 ⛔ 不动什么(防顺手扩大)
- ⛔ **不许删** `cleanStaleScopes(uid)`(spawn 前清同 uid = **档案 30 本体**)。
- ⛔ **不许把启动流程改成"什么都不做"**(孤儿会失控)—— `rehydrateAdoptedScopes()` 的扫描/探活/停孤儿三步一步都不能少。
- ⛔ **不许"重启后重新 spawn 一遍"**(换端口换名字 + 启动风暴)。
- ⛔ **认领不写回 `mains`**(否则 `enter` 复用分支会等一个**永不出现**的 `launchToken` ⇒ 用户被 503 挡住)。
- ⛔ **token 不落盘**(改官方 dsh 取 token 违 **R2**)。
- ⛔ 不改 bwrap 参数(🔴 **47 是 bubblewrap 0.4.0,`--perms` 属 0.5+** ⇒ 会起不来)。
- ⛔ 不动 `RELAY_FAILOVER_*` / `HB_SEC` / burst;⛔ 不动 relay / 选路 / 内容分发(本单与覆盖网络数据面无关)。
- ⛔ 不新增公网监听口、不改 `nft` / `nginx`(**R5**)。
- ⛔ 不 commit / 不 push(**R7**)。
- ⛔ 不改任何**生产值**(**D1**)。
---
## §4 步骤(每步自带一次可执行的验证)
> 三条硬门贯穿全程:**D1**(不改任何生产值)|**R5**(不新增公网监听口 / 不改 nft·nginx)|**R7**(不 commit / 不 push)。
### S0 · 开工对账
- 跑 §2 全部 6 条并**原样留档**(命令 + 原始输出)。任何一条不符 ⇒ 停下报告。
- 抢全局执行锁:`bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序28单A执行棒"`;**抢不到 = 有会话在跑 ⇒ 只报告并停**(**R9**:⛔ 不删锁 / 不接管 / 不等重试)。
- **验证**:guard 返回「✓ 已持全局执行锁」。
### S1 · 造**真实形态**夹具(⚠️ 因为两机活跃实例 = 0,本步不可省)
- 照序 ㉕ 的做法:造一个**真 systemd scope + 真 argv + 真监听端口**的既有实例(`systemd-run --scope` 起一个监听 `127.0.0.1:<区内端口>` 的进程,`--chdir <dataRoot>/users/<uid>/…`,`-u dsh-<uid>-<hex>.scope`)。
- **验证**:`ssh -p 22 bt-server 'systemctl list-units --type=scope --all | grep -c "dsh-"'` ⇒ **≥ 1**;`ss -lntp 'sport = :<端口>'` ⇒ 有监听。
- ⚠️ 夹具**必须是真实形态** —— 序 ㉕ 已实证:假夹具(只有 unit 名、无真进程)会让"认领逻辑工作"这个结论**站不住**。
### S2 · 逐处最小改动(三处,⛔ 顺序无关但必须三处都动)
| 处 | 位置 | 改前语义 | 改后语义(**最小差异**) |
|---|---|---|---|
| ① | `orchestrator.ts` `LocalSpawner.teardown()` `:604` | `clearInterval(reapTimer)` + **`for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)`** | 保留 `clearInterval(reapTimer)`;**删掉那行循环**,改为不做任何实例处置。注释写明:回收责任移交「启动认领 + TCP 探活判孤儿」+ `cleanStaleScopes(uid)`(访问时替换)+ idle-reap(缺省 60 s / 7 d / 4 上限)。 |
| ② | `remote-spawner.ts` `:329` | 空体(只有一行注释) | **语义不变**;补一句断言式注释 + (可选)在函数体内加一行"可被 grep 的守卫标记",供 §5 对冲项的静态断言使用。 |
| ③ | `leased-spawner.ts` `:252` | `this.stopHeartbeat()` + `await this.inner.teardown()` | **拆开两件事**:`stopHeartbeat()` **保留**(心跳停 ⇒ 租约按 TTL 过期,这是"进程不在就别再续租"的正确语义);`await this.inner.teardown()` **保留**(它只是转发,inner = `RemoteSpawner` ⇒ no-op)。⇒ **净改动 ≈ 注释 + 守卫标记**;⛔ **严禁在此处向 worker 下发 `POST /stop`**(今天没有,将来也不许)。 |
- **验证**:`grep -n -A6 "async teardown"` 三处,人工逐字比对"改后 ≠ 改前"仅限上表差异(处②/③ 应为"语义不变")。
- ⚠️ 处① 的 `this.mains.delete/watchdogs.delete/lastActive.delete` **不在 `teardown()` 里**(`teardown()` 只调 `stop()`)—— `stop()` 内部会做清理,**`stop()` 本身一行不动**(它仍是"用户显式停实例"的正路,被路由层调用)。
### S3 · 单测(**先红后绿**)
- 新增 `test/orchestrator-teardown.test.mjs`,三组断言:
- ① `LocalSpawner.teardown()` 后,假 `spawnInstance` 造出的实例**未被停**(`stop` 桩调用计数 **= 0**);
- ② `RemoteSpawner.teardown()` 对 HTTP 桩的下发计数 **= 0**;
- ③ `LeasedSpawner.teardown()` 后:**心跳停**(计数 **= 1**)∧ 实例停(计数 **= 0**)。
- **先红**:临时把三处改回原语义 ⇒ **3 红**(逐个点名是哪一处)⇒ 还原 ⇒ **全绿**、md5 逐字一致。
- 运行:`node --test test/orchestrator-teardown.test.mjs`(rc=0)。
- ⛔ **不改 `package.json`**(硬编码文件列表,加进去会改 `npm test` 总数)。
### S4 · 真机先红(新判据对旧生产)
- 在**未部署新 lib** 的生产上跑新增的 `OBS-22` ⇒ 期望 **FAIL**,并打印可区分证据(见 §5)。
- **验证**:探针 rc=1 且 FAIL 点名 `OBS-22`。
### S5 · 部署 + 真机后绿
- `npm run build` ⇒ 铺 `lib/supervisor/{orchestrator,remote-spawner,leased-spawner}.js` 到**四处**:47 `/opt/dshs/lib/supervisor/`、106 `/opt/dshs-cluster/lib/supervisor/`(⚠️ 若 relay 侧也有同名副本则一并核;本单不涉及 relay)。
- 回滚点:`/opt/dsh/backups/seq28-<ts>/`(先备份旧 `.js`,md5 记录)。
- 重启:47 `systemctl restart dshs`、106 `systemctl restart dshs-worker`(R8:开发环境服务器,动手前一句话说明在动什么)。
- **验证**:`md5sum` 各副本同值;重启后 §2-3 的单元 `is-active` 全 `active`。
### S6 · 主判据(P1 / P2)+ 对冲项
- 按 §5 执行 P1(scope 数守恒,**两侧都 ≥ 1**)、P2(认领报告自洽且 `scanned > 0`)、对冲项(静态 + 动态 + `OBS-22`)。
- **验证**:三项全绿且 `OBS-22` PASS;P1 成立而 P2 不成立时**必须点名**(见 §5 的"分层判定")。
### S7 · 零回归 + 收口
- `npm.cmd test` ⇒ **不退化**(§2-6 现核基线)|`node scripts/overlay-probe.cjs --scene all --table` ⇒ **12 PASS / 0 SKIP / 0 FAIL**|`node scripts/overlay-probe.cjs --table` ⇒ 基线 + 本棒新增 1 行(`OBS-21` 仍 FAIL = 线内在册缺口,**非本单引入**,报告里点名)。
- 清理夹具(S1 造的 scope 全部 `systemctl stop` + 撤销)——⚠️ **夹具不得留在生产**。
- 回填本单 **§8**;推进 `接续入口_覆盖网络线_20260916.md` §0 + §2;写工作区日志(append-only)。
---
## §5 验收判据(**可机器断言、可被第三方复现**)
> 判据分三层:**P0 前提 → P1 主判据 → P2 回收链**。🔴 **P1 与 P2 必须分开报** —— 这正是序 ㉗ 把「判据成立」与「冗余/链路建成」分开的同一手法:**P1 绿而 P2 红** 是一个**合法且重要**的结果,⛔ 不得合并成"全绿"。
### P0 · 前提(夹具就位)
```bash
ssh -p 22 bt-server 'systemctl list-units --type=scope --all --no-legend | grep -c "dsh-"'
```
- **期望**:**≥ 1**。`0` ⇒ **P0 不成立 ⇒ 停下来造夹具**(§4 S1),⛔ 不许直接跑 P1(`0` 会让判据两边都是 0)。
### P1 · 主判据(**退出不再杀实例**)
```bash
N_BEFORE=$(ssh -p 22 bt-server 'systemctl list-units --type=scope --all --no-legend | grep -c "dsh-"')
ssh -p 22 bt-server 'systemctl restart dshs'
sleep $(( N_BEFORE * 1 + 5 )) # 认领节流 500ms/条 + 单条探活 2000ms ⇒ 留足余量
N_AFTER=$(ssh -p 22 bt-server 'systemctl list-units --type=scope --all --no-legend | grep -c "dsh-"')
echo "before=$N_BEFORE after=$N_AFTER"
```
- **判据**:**`N_AFTER == N_BEFORE`**(rc=0 **且** `N_BEFORE ≥ 1`)。
- 🔴 **反例条款(防空绿)**:`N_BEFORE == 0 && N_AFTER == 0` ⇒ **判 FAIL**(不是 PASS)。判据必须能区分"守恒"与"两边都空"。
- 🔴 **反向夹具(证明判据真的在测这件事)**:把处① 临时改回原语义(或用一个 `DSHS_TEARDOWN_STOP=1` 的**测试专用**接线 —— ⛔ 该接线**只进单测、不许进生产 env**)⇒ 重跑 ⇒ **`N_AFTER == 0` ⇒ 判据必须变红**。**先红后绿**两腿都要留原文。
- **同一判据在 106 上复跑一遍**(`ssh -p 22 test106 '…restart dshs-worker…'`)—— ⚠️ **两机都要绿**:47 走的是 `dshs`(Manager)的 `LocalSpawner`/`LeasedSpawner→RemoteSpawner`,106 走的是 `dshs-worker`(`agent.ts:143` 的 `LocalSpawner`),**同一语义三份实现 ⇒ 必须逐台断言**。
### P2 · 回收链是否真建成(**与 P1 分开**)
```bash
E=$(date -u +%s)
ssh -p 22 bt-server "journalctl -u dshs --since @$E --no-pager 2>/dev/null | grep -c '\[rehydrate\]'"
ssh -p 22 bt-server "journalctl -u dshs --since @$E --no-pager 2>/dev/null | grep -o '\[rehydrate\] summary .*'"
```
- **期望**:① 行数 **≥ 1**;② summary 满足 **`adopted + stopped === scanned`** 且 **`scanned > 0`**;③ 有 `[rehydrate] adopted …`(含 `scope=` / `uid=` / `port=`)+ `[rehydrate] probe OK … :<port>`。
- 🔴 **分层判定(本单最重要的一条)**:
- `scanned > 0` ∧ `adopted ≥ 1` ∧ `probeOk ≥ 1` ∧ 自洽 ⇒ **P1 + P2 双绿**。
- `scanned == 0` ∧ `N_AFTER == N_BEFORE > 0` ⇒ **P1 绿、P2 红** ⇒ 报告里**必须点名**:"**实例没被杀(P1 成立),但认领没扫到存量 scope(回收链未建成)**",⛔ **不得写"全绿"**。
- `scanned > 0` ∧ `stopped > 0` ⇒ 逐条读 `[rehydrate] ⛔ …` 原因行,确认"按旧行为停掉"的是**端口不通**的条目(判据口径 = **孤儿 = 端口不通**,⛔ 不是"启动了却不认识")。
- ⚠️ **journalctl 必须用 `--since @<epoch>`**(不吃 `date -Is` 的时区偏移),且**"查询失败"必须与"确无该行"可分**:把输出的**原文字节数**一并打印(`0 字节` = 读取失败/`>0` = 真没有该行)。
### 对冲项 · "同一语义三份实现"的自动化对冲(**B 的专属风险**)
- **静态**:
```bash
cd D:/github/dsh_shenxian
grep -n -A6 'async teardown' src/supervisor/orchestrator.ts src/supervisor/remote-spawner.ts src/supervisor/leased-spawner.ts \
| grep -nE 'this\.stop\(|inner\.stop\(|killInstance\(|/stop'
```
**期望 = 无命中**(rc=1)。🔴 **任何一处仍在 `teardown()` 里停实例 / 向远端下发停止 ⇒ 判据判红**。
- **动态**:`test/orchestrator-teardown.test.mjs` 三组断言全绿(§4 S3)。
- **探针**:新增 **`OBS-22`**,**三判据**:
① 重启前后 scope 数守恒(`N_AFTER == N_BEFORE ≥ 1`);
② `adopted + stopped === scanned` ∧ `scanned > 0`;
③ 三处 `teardown()` 的静态守卫(上条 grep 无命中)。
⛔ **缺行即 FAIL**,并把 `journalctl` 的**原文字节数**一并打出(`0` = 读取失败 vs `>0` = 真无该行,二者必须可分)。
⚠️ **编号须现核顺延**:`grep -oE 'OBS-[0-9]+' scripts/overlay-probe.cjs | sort -u | tail -1`(本单立单时 = `OBS-21`)—— 若已被占用则顺延(序 ㉖ 踩过"编号被前序占用")。
### 判据自证表(**必须原样回填 §8**)
| 层 | 判据 | 命令 | 期望 | 实测 |
|---|---|---|---|---|
| P0 | 夹具就位 | §5 P0 | ≥ 1 | 待填 |
| P1 | 47 scope 守恒 | §5 P1 | `N_AFTER == N_BEFORE ≥ 1` | 待填 |
| P1 | 106 scope 守恒 | §5 P1(test106) | 同上 | 待填 |
| P1 | 反向夹具变红 | 临时还原处① 后重跑 | `N_AFTER == 0` | 待填 |
| P2 | 认领行存在 | §5 P2① | ≥ 1 | 待填 |
| P2 | 报告自洽 | §5 P2② | `adopted+stopped==scanned ∧ scanned>0` | 待填 |
| 对冲 | 静态守卫 | §5 对冲-静态 | 无命中 | 待填 |
| 对冲 | 动态单测 | `node --test test/orchestrator-teardown.test.mjs` | rc=0 | 待填 |
| 对冲 | `OBS-22` | `node scripts/overlay-probe.cjs --table` | PASS | 待填 |
| 零回归 | `npm test` | `npm.cmd test` | 不退化(现核基线) | 待填 |
| 零回归 | 场景 | `--scene all --table` | 12 PASS / 0 SKIP / 0 FAIL | 待填 |
| 零回归 | 探针 | `--table` | 基线 +1 行 | 待填 |
| D1 | 生产值未改 | `grep -c COOLDOWN_MS=0 <演练/夹具/回滚路径文件>` | **0** | 待填 |
---
## §6 回滚
**回滚 = 恢复三处 `teardown` 原语义 ⇒ 秒级。** 两条路,任选其一(**优先路 A**:最干净)。
### 路 A · 语义回滚(推荐,分钟级)
```bash
cd D:/github/dsh_shenxian
# ① 三处改回原语义(处① 恢复那行循环;处②/③ 恢复为空体/原写法)
git diff src/supervisor/ # 人工核对只有这三处
npm run build
scp -P 22 lib/supervisor/orchestrator.js bt-server:/opt/dshs/lib/supervisor/
scp -P 22 lib/supervisor/remote-spawner.js bt-server:/opt/dshs/lib/supervisor/
scp -P 22 lib/supervisor/leased-spawner.js bt-server:/opt/dshs/lib/supervisor/
scp -P 22 lib/supervisor/*.js test106:/opt/dshs-cluster/lib/supervisor/
ssh -p 22 bt-server 'systemctl restart dshs'
ssh -p 22 test106 'systemctl restart dshs-worker'
```
- **验收**:重启后 `dsh-*.scope` 计数 **回落到 0**(= 恢复"退出即收")⇒ 这就是"回滚生效"的机器判据。
- ⚠️ 回滚**会带走存活实例**(这正是原语义)⇒ **回滚前先看有没有在线用户**。
### 路 B · 产物回滚(秒级,最省事)
```bash
ssh -p 22 bt-server 'cp /opt/dsh/backups/seq28-<ts>/{orchestrator,remote-spawner,leased-spawner}.js /opt/dshs/lib/supervisor/ && systemctl restart dshs'
ssh -p 22 test106 'cp /opt/dsh/backups/seq28-<ts>/*.js /opt/dshs-cluster/lib/supervisor/ && systemctl restart dshs-worker'
```
- **回滚点**:`/opt/dsh/backups/seq28-<ts>/`(**S5 落盘时创建**,旧 md5 一并记录在 §8)。
- ⚠️ 路 B 必须有 `seq28-<ts>` 存在;**没有回滚点不准上线**。
### 🔴 回滚路径硬禁令
⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径** —— 它会**自锁**(候选链卡在第一个失败候选上,实测 **121–123 s 无切换**);判定见 §9-1。
---
## §7 决策点 · 影响面与关系
### 7.1 决策点
- **已定(用户,2026-09-17 23:3x)**:执行候选 **`B`**(三处同改)|`C` **登记为架构目标、本阶段不进执行**。
- **已定(本棒,可推翻)**:
- 处② 取证已是 no-op ⇒ **只补断言/注释固化**,不做语义改动(依据 = §0.3-1,`git show HEAD:` 逐字比对)。
- 处③ 保留 `stopHeartbeat()`(租约语义正确),**只把"停实例"剥离**。
- 探针编号 = `OBS-22`(现核顺延)。
- **待定 = 空**。⛔ 本单无上抛项;执行棒若遇边界外事项(业务优先级 / 花钱 / 合规 / 凭据 / 影响面超出本平台)⇒ 停下报告。
### 7.2 🔑 架构目标 `C`(systemd scope 单一真相源)—— **登记,⛔ 不进本阶段执行**
**是什么**:把"实例归属"从 **Manager 进程内存**(`mains` / `watchdogs` Map)搬到 **systemd scope 这一单一真相源** ⇒ Manager 退化为"发现 + 代理 + 记账";Manager 重启**天然**不影响实例,不再需要"退出路径不收 + 启动认领"这对补丁。
**为什么现在不做**(三条,客观):
1. **收益随规模兑现,当前规模收益≈0**:47 只有 **0–2** 个实例(本棒实测活跃 scope = 0/0),"归属不一致"的痛点还没出现。
2. **106 的 provisioner 尚未铺** ⇒ C 的收益(大规模自动编排)在 106 上无处落地。
3. **改动面最大**:C 要动 spawn 路径 + 归属存储 + 路由层,比 B(3 个文件)大一个量级,且**与"进程退出"这条路径完全解耦后**才能真正验证。
**什么信号出现才回头做 C**(**任一命中即回头**):
- 实例数 **≥ 阈值**(建议先与本线参数表对齐,例如每 host 常驻实例数 ≥ 8,即 ≥ 2× 缺省 `maxIdleInstances=4`);
- 出现**第二种 spawner**(今天的实现形态扩到第 4 类 ⇒ "同一语义 N 份实现"的对冲成本超过 C 的一次性成本);
- 出现"**进程被 SIGKILL 后无人回收**"的**实测案例**(B 的认领链只在**启动时**扫,SIGKILL 后若不重启 → 实例长期无人管;今天靠 idle-reap 兜,但 TTL = **7 天**)。
**从 B 迁移到 C 的路径**(顺序,⛔ 不要跳跃):
1. 本单(B)先落 ⇒ 拿到"退出不停实例 + 启动认领"这条**已验证**的容错链(= C 的**安全网**);
2. 再把"实例归属"抽成**可替换的后端**(内存 Map ↔ systemd scope 查询),**先并存**、以 B 的行为为基线做对照;
3. 最后把 Manager 的 `mains` 降级为**缓存**,归属判定只认 scope;此时 **B 的"退出不停实例"就是天然行为**(不再是补丁),可回头**删掉** `teardown()` 的守卫断言;
4. 每一步都保留 B 的回滚路径(§6)作为兜底。
### 7.3 影响面与前提(**必须接受,否则不要做 B**)
- 🔴 **影响面 = 所有实例的生命周期语义**:实例**不再随进程退出而回收**。
- 🔴 **前提(明写)**:**进程长期不重启时,实例由 idle-reap / 用户访问替换来收** —— 本棒已量化:`maxIdleInstances = 4`(每 host)+ `instanceIdleTtlSeconds = 604800`(**7 天**)+ `idleReapIntervalSeconds = 60`(`src/config.ts:300-302`,两机 env 未覆盖 ⇒ 走缺省)。**即:最坏情形下实例会占着端口/内存最长 7 天,或被"第 5 个实例"挤掉。**
- ⚠️ **106 的 provisioner 尚未铺** —— 本单**不依赖**它:106 侧同样走"`dshs-worker` 退出不停实例 + 启动认领",idle-reap 也走同一份代码缺省。⚠️ 但"**实例长期不重启时谁来收**"在 106 上只靠 idle-reap(7 天),**记入 §10-3**。
### 7.4 与 lease / 配额记账 / 端口分配 / `dsh_hosts` / provisioner 的关系
| 对象 | 本单下的影响 | 处置 |
|---|---|---|
| **lease**(`src/supervisor/lease.ts`) | `LeasedSpawner.teardown()` 仍 `stopHeartbeat()` ⇒ 租约按 TTL(`DSHS_CLUSTER_LEASE_TTL_MS=30000`,47 实测)过期 ⇒ 归属记录会与"仍在跑的实例"**不一致**。🔴 这是 B 的**真实新增风险**。 | 靠现有的两道兜住:`cleanStaleScopes(uid)`(spawn 前清同 uid ⇒ 防双实例共 profile)+ 认领时的**探活**。⚠️ **未实测** ⇒ §10-1 + §9-6 回头条件。 |
| **配额记账**(`dsh_hosts.used_mb` / 心跳) | Manager 重启后心跳中断 ⇒ host 可能被短暂判离线,但实例仍在 ⇒ 记账**漂移**。worker 心跳恢复后**重报**(`used_mb` 由 worker 本地核算)⇒ 应自愈。 | §10-2 未实测。 |
| **端口分配**(`--base` / `--span`,`src/supervisor/spawn.ts:79/106` 的 `findFreePortInRange` / `findInstancePort`) | 🔴 **端口不再随进程退出而释放** ⇒ 区间**占用更久**、`findFreePortInRange` 耗尽风险 **↑**。 | 替换路径(`cleanStaleScopes(uid)` → 换端口换 token)会释放旧端口。⚠️ 耗尽会**抛** `实例端口区间已耗尽` ⇒ §9-3 回头条件(⛔ 不许私自扩 `--span`)。 |
| **`dsh_hosts` 表** | **不受影响** —— 该表记 host/endpoint/`via`/容量,与实例生命周期无关(`src/db/pg.ts:605`)。 | 无动作。 |
| **provisioner**(106 未铺) | **不阻塞本单**(见 §7.3)。 | 无动作;若 provisioner 落地后出现"自动铺 ⇒ 自动起",须回头核 §9-6。 |
---
## §8 回报格式(**留空给执行棒**)
> 执行棒**逐节回填**,每节必须给**命令 + 原始输出(或原文摘录)+ 判定**。⛔ 不许只写结论。
### 8.1 开工对账(§2 六条)
**抢锁**:`✓ 已持全局执行锁(覆盖网络线-序30执行棒-单A)` ✅
| # | 核实内容 | 实测(2026-09-18 00:5x) | 判定 |
|---|---|---|---|
| 0 | 本单 **§8 前前缀** | `sed '/^## §8 回报格式/,$d' <本单> \| md5sum` = **`c9aeac82381f569fc8b9effad0fefa99`** | ✅ 与单里记的一致 |
| 1 | `git status --porcelain src/supervisor/` | **仅** ` M src/supervisor/orchestrator.ts`(序㉕ 留下的改动) | ✅ 与期望一致 |
| 1 | 三处 `teardown()` 现状(**改前原文**) | ① `orchestrator.ts:604` = `if (this.reapTimer !== undefined) clearInterval(this.reapTimer)` + `for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)`|② `remote-spawner.ts:329` = **空体,只有一行注释**(`远端实例的寿命长于任何单个 Manager 副本 ⇒ …`)|③ `leased-spawner.ts:252` = `this.stopHeartbeat()` + `await this.inner.teardown()` | ✅ **逐字与 §2 期望一致** ⇒ ⛔ 处① 未被别人做过;处② 的 no-op 取证(§0.3-1)**复核成立** |
| 1 | HEAD | `04776af` | ✅ |
| 2 | 认领链计数 | `grep -c rehydrateAdoptedScopes src/supervisor/orchestrator.ts` = **2**(≥2)|`grep -c cleanStaleScopes` = **4**(≥1) | ✅ ⛔ 均非 0 |
| 3 | 两机活跃实例数 | 47 = **0** / 106 = **0** | ✅ 与期望一致 ⇒ **S1 造夹具不可省** |
| 4 | 106 worker 侧 idle-reap 是否走缺省 | `ssh -p 22 test106 "systemctl show dshs-worker -p Environment \| tr ' ' '\n' \| grep -iE 'IDLE\|REAP\|TTL'"` ⇒ **无输出**(rc=1) | ✅ 走代码缺省(60 s / 7 d / 4 上限) |
| 5 | 回滚点存在性 + 产物一致性 | 两机 `/opt/dsh/backups/` 下 `seq25-…` / `seq26-…` / `seq27-20260918-000259` 均在册;两机 `lib/supervisor/orchestrator.js` md5 **同值** = **`5bfb15e00b592c293548ae17b639399b`**(=序㉕ 铺的值,**未变** ⇒ 未写死) | ✅ |
| 6 | 基线三件套 + 参数表指纹 | `npm.cmd test` = **201 tests / 200 pass / 0 fail / 1 skip**(=序㉗ 收口值)|参数表指纹(§10 前截断) = **`7fc5889341b99fb26bd05cad0313960b`**|`overlay-probe --table` 基线 = **19 PASS / 1 SKIP / 1 FAIL**(1 FAIL = `OBS-21`,线内在册缺口)|`grep -oE 'OBS-[0-9]+' scripts/overlay-probe.cjs \| sort -u \| tail -1` = **`OBS-21`** ⇒ 新项编号 = **`OBS-22`** | ✅ 全部与期望一致(编号无须顺延) |
**S0 判定 = 六条全符合期望 ⇒ 开工。零不符、零停下。**
### 8.2 S1 夹具(真实形态)—— 🔴 **方法修正:用「平台真路径 spawn」,⛔ 不用合成 scope**
**为什么改方法(技术自决,可推翻 ⇒ 附理由与证据)**:单 §4 S1 原写「用 `systemd-run --scope` 造一个真 argv 的既有实例」。**开工后取证发现该夹具不可判别**:`LocalSpawner.teardown()` 的旧语义是 `for (user of mains.keys()) await this.stop(user)` —— **只停"自己在册(`mains`)"的实例**,而合成 scope **不在 `mains` 里** ⇒ 旧代码下它也**不会被杀** ⇒ `N_BEFORE == N_AFTER ≥ 1` 在**改前改后都成立** ⇒ 判据恒绿(**假绿**),且 §5「反向夹具必须变红」**无法达成**。
⇒ 改为**用平台真实路径 spawn**(`mksess` 临时会话 + `POST /api/dsh/enter`,即 R4 规定的实例面手段)⇒ 得到的实例**真 systemd scope + 真 argv + 真监听端口 + 真被 worker 的 `mains` 追踪** —— 比合成 scope **严格更强**,且**使 P1 具备判别力**(见 8.5 真机先红:旧生产下实测 `1 → 0`)。
**夹具记录**:
| 项 | 47(w-47 · 用户 `admin` / uid 114801) | 106(w-106 · 用户 `guest` / uid 100002) |
|---|---|---|
| scope 名(首次) | `dsh-114801-e9bc4a4e.scope` | `dsh-100002-70a193b7.scope` |
| 实例端口 | `20000`(本机在听:`ss -lntp` 有 `127.0.0.1:20000`) | `21000` |
| 真实 argv(摘) | `--chdir /var/lib/dshs/users/cce6d1cd-…/ws -- setpriv --reuid 114801 --regid 114801 --clear-groups /usr/local/bin/dsh --profile web --host 127.0.0.1 --port 20000` | `--chdir /var/lib/dshs/users/4092b965-…/ws -- setpriv --reuid 100002 … --port 21000` |
| 起止 | 起 00:56:5x(`enter` 200)|RED 腿 00:58:40 被杀|GREEN 腿 01:0x 重建 | 起 00:57:0x(`enter` 200)|RED 腿 00:58:48 被杀|GREEN 腿 01:0x 重建 |
| `enter` 原文 | `{"kind":"session","instance":{"id":"a3f6076e-…","port":20000,"status":"running"},"url":"https://admin.alotbuy.com/"}` | `{"kind":"session","instance":{"id":"818a0a27-…","port":21000,"status":"running"}}` |
⚠️ **一处如实留白**:`ssh -p 22 bt-server 'enter 时 Host: guest.alotbuy.com ⇒ http=404 not found'` —— guest 子域用**主域 Host** 才路由到 `/api/dsh/enter`(admin 同)。这是**探针/验收脚本的 Host 选择问题,⛔ 不是平台缺陷**(会话本身已由 `sid` cookie 认定,换 Host 后即 200)。
### 8.3 三处改动(改前 / 改后原文)
| 处 | 文件 | 改前 | 改后 | 语义变动量 |
|---|---|---|---|---|
| ① | `src/supervisor/orchestrator.ts` `LocalSpawner.teardown()` | `clearInterval(reapTimer)` + **`for (…) await this.stop(userId)`**(逐个杀在册实例) | `clearInterval(reapTimer)`;**循环整行删除**;补 17 行注释(回收责任移交三条:`rehydrateAdoptedScopes` / `cleanStaleScopes(uid)` / idle-reap)+ 守卫标记 `// ⛔ 退出不停实例(guard: teardown-must-not-stop-instances)` | 🔴 **真改**(本单唯一语义变动处) |
| ② | `src/supervisor/remote-spawner.ts` `teardown()` | 空体(一行注释) | 空体(`guard:` 守卫标记 + 原注释 + 5 行"取证已是 no-op / 只固化断言"说明) | **≈0(语义不变,只补守卫)** |
| ③ | `src/supervisor/leased-spawner.ts` `teardown()` | `stopHeartbeat()` + `await this.inner.teardown()` | **两行都保留**(拆开"停心跳"与"停实例"的耦合语义)+ 守卫标记 + 8 行说明(含"心跳停 ⇒ 租约过期 ⇒ 归属记录与实际不一致"这条如实账) | **≈0(语义不变,只补注释/标记)** |
**改动前后逐字核对**:`grep -n -A7 "async teardown"` 三处,差异**仅限上表**(处①删 1 行;处②/③ 增注释与标记)⇒ ✅ 未越界(⛔ 未碰 `stop()`、未碰 `reapOnce`、未碰认领链)。
**`stop()` 仍一行未动**(它仍是"用户显式停实例"的正路,被路由层调用)。
### 8.4 单测先红后绿(原文级)
新增 `test/orchestrator-teardown.test.mjs`(9 用例;**刻意不进 `npm test`** —— `package.json` 的 `test` 是硬编码文件列表,加进去会改测试总数,照 序㉕ 先例)。
**运行**:`node --test test/orchestrator-teardown.test.mjs`
**红腿(临时把三处改回原语义)** ⇒ `# tests 9 / # pass 6 / # fail 3`:
```
not ok 1 - T1 ⛔ LocalSpawner.teardown() 不得停任何在册实例(stop 桩计数 = 0)
error: |- expected: 0 actual: 2 operator: 'strictEqual'
not ok 5 - T5 ⛔ 三处 teardown() 体内均无停实例 / 下发停止(静态守卫,≡ 交接单 §5 对冲项)
actual: [ 'orchestrator.ts: for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)' ]
not ok 6 - T6 守卫标记在位(三处都带 guard: teardown-must-not-stop-instances,防被静默改回去)
以下文件的 teardown() 缺守卫标记(逐处点名)⇒ orchestrator.ts / remote-spawner.ts / leased-spawner.ts
```
⇒ 🔴 **3 红且逐处点名**(T1 动态 = `stop` 桩实际被调 **2** 次;T5/T6 静态 = 逐文件点名)。
**同时如实标注**:`T4`(处③ 的动态断言)改前改后**都是绿的** —— 因为处③ 的 `inner` 是 `RemoteSpawner`,**它的 `teardown()` 取证本来就是 no-op**(§0.3-1)⇒ 候选 `B` 的**唯一语义变动处是 ①**,②/③ 是"语义固化 + 机器断言"、⛔ 不是"修 bug"。**这条不掩饰,已写进单测文件头。**
**绿腿(还原后)** ⇒ `# tests 9 / # pass 9 / # fail 0`(rc=0);**还原为字节级**:三文件 md5 与改动后**逐个相同** —— `orchestrator.ts c831bc75f6636603411949d43eb84136` / `remote-spawner.ts 1279b948c4e2dd6ca021acb5b9fd6f27` / `leased-spawner.ts f891797ba8a7ee07d9fdca0d83f07461`。
### 8.5 真机先红(新判据对旧生产)+ 🔴 两种判别器并列
**(甲)P1 判据对旧生产 ⇒ 必须 FAIL(判别力证明)**:**未部署**新 lib 时(生产 orchestrator.js = `5bfb15e0…`,旧语义),带**真实例夹具**跑 `restart dshs-worker`:
```
############ 47 ############ (epoch=1789664319)
N_BEFORE=1 --- restart 前 scope 明细 --- dsh-114801-e9bc4a4e.scope
N_AFTER=0 --- restart 后 scope 明细 --- (none)
--- rehydrate 日志(journalctl --since @1789664319) --- RAW_BYTES=125
Sep 18 00:58:40 iZrj99af19cibck1ge93tqZ node[826743]: [rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)
############ 106 ############ (epoch=1789664328)
N_BEFORE=1 --- restart 前 scope 明细 --- dsh-100002-70a193b7.scope
N_AFTER=0 --- restart 后 scope 明细 --- (none)
--- rehydrate 日志(journalctl --since @1789664328) --- RAW_BYTES=120
Sep 18 00:58:48 VM-0-8-opencloudos node[258630]: [rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)
```
⇒ **两机 `1 → 0`** ⇒ 判据 `N_AFTER == N_BEFORE ≥ 1` 判 **FAIL** ✅(这就是**真机反向夹具**:判据确实在测这件事,⛔ 不是恒绿)。同时复现了序㉕ 的真凶现场。
**(乙)新 `OBS-22` 对旧产物 ⇒ 必须 FAIL 并逐处点名**(夹具腿,夹具内容 = **真实生产产物字节**:旧 = 回滚点 `/opt/dsh/backups/seq28-20260918-005909/`,新 = 已部署):
```
⚠️ FIXTURE FAIL OBS-22 …:47 ❌ 缺标记(orchestrator,remote-spawner,leased-spawner) 停实例命中 orchestrator「await this.stop(userId);」
|106 ❌ 缺标记(orchestrator,remote-spawner,leased-spawner) 停实例命中 orchestrator「await this.stop(userId);」
⚠️ FIXTURE PASS OBS-22 …:47 静态守卫 ✓|106 静态守卫 ✓
```
⇒ **红腿 FAIL 并逐处点名** ✅|**绿腿 PASS** ✅。
⚠️ **可区分性证据(本线的老坑)**:红腿那次运行的整体输出是 `❌ 8 项红:OBS-11 , OBS-13 , OBS-14 , OBS-16 , OBS-17 , OBS-19 , OBS-20 , OBS-22` —— 前 7 项是**夹具模式未给对应夹具**的固有 FAIL(⛔ 不是判据结果),只有 `OBS-22` 是本项的真实读数;故上面只摘 `OBS-22` 行。
⚠️ **`RAW_BYTES=0` 的可区分性**:47 `dshs` 的 `[rehydrate]` 在窗口内 **0 字节**,但这**不是读取失败** —— 同窗口 `journalctl -u dshs --since @<epoch> \| wc -c` = **102 字节**(`-- Logs begin … -- / -- No entries --`)⇒ 读取正常、**确无该行**;且**全量历史** `journalctl -u dshs --no-pager \| grep -c '[rehydrate]'` = **0**(详见 8.11 第 1 条)。
### 8.6 部署对账
**本机 `npm run build` 产物 md5**:`orchestrator.js 1456d1609f5872c2d635ae7cf4f5c26b`|`remote-spawner.js 0438afd5738176999d3a4f4f404c9435`|`leased-spawner.js 12ba045990b2054aaeebeb2c95d70f83`
**部署四处(md5 逐个相同)**:
| 机器 | 目录 | orchestrator.js | remote-spawner.js | leased-spawner.js |
|---|---|---|---|---|
| 47 | `/opt/dshs/lib/supervisor/` | `1456d160…` ✓ | `0438afd5…` ✓ | `12ba0459…` ✓ |
| 47 | `/opt/dsh-relay/lib/supervisor/` | `1456d160…` ✓ | `0438afd5…` ✓ | `12ba0459…` ✓ |
| 106 | `/opt/dshs-cluster/lib/supervisor/` | `1456d160…` ✓ | `0438afd5…` ✓ | `12ba0459…` ✓ |
| 106 | `/opt/dsh-relay/lib/supervisor/` | `1456d160…` ✓ | `0438afd5…` ✓ | `12ba0459…` ✓ |
**回滚点** = **两机** `/opt/dsh/backups/seq28-20260918-005909/`(旧 md5 已记录,含 relay 侧):
```
5bfb15e00b592c293548ae17b639399b orchestrator.js (= 序㉕ 铺的生产值)
0b0e95f284ad39aea17286446f3c07a1 remote-spawner.js
0f473774d116b80c2f57bc22077a0f9c leased-spawner.js
fad7926494578258f86a91eac613a204 relay-orchestrator.js ⟵ ⚠️ 见 8.11 第 2 条(relay 侧曾落后 2 个版本)
0b0e95f284ad39aea17286446f3c07a1 relay-remote-spawner.js
0f473774d116b80c2f57bc22077a0f9c relay-leased-spawner.js
```
**重启(R8:开发环境服务器)**:`2026-09-18 01:0x` —— 47 `systemctl restart dshs` + `restart dshs-worker`;106 `restart dshs-worker`。三单元 `is-active` = `active`(+ `dshs-relay` active)。门户 `curl -H 'Host: alotbuy.com' 127.0.0.1:3080/` = **200**。
⚠️ **⛔ 未重启 `dshs-relay`** —— 它**不加载** `supervisor/*`(只有 `dshs` / `dshs-worker` 用 `LocalSpawner`)⇒ 不必动数据面。
⚠️ **动生产前已取证**:两机活跃实例 = **0/0**(S0 第 3 条)⇒ 本次部署/重启**未影响任何在线用户**。
### 8.7 主判据 P0 / P1 / P2 + 对冲项(逐条原文)
**照 §5「判据自证表」原样回填**:
| 层 | 判据 | 命令 | 期望 | 实测 | 判定 |
|---|---|---|---|---|---|
| P0 | 夹具就位 | §5 P0 | ≥ 1 | 47 = **1**(`dsh-114801-507657e0.scope` + `127.0.0.1:20000` 在听)/106 = **1**(`dsh-100002-a58dd77b.scope` + `127.0.0.1:21000` 在听) | ✅ |
| P1 | 47 scope 守恒(**Manager**) | §5 P1(`restart dshs`) | `N_AFTER == N_BEFORE ≥ 1` | `before=1 after=1`(⚠️ **非判别腿**,理由见 8.11-1) | ✅ |
| P1 | 47 scope 守恒(**worker**) | §5 P1(`restart dshs-worker`) | 同上 | `N_BEFORE=1 → N_AFTER=1`,明细前后均为 `dsh-114801-507657e0.scope` | ✅ |
| P1 | 106 scope 守恒(worker) | §5 P1(`test106`) | 同上 | `N_BEFORE=1 → N_AFTER=1`,明细前后均为 `dsh-100002-a58dd77b.scope` | ✅ |
| P1 | **反向夹具变红** | 未部署新 lib 时重跑 | `N_AFTER == 0` | 47 **`1 → 0`** / 106 **`1 → 0`**(原文见 8.5 甲) | ✅ 判据**有判别力** |
| P2 | 认领行存在 | §5 P2① | ≥ 1 | 47 `RAW_BYTES=443`(3 行)/106 `RAW_BYTES=428`(3 行) | ✅ |
| P2 | 报告自洽 | §5 P2② | `adopted+stopped==scanned ∧ scanned>0` | 47 `{"scanned":1,"adopted":1,"stopped":0,"probeOk":0,…}` ⇒ **1+0==1 ✓ ∧ scanned=1 ✓**|106 同值 | ✅ |
| P2 | adopted / probe OK 原文 | §5 P2③ | 含 `scope=` / `uid=` / `port=` + `probe OK … :<port>` | 47:`[rehydrate] adopted dsh-114801-507657e0.scope uid=114801 role=main port=20000 user=cce6d1cd-…` + `[rehydrate] probe OK dsh-114801-507657e0.scope :20000`|106:`adopted dsh-100002-a58dd77b.scope uid=100002 … port=21000` + `probe OK … :21000` | ✅ |
| 对冲 | 静态守卫 | §5 对冲-静态 | 无命中 | `T5` 全绿(三文件 teardown 窗口内 **0** 命中)+ `T9` 反向夹具自证判别器**有**判别力 | ✅ |
| 对冲 | 动态单测 | `node --test test/orchestrator-teardown.test.mjs` | rc=0 | **9 pass / 0 fail**,rc=0 | ✅ |
| 对冲 | `OBS-22` | `node scripts/overlay-probe.cjs --table <参数表>` | PASS | `PASS OBS-22 … 47 静态守卫 ✓|认领面:scanned=1 adopted=1 stopped=0 ✓|106 静态守卫 ✓|认领面:scanned=1 adopted=1 stopped=0 ✓` | ✅ |
| 零回归 | `npm test` | `npm.cmd test` | 不退化 | **201 / 200 / 0 / 1**(=基线逐字一致) | ✅ |
| 零回归 | 场景 | `node scripts/overlay-failover-drill.cjs --scene all --table <参数表>` | 12 PASS / 0 SKIP / 0 FAIL | **12 PASS / 0 SKIP / 0 FAIL**(rc=0) | ✅ |
| 零回归 | 探针 | `node scripts/overlay-probe.cjs --table <参数表>` | 基线 +1 行 | **21 PASS / 0 SKIP / 1 FAIL** = 基线(19P/1S/1F,21 行)+ **新增 `OBS-22`(PASS)**;`OBS-21` 仍 FAIL(**线内在册缺口,⛔ 非本单引入**);⚠️ `OBS-09` 由 SKIP → **PASS**(存量实例在册且码 401 ∈ 集合 = 正常改善) | ✅ |
| **D1** | 生产值未改 | `grep -c 'COOLDOWN_MS=0'`(本次全部演练/夹具/回滚路径文件) | **0** | **0** | ✅ |
**🔴 P1 与 P2 分层判定(本单最重要的一条)**:本次**两边**均为 `scanned=1 ∧ adopted=1 ∧ stopped=0 ∧ 自洽` ⇒ **P1 + P2 双绿**,⛔ 未出现「`scanned == 0` 而数守恒」那种"P1 绿、P2 红"的混合态 ⇒ **不存在"实例没被杀但回收链未建成"的情形**(该口径仍已写进 `OBS-22` 的判据与参数表 §6,供将来出现时如实点名)。
⚠️ **另有一条真机数据点(意外收获)**:`--scene all` 演练的「幕4 前置」**含一次 Manager 重启**,演练结束时 47 的 `dsh-114801-507657e0.scope` **仍在册且端口在听** ⇒ Manager 重启不杀实例这条**在真实演练路径上再复现一次**。
### 8.8 零回归三件套
```
$ npm.cmd test ⇒ # tests 201 / # pass 200 / # fail 0 / # skipped 1
$ node scripts/overlay-failover-drill.cjs --scene all --table <参数表>
⇒ # 结果:12 PASS / 0 SKIP / 0 FAIL (rc=0)
$ node scripts/overlay-probe.cjs --table <参数表>
⇒ 21 PASS / 0 SKIP / 1 FAIL(OBS-21 = 线内在册缺口;新增 OBS-22 = PASS)
❌ 1 项红:OBS-21
```
⚠️ **三段全部同值或更好** ⇒ 零回归成立。🔴 `npm test` **未把新单测纳入**(刻意;见 8.4),故总数 201 与基线**逐字相同**。
### 8.9 边界自证
- ⛔ **未改任何生产值**(**D1**):本次**没有**改任何 systemd drop-in / `EnvironmentFile` / nginx·nft 配置;参数表 §3.8 新增的 6 个 `TEARDOWN_*` 键**只被探针读取**,⛔ 未写进任何 env。🔴 `grep -c 'RELAY_FAILOVER_COOLDOWN_MS=0'`(本次全部演练/夹具/回滚路径文件)= **0**。
- ⛔ **未新增公网监听口**(**R5**):`PASS OBS-11` ⇒ `必在 7 允许 4 区间 3 派生 2 实际 77 多出 0 缺失 0`,`relay 口绑定回环=1/1 条`。
- ⛔ **未改 `nft` / `nginx`**(**R5**):`OBS-11` ⇒ `nft accept 0 多出 0`;本次**零** nft/nginx 读写。
- ⛔ **未 commit / 未 push**(**R7**):`git rev-parse --short HEAD` 仍 = **`04776af`**(改动全部留在工作区)。
- ⛔ **未改 bwrap 参数**(🔴 47 是 bubblewrap 0.4.0)|⛔ **未删** `cleanStaleScopes(uid)` / `cleanAllStaleScopes()`(`T7` 断言在册)|⛔ **未把启动流程改成"什么都不做"**(`rehydrateAdoptedScopes` 三步齐备,日志可证)|⛔ **未"重启后重新 spawn 一遍"**(认领路径 `T18`/`R18` 先例断言:`adoptOne` 不写 `mains`、不 `spawn`)|⛔ **认领不写回 `mains`**(本单未碰认领代码)|⛔ **token 不落盘**(本单未碰凭据路径)|🔑 判据口径 = **孤儿 = 端口不通**(本单未改该口径)。
- 🔎 **一处如实登记的"范围新增"**:部署面**多铺了两处** `relay 侧 /opt/dsh-relay/lib/supervisor/`(见 8.11 第 2 条)—— 属**消除既存不一致**、非语义改动。
### 8.10 指纹
- **参数表指纹**:**`7fc5889341b99fb26bd05cad0313960b`** → **`3b295a8c43aafc4a62c6f1a59ea0b43a`**(§3.8 新增 6 键 + §6 新增 `OBS-22`;§10 内已同步登记,⚠️ §10 不计入指纹)
- **本单 §8 前前缀** = **`c9aeac82381f569fc8b9effad0fefa99`**(⚠️ **回填 §8 后仍不变** —— §8 是截断点,`✅ 已验证`)
- **全文 md5 / 行数** = 见 §11.4(⛔ 自指,只能现算)
### 8.11 如实留档(**必填,哪怕"没有"**)
1. 🔴 **修正了本单 §5 P1 的一条前提(上游假设错)**:单里写「47 走的是 `dshs`(Manager)的 `LocalSpawner`」。**实测 47 是 `DSHS_DEPLOY_MODE=cluster`**(`/etc/systemd/system/dshs.service.d/cluster.conf`)⇒ `web/server.ts:931-932` 走 `LeasedSpawner(new RemoteSpawner(…))`、**根本不构造 `LocalSpawner`** ⇒ 47 的 `dshs` **不打 `[rehydrate]`**(窗口内 0 字节 + **全量历史计数 0**)⇒ 其 `LocalSpawner.teardown()` 在生产上是**死代码**。**真正会执行 `LocalSpawner.teardown()` 的只有两台 `dshs-worker`**(`agent.ts:143`)。⇒ 已把该修正写进参数表 §3.8(`TEARDOWN_UNIT_47` 行)与 §6 `OBS-22`。**这不影响修复本身**(三处同改覆盖两处执行点),但**影响判据的落点**:⛔ 拿「47 `restart dshs`」当判别腿会得到**恒绿**(Manager 从来不停远端实例)⇒ 本单把判别腿放在 **两台 worker** 上(见 8.5 甲:`1 → 0`)。
2. 🔎 **一处"范围新增"(如实登记,依据 R8 + R11 + 本线 序㉖ 先例)**:部署时**先核**发现 `/opt/dsh-relay/lib/supervisor/`(两机都在)**另有一套 supervisor 副本**,且其 `orchestrator.js` = **`fad7926494578258f86a91eac613a204`** —— 那是 **序㉕ 之前**的值(落后 **2 个版本**:既无认领、也无本次改动),`remote-spawner.js` / `leased-spawner.js` 同样陈旧。**处置 = 一并铺平(四处 md5 全同)**,并把三份旧值以 `relay-*.js` 名存进同一回滚点 ⇒ **回滚完整**。依据 = 本线 序㉖ 的同型处置("`dshs` 侧 `server.js` 比 `dsh-relay` 侧落后一个版本 ⇒ 两边同铺已消除该既存不一致")。⚠️ **⛔ 未重启 `dshs-relay`**(它不加载 supervisor ⇒ 无行为影响)。
3. ⚠️ **两条与"本单语义无关"的红,均已归因(§9-8 要求)**:
- **`OBS-08`(端点表全在线)曾在替换窗口内 FAIL 一次**:实测 `端点表 2 条 / 离线 1 条`,明细 = `w-106:21000 online=false`(该实例**已换端口**、旧条目未回收)。**归因 = 暂态**:随后复测即 `2 条 / 离线 0 条`,清理前端点表已**自愈**(`19000 online=true` + `21001 online=true`,stale 条目消失)。⇒ **判为暂态,与序㉕ §8 的同型记录一致**("曾一次 FAIL、重跑即回绿 ⇒ 暂态"),⛔ **非本单引入**。⚠️ **但成因值得点名**:它出现在 **B 的「访问时自然替换」动作之后** —— 见第 4 条。
- **`OBS-16`(门窗口 ΔstatusHits ≤ 1)曾在一次运行里 FAIL**(Δ=2):该次运行**与 `overlay-failover-drill` 长跑并行**(同一时段另有演练在打 `/status`)⇒ 归因 = **测量期间的第三方命中**、⛔ 非语义问题;**终态复跑 Δ=1 ⇒ PASS**。
- ⚠️ **`OBS-11` 同次 FAIL**(`实际 79 多出 1`)=上述 stale 端点占的 1 个监听 ⇒ **终态 `实际 77 多出 0` ⇒ PASS**。
4. ⭐ **一个由"B"直接产生的真实现场(收益边界实证 · 也是本棒最有价值的副产品)**:做完 S6 后,我用**临时 session 走了一次真实用户可见面**(`curl -H 'Host: guest.alotbuy.com' -b sid=… 127.0.0.1:3080/`)⇒ **`http=200 bytes=62451` 真 HTML、`502` 标记 0**(= 序⑬ Q2 的判据再次满足)。**同刻** relay 端点表从 `{19000 ✓, 21000 离线}` 变为 `{19000 ✓, 21000 ✗, **21001 ✓**}` ⇒ **平台在用户访问时把实例换到了新端口 `21001`**(`launchToken` 不可恢复 ⇒ 走"自然替换"),旧实例 scope 被 `cleanStaleScopes(uid)` 收掉。⇒ 🔴 **这正是单 §9-9 / §10-4 预告的收益边界,第一次以真机时序证据呈现**:**「实例没被杀(P1 成立)」+「重启后不能直接进,用户下次访问时自然替换」**。⚠️ **附带现象**:替换窗口内会短暂留下**一条 stale 端点条目(离线 + 占 1 个监听)**,`OBS-08`/`OBS-11` 在该窗口内会红;**自愈**——但**自愈时延未测**(⇒ 建议作为独立观测项,⛔ 本单未扩大范围去做)。
5. ✅ **未发现** `adopted + stopped !== scanned`(§9-4 回头条件未命中)|未发现**双实例共 profile**(§9-2 未命中)|未出现**端口区间耗尽**(§9-3 未命中)|未出现"租约过期后另一 Manager 接管同 uid"(§9-6 未命中)|未改 bwrap(§9-7 未命中)。
6. ⚠️ **`summary` 里 `probeOk` 恒为 0(既有观测面缺陷,如实记录、⛔ 未改)**:真机日志顺序为 `adopted …` → `summary {"…","probeOk":0,…}` → `probe OK … :<port>` ⇒ **summary 在探活完成前就打出来了**(`probeOk`/`probeFail` 计数来不及累加)。⇒ 判据⛔ **不能**用 summary 里的 `probeOk ≥ 1`(会假红);本单 P2 用的是 `adopted + stopped === scanned` + 独立的 `probe OK` 行(两者都成立)。**属 `rehydrateAdoptedScopes` 的既有行为、⛔ 非本单引入**(本单只动 `teardown()`),**登记为独立后续项**(修它 = 把 summary 挪到探活回调之后)。
7. ⚠️ **`enter` 的 Host 选择**:`-H 'Host: guest.alotbuy.com'` ⇒ `404 not found`;`-H 'Host: alotbuy.com'` ⇒ `200`(admin 同)。⇒ 是**验收脚本的 Host 写法**问题(会话已由 `sid` cookie 认定),**⛔ 非平台缺陷**,已写进 8.2。
8. ✅ **夹具已全部清理**:两机 `dsh-*.scope` 计数 **0 / 0**(临时 session 亦按 R4 删净:`DELETE 0` / 计数 0)⇒ **⛔ 未在生产留任何夹具**。
9. ⚠️ **本单 §2 的"正确排期口径"段经全工作区检索 = 不存在**(`grep -rn "正确排期口径" .workbuddy/ *.md` ⇒ 0 命中)⇒ 该"疑似被误编辑"的线索**经取证判为不成立**,⛔ 未做任何"修正"(⛔ 不凭印象改文档)。
### 8.12 回滚路径补正(**与 §6 配套;🔴 §6 正文一字未改,以保 §8 前前缀**)
🔴 **本棒实际部署了 4 处**(见 8.6),而**单 §6 只写了 2 处**(47 `/opt/dshs/lib/supervisor/` + 106 `/opt/dshs-cluster/lib/supervisor/`)⇒ **回滚时若不补 relay 侧两处,`/opt/dsh-relay/lib/supervisor/` 会仍留着本次改动 ⇒ 回滚不完整**。
⚠️ **§6 正文一字未动**(补正放在本节的唯一理由:§6 在 `## §8 回报格式` **之前**,改它会**打穿 §8 前前缀 `c9aeac82381f569fc8b9effad0fefa99`**)。
**路 A(语义回滚)· 补全 4 处** —— 在 §6 路 A 命令后**追加两行**:
```bash
scp -P 22 lib/supervisor/{orchestrator,remote-spawner,leased-spawner}.js bt-server:/opt/dsh-relay/lib/supervisor/
scp -P 22 lib/supervisor/{orchestrator,remote-spawner,leased-spawner}.js test106:/opt/dsh-relay/lib/supervisor/
```
**路 B(产物回滚)· 补全 4 处**(回滚点两机均为 `/opt/dsh/backups/seq28-20260918-005909/`):
```bash
ssh -p 22 bt-server 'cd /opt/dsh/backups/seq28-20260918-005909 && cp orchestrator.js remote-spawner.js leased-spawner.js /opt/dshs/lib/supervisor/ && for f in orchestrator remote-spawner leased-spawner; do cp relay-$f.js /opt/dsh-relay/lib/supervisor/$f.js; done && systemctl restart dshs'
ssh -p 22 test106 'cd /opt/dsh/backups/seq28-20260918-005909 && cp orchestrator.js remote-spawner.js leased-spawner.js /opt/dshs-cluster/lib/supervisor/ && for f in orchestrator remote-spawner leased-spawner; do cp relay-$f.js /opt/dsh-relay/lib/supervisor/$f.js; done && systemctl restart dshs-worker'
```
⚠️ 回滚点里的 `relay-*.js` 是**铺平前的旧值**(`relay-orchestrator.js` = 序㉕ 之前的陈旧版)⇒ 路 B 会同时把 relay 侧退回**旧版**,这正是"回到本棒之前"的**完整**语义。
✅ **回滚点内容已复核**(2026-09-18 05:3x 现读):两机均含 **6 个**文件 —— `orchestrator.js` 82401 B / `remote-spawner.js` 13284 B / `leased-spawner.js` 9369 B / `relay-orchestrator.js` 70994 B / `relay-remote-spawner.js` 13284 B / `relay-leased-spawner.js` 9369 B。
✅ **当前生产 md5 已复核**(同上时刻):两机 **4 处** 三文件全同 —— `orchestrator.js` = `1456d1609f5872c2d635ae7cf4f5c26b` / `remote-spawner.js` = `0438afd5738176999d3a4f4f404c9435` / `leased-spawner.js` = `12ba045990b2054aaeebeb2c95d70f83` ⇒ **部署后未被任何后续动作漂移**;两机 `dshs-worker` / `dshs-relay` 均 **active**。
---
## §9 回头条件(**一出现必须回头**)
1. 🔴 **⛔ 任何一棒都不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进回滚 / 演练 / 夹具路径。** 依据:该值**看起来合法、实际会自锁** —— `num()` 的 `/^\d+$/` 放行 `'0'`,但归零会让"失败候选必须被排除"一起失效 ⇒ 候选链卡在第一个失败候选上(**实测 121–123 s 无切换**)。本单的验收里 `grep -c` 该值必须 = **0**。
2. **出现"双实例共 profile"**(档案 30 复现:会话 / settings 写冲突)⇒ **立刻回滚**(§6)+ 报告。
3. **端口区间耗尽**(`实例端口区间已耗尽`)⇒ 停下报告,⛔ **不许私自扩 `--span`**(属参数表改动,须另立单)。
4. **`adopted + stopped !== scanned`** ⇒ 停下报告(自洽性破了 ⇒ 判据不可信)。
5. **需要超出 §3.1 在册文件集** ⇒ 停下报告(**R7**)。⚠️ 本单已凭用户「选 B」解除了处②/③ 的阻塞,**不含第三次扩范围**。
6. **出现"租约过期后另一 Manager 接管同一 uid"的实测案例** ⇒ 停下报告(双写风险;见 §7.4 lease 行)。
7. **需要改 bwrap 参数** ⇒ ⛔ 停(🔴 **47 是 bubblewrap 0.4.0,`--perms` 属 0.5+ ⇒ 沙箱起不来 = 所有实例全挂**)。
8. **零回归出现"新"的红**(非本单引入)⇒ 先归因(判别器 / 日志原文)再决定是否继续;⛔ 不许"重跑一次就好了"了事。
9. **`enter` 的真实用户可见面出现 503 **时,注意区分**:这是 B 下**已知且刻意保留**的边界(`launchToken` 不可恢复 —— 序 ㉕ §0 的客观边界),⛔ **不是本单引入的缺陷**,但**必须如实写进 §8.11**(收益边界 = "不被杀 + 访问时自然替换",⛔ 不是"重启后直接可用")。
---
## §10 未验证项(**一个都不许编数**)
| # | 未验证项 | 为什么重要 | 怎么验(口径) |
|---|---|---|---|
| 1 | **租约过期 ⇒ 另一 Manager 接管同 uid 的窗口风险** | B 下实例不随进程死,归属记录会与存活实例不一致 ⇒ 理论上有"两个 Manager 都以为自己该管这个 uid"的窗口 | 起第 2 个 Manager 副本(本机多实例,用户 2026-09-17 已授权「本机内存大 可以模拟多台」)⇒ 停其一 ⇒ 等 `LEASE_TTL_MS=30 s` 过期 ⇒ 观察是否出现双 owner / 是否触发 `cleanStaleScopes` 替换 |
| 2 | **`used_mb` 记账在 Manager 重启后是否自愈** | 记账漂移会让容量决策失准 | 重启前后各读一次 `dsh_hosts.used_mb`(47 的 PG:`127.0.0.1:15432`),观察心跳恢复后是否回到真值 |
| 3 | **106 侧 idle-reap 是否真的在跑** | §7.3 的"谁来收"前提在 106 上只靠它 | 106 上造一个空闲实例 ⇒ 观察 `idleReapIntervalSeconds=60 s` 的扫描日志;⚠️ **TTL 7 天 ⇒ 短窗口内验不到"收"**,只能验"扫描在跑"(须显式写清这个口径) |
| 4 | **B 的真实收益边界(用户可见面)** | 收益是"不被杀 + 访问时自然替换",**不是**"重启后可直接进"(`launchToken` 不可恢复 ⇒ `enter` 仍 503) | 用临时 session(**R4**:`mksess` 直插、用完即删)走一次 `POST /api/dsh/enter`,记录 503 与其后"自然替换"(换端口换 token)的全过程 |
| 5 | **认领探活对启动时长的影响** | `scanned × stagger(500 ms) + 单条 probe(2000 ms)`;实例多时启动会变慢 | 用 N = 1 / 4 / 8 个夹具 scope 各跑一次,记录"启动 → `[rehydrate] summary` 出现"的墙钟 |
| 6 | **性能/成本:端口区间长期占用** | 见 §7.4 端口行 | 连续 5 次 spawn/替换,记录区间占用峰值与是否出现耗尽告警 |
---
## §11 指纹与状态
### 11.1 归档号(**本棒已原子占号**)
- **现核最大号**(⛔ **不写死**,取号命令):`ls 04-调整方案/ | grep -oE '^[0-9]+' | sort -n | tail -1` ⇒ **本棒实测 = `128`**。
⚠️ **prompt 写的"归档编号至 112"已过期**(现核 128 ⇒ 序号 113–128 是立单时的历史值)⇒ 任何引用都必须**复跑取号**。
- **占号动作(已执行)**:`mkdir 04-调整方案/.lock-129` ⇒ **成功** ⇒ 本单归档号 = **129**。
- 🔴 **63 是空号,⛔ 不补占**(已核:`ls 04-调整方案/ | grep -E '^63'` = 空)。
- ⚠️ **本棒不落 04 孪生** —— 取证结论:本线的 `交接单_*.md` **均无** `04-调整方案/` 孪生(逐条比对 16 份单 = 0 命中);带孪生的是**方案类文档**(如 `123-方案规划方法-覆盖网络线提炼.md` ↔ 根同名,md5 同值 `cf063cc0…`)。⇒ **占号窗口在落单后即释放**,执行棒收官建档案时**必须重新现核取号**。
### 11.2 指纹
- **§8 前前缀**(口径:`sed '/^## §8 回报格式/,$d' <本单> | md5sum`):**见下方"落单后实测"** —— 每次改本单(含回填 §8)都要重算。
- ⚠️ **§8 一旦回填,前缀指纹不变**(§8 在截断点之后);**全文 md5 会变**。
- **落单后实测**(2026-09-18 00:5x,序 ㉘ 规划棒):
- **§8 前前缀** = **`c9aeac82381f569fc8b9effad0fefa99`**(⚠️ 本值**稳定** —— §11 在截断点之后,改 §11/回填 §8 都不动它)
- **全文 md5(§11 本段回填前快照)** = **`dd4f95d5dcae747d63437c8d0ede5fd8`**
- **全文 md5(§11 本段回填后最终)** = **`见下方 §11.4`**(写完本段后现算;⚠️ 二者**必然不同** —— 口径必须写明,⛔ 不许把快照当最终值引用)
- **行数** = **383**(快照时刻;回填 §8 后会增长)
### 11.3 状态
- **本单状态**:**✅ 已执行 · 待归档**(2026-09-18 00:5x–01:0x 由「**覆盖网络线-序 ㉚ 执行棒 · 单 A**」执行完毕;**P1 + P2 双绿**、零回归三件套全绿、夹具已清理、生产 4 处已上线)。
- **执行棒回报** = 本单 **§8.1–§8.12**(全节已回填;§8.12 为回滚路径补正)。
- **本棒(序 ㉘ 规划棒)边界自证**:⛔ **零代码改动** · ⛔ **零服务器改动**(只读取证:`systemctl list-units` / `systemctl show -p Environment` 两条只读命令 / 两机各一次)· ⛔ 未 commit / 未 push · ⛔ 不改任何生产值。
- **上游单状态**:`交接单_实例逐步拉起_20260917.md` **§8.3 已由本单结清**(B 被采纳 ⇒ 该单的"待拍板"项关闭)。
- 🔴 **本单遗留的独立后续项**(均已在 §8.11 / §8.12 点名,⛔ 未扩大范围去做):① `summary.probeOk` 恒 0(既有观测面缺陷)② 「自然替换窗口内 stale 端点条目」的自愈时延未测 ③ §10 的 6 条未验证项(一条未验)④ 架构目标 `C`(§7.2,登记不执行)。
### 11.4 全文 md5 的口径(⛔ **不写死**)
- 全文 md5 **每改一次本单都会变**(回填 §8 尤其)⇒ **权威取数 = 现算**:
`md5sum "E:/ProgramData/AI技能/aliyun-dsh-server/交接单_退出路径不杀实例_20260918.md"`
- **落单快照**(§11.4 本段写入前的最后一次现算,2026-09-18 00:5x)= **`b1b93471081e10d9be9c28aa9b5c1d3c`**(**387 行**)。
⚠️ 本行写入后该值**即失效** ⇒ **引用前必须复跑上面的命令**(本库已有"写死基线 40 分钟内被打穿"的实证)。
- **§8 前前缀** 不受影响(§8 是截断点)⇒ 仍是 §11.2 的 **`c9aeac82381f569fc8b9effad0fefa99`**。
### 11.5 执行后实测(2026-09-18 05:3x · 供第三方复现)
- **§8 前前缀**(现算,口径 `sed '/^## §8 回报格式/,$d' <本单> | md5sum`)= **`c9aeac82381f569fc8b9effad0fefa99`** ⇒ 🔴 **回填 §8 + 新增 §8.12 之后仍逐字不变**(= "§8 是截断点"这条口径的**自证**)。
- **全文 md5 / 行数**:⛔ **不写死**,权威取数 = `md5sum <本单>` 现算(本单每改一次都会变)。
- **生产 md5(两机 4 处、三文件)**:`orchestrator.js = 1456d1609f5872c2d635ae7cf4f5c26b` / `remote-spawner.js = 0438afd5738176999d3a4f4f404c9435` / `leased-spawner.js = 12ba045990b2054aaeebeb2c95d70f83`。
- **两机活跃实例 scope** = **0 / 0**(夹具已清);`dshs-worker` / `dshs-relay` = **active**。