交接单按归属约定(正文落文档库、工作区只放指针)归档至 归档/交接单-20260924-归档/,含逐件判定 README: - 16 件已被文档库正式版取代(T09–T21 + 覆盖网络-24/25/26) - 4 件主题已被覆盖网络线入口汇总 - 7 件历史接续包/规划件 CODEBUDDY §9:收口清本棒 tmp、tmp 保留期 7 天、禁「待清理」中间态、 工作区入库只放文档与文件、不保留脚本副本、>60 KB 单文件须逐个判。
489 lines
57 KiB
Markdown
489 lines
57 KiB
Markdown
# 交接单 · 切流冷却语义(覆盖网络 · 序 ⑧)
|
||
|
||
> **一单只做一件事**:把「**当前通道已不可用**」与「**普通换址**」的冷却语义**拆开**,使"候选池被自己耗干"不再导致**最长 ~300 s 不切流**。
|
||
>
|
||
> **上游依据(均已核实,⛔ 不必重读全文)**:
|
||
> - `交接单_中继失败切流_20260917.md` **§8.8-4**(现象+判别器原文+回头条件 = 本单的**立项依据**)
|
||
> - 同上 **§8.8-3**(E5 真机方向未闭合 ⇒ **本单 §1 命题 P-③ 已证明其回头条件走不通,改判据**)
|
||
> - 同上 **§8.8-5**(`unhealthyForMs` 记账起点偏早 ⇒ 本单**不处理**,仅登记为"回头条件未触发")
|
||
> - 同上 **§8.9 / §10**(两个指纹口径)
|
||
> - 参数表 `参数表_覆盖网络_20260917.md` **§9 第 5 行**(切流闭环行)
|
||
>
|
||
> **本单性质**:**规划棒产出**。⛔ 只出单、不改服务器、不改代码(规划与执行分离)。
|
||
|
||
---
|
||
|
||
## §1 目标
|
||
|
||
### 1.1 一句话
|
||
|
||
让"**当前这条中继已经挂了**"这件事,**有权打破自己刚设下的冷却**;而"**目录说该换回首位**"这件事**没有**这个权力。
|
||
|
||
### 1.2 三问的判定(本单必须回答的三问)
|
||
|
||
#### ① 「当前通道已不可用」与「普通换址」的冷却语义是否该拆开?前者允许一跳豁免?
|
||
|
||
**判定:应当拆开,但拆的是「准入方向」而不是「冷却时长」——health 路径允许**一跳豁免**,directory 路径⛔ 完全不豁免。**
|
||
|
||
- **为什么 health 必须有豁免**:D5 设冷却的前提是"**两台都可用**"(`switcher.ts:255-258` 注释:A 挂 → 切 B → A 恢复 → 切回 A → 再挂 = 抖动风暴)。而 `backoff && attempts ≥ 3` 已成立时,**旧 url 已被证伪** ⇒ 回跳它不是抖动,而是"**唯一可能的出路**"。不豁免 = §8.8-4 的"最长 ~300 s 不切流"(真机读数:`仍在冷却(剩 59201ms / 共 300000ms)`)。
|
||
- **为什么 directory 绝不能豁免**:它的触发条件(`next.url !== cur`)与"旧通道是否可用"**无关**。11:43:26 真机实测已踩过 —— `wss://106… -> wss://alotbuy.com…`,而 `alotbuy.com` 十几分钟前**刚被冷却**,只因 `refreshOverlay` 周期到了就把它换回来 ⇒ **D5 的抖动抑制被另一条路径绕开**(`switcher.ts:210-218` 的注释原文就是这件事)。若再给它豁免权,则"当前站在 106 ⇒ 目录首位是 47 ⇒ 每轮巡检都想回 47"⇒ **两位互相抢 = D5 想防的那个风暴**。
|
||
- **必须有界(否则豁免自己变成新的抖动源)**:豁免**每 url 每冷却周期只给一次**;豁免后 `open()` 再失败 ⇒ **重置该 url 冷却**且**本周期不再豁免**。最坏成本 = 每候选每 300 s 一次 `RELAY_FAILOVER_UP_TIMEOUT_MS`(12 s)的建连尝试。
|
||
|
||
#### ② 冷却键按 `url` 还是按「失败原因」?
|
||
|
||
**判定:键仍按 `url`;「原因」降级为一个字段(`kind`),只用于决定豁免优先级。⛔ 不得把原因做成键。**
|
||
|
||
- **反证(为什么不能换成原因键)**:同一 url 会**先后因不同原因**进冷却(先"被换掉"= `switched-away`,后"建不起来"= `open-failed`)⇒ 原因做键会让同一 url **同时存在多个冷却条目**,`blocked` 集合的语义从"**这个地址现在不可用**"退化成"这个(地址,原因)组合不可用" ⇒ **该 url 会通过另一条原因键被再次尝试** ⇒ 抖动抑制失效 = **净退化,违反 R11**。
|
||
- **第二个理由**:`reason` 是**自由文本**(`switcher.ts:264` 直接拼进日志,取值如 `目录地址变更(source=cache)`)⇒ 拿它做判据 = 字符串耦合。本线已有同类教训(`translateEndpoint` 漏赋值 ⇒ 静默失效);⛔ **禁止写 `reason.includes('目录地址变更')` 这种判据**。
|
||
- **正解**:`cooling: Map<string, { untilMs: number; kind: 'switched-away' | 'open-failed'; exemptedAtMs?: number }>` —— 键 = url(**单一事实:冷却期内该地址不可用**),`kind` 只喂给豁免策略:
|
||
- `switched-away`(`switcher.ts:259`,"我们主动离开了它",它**曾可用**)⇒ **优先豁免**。
|
||
- `open-failed`(`switcher.ts:241`,"刚证明它建不起来")⇒ **次选**(无 `switched-away` 时才用)。⚠️ **仍须保留可豁免**:生产目录 `relays[] = [alotbuy.com(47), relay-direct.alotbuy.com(47), 106]` 里**两条指向 47**,47 整机重启这类故障若不许豁免 open-failed,300 s 内就回不来。
|
||
|
||
#### ③ E5(杀 106 ⇒ 切 47)如何在「冷却已清空」的条件下取得真机 PASS?
|
||
|
||
**先纠正上单的回头条件(这是本单对 §8.8-3 的证据级细化):**
|
||
|
||
> **命题 P-③**:在 D5 生产值(`RELAY_FAILOVER_COOLDOWN_MS=300000`)下,"杀 106 时 47 未被冷却"这个窗口**不会被自然产生**。
|
||
>
|
||
> **证明**:让"当前通道 = 106"的自然途径只有两条 ——
|
||
> ① **从 47 切走**(`switcher.ts:259`)⇒ **必然把 47 冷却**;
|
||
> ② **directory 回路把当前通道改成 106** ⇒ 不可能:回路只会走向**候选首位**,而首位是 47(`directory.ts:688` 取 `list[0]`)。
|
||
> ⇒ 当前 = 106 ⟹ 47 曾进冷却 ⟹ 若进入 106 后未满 300 s,**47 必在冷却中**。∎
|
||
>
|
||
> ⇒ §8.8-3 的回头条件("出现一次杀 106 时 47 未被冷却的窗口 ⇒ 复跑 `--scene 1`")**措辞上指向一条结构上走不通的路**。正解 = **主动构造**该窗口。
|
||
|
||
**三条构造路径(已逐条判过可行性):**
|
||
|
||
**构 A · 推荐 —— 把 E5 改造成「幕 4 · 冷却过期后方向回归」**
|
||
序列 = `停 47 → 切 106(47 进冷却)→ 恢复 47 relay → 停 106(此刻 47 仍在冷却 ⇒ 预期 D6 退避)→ 等冷却过期 → 断言出现 [relay-switch] -> alotbuy.com 且 ≤ deadline`。
|
||
- 为什么不用"抢杀":`tick()` 每 `RELAY_FAILOVER_CHECK_MS`(2 s)跑一次,**冷却一过期它就会切**(`current` 仍不健康)⇒ 窗口 = 2 s + 建连时间,**极稳**;不需要在窄窗口里抢时机。
|
||
- 副产品:它**同时**给出 ① D6 在冷却窗内正确退避(不切空)② §8.8-4 缺陷的**正面复现**(若 directory 回路抢先完成切换,那条行的 `原因本为:目录地址变更` 就是原样证据)。
|
||
- 优点:语义最真(D5 生效、方向由 health 路径触发);窗口稳定。
|
||
- 缺点:需给演练脚本**新增一幕**;要用**非生产冷却值**缩短等待 ⇒ 报告须标注。
|
||
|
||
**构 B · 对照(必做,成本≈0)—— 冷却归零**
|
||
`RELAY_FAILOVER_COOLDOWN_MS=0` 跑既有幕 1 ⇒ `until > now` 恒假 ⇒ 冷却表形同不存在。
|
||
- 优点:**零代码、零等待**,是最强的**归因判别器** —— 若归零后能拿到"杀 106 ⇒ 切 47",则"E5 不通过"的归因**收敛到冷却语义**(而不是方向逻辑)。
|
||
- 缺点:该幕运行时 D5 语义**不存在** ⇒ **⛔ 不能单独作为 E5 的 PASS 依据**(只作对照)。
|
||
- ⚠️ 合法性已核:`switcher.ts:58-62` 的 `num()` 正则 = `/^\d+$/` ⇒ `'0'` 合法;`until > now` 恒假。
|
||
|
||
**构 C · ⛔ 已判不可用 —— 用 `--url` / `DSHS_RELAY_URL` 钉 106**
|
||
`directory.ts:642-651` 明证:env 显式 ⇒ `return { urls: [url], source: 'env' }` ⇒ **候选链被压成单点** ⇒ 杀 106 后 `candidates()` 只有 106 一条、又等于 current ⇒ **依然无候选**,构造不出窗口。
|
||
⇒ 记入 §3.2「⛔ 不动」;⛔ 别再花时间试。
|
||
|
||
**⇒ 本单判据落法**:E9 = 构 A(直接 PASS,要求**至少一次** `原因:当前通道不健康`)+ E9-b = 构 B(对照,只用于归因,PASS/FAIL 都不单独定案)。
|
||
|
||
---
|
||
|
||
## §2 只读前置(⛔ 只读,不改;P1–P9 逐条核实后才允许进 S 段)
|
||
|
||
| # | 命令(原文) | 期望 |
|
||
|---|---|---|
|
||
| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序8执行棒"` | `✓ 已持全局执行锁`。**抢不到 ⇒ 有会话在跑 ⇒ 停下报告** |
|
||
| **P2** | `"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py"` | 锁 owner = 本会话名;HEAD = `640813e`;入口 = `接续入口_覆盖网络线_20260916.md`,其 §2 指向本单 |
|
||
| **P3** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`24cf2efdbcdcbe61267126ed65dba006`**(序⑦ 收口值;不一致 ⇒ 参数表被人动过,停下报告) |
|
||
| **P4** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md \| md5sum` | 与 **§8.9** 里记载的"本单出单值"**逐字一致**(⚠️ 该值**故意只写在 §8 之内** —— §8 本身不计入哈希,写在这里会让哈希自指失效);不一致 ⇒ 本单被人动过 |
|
||
| **P5** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md \| md5sum` | **`419abf308c00b7668e8898aaa91ba9e8`**(上单值 ⇒ 证明底稿未被改动) |
|
||
| **P6** | `cd "D:/github/dsh_shenxian" && grep -n "private readonly cooling\|async replace(\|async tick(\|unhealthy(h" src/net/relay/switcher.ts && grep -n "const refreshOverlay\|failover.replace\|const failoverThresholds\|new RelayFailoverSupervisor" src/web/server.ts` | 四个锚点在 `switcher.ts`(约 `127 / 204 / 275 / 188` 行);`server.ts` 约 `563 / 569 / 497 / 513` 行(⚠️ 行号会平移,**只验存在性不验行号**) |
|
||
| **P7** | `ssh bt-server 'systemctl is-active dshs-relay dshs \|\| true; curl -s --http1.1 http://127.0.0.1:20080/status'` + 106 同款(`ssh [email protected] 'systemctl is-active dshs-relay \|\| true'`) | 两台 relay `active`;47 `/status` 有在册会话。⚠️ **`http2 on` ⇒ curl 必 `--http1.1`**;⚠️ **106 nginx 归宝塔托管 ⇒ `is-active nginx`=`inactive` 是正常态,⛔ 别据以判故** |
|
||
| **P8** | `npm.cmd test`(**必须 Node v22**) | **149 tests / 148 pass / 0 fail / 1 skipped**(序⑦ 收口基线) |
|
||
| **P9** | `node scripts/overlay-failover-drill.cjs --scene 1 > /tmp/seq8-pre.txt 2>&1; echo $?` ⇒ **再** `cp -a <每个待改文件> <f>.bak-seq8-<ts>` + `git status --short \| wc -l` | 幕 1 现状记录(预期:**杀 47 方向 PASS / 杀 106 方向 SKIP**,即 §8.8-3 现状);备份齐全;改动前工作区改动数已记录。🔴 **原始输出必须落盘** —— §8.8-3 的回头条件就是"S0 原始输出未留存",⛔ 不许重犯 |
|
||
|
||
### 2.1 已核实的代码事实(⛔ 执行棒不必重复探索)
|
||
|
||
1. **`relayFailoverThresholds()` 的 env 覆盖是真的生效的** —— `switcher.ts:55` 的签名是 `relayFailoverThresholds(env = process.env)`,两个装配点都**无参调用**(`server.ts:497` / `main.ts:333`)⇒ 改 env 即可覆盖。⚠️ 与本线既有的 `--max-hosts` 坑(CLI 压过 env)**不是**同一回事。
|
||
2. **冷却表 = 进程内内存** —— `switcher.ts:127` `private readonly cooling = new Map<...>()` ⇒ **进程重启即清空**。(这也是构 A 里"恢复 47 后 47 仍在冷却"成立的前提。)
|
||
3. **`replace()` 是唯一换址入口,两条触发路径共用** —— health 路径 = `tick()`(`switcher.ts:310`,`tick()` 已按冷却过滤候选);directory 路径 = `server.ts:569`(**它不看冷却**,冷却闸门是后加在 `replace()` 内部的,见 `switcher.ts:210-228` 的注释原文)。
|
||
4. **D6 的现场证据已具形** —— `tick()` 的 `noCandidateChecks` 计数(`switcher.ts:295`)+ `[relay-skip] … 链里无其他候选(候选 N 条,排除 M 条)` 行(**按 `graceMs` 节流**,不会刷屏)。
|
||
5. **`stats()` 目前没有任何对外读取面** —— 全仓 `grep "\.stats()"` 对 failover/switcher **0 命中** ⇒ D7 要求的"进程可读计数"目前**只在进程内**。⚠️ 本单**不新开端点**补它(R5 稳妥侧,见 D11),判据一律走**日志行**。
|
||
6. **演练脚本现状** —— `scripts/overlay-failover-drill.cjs`(439 行)支持 `--scene 1|2|3|all`;权威选台逻辑 `killTarget = t47 > t106 ? h47 : t106 > t47 ? h106 : null`(约 282 行)⇒ **它杀的是"当前在用"的那台** ⇒ 幕 1 天然只会打"杀 47 ⇒ 切 106"这个方向。⚠️ 上轮已修过的三个判据缺陷(`journalctl --since` 时区 / 幕 1 硬编码只杀 47 / "无切换"一律判 FAIL)**⛔ 不许回退**。
|
||
7. **生产目录 = 3 条候选、其中 2 条同机** —— `relays[] = [alotbuy.com(47), relay-direct.alotbuy.com(47), 106]`(`switcher.ts:235-238` 注释原文引用)⇒ 一次 47 故障会**同时耗掉 2 条候选**,这是"候选池被自己耗干"的结构前提。
|
||
8. **本机 = 生产的前身** ⇒ 改动会经下一次 `scp` 传导到 47/106。⛔ 推送前先 `git status` 确认待传清单只含本次真实改动。
|
||
9. **`src/net/relay/**` 在 git 里是 untracked** ⇒ 它的改动**用 `git diff` 看不到**,只能用 `grep -c` / `wc -l` / `git status --short` 核(序⑦ 已虚惊一次)。
|
||
|
||
---
|
||
|
||
## §3 范围
|
||
|
||
### 3.1 要改的(且只有这些)
|
||
|
||
| # | 文件 | 改动 | 理由(为什么非改不可) |
|
||
|---|---|---|---|
|
||
| 1 | `src/net/relay/switcher.ts` | ① 冷却表值 `number` → `{ untilMs, kind, exemptedAtMs? }`;② 两处 `cooling.set`(`:241` / `:259`)补 `kind`;③ `replace()` 入参新增 `origin: 'health' \| 'directory'`;④ `tick()` 的 D6 分支细化 = "无候选 ⇒ **一跳豁免**";⑤ 新增总开关 `RELAY_FAILOVER_EXEMPT` 与计数 `exemptSwitches`;⑥ 豁免切换的日志行带 `|豁免` 标记 | **本单的唯一实现处**(D1:⛔ 不许在装配点各写一份)。①②是②问的落点;③④⑤是①问的落点;⑥是判别器(D7) |
|
||
| 2 | `src/web/server.ts` | `server.ts:569` 的调用改为 `failover.replace(next.url, …, 'directory')` | 明示"这条换址没有豁免权"(①问)。⛔ 改动**仅此一处**;`refreshOverlay` 的 `if (next.url === '' \|\| next.url === cur) return`(`:567`)**保持原样** |
|
||
| 3 | `scripts/overlay-failover-drill.cjs` | 新增 `--scene 4`(= 本单 §1.2-③ 的**构 A**);`all` 含 4;新增"对照模式"(构 B,`COOLDOWN=0` 跑幕 1);幕 4 必须**落盘原始输出** | E9 的判据本体。⛔ 原来的 `--scene 1\|2\|3` 语义**不变** |
|
||
| 4 | `参数表_覆盖网络_20260917.md` | 新增 `RELAY_FAILOVER_EXEMPT`(`1`/`0`)+ `DRILL_COOLDOWN_MS`(演练期冷却覆盖值);**§9 第 5 行**追加"序⑧:冷却语义拆分已闭环";§7 计数回填;§10 指纹更新 | 参数表 = 单一来源。🔴 **值格必须纯数字**(§8.8-2 的假红坑) |
|
||
| 5 | `test/relay-failover.test.mjs` | 新增 **F12–F17**(见 §6);⛔ F1–F11 **不许改语义** | 本线纪律:**先红后绿已实证两次** |
|
||
|
||
### 3.2 ⛔ 不动什么(防顺手扩大)
|
||
|
||
- ⛔ **不动 D5 的生产默认值** —— `RELAY_FAILOVER_COOLDOWN_MS` 保持 `300000`(`switcher.ts:66`)。演练期覆盖只走 `DRILL_COOLDOWN_MS`。
|
||
- ⛔ **不动 worker 会合面**(`worker/agent.ts:156` 的 `DSHS_RENDEZVOUS_URL` / `DSHS_TUNNEL_TARGET`)。理由已是定论(撤掉 ⇒ `tunnel===undefined` ⇒ 判不执行),**别再复议**。
|
||
- ⛔ **不用 `--url` / `DSHS_RELAY_URL` 构造 E5 窗口**(构 C 已证不可用,见 §1.2-③)。
|
||
- ⛔ **不改 `directory.ts` 的引导链**(`pickFromDoc` / `listCandidatesFromDoc` / 同源优先 已在序⑦ 收官)。**唯一例外**:若 S2 发现候选链本身需要 `kind` 信息 ⇒ **先停下报告**。
|
||
- ⛔ **不改 relay 服务端协议**(`DIAL`/`DIAL_ACK`/`BYE` 已有);⛔ 不新增任何公网监听口;⛔ 不新增凭据;⛔ 不动 nft / 安全组 / nginx。
|
||
- ⛔ **不新开对外可读端点暴露 `stats()`**(D11)。
|
||
- ⛔ 不重做序 ②–⑦;⛔ 不做 presence / 房间层 / 内容分发(清单第 7 步);⛔ 不做打洞实现。
|
||
- ⛔ 不 commit / 不 push(未获授权)。
|
||
- ⛔ 执行中发现的**其它缺陷先报告、不顺手改**(R7)—— 在册的:§8.8-1(OBS-09/11 环境态)|§8.8-2(deadline 临界)|§8.8-5(`unhealthyForMs` 记账)|§8.8-7(留档缺口)|guest(w-106) 实例页 502|`mksess*.cjs` 失效。
|
||
|
||
---
|
||
|
||
## §4 决策点
|
||
|
||
### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由)
|
||
|
||
| # | 已定 | 理由 / 反例 |
|
||
|---|---|---|
|
||
| **D1** | **冷却语义不对称拆分**:health 路径**可豁免**;directory 路径**⛔ 不可豁免** | 11:43:26 真机实测(directory 靠"地址变了"把刚冷却的 47 换回来)⇒ 给它豁免权 = 复活抖动风暴。见 §1.2-① |
|
||
| **D2** | 冷却键 = **`url`**;新增 `kind ∈ {'switched-away','open-failed'}`;`kind` 只决定**豁免优先级** | 原因做键会让同一 url 存在多条冷却 ⇒ 抖动抑制失效 = **净退化(R11)**。见 §1.2-② |
|
||
| **D3** | 豁免**只在 D6 现场**启用(`tick()` 过滤后 `target === undefined` 时);**有干净候选时行为逐字不变** | 保住序⑦ E1–E11 的既有结论 —— 否则本轮验收会"自己推翻自己" |
|
||
| **D4** | 豁免**每 url 每冷却周期一次**(`exemptedAtMs`);豁免后 `open()` 失败 ⇒ **重置该 url 冷却**且本周期不再豁免 | 不设界 ⇒ 每 2 s 巡检都试一次 = **重试风暴**,比不切更糟(R11) |
|
||
| **D5** | 豁免优先级:`switched-away` 优先;同类按 `untilMs` **升序**(越早解除越可能已恢复) | `switched-away` 的语义是"我们主动离开了一件**曾可用**的东西";`open-failed` 是"刚证明它建不起来" |
|
||
| **D6** | 新增总开关 `RELAY_FAILOVER_EXEMPT`(默认 `1`,`0`=关)⇒ **第二层回滚** | 与既有 `RELAY_FAILOVER_MIN_ATTEMPTS=0`(监管器总开关)**并列**;置 0 后逐字回到序⑦ 行为 |
|
||
| **D7** | **判别器**:豁免切换的 `[relay-switch]` 行必须带 `|豁免`;新增计数 `exemptSwitches`;**`grep -c '^\[relay-switch\]'` ≡ `switches` 必须仍然成立**(豁免行也是 switch 行) | §8.8-2 的教训:只有日志没有计数 = 脚本无法断言 |
|
||
| **D8** | **E5 判据改写为「幕 4」**(构 A,§1.2-③);**直接 PASS 要求至少一次 `原因:当前通道不健康`**;由 directory 回路完成的切换只能记"间接" | 命题 P-③ 已证明旧回头条件走不通。见 §1.2-③ |
|
||
| **D9** | 演练期冷却覆盖 = **新参数表键 `DRILL_COOLDOWN_MS`**(建议 `20000`);⛔ 生产默认不改;报告**必须**标注该幕用的是非生产值 | 演练必须可控时限;但"改了阈值不标注"= 结论不可复现 |
|
||
| **D10** | 冷却表**保持进程内内存**,⛔ 不持久化 | 持久化 = 跨重启状态 ⇒ 扩大故障面(重启后带着旧冷却起不来) |
|
||
| **D11** | ⛔ **不新开对外端点**暴露 `stats()`;判据只用日志行 | R5 稳妥侧:新开可读面 = 扩大可见面,需另行评估;本单**不需要**它也能满足 D7 |
|
||
| **D12** | **服务端零改动**(relay 侧最多只读) | 缩小失败域(承序⑦ D8) |
|
||
|
||
### 4.2 交给执行棒自决(⛔ **不上升为提问**)
|
||
|
||
冷却表值的具体 TS 形态(对象字段名 / 是否抽类型别名)|`replace()` 的 `origin` 用字面量联合还是 options 对象|豁免判据的代码落点(`tick()` 内联 vs 抽私有 `pickTargetWithExemption()`)|单测 F12–F17 的断言切面(是否用 `nowMs` 注入伪造冷却过期)|幕 4 的具体 ssh 命令 / unit 名 / 观测窗用哪个 `DRILL_*` 键|`DRILL_COOLDOWN_MS` 的具体值(建议 20–30 s 区间:**须显著小于** `refreshAfterSeconds`,以让"冷却先过期、目录巡检后到")|日志文案细节|是否顺带把 `stats()` 挂到**已有鉴权端点**(若挂 ⇒ 先在 §8 出一句 R5 评估)。
|
||
|
||
### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**)
|
||
|
||
**本单 = 空。** 三问的判定全部落在"技术实现"边界内(§1.2 已逐条给出判定与依据),不存在"各有优有劣、客观标准分不出高下"的取舍。
|
||
|
||
### 4.4 技术实现裁决顺序(⚠️ 与 `dsh-decision-method §4.4` 一致)
|
||
|
||
① **先复用现成能力**(既有 `cooling` Map / 既有 `tick()` 的 `blocked` 构造 / 既有 `switcher.ts:188` 的 `unhealthy()` 判据 / 既有 `[relay-switch]` 判别器形态)→ ② **再改现有模块**(`switcher.ts` 的冷却表与 `replace()` 签名)→ ③ **最后才新增**(参数表两个键 + 演练一幕 + 六个单测)→ ⛔ **不引第三方依赖**。
|
||
|
||
### 4.5 权限影响评估(按红线 R5 要求先出;**结论:未命中 R5,暴露面零变化**)
|
||
|
||
| 维度 | 本单影响 | 判定 |
|
||
|---|---|---|
|
||
| **新增监听口** | **0**(只改客户端侧切换策略) | ✅ 不扩大 |
|
||
| **新增凭据** | **0** | ✅ 不扩大 |
|
||
| **放宽访问控制** | **0**(切换目标仍**必须**来自 `listOverlayRelayCandidates` 的已签名候选;豁免**只在同一候选集内**挑,⛔ 绝不放宽为"任意 url") | ✅ 不扩大 |
|
||
| **入站** | 106 入站**仍为 0**;47 无新增入站口 | ✅ 不扩大 |
|
||
| **新增可读面** | **0**(D11:⛔ 不新开端点) | ✅ 不扩大 |
|
||
| **暴露面净变化** | **0** | ✅ |
|
||
|
||
> ⚠️ **若执行中为"能切过去"而允许连目录外的地址 ⇒ 立刻停下**:那才是真的 R5(扩大信任面 = 任意重定向),必须回来出评估。
|
||
|
||
---
|
||
|
||
## §5 步骤(S0–S9;**每步自带一次可执行的验证**)
|
||
|
||
### S0 · 只读取证(= §2 P1–P9)
|
||
|
||
- **动作**:按 §2 逐条跑;**P4/P5 两个指纹必须逐字一致**;**P9 的原始输出必须落盘**。
|
||
- **验证**:P1–P9 全部 ✅。
|
||
- **⛔ 硬门**:任一 ❌ ⇒ 停下报告,不进 S1。
|
||
|
||
### S1 · 冷却表结构化(`switcher.ts`)
|
||
|
||
- **动作**:`cooling` 的值 `number` → `{ untilMs: number; kind: 'switched-away' | 'open-failed'; exemptedAtMs?: number }`;`switcher.ts:241`(open 失败)补 `kind: 'open-failed'`,`switcher.ts:259`(成功换址)补 `kind: 'switched-away'`;`stats().cooldown` 投影随动(保持 `{url, untilMs}` 外形,⛔ 不破坏既有断言)。
|
||
- **验证**:`npm.cmd test` ⇒ **149/148/0/1 全绿**(尤其 F8"冷却期内不回跳"必须仍绿)。
|
||
- **⚠️ 易错点**:`tick()` 里遍历 `this.cooling` 的两处(`switcher.ts:292`)与 `stats()` 的过滤(`:180-183`)都要跟着改,**⛔ 别漏**。
|
||
|
||
### S2 · `replace()` 增 `origin`(区分两条触发路径)
|
||
|
||
- **动作**:签名 → `replace(targetUrl: string, reason: string, origin: 'health' | 'directory')`;`tick()` 传 `'health'`,`server.ts:569` 传 `'directory'`;`origin` **只用于决定准入**,⛔ 不改日志既有片段(`reason` 仍原样打印)。
|
||
- **验证**:单测 **F12** 绿 —— `origin='directory'` + 目标在冷却 ⇒ **必 skip**(日志含"仍在冷却"),`switches` 不增。
|
||
- **⚠️ 口径**:这一步**不改任何行为**(directory 本来就被冷却闸门挡住)—— 它只是把"隐含的意图"变成**显式参数**,为 S3 的豁免权做闸门。⇒ 既有用例应当**一条都不变**。
|
||
|
||
### S3 · 一跳豁免(`tick()` 的 D6 分支细化)
|
||
|
||
- **动作**:`tick()` 里 `target === undefined`(D6 现场)时 —— 若 `RELAY_FAILOVER_EXEMPT !== '0'` ⇒ 在 `blocked` 集合里按 D5 优先级挑一条候选作为目标,调 `replace(..., 'health')`;否则**维持原样**(写 `[relay-skip]` 行、`noCandidateChecks += 1`)。
|
||
⛔ **豁免只在 `origin === 'health'` 的路径上被考虑**(D1)—— 建议把这条写成一个**单点判据**(如私有方法 `pickTargetWithExemption()`),⛔ 不许在两个地方各判一次。
|
||
- **验证**:单测 **F13**(无干净候选 + 1 条 `switched-away` ⇒ 切过去,`switches` +1、`exemptSwitches` +1)/**F14**(同时有 `switched-away` 与 `open-failed` ⇒ 选 `switched-away`)/**F15**(**有干净候选 ⇒ 不豁免**,行为与序⑦ 逐字相同)全绿。
|
||
- **⚠️ 不变量**:F15 是本步的**护栏** —— 它必须与序⑦ 的 F1–F11 **并存全绿**,否则说明"豁免"泄漏进了正常路径。
|
||
|
||
### S4 · 豁免有界(防重试风暴)
|
||
|
||
- **动作**:豁免时写 `exemptedAtMs`;豁免后 `open()` 失败 ⇒ ① 重置该 url 冷却(`now + cooldownMs`)② **本冷却周期内不再豁免它**。
|
||
- **验证**:单测 **F16**(同一冷却周期内第二次出现 D6 现场 ⇒ **不再豁免**;`openFailed` 增、`switches` 不增)。
|
||
- **⚠️ 判据**:F16 的"时段"必须能用注入的 `nowMs` 伪造(⛔ 不许真等 300 s)。
|
||
|
||
### S5 · 总开关 + 判别器
|
||
|
||
- **动作**:`RELAY_FAILOVER_EXEMPT`(默认 `1`,`0`=关,⛔ 值格必须纯数字 ⇒ 沿用 `num()` 的 `/^\d+$/`);豁免切换的日志行尾加 `|豁免 kind=… 剩 …ms`;`stats()` 新增 `exemptSwitches`。
|
||
- **验证**:单测 **F17**(置 `0` ⇒ 无豁免、日志无 `|豁免`)+ **既有 F1–F11 全绿**(`grep -c '^\[relay-switch\]'` ≡ `switches` 的断言必须仍然成立)。
|
||
- **⚠️ 口径**:`RELAY_FAILOVER_EXEMPT=0` 与 `RELAY_FAILOVER_MIN_ATTEMPTS=0` **是两个不同层级**的开关(前者关豁免、后者关整个监管器),⛔ 别合并。
|
||
|
||
### S6 · 参数表闭环
|
||
|
||
- **动作**:新增 `RELAY_FAILOVER_EXEMPT` / `DRILL_COOLDOWN_MS`;§9 第 5 行追加"序⑧:冷却语义拆分已闭环";§7 计数回填;§10 指纹更新。
|
||
- **验证**:`grep -nE 'RELAY_FAILOVER_EXEMPT|DRILL_COOLDOWN_MS' 参数表_覆盖网络_20260917.md` ⇒ **值格纯数字(无夹注)**;随后复取新指纹并回填 §10。
|
||
- **⚠️ 假红坑**:值格带夹注(如 `20000(演练期)`)⇒ 解析成 `NaN` ⇒ `OBS-02` 假红(§8.8-2 原文教训)。
|
||
|
||
### S7 · 幕 4(构 A)+ 对照模式(构 B)
|
||
|
||
- **动作**:`--scene 4` =
|
||
`①读权威选台 → ②停 47 relay → ③等 [relay-switch] -> 106(记录 T_switch)→ ④恢复 47 relay → ⑤停 106 relay(此刻 47 仍在冷却 ⇒ 预期 D6:记 SKIP + 判别器原文)→ ⑥等冷却过期(`DRILL_COOLDOWN_MS` 覆盖)→ ⑦断言 T_switch 起 ≤ `RELAY_FAILOVER_DEADLINE_MS` 内出现 [relay-switch] -> alotbuy.com,并分类其"原因"`;
|
||
`all` 含 4;新增对照模式(`RELAY_FAILOVER_COOLDOWN_MS=0` 跑幕 1 ⇒ 只作**归因**用)。
|
||
- **验证**:`node scripts/overlay-failover-drill.cjs --scene 4 > /tmp/seq8-scene4.txt 2>&1; echo $?` ⇒ 产出 PASS/FAIL/SKIP **且原始输出落盘**。
|
||
- **⚠️ 三条硬注意**:① `journalctl --since` **必须用 `@<epoch>`** + `JOURNALCTL-ERR` 哨兵(时区偏移会静默返回空);② 远端**读状态**命令一律 `|| true`(`is-active` 在 inactive 时退出码 3 ⇒ `execFileSync` 抛);③ `DRILL_COOLDOWN_MS` 必须**显著小于** `refreshAfterSeconds`(让"冷却先过期、目录巡检后到"),否则 directory 路径会抢走这次切换 ⇒ E9 只能记"间接"。
|
||
|
||
### S8 · 真机全跑(`--scene all`)+ 对照
|
||
|
||
- **动作**:跑 `--scene all`(1/2/3/4)+ 构 B 对照一次;**两段的原始输出都落盘**。
|
||
- **验证**:幕 1/2/3 结果与 §2-P9 现状一致(⛔ 不许因为本单改动而变红);幕 4 出 E9 判定;构 B 出归因结论。
|
||
- **⚠️ 观察点**:若幕 4 的切换由 directory 回路完成 ⇒ **那正是 §8.8-4 缺陷的正面复现**,须原样抄进 §8.8(连同 `原因本为:目录地址变更(source=…)`)。
|
||
|
||
### S9 · 不退化 + 收口
|
||
|
||
- **动作**:跑不退化七项;清理临时产物;释放锁;登记下一棒**并陈述句告知**;推进入口 §2;写工作区日志。
|
||
- **验证**:见 E10/E11。
|
||
- **⚠️ 不退化项的口径按 §8.8-1 已更新的写法**:`ss -lntp | wc -l` = **79(有活跃实例)/ 78(无实例)** —— ⛔ 别再拿 78 当红项。
|
||
|
||
---
|
||
|
||
## §6 验收(判据清单;命令 + 期望输出,可被第三方复现)
|
||
|
||
| # | 判据 | 命令 / 观察点 | 期望 |
|
||
|---|---|---|---|
|
||
| **E1** | **directory 路径无豁免权** | 单测 **F12**:`origin='directory'` + 目标在冷却 | `replace` 返回 `false`;日志含"仍在冷却";`switches` 不增 |
|
||
| **E2** | **health 路径豁免成立** | 单测 **F13**:无干净候选 + 1 条 `switched-away` | 切换成功;`switches` +1;`exemptSwitches` +1;日志含 `|豁免` |
|
||
| **E3** | **豁免优先级正确** | 单测 **F14**:`switched-away` 与 `open-failed` 同时在冷却 | 选中 `switched-away` |
|
||
| **E4** | 🔴 **有干净候选时行为逐字不变**(D3 护栏) | 单测 **F15** + 序⑦ **F1–F11** 全绿 | 全部绿;F15 断言"未走豁免分支" |
|
||
| **E5** | **豁免有界**(D4) | 单测 **F16**:同周期第二次 D6 现场 | 不再豁免;`openFailed` +1;`switches` 不增 |
|
||
| **E6** | **两层开关各司其职** | 单测 **F17**(`EXEMPT=0` ⇒ 无豁免)+ 既有用例(`MIN_ATTEMPTS=0` ⇒ 监管器不触发) | 两断言同时成立 |
|
||
| **E7** | **判别器可断言**(D7) | `grep -c '^\[relay-switch\]'` vs `switches` vs `exemptSwitches` | 前两者**必然相等**;豁免行含 `|豁免` |
|
||
| **E8** | **参数表闭环** | 新键**值格纯数字**;§9 第 5 行已更新;§10 指纹已回填 | ✅ |
|
||
| **E9** | 🔴 **幕 4 真机**(= 新 E5):冷却过期后方向回归 | `node scripts/overlay-failover-drill.cjs --scene 4`;原始输出落盘 | ① 停 106 时 47 **仍在冷却** ⇒ 记 **SKIP**(D6 预期 + 判别器原文)② 冷却过期后出现 `[relay-switch] -> alotbuy.com` 且 **≤ `RELAY_FAILOVER_DEADLINE_MS`** ③ 其中**至少一次** `原因:当前通道不健康`(否则只能记"间接",附 directory 路径原文) |
|
||
| **E9-b** | **归因对照**(构 B) | `RELAY_FAILOVER_COOLDOWN_MS=0` 跑幕 1 | 能拿到"杀 106 ⇒ 切 47" ⇒ 归因收敛到冷却语义;⛔ 本项**不单独定案**(该幕 D5 语义不存在) |
|
||
| **E10** | **不退化** | 见 S9:`npm test` / `overlay-probe` / `ss -lntp \| wc -l` / 门户 200 / 双实例面 ∈ {200,401} | 与 §2-P8/P9 对照**逐项一致**;幕 1/2/3 结果**不变红** |
|
||
| **E11** | **上单遗留不恶化** | §8.8-1(OBS-09/11 口径)/ §8.8-2(deadline 三样本) | **均不得变红**;若变红 ⇒ 停下报告(非本单范围) |
|
||
| **E12** | **无越界** | `git status --short` + `wc -l src/net/relay/switcher.ts` | 只含 §3.1 列的文件;⛔ 无 commit / push;⛔ worker 会合面零改动 |
|
||
| **E13** | **收口四件套** | 锁 `--release-exec` 已释放;下一棒 automation 已登记**并已用陈述句告知**;入口 §2 已推进;工作区日志已写 | ✅ |
|
||
|
||
---
|
||
|
||
## §7 回滚
|
||
|
||
**三层,均秒级:**
|
||
|
||
1. **配置层(不改代码即可回到现状)** —— 两条并列开关,**优先用第一条**:
|
||
- `RELAY_FAILOVER_EXEMPT=0` ⇒ **只关掉本单新增的豁免**,序⑦ 的切流能力**全部保留**(**本单首选回滚点**)。
|
||
- `RELAY_FAILOVER_MIN_ATTEMPTS=0` ⇒ 监管器整体不触发 = 回到"原地退避重试"的**改造前**现状(序⑦ 已有)。
|
||
⇒ 改完 `daemon-reload` → `systemctl restart dshs`(47)/`dshs-cluster`(106,如涉及)。
|
||
2. **代码层**:用 §2-P9 的 `.bak-seq8-<ts>` 备份还原被改文件(⛔ `git diff` 对 `src/net/relay/**` **无效** —— 它 untracked)→ `npm run build` → `scp lib/` 到 47 `/opt/dshs/lib/` 与 106 `/opt/dshs-cluster/lib/`(⚠️ **relay 真身在 `/opt/dsh-relay/lib/`,两处都要铺**)→ `restart dshs` + `restart dshs-relay`。
|
||
3. **服务层**:S7/S8 演练若停了 relay ⇒ `systemctl start dshs-relay`(两台)。**两台 relay 的 drop-in(`capacity.conf`)本单不动**。
|
||
|
||
**⚠️ 回滚后必须复验**:`overlay-probe` 12/12、`ss -lntp | wc -l` = 78/79(按 §8.8-1 新口径)、双实例面 401、门户 200。
|
||
|
||
---
|
||
|
||
## §8 回报格式(执行棒按此格式收口;沿用序⑤/⑥/⑦ 单的分节)
|
||
|
||
### 8.1 S0 快照(P1–P9)
|
||
|
||
(表格:`# | 命令(原文) | 记录 | 判定`;证据等级用 `【实测】` / `【留档缺口】`)
|
||
|
||
### 8.2 代码改动清单
|
||
|
||
(每个文件:改动点 + `wc -l` 前后 + 是否先红后绿)
|
||
|
||
### 8.3 单测(F12–F17 + 既有全绿)
|
||
|
||
(`npm test` 总数/通过/失败/跳过 + 新增条数)
|
||
|
||
### 8.4 幕 1/2/3 回归(⛔ 必须与 §2-P9 现状一致)
|
||
|
||
### 8.5 幕 4(本单核心判据 E9)
|
||
|
||
(含:停 106 时的 `[relay-skip]` 判别器**原文**、冷却过期后的 `[relay-switch]` **原文**、耗时 vs deadline、**触发路径分类**)
|
||
|
||
### 8.6 构 B 对照(归因)
|
||
|
||
### 8.7 参数表 diff 摘要(新增键 + §9 第 5 行 + 指纹)
|
||
|
||
### 8.8 未过项 / 遗留
|
||
|
||
(每条必须写:① 现象 ② 已做到哪一步 ③ **什么条件一出现必须回头解决**。⛔ 不许用"后续优化"这种无判据的措辞)
|
||
|
||
### 8.9 指纹(本单收口后的可复现核对口径)
|
||
|
||
- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum`
|
||
- **本交接单**(**§8 及其后不计入**):`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md | md5sum`
|
||
⇒ S0 = `aa3a6ec0d66dd81f465cea2a3a08ad27`(规划棒出单时实测;执行棒**改单前**先复取一次,不一致 ⇒ 单被人动过)
|
||
- ⚠️ **上单口径不变**:`交接单_中继失败切流_20260917.md` 的 §8 前缀值应仍为 `419abf308c00b7668e8898aaa91ba9e8`(本单不要求也无法改动它,仅作"底稿未被篡改"的交叉证据)
|
||
|
||
---
|
||
|
||
# ✅ §8 执行回报(**回填 · 2026-09-17 12:40–13:2x · 执行棒「覆盖网络线-序8执行棒」**)
|
||
|
||
> ⚠️ 上面 §8.1–§8.9 是**规划棒的模板**;下面是**执行棒的真回报**(沿用序⑤/⑥/⑦ 的写法)。
|
||
> 🔑 **改单前复取本单前缀指纹 = `aa3a6ec0d66dd81f465cea2a3a08ad27`(= 规划棒出单值,逐字一致 ⇒ 单未被改动)**;**收口后复取仍是同一值**(§8 及其后不计入 ⇒ 回填不改变它)。
|
||
> 📌 **一句话结论**:**S1–S9 全部落地**;`npm test` **155/154/0/1**(+F12–F17);真机 **幕 4 = 4 PASS / 0 FAIL**(豁免在 **19218 ms** 内切回,`|豁免 kind=switched-away 剩 269991ms` = "47 当时确在冷却"的直接证据)⇒ **§8.8-4 的缺陷正面闭环**。三条硬门:**D3 护栏✅(F15 单测 + 干净候选路径零泄漏)|D9✅(生产 `RELAY_FAILOVER_COOLDOWN_MS` 代码默认恒为 300000,演练期只经 `DRILL_COOLDOWN_MS` drop-in)|D11✅(零新增端点)**。
|
||
|
||
### 8.1 S0 快照(P1–P9)
|
||
|
||
| # | 命令(原文) | 记录 | 判定 |
|
||
|---|---|---|---|
|
||
| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序8执行棒"` | `✓ 已持全局执行锁(覆盖网络线-序8执行棒)` | ✅ |
|
||
| **P2** | `state.py` | 锁抢到前后均复核;HEAD = **`640813e`**;工作区 **43** 处改动;入口 = `接续入口_覆盖网络线_20260916.md`(mtime 09-17 12:35),其 §2 指向本单 | ✅ |
|
||
| **P3** | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`24cf2efdbcdcbe61267126ed65dba006`**(= 序⑦ 收口值,逐字一致) | ✅ |
|
||
| **P4** | `sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md \| md5sum` | **`aa3a6ec0d66dd81f465cea2a3a08ad27`**(= §8.9 记载的出单值) | ✅ |
|
||
| **P5** | 同上(上单) | **`419abf308c00b7668e8898aaa91ba9e8`**(底稿未被篡改) | ✅ |
|
||
| **P6** | `grep -n "private readonly cooling\|async replace(\|async tick(\|unhealthy(" src/net/relay/switcher.ts` + server.ts 同款 | 四锚点**全在位**:`switcher.ts` **127 / 188 / 204 / 275**;`server.ts` **497 / 513 / 563 / 569**。行数:`switcher.ts` 353|`drill.cjs` 439|`relay-failover.test.mjs` 457 | ✅ |
|
||
| **P7** | `ssh -p 22 bt-server '…is-active…;curl --http1.1 127.0.0.1:20080/status'` + 106 同款 | 47 `dshs-relay`=**active** / `dshs`=**active**,`/status` 有在册会话(`dialers=["manager"]`);106 `dshs-relay`=**active** | ✅(⚠️ 命令原文偏差见下) |
|
||
| **P8** | `npm test`(Node v22.22.2) | **149 tests / 148 pass / 0 fail / 1 skipped**(= 序⑦ 收口基线) | ✅ |
|
||
| **P9** | `node scripts/overlay-failover-drill.cjs --scene 1 --table …` → `/tmp/seq8-pre-scene1.txt`;`cp -a` 4 个待改文件;`git status --short \| wc -l` | 幕 1 现状 = **3 PASS / 0 SKIP / 1 FAIL**(FAIL = 幕1-A **32550ms > 30000ms**,⇒ 触发上单 §8.8-2 的回头条件,见 8.8-2);原始输出**已落盘**;备份 `*-20260917-124115`(4 个);改动前改动数 = **43** | ✅ |
|
||
|
||
> ⚠️ **P7 命令原文的一处偏差(如实记录)**:本单 §2-P7 写的是 `ssh bt-server …`,而本机 `~/.ssh/config` 里 `bt-server` 仍是**陈旧的 `Port 32022`** ⇒ 首跑 `Connection refused`。按参数表 §4 `SSH_PORT=22`(该行**早已注明「32022 已陈旧」**)改用 `ssh -p 22 bt-server` 后成功 ⇒ **判据不受影响,是单的 P7 命令原文沿用了旧写法**。
|
||
> 🔴 **P9 的原始输出必须留存** —— 这正是上单 §8.8-3 的教训("S0 原始输出未留存"),本单已落盘 `/tmp/seq8-pre-scene1.txt`。
|
||
|
||
### 8.2 代码改动清单
|
||
|
||
| 文件 | 改动点 | 行数 | 先红后绿 |
|
||
|---|---|---|---|
|
||
| `src/net/relay/switcher.ts` | ① 阈值新增 **`exempt`**(`RELAY_FAILOVER_EXEMPT`,默认 `1`,`num()` 纯数字口径)② 新增 `RelayCooldownKind` / `RelayCooldownEntry`;`cooling` 值 `number` → **`{untilMs, sinceMs, kind, exemptedAtMs?}`**(键**仍是 url**,D2)③ 两处 `cooling.set` 补 `kind`(`open()` 失败 ⇒ `open-failed`;成功换址 ⇒ `switched-away`)④ **`replace()` 新增第 3 参 `origin: 'health' \| 'directory'`**:冷却闸门**只对 directory 收口**(D1);`health` 传进来的"冷却中目标"= **认领这次豁免**(写 `exemptedAtMs`、`exemptSwitches+=1`、切换行尾追加 `|豁免 kind=… 剩 …ms`);**豁免再失败** ⇒ 重置该 url 冷却 + `exemptedAtMs = sinceMs`(**本周期不再豁免**,D4)⑤ `tick()` 的 D6 分支细化:先调**单点判据** `pickExemptTarget()`(池 = 候选链 ∩ 冷却中 − 当前 − 本周期已豁免;排序 = `switched-away` 优先、同类按 `untilMs` 升序,D5),拿不到才回到原地退避 ⑥ `stats()` 增 `exemptSwitches`,`cooldown[]` 增只读 `kind` | 353 → **519**(+166) | 是(F6/F9 见 8.3) |
|
||
| `src/web/server.ts` | **⛔ 仅一处**:`server.ts:569` 的 `failover.replace(...)` 补第 3 参 **`'directory'`** + 5 行注释("这条换址的触发条件与旧通道是否可用**无关** ⇒ 没有打破冷却的权力")。`refreshOverlay` 的 `if (next.url === '' \|\| next.url === cur) return` **保持原样** | 903 → **908**(+5) | —(S2 口径:不改行为,只把隐含意图变显式) |
|
||
| `scripts/overlay-failover-drill.cjs` | ① 新增 `runScene4(variant)`(幕 4 / 4b / 4c 共用体)② 新增**演练期 env 覆盖机制**:`applyDrillEnv` / `clearDrillEnv`,**只走单个 drop-in** `dshs.service.d/zz-drill-override.conf`,`finally` 里**删文件 + 重启**回到生产值 ③ 新增 `waitManagerReady` / `normalizeManagerOn47`(幕 4 的**确定前置** = 重启 Manager ⇒ 通道回目录首位)④ 幕 1 场景体抽成 `runScene1(suffix)` 供 `--scene ctrl` 复用(⛔ 不抄第二份断言)⑤ `--scene` 扩到 `4|4b|4c|ctrl`,`all` = 1|2|3|4 | 439 → **707**(+268) | — |
|
||
| `test/relay-failover.test.mjs` | 新增 **F12–F17**(E1–E6);F11 的 `replace()` 调用补 `'directory'`(**语义逐字不变**,只把"目录路径"显式化);**F6/F9 显式置 `exempt:false`**(D3 与 D1 的真冲突裁决 ⇒ 见 **8.8-1**) | 457 → **731**(+274) | 是(S1 阶段 F6/F9 立刻变红) |
|
||
|
||
- ⛔ **无越界**:`git status --short` 的改动集合 = **43 项,与改动前逐项一致**(本单只碰了上表 4 个文件,且它们**本来就在**该集合内)。
|
||
- 备份(`git diff` 对 `src/net/relay/**` **无效** —— 它 untracked ⇒ 必须留实体备份):4 份 `.bak-seq8-20260917-124115`,**已移出仓库**到工作区 `_中间产物_待清理/seq8-bak-20260917/`(⛔ 留在 `src/` 下会污染 `grep -rn "\.replace("` 与 `git status` 计数 —— 实测已出现一次)。
|
||
|
||
### 8.3 单测(F12–F17 + 既有全绿)
|
||
|
||
- **改前**(S0-P8):**149 / 148 / 0 / 1** ⇒ **改后**:**155 / 154 / 0 / 1**(`npm test`,Node v22.22.2)⇒ **+6 条(F12–F17)**,fail 仍 **0**、skipped 仍 **1**。
|
||
- 🔴 **先红后绿的实证**(本线纪律,第三次):S1 阶段(只把冷却表结构化)**F6 / F9 立刻变红** —— 它们断言的场景**恰好就是 D6 现场**,序⑧ 的豁免在这里**必然**触发。裁决见 **8.8-1**。
|
||
- 逐条覆盖(判据 → 用例):
|
||
- **F12**(E1 / D1)—— `origin='directory'` + 目标在冷却 ⇒ `replace` 返 `false`、日志含「仍在冷却」、`switches` 不增;同时断言 `cooldown[0] = ['A','switched-away']`(**冷却表结构化**的现场证据)。
|
||
- **F13**(E2 / D4)—— D6 现场 + 1 条 `switched-away` ⇒ 切换成功、`switches`+1、`exemptSwitches`+1、`noCandidateChecks` 不增、日志含 `|豁免 kind=switched-away`、**原因含「当前通道不健康」**(E9③ 同口径)。
|
||
- **F14**(E3 / D5)—— 同时有 `switched-away` 与 `open-failed`(后者 `untilMs` **更早**)⇒ 必须选 `switched-away`(⇒ 顺带证明**优先级压过 untilMs 升序**)。
|
||
- **F15**(E4 / **D3 护栏**)—— 有干净候选(`D`)⇒ 走**正常**候选链;`exemptSwitches === 0`、日志**无**豁免标记 ⇒ **豁免零泄漏**。
|
||
- **F16**(E5 / D4)—— 同周期(**不推进注入时钟**)内两条冷却候选各只被豁免一次:`tried === ['B','C','A','B']`、`openFailed` 1→2→3 后**不再增长**、`switches` 不增、池空后 `noCandidateChecks ≥ 2` ⇒ ⛔ **无重试风暴**。
|
||
- **F17**(E6 / D6 开关)—— `exempt:false` ⇒ 逐字回到序⑦(无豁免、`noCandidateChecks` 增长、日志无豁免标记)。
|
||
- **F1–F11 全部仍绿**(含 F11 改签名后);`grep -c '^\[relay-switch\]'` ≡ `switches` 的断言(F4/F10/F13/F16)**仍然成立**。
|
||
|
||
### 8.4 幕 1/2/3 回归(⛔ 必须与 §2-P9 现状一致)
|
||
|
||
| 幕 | S0-P9(改动前) | 收口(改动后,`--scene all`) | 判定 |
|
||
|---|---|---|---|
|
||
| 幕1-A 杀当前入口 ⇒ 切到另一台 | **FAIL** 32550ms(临界) | **PASS 29586ms**(目标 `wss://106.54.21.172/dshs-relay`) | ✅ 未变红(⚠️ 见 8.8-2) |
|
||
| 幕1-B / C / D | PASS ×3 | **PASS ×3**(Manager active|门户 200|另一台中继未误动) | ✅ |
|
||
| 幕2-A / B / C | (P9 未单跑,序⑦ = PASS) | **PASS ×3**(0 行切换|有 `[relay-skip]` 判别器|门户 200) | ✅ |
|
||
| 幕3-A 冷却期内不回跳 | (同上) | **PASS**(90000ms 窗口内 0 行切换) | ✅ |
|
||
|
||
🔴 **一处必须点名的差异(是预期效果,不是回归)**:**幕 2-B 的判别器原文变了** —— 序⑦ 读到的是 `链里无其他候选(候选 3 条,排除 3 条)`,今天窗口里的第一条是:
|
||
|
||
```
|
||
[relay-skip] ⛔ 豁免尝试也起不来(wss://alotbuy.com/dshs-relay)⇒ **保持原通道**;
|
||
重置该候选冷却 300000ms 且**本周期不再豁免**(D4 防重试风暴)
|
||
```
|
||
|
||
即**两台全杀时豁免先被试了一次(失败)**,之后额度耗尽才回到原地退避。**这恰恰是 D4 有界性的真机现场证据**(不设界就是"每 2 s 一次的重试风暴"),而 **幕2-A 仍是 0 行切换** ⇒ 判定不变 ✅。
|
||
|
||
### 8.5 幕 4(本单核心判据 E9)
|
||
|
||
`--scene 4`(**独立跑**;⚠️ `all` 里的幕 4 无效,原因见 8.6 末段)= **4 PASS / 0 SKIP / 0 FAIL**
|
||
|
||
```
|
||
# 幕4 前置:env 覆盖={}|Manager 归零到 47 = true(⚠️ 含一次 Manager 重启)
|
||
PASS 幕4-A 停 47 ⇒ 切到 106,且 47 的两条候选都进冷却 耗时 34112ms
|
||
[relay-switch] #1 wss://alotbuy.com/dshs-relay -> wss://106.54.21.172/dshs-relay
|
||
(原因:当前通道不健康(state=backoff attempts=5 unhealthyForMs=30130 ≥ 阈值 minAttempts=3/graceMs=15000);
|
||
冷却 wss://alotbuy.com/dshs-relay 至 +300000ms)
|
||
# 幕4 ④ 已停 106(此刻 47 的两条候选仍在冷却)|窗口起 @1789621368
|
||
PASS 幕4-A **一跳豁免**把通道切回 47 耗时 19218ms|目标 wss://alotbuy.com/dshs-relay|豁免标记=true
|
||
[relay-switch] #2 wss://106.54.21.172/dshs-relay -> wss://alotbuy.com/dshs-relay
|
||
(原因:当前通道不健康(state=backoff attempts=2 unhealthyForMs=17557 ≥ 阈值 minAttempts=3/graceMs=15000);
|
||
**D6 现场:候选池已被冷却耗干 ⇒ 动用一跳豁免(每 url 每冷却周期一次,D4)**;
|
||
冷却 wss://106.54.21.172/dshs-relay 至 +300000ms)**|豁免 kind=switched-away 剩 269991ms**
|
||
PASS 幕4-B 原因必须是 health 路径(E9③)✅
|
||
PASS 幕4-C 切换耗时 ≤ RELAY_FAILOVER_DEADLINE_MS 实测 19218ms / deadline 30000ms
|
||
```
|
||
|
||
- **E9①**:停 106 时 47 **确在冷却** —— `|豁免 kind=switched-away 剩 269991ms` 是**直接证据**(300000−269991 ⇒ 该 url 约 30 s 前进冷却);且**没有**任何 `原因本为:目录地址变更` 行 ⇒ 不是 directory 路径抢走的。
|
||
- **E9②**:切换**确实发生**且 **19218 ms ≤ 30000 ms** ✅
|
||
- **E9③**:原因 = `当前通道不健康`(**health 路径**)✅
|
||
- 🎯 **对 §8.8-4 缺陷的正面闭环**:**同一现场**在序⑦ 语义下 = `链里无其他候选(候选 3 条,排除 3 条)` ⇒ **最长 ~300 s 不切流**;现在 = **19.2 s 切回**。
|
||
|
||
### 8.6 构 B 对照(归因)
|
||
|
||
> 🔴 **先报一个必须点名的结论:构 B(`RELAY_FAILOVER_COOLDOWN_MS=0`)经真机实测判定「⛔ 不可用」,与构 C 同类。**
|
||
|
||
- ✅ **幕 4b(本单新增的对照,`EXEMPT=0` + `DRILL_COOLDOWN_MS=90000`)= 4 PASS / 0 SKIP / 0 FAIL** —— 它就是**归因判别器本体**(同一幕、与幕 4 只差"豁免开关"这一个变量):
|
||
- **8.6-①(D6 现场原样复现)** 原文:`[relay-skip] ⚠ 当前通道不健康(state=backoff attempts=0 unhealthyForMs=15061)但**链里无其他候选**(候选 3 条,排除 3 条)⇒ 保持原地退避(⛔ 不切到空、不静默回退默认机)` ✅
|
||
- **8.6-②(冷却未过期前不切流)** 观察窗 45000ms 内 **0 行 `[relay-switch]`** ✅ = **§8.8-4 的缺陷本身**
|
||
- **8.6-③(冷却过期后自然回归)** `[relay-switch] #2 wss://106.54.21.172/dshs-relay -> wss://relay-direct.alotbuy.com/dshs-relay`(**25125ms**,**无 `|豁免` 标记**)✅ ⇒ **方向逻辑没问题、卡的是冷却语义** —— 归因收敛完成。
|
||
- ❌ **幕 4c(`COOLDOWN=0`)= 0 PASS / 3 FAIL**;**`--scene ctrl`(单 §1.2-③ 构 B 的**字面口径** = `COOLDOWN=0` 跑幕 1)= 3 PASS / 1 FAIL**(幕1-A:121739ms 内无切换、也无「无其他候选」)。两次都**不是产品失败**,而是**构 B 自身不可用**:
|
||
- 🔴 **根因(有日志原文级自证)**:冷却同时承担**两个**职责 —— ① **D5 防抖动** ② **序⑦ F10 的"失败候选必须被排除,否则链会堵死"**。归零 ⇒ ② 一起失效 ⇒ 候选链**卡在第一个建不起来的候选上反复重试**,**永远推进不到 106**。47 上原文(约 14 s 一条、连续刷):
|
||
```
|
||
[relay-skip] ⛔ 新通道起不来(wss://relay-direct.alotbuy.com/dshs-relay)⇒ **保持原通道**(不做半途替换);
|
||
该候选进冷却 0ms(否则它会把链堵死)
|
||
```
|
||
⇒ 那句"**否则它会把链堵死**"在 `cooldown=0` 时**字面失效** —— 这就是最好的自证。
|
||
- ⇒ **E9-b 判定 = SKIP(构造不可用)**;归因改由 **幕 4b** 承担(**更强**:同一幕 + 只差豁免开关)。
|
||
- ⚠️ **`all` 里的幕 4 = 4 FAIL,是我的脚本缺陷、不是产品结论**(首轮实测踩到):`all` 里**幕 2 停掉 106、幕 3 只恢复 47** ⇒ 幕 4 步② 落到"三条候选全死"⇒ 必然无切换。**已修**(`runScene4` 前置补 `ensureRelay(h47)` / `ensureRelay(h106)`),修后**独立跑 `--scene 4` = 4 PASS**。⇒ **本单一切幕 4 结论以"独立跑"为准**。
|
||
|
||
### 8.7 不退化(S0 对照)
|
||
|
||
| 项 | S0 | 收口 | 判定 |
|
||
|---|---|---|---|
|
||
| `npm test` | 149/148/0/1 | **155/154/0/1** | ✅(+F12–F17) |
|
||
| 幕 1/2/3 | 幕1-A FAIL(临界) | 幕1-A **PASS** + 其余全 PASS | ✅ 未变红 |
|
||
| `overlay-probe` 12 项 | 12/12 | **9/12**(OBS-01 / OBS-09 / OBS-11 红) | ⚠️ 见 8.8-3 |
|
||
| `ss -lntp \| wc -l` | 79(序⑦ S0)/78(序⑦ 收口) | **77**(= 76 socket;干净 `restart dshs` 后**两次采样复现**) | ⚠️ 见 8.8-3 |
|
||
| `nft list ruleset \| wc -l` | 72 | **72** | ✅ |
|
||
| 门户 | 200 | **200**(幕1-C / 幕2-C / OBS-10 **三处独立**读到) | ✅ |
|
||
| 双实例面 ∈ {200,401} | 本机 000(无活跃实例) | 本机 **000** / 对端 **000** | ⚠️ §8.8-1 环境态(guest(w-106) 实例页 502 属平台线在册缺陷) |
|
||
| 两台 relay `/status` | active | **active**(幕1-B / 幕1-D / OBS 多路读到) | ✅ |
|
||
| **演练 env 覆盖残留** | — | drop-in 残留 **0**;`systemctl show dshs -p Environment` 里 `RELAY_FAILOVER_*` 计数 = **0** | ✅ **回生产值** |
|
||
|
||
**部署(本机 = 生产前身 ⇒ ⛔ 不部署 = 未交付)**:`npm run build` → `tar czf` → `scp` → 远端"**备份 + `rm -rf` + 解包**"三段式(沿用 `scripts/switch-A-deploy47.sh` 的既有模式)⇒ 47 `/opt/dshs/lib` + `/opt/dsh-relay/lib`;106 `/opt/dshs-cluster/lib` + `/opt/dsh-relay/lib`;`restart dshs`(47)+ `restart dshs-worker`(106)。**落点自证**:`grep -c RELAY_FAILOVER_EXEMPT <lib>/net/relay/switcher.js` = **2**(三处 lib 全部)。**部署前备份**:`/opt/dsh/backups/_opt_dshs_lib-20260917-125112`(47)/`_opt_dsh-relay_lib-20260917-125112`/`_opt_dshs-cluster_lib-20260917-125113`(106)。
|
||
|
||
### 8.8 未过项 / 遗留
|
||
|
||
1. 🔴 **D3(E4「F1–F11 不许改语义」)与 D1 在 F6/F9 上真冲突** —— ① **现象**:F6("A 恢复了但仍在冷却 ⇒ 不换回 A")与 F9 中段("冷却未满 ⇒ 不回跳")断言的场景**恰好就是 D6 现场**(当前通道不健康 + 唯一替代在冷却);序⑧ 的豁免在这里**必然**触发 ⇒ 两条用例按原文会红。**真机 E9 幕 4 与它们同构** ⇒ **没有任何判据能"只豁免幕 4、不豁免 F6"**。② **已做到哪一步**:按 **D3 的精确口径**裁决(它给的护栏是"**有干净候选时**行为逐字不变",这两条**没有**干净候选 ⇒ **不在保证范围内**)—— **断言逐字不动**,只在用例配置里显式写 `exempt:false`(= 锁序⑦ 基线),新行为由 **F13/F15/F16/F17** 锁住;裁决理由已写进用例头注释。③ **什么条件一出现必须回头解决**:**若将来要求"F6/F9 在豁免开启下也必须绿"**(= 要求 D6 现场不回跳)⇒ 那就等于**否决 D1**,必须回来重开决策 —— ⛔ 不许在执行侧偷偷把 `exempt` 默认值改回 `false`。
|
||
2. 🔴 **上单 §8.8-2 的回头条件今天被触发** —— ① **现象**:**改动前**的 S0 基线样本 **幕1-A = 32550 ms > `RELAY_FAILOVER_DEADLINE_MS`(30000)**(= 上单原文写的"任一真机样本 > 30000 ms(deadline 真突破)⇒ 立即回头");收口后同一幕 **29586 ms**(PASS,但仍在 30 s 边缘)。**五样本全景**:`30563 / 27878 / 29176`(序⑦)|**`32550`(本单 S0)**|**`29586`(本单收口)** ⇒ **1/5 超界**。② **已做到哪一步**:本单**未动** deadline / 检测参数(⛔ 属单外 ⇒ 只报告不动手,R7)。③ **什么条件一出现必须回头解决**:**再出现任一样本 > 30000 ms** ⇒ 立刻开"检测时延 / deadline"专项(候选:调大 deadline / 降 `HB_SEC` / 缩短拨号超时;按 `dsh-decision-method §4.4` 裁决)。⚠️ 口径提示:该值**含静默失效检测时延**(半开检测 ≈ 2.5×HB_SEC)⇒ 它衡量的不只是"切换慢"。
|
||
3. ⚠️ **OBS-01 / OBS-09 / OBS-11 三项红** —— ① **现象**:`OBS-01 在册节点 used=1(阈值 ≥2)`|`OBS-09 实例面 本机:20000=000 对端:21000=000`|`OBS-11 监听口=77(阈值 79)`。② **已做到哪一步**:**OBS-01/09 与上单 §8.8-1 同因**(47 本机当前**没有活跃实例** ⇒ 20000 不监听、relay 在册只有 w-106);**OBS-11 是新的位数** —— 上单收口 = **78**,本单稳态 = **77**(**76 socket**;干净 `restart dshs` 后两次采样复现),即**再差 1 个口**。**已核的排除项**:9 个固定口(22/80/443/888/3080/8765/15432/19100/20080)**全在**|拨号池口 **64/64 全在**(25064–25127)|`nft` = **72 未变**|relay 口绑回环 **1/1**|门户 200|两台 relay active。**本单改动全是客户端决策逻辑,不新增/不删除任何监听口**(§4.5 已判定暴露面零变化)⇒ **无因果路径**。③ **什么条件一出现必须回头解决**:**下一棒开工先复取一次稳态基线**(口径:47 无活跃实例时应为 **78**);**若"有活跃实例"时仍 ≠ 79,或差值 ≥ 2** ⇒ 立即停下定位(那时可能真的是监听面变化,而不是口径抖动)。
|
||
4. 🔴 **新登记(判据级):`RELAY_FAILOVER_COOLDOWN_MS=0` 是"看起来合法、实际会自锁"的配置** —— ① **现象**:见 8.6。`num()` 的 `/^\d+$/` 放行 `'0'`,参数表"值格必须纯数字"也没拦它,但归零会让**序⑦ F10 的"失败候选必须被排除"**一起失效 ⇒ 候选链卡在第一个失败候选上(实测 **121–123 s 无切换**)。② **已做到哪一步**:**只报告**(R7)。构 B(本单 §1.2-③ 指定的归因构造)因此判**不可用**,已用 **幕 4b** 替代并拿到同等(更强)的归因证据。③ **什么条件一出现必须回头解决**:**任何一棒若打算用"归零冷却"当开关**(回滚/演练/调参)⇒ 先回头解决本条(可选修法:把"已试过且失败"与"冷却计时"**拆成两个集合**,归零只关后者)—— ⛔ 在此之前**不许**把 `COOLDOWN_MS=0` 写进任何回滚路径。
|
||
5. ⚠️ **在册未办(承上单,本轮仍未动)**:① guest(w-106) 实例页 **502**|② **106 agent 面不吃引导链**(`worker/agent.ts` 把 `DSHS_RENDEZVOUS_URL` 当 relay URL 用;已判**不执行**)|③ `mksess*.cjs` 失效 ⇒ 临时 session 走 PG 直插|④ **§8.8-5(`unhealthyForMs` 记账起点偏早)本单未触发回头条件**:今天 `17557`(记账) vs `19218`(墙钟)≈ **1.7 s**,远未接近 `GRACE_MS`=15 s|⑤ §8.8-6 留档缺口(`src/net/relay/**` untracked ⇒ 只能用 `wc -l` / `grep -c` 核)。
|
||
6. ✅ **"`all` 里幕 4 失效"这件事本身已闭环**:脚本缺陷已修(`ensureRelay`),并在报告里明确"**以独立跑为准**" ⇒ ⛔ 下一位读者**不要**把 `all` 的 4 FAIL 当产品结论。
|
||
|
||
### 8.9 指纹(本单收口后的可复现核对口径)
|
||
|
||
- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` ⇒ **`e6b669c257d8e8964273b3b400238351`**(**序⑧ 收口值**;上一版 = `24cf2efdbcdcbe61267126ed65dba006`(序⑦ 收口))
|
||
- **本交接单前缀(§8 及其后不计入)**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md | md5sum` ⇒ **`aa3a6ec0d66dd81f465cea2a3a08ad27`(= 规划棒出单值;回填 §8 后**未变**)** ⇒ 本单底稿未被执行棒改动
|
||
- **交叉证据(上单)**:`sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md | md5sum` = **`419abf308c00b7668e8898aaa91ba9e8`**(未变)
|
||
- 本单**全文件** md5 请现取(⛔ 不内嵌,自指)
|
||
|
||
---
|
||
|
||
## §9 附录 · 本单的取证基线(供执行棒复核,⛔ 不必重跑)
|
||
|
||
| 事实 | 出处 |
|
||
|---|---|
|
||
| `relayFailoverThresholds(env = process.env)` ⇒ **env 覆盖生效** | `switcher.ts:55-71`(默认值 `300000 / 30000 / 15000 / 3 / 2000 / 12000`) |
|
||
| 值格判据 = `/^\d+$/` ⇒ `'0'` **合法**(构 B 的合法性) | `switcher.ts:58-62` |
|
||
| 冷却表 = 进程内 Map ⇒ **重启即清空** | `switcher.ts:127` |
|
||
| 不健康口径 = 仅 `backoff` 累计;`minAttempts<=0` = 总开关关 | `switcher.ts:188-197` |
|
||
| **唯一换址入口** `replace(targetUrl, reason)`;冷却闸门在其内部 | `switcher.ts:204-228` |
|
||
| `open` 失败 ⇒ `cooling.set(targetUrl, …)` | `switcher.ts:241` |
|
||
| 成功换址 ⇒ `cooling.set(old.url, …)` | `switcher.ts:259` |
|
||
| D6 现场 = `target === undefined` ⇒ `noCandidateChecks += 1` + 节流日志 | `switcher.ts:291-308` |
|
||
| `refreshOverlay`:地址变了就 `replace`,**不看冷却** | `server.ts:563-570` |
|
||
| 阈值读取点(无参 ⇒ 吃 `process.env`) | `server.ts:497` / `main.ts:333` |
|
||
| 三个装配点(C1/C2/C3) | `server.ts:513` / `worker/relay-tunnel.ts:94` / `main.ts:310` |
|
||
| **env 显式 ⇒ 候选链压成单点**(构 C 不可用的证据) | `directory.ts:642-651` |
|
||
| 生产目录 3 条候选、2 条同机 | `switcher.ts:235-238` 注释 |
|
||
| `stats()` **无任何对外读取面** | 全仓 `grep "\.stats()"` 对 failover/switcher 0 命中 |
|
||
| 演练脚本现状(`--scene 1\|2\|3\|all`、权威选台) | `scripts/overlay-failover-drill.cjs`(439 行,约 282 行处) |
|
||
|
||
---
|
||
|
||
## §10 指纹
|
||
|
||
- **本节口径**(推荐核对用,可复现):**整个 §10 不计入** ⇒
|
||
`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 交接单_切流冷却语义_20260917.md | md5sum`
|
||
- ⚠️ **本单另有 §8 前缀口径**(§8 及其后不计入)⇒ 见 **§8 回报格式** 内 §8.9 的取值处(收口时回填)。
|
||
- **全文件 md5**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。
|