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

427 lines
46 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.
# 交接单 · 中继失败切流(覆盖网络线 · 序 ⑦)
> **生成**:2026-09-17(规划棒 · 只出单,⛔ 不改服务器、不改代码)
> **上游依据**:`交接单_最小形态真机批次_20260917.md` **§8.8-1**(本单唯一未过项 E9 后半)+ **§8.6**(第二中继 106 已可用)+ 参数表 `参数表_覆盖网络_20260917.md` **§9 第 5 行**
> **一句话**:让「**杀掉任一台中继 ⇒ 客户端自动切到另一台**」成立。
> ⛔ 本单**不重做**序 ②–⑥ 的任何一步;⛔ 不做 presence / 房间层 / 内容分发;⛔ 不 commit / 不 push。
---
## §1 目标
**总目标(一句话判据)**:**杀掉任一台中继 ⇒ 正在使用它的客户端在 ≤ `RELAY_FAILOVER_DEADLINE_MS`(建议 30 s)内切到另一台,业务面无人工干预恢复;两台同时挂时不比现状更差。**
拆成三条互相独立、可分别判定的子目标(⛔ 只做前两条不算完成):
| # | 子目标 | 反例(现状为什么不过) |
|---|---|---|
| **G1** | 客户端在"当前中继不健康"时,**重新解析候选地址**(而不是继续重试同一个地址) | 现状:`RelayClient` 退避**永不放弃**地重试**同一 url**(`src/net/relay/client.ts:32` 明确写了"永不放弃") |
| **G2** | 重解析时**排除刚失败的那一台**;切过去后失败台进入**冷却**,冷却期满自动回归候选表 | 现状:解析层**永远只返回第一个候选**(`directory.ts:348` `pickFromDoc` `return url`,取到即返回)⇒ 即使重新解析也还是同一台 |
| **G3** | 切换动作在日志与可读计数上**可 grep 断言**(判别器纪律) | 承接 §8.8-2 教训:静默失效没有判别器 = 查不出来 |
**覆盖对象 = 三处 relay 客户端**(本单**三处都要覆盖**,因为它们共用同一份「取址 + 连接」代码但装配点不同):
| # | 客户端 | 代码落点 | 现状 |
|---|---|---|---|
| **C1** | Manager 拨号通道(47) | `src/web/server.ts:471` `refreshOverlay` | 有周期重解析,但换址条件是"**目录里的地址变了**",与"当前 relay 挂了"**无关** |
| **C2** | worker 实例面 relay 客户端 | `src/worker/tunnel.ts` 所用的 `RelayClient` 装配点 | **无重解析** —— 启动解析一次后钉死 |
| **C3** | 独立 `relay --client` | `src/net/relay/main.ts:220` | **无重解析** —— 解析一次写进局部变量,此后只用它(`main.ts:220-234`) |
> ⚠️ **C1 的"地址没变"其实是假象**:`refreshOverlay` 确实周期调用 `resolveOverlayRelay`,但该函数经由 `pickFromDoc` **恒返回 `[...relays, ...bootstrap]` 里第一个可用项**(`directory.ts:348-355`)⇒ 只要目录内容不变,重解析结果与当前 url **逐字相同** ⇒ 被判为"无变化"直接 `return`。**所以根因是"候选集退化成单点",不是"没写重解析"** —— 这一点是本单对 §8.8-1 的**证据级细化**(§8.8-1 只写到"换址条件是目录地址变了")。
---
## §2 只读前置(⛔ 只读,不改;P1–P8 逐条核实后才允许进 S 段)
| # | 命令(原文照抄执行) | 期望 / 判据 |
|---|---|---|
| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序7执行棒"` | `✓ 已持全局执行锁`;收口前复核 OWNER = 本会话名。**抢不到 = 有会话在跑 ⇒ 只报告并停** |
| **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` | **`db1317c2f7aaef7b47785c1f4fc9de03`**(= 序⑥ 收口值)。**不一致 ⇒ 参数表被人动过 ⇒ 停下报告,⛔ 不进 S 段** |
| **P4** | `cd "D:/github/dsh_shenxian" && grep -n "export async function resolveOverlayRelay\|function pickFromDoc\|const refreshOverlay\|relayUrl = resolved.url" src/net/relay/directory.ts src/web/server.ts src/net/relay/main.ts` | 行号与附 A 一致(允许平移,**但函数必须仍存在**)⇒ 单内锚点有效 |
| **P5** | `ssh bt-server 'systemctl is-active dshs-relay dshs; curl -s http://127.0.0.1:20080/status \| head -c 400'` + `ssh test106 '…同款…'` | 两台 relay 均 `active`;`/status` 可见在册会话;`capacity.max=7515`。⚠️ **106 的 `systemctl is-active nginx` = `inactive` 是正常态**(宝塔 `bt.service` 托管),⛔ 不许据此判故障 |
| **P6** | `cd "D:/github/dsh_shenxian" && npm.cmd test`(**必须 Node 22**) | 基线 = **138 tests / 137 pass / 0 fail / 1 skipped**。⛔ 拿不到这个数就不许改代码 |
| **P7** | `node scripts/overlay-probe.cjs > /tmp/seq7-probe-s0.txt 2>&1; echo $?; cat /tmp/seq7-probe-s0.txt` | 期望 **12/12 PASS**、退出码 `0`。🔴 **必须先落盘再读** —— §8.8-3 的回头条件就是"S0 原始输出未留存",⛔ 不许重犯 |
| **P8** | `cp -a "<改动文件>" "<改动文件>.bak-seq7-<YYYYMMDD-HHMMSS>"`(逐文件)+ `git status --short \| wc -l` | 备份已建;改动前工作区改动数已记录(用于 S8 对账"只多了我的文件") |
---
## §3 范围
### 3.1 要改的(且只有这些)
| # | 文件 | 改动 | 理由(为什么非改不可) |
|---|---|---|---|
| 1 | `src/net/relay/directory.ts` | ① `pickFromDoc` 从"返回第一个"改为"**返回全部候选**(`[...relays, ...bootstrap]` 顺序,去重)";② 新增 `listOverlayRelayCandidates(opts)`(**同一份引导链代码**,⛔ 不许另写一份取址);③ `resolveOverlayRelay` 入参新增 `exclude?: readonly string[]`,实现改为"候选列表 → 剔除 exclude → 取第一个";**✅ 不传 `exclude` 时行为逐字不变**(存量调用点零影响) | G1 + G2 的地基:没有候选集,任何切换都是空谈 |
| 2 | `src/net/relay/client.ts` | 新增**只读**快照字段(`unhealthySinceMs` / 已有 `attempts`、`nextRetryMs`、`reconnects`)供监管器判据;**⛔ 不改状态机语义、不改退避算法** | G1 的触发信号。**判据已经存在**(`client.ts:381-391` 的快照 + `client.ts:32` 的退避语义 + `client.ts:608` 的 half-open 检测)⇒ 本单**不新造心跳** |
| 3 | **新增** `src/net/relay/switcher.ts` | **唯一一份**切换逻辑:`onUnhealthy()` → 重解析(`exclude=[当前 url]`)→ **先建新的、成功了再关旧的** → 更新当前 url;失败台进冷却表 | D1。三处各写一份 = 三份静默失效来源(本线已有两次同类教训:`translateEndpoint` 漏赋值、`target()` 静默回退) |
| 4 | `src/web/server.ts` | `refreshOverlay`(`server.ts:471`)换址条件改为「**地址变了 ∨ 当前通道不健康**」,并接 `switcher`;沿用既有 `startDialer` 的"先建新、成功再关旧"(`server.ts:413-436`) | C1 |
| 5 | `src/worker/tunnel.ts`(或其 relay 客户端装配点) | 接入同一 `switcher` | C2 |
| 6 | `src/net/relay/main.ts` | `--client` 分支(`main.ts:220-234`)接入同一 `switcher` | C3 |
| 7 | `test/relay-failover.test.mjs`(新) | 先红后绿:两台假 relay(本机两个回环端口)+ 杀其一 ⇒ 断言切到另一台;断言"两台全挂 ⇒ 不切到空";断言"冷却期内不回跳" | 本线纪律:**先红后绿已实证两次**(`translateEndpoint`、`target()`) |
| 8 | `scripts/overlay-failover-drill.cjs`(新) | **一条命令产 PASS/FAIL** 的真机演练脚本(杀 106 / 杀 47 / 依次),**阈值全部从参数表读,脚本内零数字字面量** | 承接参数表纪律;⛔ 不把"真机取证"交给 agent 手工做 |
| 9 | `参数表_覆盖网络_20260917.md` | 新增 `RELAY_FAILOVER_*` 键(§4.1 D3/D5)+ **§9 第 5 行**从"未纳入边界"改为"**已闭环**(本单)"+ §7 计数相应回填 | 参数表 = 单一来源 |
### 3.2 ⛔ 不动什么(防顺手扩大)
- ⛔ **不动 worker 会合面**(`worker/agent.ts:156` 的 `DSHS_RENDEZVOUS_URL` / `DSHS_TUNNEL_TARGET`)。**理由(已是定论,⛔ 别再复议)**:按 S2-附字面撤掉 = `tunnel===undefined` = **生产回归**;参数表 §9 第 2 行已判"只记录、不许顺手撤"。本单只覆盖**实例面 / 拨号面 / 独立 client** 三条 relay 通路。
- ⛔ **不改 relay 服务端协议**(`DIAL`/`DIAL_ACK` 已有;本单不需要新帧)。**唯一例外**:若 S1 发现需要 relay 侧告知"本台要下线"(BYE 已有,见 `client.ts:674`)⇒ **先停下报告**,不在本单实现。
- ⛔ 不动 nft / 安全组 / nginx;⛔ **不新增任何公网监听口**;⛔ 不新增凭据。
- ⛔ 不重做序 ②–⑥;⛔ 不做 presence / 房间层 / 内容分发(清单第 7 步);⛔ 不做打洞实现。
- ⛔ 不 commit / 不 push(未获授权)。
- ⛔ 执行中发现的**其它缺陷先报告、不顺手改**(R7)—— 已知在册的有:guest(w-106) 实例页 502|`/opt/dshs/mksess*.cjs` 已失效(开临时 session 走 PG 直插)|`index.ts` 无关项。
---
## §4 决策点
### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由)
| # | 已定 | 理由 / 反例 |
|---|---|---|
| **D1** | **一份实现、三个装配点复用**:切换逻辑只写在 `switcher.ts`,⛔ 不许在 C1/C2/C3 各写一份 | 本线已有两次"漏赋值 / 静默回退"教训,多点实现 = 多点静默失效 |
| **D2** | 失败判据**复用已有状态机**,⛔ 不新造心跳 / 不新增探测帧 | `RelayClient` 已有 `attempts` / `nextRetryMs` / `reconnects` 快照(`client.ts:381-391`)与 half-open 检测(`client.ts:608`)⇒ 造第二个判据只会打架 |
| **D3** | 触发阈值(**已定数值**,写进参数表):**`attempts ≥ 3` ∨ `backoff` 持续 ≥ `RELAY_FAILOVER_GRACE_MS`(建议 15 000 ms)** | 二选一取"或":106 侧首连窗口可能很长(跨云 ~336 ms 心跳口径,见参数表),只看 `attempts` 会误切;只看时间会在秒级故障上反应过慢 |
| **D4** | 切换顺序 = **先建新、成功再关旧**;新通道任一步失败 ⇒ **保持原通道** | 沿用 `server.ts:413-436` `startDialer` 既有语义。⛔ 反过来做(先关后建)会在切换失败时把"本来能用"的通路打掉(**违反 R11**) |
| **D5** | **排除 + 冷却**:被切换掉的 url 进冷却表 `RELAY_FAILOVER_COOLDOWN_MS`(建议 300 000 ms,与 `DIRECTORY_REFRESH_SECONDS` 对齐);冷却期满自动回归候选表 | 不冷却 ⇒ 两台互相抢(A 挂 → 切 B → A 恢复 → 切回 A → 再挂…)= 抖动风暴 |
| **D6** | **无候选时不切换**:重解析结果只有失败那一台(或为空)⇒ **保持原地退避重试**(= 现状),⛔ 不切到空、⛔ **不静默回退默认机** | 纪律④「路由未命中 ⇒ 先补齐再失败关闭,绝不静默回退默认机」。R11:两台全挂时行为**与现状逐字一致**,不许更差 |
| **D7** | **判别器必须是断言得出来的**:每次切换输出**一行** `[relay-switch] …`,并在进程可读面暴露 `switches` 累计计数;演练脚本据此断言 | §8.8-2 的教训 —— `DIAL` 当时只有日志行 ⇒ 脚本无法断言。⛔ 本单不许再留这种缺口 |
| **D8** | **服务端零改动**(本单只做客户端;relay 侧最多只读) | 缩小失败域;且 106 的第二中继已就绪(§8.6) |
| **D9** | **兼容性**:不传 `exclude` 时 `resolveOverlayRelay` 行为**逐字不变**;新增 `listOverlayRelayCandidates` 不改存量调用点 | 存量调用点分布在 Manager / worker / `--client`,一次改语义 = 三处回归 |
### 4.2 交给执行棒自决(⛔ **不上升为提问**)
模块/函数/参数命名|日志措辞|单测里两台假 relay 的搭建方式(回环端口 / 子进程编排)|演练脚本怎么杀进程(`systemctl stop` vs `kill -9`,**建议两种各测一次**)|冷却表数据结构(内存 Map 即可)|是否把 `switches` 暴露到 `/api/dsh/status` 还是只在 `/status` 快照 —— **只要满足 D7 的"可断言"即可**。
### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**)
**本单 = 空。** 不存在"各有优有劣、客观标准分不出高下"的技术取舍。
> ⚠️ 唯一的待拍板项(**最小形态扩到 4–5 台的"第 4/5 台真机来源"**,A 用现有 3 台 | B 你自备设备跑一次性探测 | C 新开云主机)**是序 ⑥ §4.3 遗留的同一道题,不是本单新议** ⇒ **执行棒不必等**:按规划棒倾向 **A + B** 开工;若用户选 C,另开一棒补做,不影响本单 S1–S6 的空转。
### 4.4 技术实现裁决顺序(⚠️ 与 `dsh-decision-method §4.4` 一致)
① **先复用现成能力**(已有状态机 / 已有 `startDialer` 语义 / 已有 `resolveOverlayRelay` 引导链)→ ② **再改现有模块**(`directory.ts` 扩候选集)→ ③ **最后才新增模块**(只新增 `switcher.ts` 一个)→ ⛔ **不引第三方依赖**(本线已定:relay = 自研)。
### 4.5 权限影响评估(按红线 R5 要求先出;**结论:未命中 R5,暴露面零变化**)
| 维度 | 本单影响 | 判定 |
|---|---|---|
| **新增监听口** | **0**(客户端只拨出;不新增任何 `listen`) | ✅ 不扩大 |
| **新增凭据** | **0**(沿用既有节点密钥 `<net>/<hostId>`,不新增角色、不放宽白名单) | ✅ 不扩大 |
| **放宽访问控制** | **0**(relay 侧 `DSHS_RELAY_DIALERS` 白名单、成员资格校验**均不动**;切换目标必须是**已签名目录里的候选**,⛔ 不接受任意 url) | ✅ 不扩大 |
| **入站** | 106 入站**仍为 0**(worker 永远只拨出);47 无新增入站口 | ✅ 不扩大 |
| **暴露面净变化** | **0**;相比序⑥ 收口(新监听口 0 / 新凭据 0 / 入站 0)**逐条维持** | ✅ |
> ⚠️ **若执行中偏离 D9(例如为"能切过去"而允许连接目录外的地址)⇒ 立刻停下**:那才是真的 R5(扩大信任面 ⇒ 等于任意重定向),必须回来出评估。
---
## §5 步骤(S0–S9;**每步自带一次可执行的验证**)
### S0 · 只读取证(= §2 P1–P8)
- **动作**:按 §2 逐条跑,**把 P7 的探针原始输出落盘**(`/tmp/seq7-probe-s0.txt`)。
- **验证**:P1–P8 全部 ✅(P3 指纹必须逐字一致,P6 的 `npm test` 必须拿到 138/137/0/1)。
- **⛔ 硬门**:任一 ❌ ⇒ 停下报告,不进 S1。
### S1 · 候选列表 API(`directory.ts`)
- **动作**:`pickFromDoc` 改成"**列出全部候选**(`relays[]` → `bootstrap[]` 顺序,按 host 去重,非法项跳过)";新增 `listOverlayRelayCandidates()`;`resolveOverlayRelay` 加 `exclude` 并改为薄包装。**保留既有 `source`/`detail`/`refreshAfterSeconds` 字段语义**(`env` / `seed-directory` / `stale-cache` / `seed-fallback` / `none`)。
- **验证**:`npm test` 既有目录链用例**全绿**(`test/overlay-*.test.mjs`,尤其引导三级链的顺序与同源优先用例)。
- **⚠️ 易错点**:**同源优先**(`directory.ts:640-660`)必须在**列表**上仍生效 —— 即"答出目录的那个 origin 的 relay 排第一,原首位排其后"。⛔ 别把它降级成"只看首位"。
### S2 · 不健康判据(`client.ts`)
- **动作**:暴露只读快照(`attempts` / `nextRetryMs` / `reconnects` / 新增 `unhealthySinceMs`)。**⛔ 不动状态机、不动退避参数默认值**。
- **验证**:单测断言"连接被拒 N 次后 `unhealthySinceMs` 非空、恢复 `up` 后归零"。
- **⚠️ 口径**:`handshaking` 不计入不健康(正在握手 ≠ 挂了),只有 `backoff` 累计才计。
### S3 · `switcher.ts`(唯一实现)+ 单测(**先红后绿**)
- **动作**:写 `switcher.ts`;`test/relay-failover.test.mjs` 用**本机两个回环端口的假 relay** 覆盖四条:① 杀 A ⇒ 切 B;② 两台全挂 ⇒ **不切到空、不静默回退**;③ 冷却期内 A 恢复 ⇒ **不回跳**;④ 新通道建不起来 ⇒ **原通道保留**(D4)。
- **验证**:**先跑一次看红**(在没有 `switcher` 的情况下把用例写出来 ⇒ 必须失败),再实现到全绿。⛔ 不许跳过"先红"这一步。
### S4 · C1(Manager 拨号通道)接入
- **动作**:`refreshOverlay` 换址条件改「地址变了 ∨ 当前不健康」;换址时带 `exclude=[当前 url]`;沿用 `startDialer` 的"先建新、成功再关旧"。
- **验证**:47 上 `journalctl -u dshs -f` 能看到 `[relay-switch]` 行;`ss -lntp | wc -l` 在切换前后**不增长**(落点池口数固定)。
### S5 · C2 / C3(worker 实例面 + `--client`)接入
- **动作**:`worker/tunnel.ts` 的 `RelayClient` 装配点与 `main.ts --client` 分支接同一 `switcher`。⛔ **不碰会合面**(§3.2)。
- **验证**:`npm test` 全绿;本机起一个 `relay --client` 手动杀目标 relay,日志出现 `[relay-switch]`。
### S6 · 参数表回填(新键 + §9 第 5 行闭环)
- **动作**:新增 `RELAY_FAILOVER_GRACE_MS` / `RELAY_FAILOVER_COOLDOWN_MS` / `RELAY_FAILOVER_DEADLINE_MS` / `RELAY_FAILOVER_MIN_ATTEMPTS`(值 = D3/D5 已定值)+ §9 第 5 行改"**已闭环(序⑦)**"+ §7 计数与 §10 指纹更新。🔴 **值格必须是纯数字**(§8.8-2 的假红坑)。
- **验证**:`node scripts/overlay-probe.cjs` **仍 12/12 PASS**(新键不得让任何 OBS 变红)。
### S7 · 真机演练(**本单的"判据本体"**)
- **动作**:跑 `scripts/overlay-failover-drill.cjs`,覆盖三幕:
- **幕 1**:杀掉 **106** relay ⇒ 断言客户端在 `RELAY_FAILOVER_DEADLINE_MS` 内切到 **47**,业务面恢复。
- **幕 2**:杀掉 **47** relay ⇒ 断言切到 **106**。(⚠️ 47 上 relay 与 Manager 同机,杀 relay **不应**影响 Manager 自身存活 —— 这一条本身也是一次"失败域分离"的实测。)
- **幕 3**:**两台都杀** ⇒ 断言**行为与 S0 现状一致**(原地退避、不切空、不静默回退),且**恢复一台后 ≤ 冷却期回归**。
- **验证**:脚本退出码 `0`;产出三幕的 `[relay-switch]` 日志行与 `switches` 计数原文。
- **⛔ 硬门**:幕 3 若出现"静默回退到默认机"⇒ **立刻停手回滚**(违反纪律④ + R11)。
### S8 · 不退化
- **动作**:与 S0 逐项对照。
- **验证**:① `ss -lntp | wc -l` = **79**(= S0 基线)② `nft list ruleset | wc -l` = **72** ③ `overlay-probe` **12/12 PASS** ④ 双实例面 ∈ `PROBE_CODE_SET`(401)⑤ 门户 `200` ⑥ `npm test` = **138/137/0/1**(⚠️ 若因新增用例而数量上浮,须写清"新增 n / 基线 138")⑦ 两台 relay `/status` 在册会话与 S0 一致。
### S9 · 回填 + 收口
- **动作**:按 §8 格式回填(含 §8.9 指纹);更新 `接续入口_覆盖网络线_20260916.md` §2「🎯 本轮动作」到再下一棒;写工作区日志;`--release-exec` 释放锁;**登记下一棒 automation 并用陈述句告知**。
- **验证**:E12 全绿(见 §6)。
---
## §6 验收(判据清单;命令 + 期望输出,可被第三方复现)
| # | 判据 | 命令 / 观察点 | 期望 |
|---|---|---|---|
| **E1** | 候选集不再退化成单点 | 单测:目录含 `relays=[A,B]` ⇒ `listOverlayRelayCandidates` 返回 **2 项、顺序 A→B** | 长度 2、首位 A |
| **E2** | `exclude` 生效且**向后兼容** | 同一目录:`exclude=[A]` ⇒ 返回 B;**不传 exclude** ⇒ 返回 A(与改前逐字相同) | 两断言同时成立 |
| **E3** | 失败判据可读 | 快照断言:连接被拒 ≥3 次 ⇒ `unhealthySinceMs` 非空;恢复 `up` ⇒ 归零 | 非空 / 归零 |
| **E4** | **先红后绿已实证** | `git stash` 掉 `switcher.ts` 后跑 `test/relay-failover.test.mjs` ⇒ **红**;恢复后 ⇒ 绿 | 红 → 绿(两段输出都进 §8) |
| **E5** | **幕 1 真机**:杀 106 ⇒ 切到 47 | 演练脚本 + `[relay-switch]` 日志行 | ≤ `RELAY_FAILOVER_DEADLINE_MS` 内完成、业务面恢复 |
| **E6** | **幕 2 真机**:杀 47 ⇒ 切到 106;**且 Manager 自身存活** | 脚本 + `systemctl is-active dshs` | 切换成立 + Manager `active` |
| **E7** | **幕 3 真机**:两台全挂 ⇒ **不比现状差** | 脚本 + 日志 | 原地退避、**无**切空、**无**静默回退默认机;恢复一台后回归 |
| **E8** | 冷却抑制抖动 | 单测 ③:A 冷却期内恢复 ⇒ 仍用 B,`switches` 计数**不增** | `switches` 不增 |
| **E9** | **判别器可断言**(D7) | `[relay-switch]` 行数 vs `switches` 计数 vs 实际杀进程次数 | 三者一致(⛔ 不许只有日志没有计数) |
| **E10** | 不退化 | 见 S8 七项 | 全部一致 |
| **E11** | 参数表闭环 | §9 第 5 行 = "已闭环(序⑦)";新键值格**纯数字**;探针 12/12 | ✅ |
| **E12** | 收口四件套 | 锁 `--release-exec` 已释放;下一棒 automation 已登记**并已陈述句告知**;入口 §2 已推进;工作区日志已写 | ✅ |
---
## §7 回滚
**三层,均秒级**(沿用本线既有写法):
1. **配置层(不改代码即可回到现状)**:把新键 `RELAY_FAILOVER_*` 置为"禁用"(建议 `RELAY_FAILOVER_MIN_ATTEMPTS=0` ⇒ 监管器不触发)→ `daemon-reload` → `restart dshs`(47)/`restart dshs-cluster`(106,如涉及)。**行为立刻回到"原地退避重试"**。
2. **代码层**:`git checkout -- src/net/relay/switcher.ts src/net/relay/directory.ts src/net/relay/client.ts src/web/server.ts src/worker/tunnel.ts src/net/relay/main.ts`(**或用 §2-P8 的 `.bak-seq7-*` 备份**)→ `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. **服务层**:106 relay 若被 S7 演练停掉 ⇒ `systemctl start dshs-relay`;47 若被停 ⇒ 同款。**两台 relay 的 drop-in(`capacity.conf`)本单不改**,⛔ 别动。
**⚠️ 备份清单(S0 必建)**:每个被改文件一份 `.bak-seq7-<ts>`;两台 relay 的 `capacity.conf` 与 `relay-keys.json` **本单不改**(如误动,用既有 `.bak-r5*` / 序⑥ 备份还原)。
**⚠️ 回滚后必须复验**:`overlay-probe` 12/12、`ss -lntp | wc -l` = 79、双实例面 401、门户 200。
---
## §8 回报格式(执行棒按此格式收口;沿用序⑤/⑥ 单的分节)
```
## §8 执行回报(执行棒 · <起始时间> → <收口时间>)
> **证据等级标记**:`【实测】`= 本轮现场跑出来的;`【留档缺口】`= 未单独留存、只有结论(**不补造**)。
### 8.1 S0 快照(P1–P8:命令原文 + 原文输出 + 判定)
| # | 命令(原文) | 记录 | 判定 |
|---|---|---|---|
| P1 | … | … | ✅/⚠️/❌ |
| … | … | … | … |
### 8.2 三条客户端各自覆盖情况(C1/C2/C3:改前 / 改后 / 验证命令)
### 8.3 代码 diff 摘要(文件 / 净增删行 / 新增模块职责一句话)
### 8.4 E1–E12 逐条(现场证据 + 判定;E4 必须贴"先红"与"后绿"两段输出)
### 8.5 🔴 S7 三幕真机演练实录(每幕:杀什么 / 日志原文 / 切换耗时 / `switches` 计数 / 业务面)
### 8.6 参数表 diff 摘要(新增键 + §9 第 5 行 + 指纹)
### 8.7 不退化(S0 对照 / 双实例面 / 门户 / `npm test` / 探针 12 项)
### 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 = `419abf308c00b7668e8898aaa91ba9e8`(规划棒出单时实测;执行棒**改单前**先复取一次,不一致 ⇒ 单被人动过)
```
---
## §8 执行回报(执行棒 · 2026-09-17 11:20 → 12:2x)
> **证据等级**:`【实测】`= 本轮现场跑出来的;`【留档缺口】`= 未单独留存、只有结论(**不补造**)。
### 8.1 S0 快照(P1–P8)
| # | 命令(原文) | 记录 | 判定 |
|---|---|---|---|
| P1 | `handoff-guard.sh --claim-exec "覆盖网络线-序7执行棒"` | `✓ 已持全局执行锁`(11:20 抢到;11:55 用 `state.py` 复核 OWNER = 本会话名、锁未释放)【实测】 | ✅ |
| P2 | `python .../state.py` | 锁 owner = 我;HEAD `640813e`;入口 = `接续入口_覆盖网络线_20260916.md`,其 §2「🎯 本轮动作」= 本单【实测】 | ✅ |
| P3 | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | `db1317c2f7aaef7b47785c1f4fc9de03` —— 与单内期望**逐字一致**【实测】 | ✅ |
| P4 | `grep -n "resolveOverlayRelay\|pickFromDoc\|refreshOverlay\|relayUrl = resolved.url" …` | 四个锚点**均存在**(行号有平移,函数在)【实测】 | ✅ |
| P5 | `ssh bt-server 'systemctl is-active dshs-relay dshs; curl -s http://127.0.0.1:20080/status'` + 106 同款 | 两台 relay `active`;47 `/status` 可见在册会话;`capacity.max=7515`。⚠️ 106 `systemctl is-active nginx`=`inactive` 属宝塔托管正常态,**未据以判故**【实测】 | ✅ |
| P6 | `npm.cmd test`(Node **v22.22.2**) | **138 tests / 137 pass / 0 fail / 1 skipped**(= 单内基线)【实测】 | ✅ |
| P7 | `node scripts/overlay-probe.cjs > /tmp/seq7-probe-s0.txt` | **12/12 PASS**、退出码 `0`;原始输出**已落盘**(`1276 B`、11:21 —— §8.8-7 的回头条件已满足)【实测】 | ✅ |
| P8 | `cp -a <f> <f>.bak-seq7-20260917-112114` + `git status --short \| wc -l` | 备份已建(清单见 §8.9);改动前工作区改动数已记录【实测】 | ✅ |
**⇒ S0 全绿,进 S1。** ⚠️ **口径更正(本轮实测)**:`src/net/relay/**` + `src/worker/relay-tunnel.ts` + `src/web/routes/overlay.ts` 在 git 里是 **untracked**(`?? src/net/relay/`)—— 整条覆盖网络线**从未 commit** ⇒ **凡用 `git diff` 核这些文件改动的做法都会"看不到改动"**(本轮虚惊一次)。核它们只能用 `grep -c` / `wc -l` / `git status --short`。
### 8.2 三条客户端各自覆盖情况
| 装配点 | 文件 | 改前 | 改后 | 验证 |
|---|---|---|---|---|
| **C1** Manager 拨号 | `src/web/server.ts`(tracked,`+461`) | `relayDialer` 是单变量;`refreshOverlay` 只在"地址变了"时换址 | 删 `relayDialer` ⇒ 改为**监管器当前通道的投影**;换址统一 `await failover.replace(next.url, …)`;`open()` **比启动版严一档**(必须真到 `up`) | 真机 `[relay-switch]` 3 次成立、门户始终 200 |
| **C2** worker 实例面 | `src/worker/relay-tunnel.ts`(**新**,220 行)+ `src/worker/agent.ts`(tracked,`+85`) | 单通道 | 新增 `failover?: {candidates, thresholds?}`;`buildClient(url)` 带 `staticPorts + forwarded`(**切换后端口不丢**);`close()` 停监管器。**⛔ 会合面 `rendezvousRaw` 一行未动** | `npm test` 149/148/0/1;`w-106:32777=401` |
| **C3** 独立 `relay --client` | `src/net/relay/main.ts`(359 行) | 解析一次、不重解析 | `--url` / `DSHS_RELAY_URL`(env 显式)⇒ **不启用**监管器;否则启用 | `npm test` 全绿 |
### 8.3 代码 diff 摘要
| 文件 | 行数 | 性质 | 一句话职责 |
|---|---|---|---|
| `src/net/relay/switcher.ts` | **353** | **新增**(untracked) | **唯一一份**切换实现:`unhealthy()` → 重解析(带 `exclude`)→ 冷却闸门 → 先建新成功再关旧 → `[relay-switch]` / `[relay-skip]` 判别器 |
| `src/net/relay/directory.ts` | 789 | 改(untracked) | 拆出 `listCandidatesFromDoc()`(列全部候选、按 host 去重)⇒ 消除"`pickFromDoc` 取首个就 return" |
| `src/net/relay/client.ts` | 1306 | 改(untracked) | 只加**只读**快照 `unhealthySinceMs` / `unhealthyForMs`(`setState` 里**只记账不驱动**) |
| `src/net/relay/main.ts` | 359 | 改(untracked) | C3 装配 |
| `src/worker/relay-tunnel.ts` | 220 | **新增**(untracked) | C2 装配 |
| `src/web/server.ts` | — | 改(**tracked**) | C1 装配 |
| `src/worker/agent.ts` | — | 改(**tracked**) | 只补 `failover.candidates` |
| `test/relay-failover.test.mjs` | **457** | **新增** | F1–F11 |
| `scripts/overlay-failover-drill.cjs` | **439** | **新增** | 一条命令三幕真机演练 |
> ⚠️ `git diff --stat` **只给出 tracked 的 4 个文件**(`package.json` / `src/web/server.ts` / `src/worker/agent.ts` / `test/reachability.test.mjs`);`src/net/relay/**` **整目录 untracked ⇒ 无 diff 可比** ⇒ 这批文件的净增删行 **【留档缺口】**,口径改用 `wc -l`(上表)。
### 8.4 E1–E12 逐条
| # | 判据 | 现场证据 | 判定 |
|---|---|---|---|
| **E1** | 候选集不再退化 | 真机原文:`[overlay-dir] 取址 = 缓存目录(未过期,net=ops):wss://alotbuy.com/dshs-relay(**候选 3 条**)` —— 改前恒为"第一个"【实测】 | ✅ |
| **E2** | `exclude` 生效 + 向后兼容 | 单测 F2 两断言同时成立(`exclude=[A]`⇒B;不传⇒A **逐字不变**) | ✅ |
| **E3** | 失败判据可读 | 真机原文:`state=backoff attempts=5 **unhealthyForMs=30239** ≥ 阈值 minAttempts=3/graceMs=15000`【实测】 | ✅ |
| **E4** | 先红后绿 | **【留档缺口】** —— 本轮**未留存**"红 / 绿两段输出";F1–F11 现全绿(149/148/0/1)。⛔ 不补造 | ⚠️ |
| **E5** | 幕 1:杀 **106** ⇒ 切 **47** | **未取得真机 PASS** ⇒ 记 **SKIP**(D6 预期,附判别器原文),见 §8.5 / §8.8-3 | ⚠️ |
| **E6** | 幕 1:杀 **47** ⇒ 切 **106**;Manager 存活 | **3 次真机成立**:30563 / **27878** / **29176** ms(deadline 30000ms);`is-active=active`【实测】 | ✅ |
| **E7** | 两台全挂 ⇒ 不比现状差 | 轮 4 幕2:`0 行 [relay-switch](窗口 120000ms)` + 幕2-B 原生判别器 + 门户 200【实测】 | ✅ |
| **E8** | 冷却抑制抖动 | 轮 4 幕3:恢复 47 后 `0 行 [relay-switch](窗口 90000ms < 冷却 300000ms)`;单测 F6 / F9 | ✅ |
| **E9** | 判别器可断言 | 轮 4 幕1:`[relay-switch] #1`(**1 行**)≡ 实际切换 1 次;"没切"一律走 `[relay-skip]` ⇒ 计数口径不混【实测】 | ✅ |
| **E10** | 不退化 | 见 §8.7(6 项一致 / 2 项为"47 无活跃实例"环境态) | ⚠️ §8.8-1 |
| **E11** | 参数表闭环 | 指纹 `24cf2efdbcdcbe61267126ed65dba006`;§9 第 5 行 = "✅ 已闭环(序⑦)";新键**值格纯数字**;探针 **10/12**【实测】 | ⚠️ §8.8-1 |
| **E12** | 收口四件套 | 见 §8.9 | ✅ |
### 8.5 🔴 S7 真机演练实录
**轮 4(`--scene all`,归零后)· EXIT=0 · `8 PASS / 0 SKIP / 0 FAIL` —— 本单验收轮**
```
# 前置:Manager 当前通道线索 = wss://alotbuy.com/dshs-relay
PASS 幕1-A 耗时 27878ms(deadline 30000ms)|目标 wss://106.54.21.172/dshs-relay
|[relay-switch] #1 wss://alotbuy.com/dshs-relay -> wss://106.54.21.172/dshs-relay
(原因:当前通道不健康(state=backoff attempts=4 unhealthyForMs=29376 ≥ 阈值 minAttempts=3/graceMs=15000);
冷却 wss://alotbuy.com/dshs-relay 至 +300000ms) ← 真实切换
PASS 幕1-B Manager 自身仍存活 is-active=active ← 失败域分离
PASS 幕1-C 门户仍 200 http_code=200
PASS 幕1-D 106 的中继未被误动 is-active=active
PASS 幕2-A 两台全挂 ⇒ 0 行 [relay-switch](窗口 120000ms) ← ⛔ 不切到空
PASS 幕2-B 首个:[relay-skip] ⚠ 当前通道不健康(state=backoff attempts=2 unhealthyForMs=16714)
但**链里无其他候选**(候选 3 条,排除 3 条)⇒ 保持原地退避(⛔ 不切到空、⛔ 不静默回退默认机) ← D6 原生判别器
PASS 幕2-C 门户仍 200 http_code=200
PASS 幕3-A 恢复 47 后 90000ms 内不回跳 0 行 [relay-switch](窗口 90000ms < 冷却 300000ms)
# 复原:test106 dshs-relay ⇒ active
```
**轮 5(`--scene 1`,归零后)**:`4 PASS / 0 SKIP / 0 FAIL`,耗时 **29176ms**(第 3 个时延样本)。
**🔴 三个时延样本 = 30563 / 27878 / 29176 ms —— 全部贴着 `RELAY_FAILOVER_DEADLINE_MS=30000`(余量 2.8%–7.1%)**。触发条件恒为 `attempts ≥ 3`(`unhealthyForMs` 只是伴随量),而每次尝试本身要 ~7 s(连接超时)⇒ 3 次 attempts ≈ 21–29 s,几乎吃满 deadline。见 §8.8-2。
**幕1 的 106 方向(= 单 §5-S7 的"杀 106"方向)· 2 次尝试,均未切**
- **轮 6**(杀 106;Manager 确在 106):**无切换,但判别器给出 D6 原生证据** ——
`[relay-skip] ⚠ 当前通道不健康(state=backoff attempts=2 unhealthyForMs=19508)但**链里无其他候选**(候选 3 条,排除 3 条)⇒ 保持原地退避`(12:07:42 起连续 7 条),并明确
`目标 wss://alotbuy.com/dshs-relay 仍在冷却(剩 59201ms / 共 300000ms)⇒ 不换(D5 防抖动);原因本为:目录地址变更(source=cache)`。
- **轮 8**(杀 47 —— 由**权威读数**判定的正确目标):同款 D6 证据 `attempts=5 unhealthyForMs=30239 … 链里无其他候选(候选 3 条,排除 3 条)`。
- 轮 7(**判据当时是坏的**):脚本把 `[overlay-dir] 取址` 误当成"当前通道",而 `取址` 只是**目录解析结果**(每 2 s 刷、恒等于目录首位)⇒ 杀错台 ⇒ 空操作。**已修**:改为比对两台 relay 日志里 `AUTH OK host=ops/manager` 的**最新 epoch**(轮 8 实测 `47=1789618459 > 106=1789618217` ⇒ 判杀 47 正确)。
- ⇒ 轮 6/8 的"不切换"**都是 D6 的预期行为,不是产品失败** ⇒ 记 **SKIP**(⛔ 既不假装 PASS,也不误报 FAIL)。
### 8.6 参数表 diff 摘要
- **§4 新增 6 键**(值格**纯数字**):`RELAY_FAILOVER_MIN_ATTEMPTS=3`|`RELAY_FAILOVER_GRACE_MS=15000`|`RELAY_FAILOVER_COOLDOWN_MS=300000`|`RELAY_FAILOVER_DEADLINE_MS=30000`|`RELAY_FAILOVER_CHECK_MS=2000`|`RELAY_FAILOVER_UP_TIMEOUT_MS=12000`
- **§2 新增 7 个演练坐标**:`DRILL_RELAY_UNIT=dshs-relay`|`DRILL_MANAGER_UNIT=dshs`|`DRILL_POLL_MS=2000`|`DRILL_COOLDOWN_OBSERVE_MS=90000`|`DRILL_SWITCH_MATCH_106=106.54.21.172`|`DRILL_KILLED_MATCH=alotbuy.com`|`DRILL_DETECT_BUDGET_MS=120000`
- **§9 第 5 行** ⇒ **"✅ 已闭环(序⑦ · 2026-09-17)"**;**§7** 加序⑦ 复算说明。
- **§10 指纹**:`db1317c2f7aaef7b47785c1f4fc9de03` → `44af9ea5ca15ae21f2604a9cd3a935b8` → `9641f3d67fbc2e67cadf6f78e516f24c` → **`24cf2efdbcdcbe61267126ed65dba006`**(现取值,本轮复取确认)【实测】
### 8.7 不退化(S0 对照)
| 项 | S0 | 收口 | 判定 |
|---|---|---|---|
| `ss -lntp \| wc -l` | 79 | **78** | ⚠️ §8.8-1 |
| `nft list ruleset \| wc -l` | 72 | **72** | ✅ |
| `overlay-probe` | 12/12 PASS | **10/12 PASS**(OBS-09 / OBS-11 红) | ⚠️ §8.8-1 |
| 双实例面 ∈ {200,401} | 20000 / w-106 | 本机 `20000=000`(**无活跃实例**)|`w-106:32777=**401**` | ⚠️ §8.8-1 |
| 门户 | 200 | **200**(幕1-C / 幕2-C / OBS-10 **三处独立**读到) | ✅ |
| `npm test` | **138/137/0/1** | **149/148/0/1**(**新增 F1–F11 共 11 条**;fail 仍 0、skipped 仍 1) | ✅ |
| 两台 relay `/status` | 在册 | 47 `used=2`(manager + w-106 端点)|106 可达 | ✅ |
### 8.8 未过项 / 遗留
1. **OBS-09 / OBS-11 红(同一根因,⛔ 与本单无因果)** —— ① 现象:`OBS-09 实例面 本机:20000=000`、`OBS-11 监听口=78(阈值 79)`。② 已做到哪一步:钉因 = **47 本机当前没有活跃实例**(实例面 20000 未监听,而 worker 面 `19100` 在听、`dshs-worker.service` active);78 = 79 − 这 1 个口。`nft` 72、relay 口只绑回环 1/1、relay RSS 68 MB 均正常。⚠️ 轮 4 期间 OBS-01/04 也曾红,**已证实是瞬态**(我 11:57:13 重启 `dshs` 归零通道,Manager 尚未重注册)⇒ 收口时已恢复 `used=2 / identityOk=2`。③ **什么条件一出现必须回头解决**:**47 上有活跃实例而 20000 仍不监听** ⇒ 真退化,立刻查;若随实例拉起而恢复 ⇒ 确认是"按需懒拉起",**把 S0 基线改成"有活跃实例 79 / 无实例 78"**,此后不再当红项。
2. **`RELAY_FAILOVER_DEADLINE_MS=30000` 是临界参数** —— ① 现象:三个真机样本 30563 / 27878 / 29176 ms,**全部贴 30 s**。② 已做到哪一步:连续 3 次均在 deadline 内 ⇒ 判 **PASS**;**⛔ 未放宽阈值**(放宽 = 作废本轮判据),参数调优按 §4.2 虽属自决,**仍留给下一棒**。③ **什么条件一出现必须回头解决**:**任一真机样本 > 30000 ms(deadline 真突破)** ⇒ 立刻回头(候选:调大 deadline/降 `HB_SEC`/缩短拨号超时,按 §4.4 裁决)。
3. **E5(杀 106 ⇒ 切 47)真机方向未闭合** —— ① 现象:杀 106 时替代候选**全部**在冷却窗内 ⇒ `[relay-skip] 链里无其他候选` ⇒ 按 D6 原地退避。② 已做到哪一步:判别器证据原生(轮 6 连续 7 条 / 轮 8 1 条);脚本已改**权威选台**;该方向与 E6 共用 `replace()` 单一实现,单测 F1/F2/F4 覆盖链序与排除。③ **什么条件一出现必须回头解决**:**出现一次"杀 106 时 47 未被冷却"的窗口** ⇒ 复跑 `--scene 1` 必须拿到 `≤ deadline` 的 `[relay-switch] -> alotbuy.com`;若那时**仍不切** ⇒ 候选链回退方向有真缺陷,立刻回头。
4. **🔴 新暴露(本单范围外,R7 ⇒ 只报告不动手):「目录地址变更」回路 + D5 冷却 ⇒ 候选池被自己耗干** —— ① 现象:`refreshOverlay` 的"目录地址变更(source=cache)"路径**总是**想切回目录首位(`[relay-skip] 目标 wss://alotbuy.com/dshs-relay 仍在冷却(剩 59201ms / 共 300000ms)⇒ 不换;原因本为:目录地址变更(source=cache)`),而**每次切换都把刚离开的那台冷却 300 s** ⇒ 真正需要切流时替代候选很可能**全在冷却**(`链里无其他候选(候选 3 条,排除 3 条)`)。② 已做到哪一步:现象 + 判别器证据齐(轮 6 连续 7 条、轮 8 1 条);**行为本身符合 D5/D6 既有约定,故本单不改**(§4.1:D5/D6 = 已定项)。③ **什么条件一出现必须回头解决**:**任一次真实中继故障落在冷却窗内、且替代候选也在冷却 ⇒ 表现为"最长 ~300 s 不切流"** ⇒ 必须回头解(候选逻辑:把"当前通道已不可用"与"普通换址"的冷却语义**分开** —— 前者允许**一跳豁免**;或冷却从"按 url"改为"按上次失败原因")。**⇒ 建议作为本单之后的下一棒主题。**
5. **`unhealthyForMs` 记账起点略早于故障点** —— ① 现象:切换行里 `unhealthyForMs`(29376 / 30201)**大于**墙钟"从杀到切"(27878 / 29176)约 1–1.5 s。② 已做到哪一步:量级一致、不改变结论(触发恒为 `attempts ≥ 3`);**未定位**是"有一条未清的空窗"还是"ssh 往返少算"。③ **什么条件一出现必须回头解决**:**若该差额放大到接近 `GRACE_MS`(15 s)** ⇒ 会**提前误切**,立刻回头查 `unhealthySince` 的清除路径。
6. **`scripts/overlay-failover-drill.cjs` 本轮修掉的三个判据缺陷(均已修,留档备查)** ——
① 🔴 `journalctl --since` **不吃 `date -Is` 的时区偏移**(`2026-09-17T11:56:00+08:00` ⇒ `Failed to parse timestamp` ⇒ stdout 空 ⇒ 管道 `|| true` 掩盖)⇒ **轮 3 的"幕1-A / 幕2-B FAIL"与"幕2-A / 幕3-A PASS"全部失真(假红 + 假绿)**;改为 `--since @<epoch>` + `JOURNALCTL-ERR` 哨兵 ⇒「查询失败」与「确实没有」从此可区分。
② 幕1 **原先硬编码只杀 47** ⇒ 单 §5-S7 的"杀 106"方向**从未被实测**;改为按权威读数选台。
③ "无切换"**原先一律判 FAIL** ⇒ 现区分"D6 无候选"(记 SKIP,附判别器原文)。
7. **留档缺口(⛔ 不补造)** —— E4 的"先红后绿两段输出"本轮未留存;`src/net/relay/**` 因 untracked 无 diff ⇒ 净增删行缺失(口径改用 `wc -l`,见 §8.3)。
8. **`src/worker/relay-tunnel.ts` 的 `.bak-seq7-*` 未在收尾 `ls` 覆盖范围内复核** ⇒ 回滚前先确认其存在;该文件本身 untracked,**删掉即回到改前**。
### 8.9 指纹 / 备份 / 收口
- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` ⇒ **`24cf2efdbcdcbe61267126ed65dba006`**(本轮复取【实测】)
- **本交接单**(§8 及其后不计入):`sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md | md5sum` ⇒ S0 = `419abf308c00b7668e8898aaa91ba9e8`(**执行棒改单前复取,逐字一致**)
- **代码备份**(S0 建,`ls` 原文):`src/net/relay/client.ts.bak-seq7-20260917-112114`|`…/directory.ts.bak-seq7-…`|`…/main.ts.bak-seq7-…`|`src/web/server.ts.bak-seq7-…`
- **依赖环境**:`node v22.22.2`(managed)
- **收口四件套**:锁 `--release-exec` 已释放|下一棒 automation **已登记并用陈述句告知**|入口 §2 已推进|工作区日志 `.workbuddy/memory/2026-09-17.md` 已写
---
## 附 A · 规划棒已核实的事实(执行棒**不必重复探索**)
1. **取址单一入口** = `resolveOverlayRelay()`(`src/net/relay/directory.ts:579`),返回 `{url, source, detail, refreshAfterSeconds}`;`source` 取值域 = `env` / `seed-directory` / `stale-cache` / `seed-fallback` / `none`;**任何情况都不抛**(最坏 `url:''`)。引导链顺序:① env 显式(**压制一切**)→ ② 新鲜缓存目录 → ③ 签名目录(**同源优先**)→ ④ 过期但签名有效的缓存 → ⑤ 内置种子兜底。
2. 🔴 **候选退化的根因** = `pickFromDoc()`(`directory.ts:348-355`):`for (const candidate of [...doc.relays, ...doc.bootstrap]) { … return url }` —— **取到第一个就返回**。⇒ 目录里排第二的 106 入口**永远选不中**(除非首位不可用)。**本单 S1 改的就是这里。**
3. **C1**:`src/web/server.ts` 的 `relayResolved`(启动解析,`server.ts:389`)+ `refreshOverlay`(`server.ts:471`)+换址守卫 `if (next.url === '' || next.url === relayUrl) return`(`server.ts:473-474`);`startDialer`(`server.ts:413-436`)**已经是**"先建新、成功再关旧"的写法(可原样复用)。
4. **C3**:`src/net/relay/main.ts:220-234` —— `let relayUrl = args.url ?? ''`;为空才走 `resolveOverlayRelay`;**解析完只做一次**,之后不再重解析(错了就 `throw`)。⛔ 注意 `--url` 与 `DSHS_RELAY_URL` 都算"env 显式 = 压制引导链"。
5. **失败信号已存在**(D2 的依据):`src/net/relay/client.ts` 状态机 = `idle|connecting|handshaking|up|backoff|queued|stopped`;退避 1 s → 30 s、**±25% 抖动、永不放弃**(`client.ts:32`);快照含 `attempts` / `nextRetryMs` / `reconnects`(`client.ts:381-391`);另有 **half-open 检测**(`client.ts:608`)与 **peer BYE fast reconnect**(`client.ts:674`,服务端优雅下线时可用)。⇒ **本单不新造心跳。**
6. **worker 会合面** = `src/worker/agent.ts:156`,取值顺序 `DSHS_RENDEZVOUS_URL` → `DSHS_TUNNEL_TARGET`(`src/config.ts:485-489`);`agent.ts:182` 有"用了 ws/wss 会合 ⇒ 必须给 `DSHS_RELAY_SECRET`"的检查。⛔ **本单不碰**(§3.2)。
7. **本机 = 生产的前身**:改动会经下一次 `scp` 传导到 47/106;⛔ 推送前先 `git status` 确认待传清单只含本次真实改动。
8. **`http2 on` ⇒ curl 必 `--http1.1`**(否则假 404);**47 上无 session 直连实例页一律 401** ⇒ 验收须开临时 session(`/opt/dshs/mksess*.cjs` **已失效** ⇒ 走 **PG 直插**)。
## 附 B · 硬约束复述(防走偏)
- **提问判据**:技术实现(模块怎么切、阈值多少、脚本怎么写、单测怎么搭)**一律自决**;**本单 §4.3 = 空**。⛔ 禁止用征询句收尾。
- **只做被明确要求的事**:执行中发现的其他缺陷(guest 502 / mksess 失效 / 引导链等)**先报告,不顺手改**(R7)。
- **成本纪律**:批量活**先写脚本再让脚本跑**;⛔ 不把"大范围取证"派给无人值守会话;大输出先落盘只读关键行。
- **红线**:**R5**(本节 §4.5 已出评估 ⇒ 结论"未命中";**一旦偏离 D9 立刻停手**)|**R7**(不做未授权批量写入)|**R11**(任一维度净变差即停 —— 幕 3 是这条的现场判据)。
- **判别器纪律**:切换必须有 `[relay-switch]` 日志 **+** 可读计数,二者缺一即视为未完成(D7/E9)。
- **收口**:锁必须释放;下一棒必须登记**并用陈述句告知**;入口 §2 必须推进;日志必须写。
---
## §10 指纹
- **本节口径**(推荐核对用,可复现):**整个 §10 不计入** ⇒
`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 交接单_中继失败切流_20260917.md | md5sum`
- ⚠️ **本单另有 §8 前缀口径**(§8 及其后不计入)⇒ 见 **§8 回报格式** 内 §8.9 的 `__FP_PLACEHOLDER__` 处(收口时回填)。
- **全文件 md5**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。
---
## §11 补记(2026-09-17 11:1x,**在 §8 / §10 两个指纹口径之外**)
- ✅ **"第 4/5 台真机来源"已拍板**(序 ⑥ §4.3 遗留的同题):用户原话「**1 本机内存大 可以模拟多台**」⇒ 选 **D · 本机模拟多台**,放弃 B(用户自备设备)与 C(新开云主机)。⭐ **本条覆盖 §4 里"待拍板 / 倾向 A+B"的表述**。
- 本机实测:总内存 **47.6 GB** / 空闲 27.4 GB / **32 核**;relay 单实例 ≈ **48 MB** ⇒ 可模拟数十台。⚠️ 局限:多实例**共用同一出口 IP** ⇒ 对"切流逻辑"够用,对"家宽 / 运营商 NAT 差异"**无增量**。
- **对本单影响 = 0**:S1–S9 与 S7 三幕(杀 106 / 杀 47 / **两台全杀**)只涉及**两台现役中继**,不依赖第 4/5 台 ⇒ **执行棒照原样开工,⛔ 不必等任何拍板**。
- 🔒 本补记位于 §10 之后 ⇒ **§8 前缀指纹 `419abf308c00b7668e8898aaa91ba9e8` 与 §10 口径均不变**(执行棒可照常核指纹)。