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

46 KiB
Raw Blame History

交接单 · 中继失败切流(覆盖网络线 · 序 ⑦)

生成: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 口径均不变(执行棒可照常核指纹)。