Files
dsh_ai1net_server/归档/交接单-20260924-归档/交接单_切流冷却语义_20260917.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

489 lines
57 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.
# 交接单 · 切流冷却语义(覆盖网络 · 序 ⑧)
> **一单只做一件事**:把「**当前通道已不可用**」与「**普通换址**」的冷却语义**拆开**,使"候选池被自己耗干"不再导致**最长 ~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**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。