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

57 KiB
Raw Blame History

交接单 · 切流冷却语义(覆盖网络 · 序 ⑧)

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