Files
dsh_ai1net_server/归档/交接单-20260924-归档/交接单_退出路径不杀实例_20260918.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

64 KiB
Raw Blame History

交接单 · 退出路径不杀实例(打通 E1 缺口 · 候选 B)

  • 序号:覆盖网络线 序 ㉘ · 规划棒 → 单 A(执行棒待跑)
  • 立单:2026-09-18 00:5x
  • 用户拍板:「选 B」(2026-09-17 23:3x,原话)—— 即三处 teardown() 一起改;同轮另有 「C 登记为架构目标」
  • 上游依据:交接单_实例逐步拉起_20260917.md §8.3(A/B/C 逐条优缺点;倾向 B)|同单 §3.1(在册文件集)|同单 §11(序 ㉕ 真凶取证)
  • 归档号:129(占号动作已执行,见 §11;⛔ 63 是空号、不补占)
  • 状态:待执行

本单与 交接单/README.md §二 8 段模板的对应:§1 目标|§2 只读前置|§3 范围|§4 步骤|§5 验收(模板 6)|§6 回滚(模板 7)|§7 决策点 + 影响面(模板 5 + 本单要求 ④⑤)|§8 回报格式(留空给执行棒)|§9 回头条件|§10 未验证项|§11 指纹与状态。


§0 摘要 · 口径演变留档 · 三条取证事实

0.1 摘要(一句话)

把「退出进程即停掉所有在册实例」这一行为去掉 —— 退出进程把存活实例留给下一个进程,回收责任完全交给「启动认领(rehydrateAdoptedScopes())+ TCP 探活判孤儿」这一套(序 ㉕ 已实测工作)。主判据 = 重启 Manager / worker 后,两机 dsh-*.scope 计数守恒且认领报告自洽。

0.2 🔴 口径演变留档(必须如实保留)

时点 用户答 落盘口径
2026-09-17 23:2x 第 ① 问「B 和 C 哪个更符合长期主义」⇒「选B」;第 ② 问(实操项)⇒「选 C(实例由独立单元托管)」 ①②指向同一件事(序 ㉕ 的 E1 缺口)而答案相反 ⇒ 主代理按 ②=C 落盘,并开纠错窗口
2026-09-17 23:3x 行使纠错:先「选 B」、再「C 登记为架构目标」 最终 = B 先做 + C 登记为架构目标(= 23:1x 的原建议)。③ 加密档位「选 C 组密钥加密」未变

⇒ 🔴 本单按「B 先执行」出单;C 只能作为"架构目标"段落出现(见 §7.2),⛔ 不得按 C 出"实例解耦"的执行单。 若读到任何旧文本仍写"按 C 执行",一律以本段为准。

0.3 🔬 本棒三条取证事实(均为本棒现场实测,⛔ 与上游结论冲突时以本棒为准)

  1. 🔴 处② RemoteSpawner.teardown() 已经就是 no-op —— src/supervisor/remote-spawner.ts:329-331 与 HEAD 版逐字相同(git show HEAD:… 已核),函数体只有一行注释:远端实例的寿命长于任何单个 Manager 副本 ⇒ 由 Manager 的归属/租约管理,不在关闭时清。 ⇒ 用户口径"三处一起改"与"处② 已符合"不矛盾:三处都纳入在册范围、都加"退出不停实例"的机器断言,但处② 语义改动量 ≈ 0(只补守卫/注释固化,防将来被改回去)。
  2. 🔴 两机当前活跃实例 scope 数 = 47: 0 / 106: 0(本棒 00:4x 实测:ssh -p 22 bt-server / ssh -p 22 test106 各 systemctl list-units --type=scope --all | grep -c "dsh-")⇒ 验收必须先在真实形态夹具上做(照序 ㉕ 的做法:真 systemd scope + 真 argv + 真监听端口),⛔ 不许拿"0 个实例"当绿(0 == 0 是假绿;见 §5 P1 的反例条款)。
  3. idle-reap 缺省是启用的,且 TTL 很长 —— src/config.ts:300-302:DEFAULT_MAX_IDLE_INSTANCES = 4|DEFAULT_INSTANCE_IDLE_TTL_SECONDS = 60*60*24*7(7 天)|DEFAULT_IDLE_REAP_INTERVAL_SECONDS = 60;两机 env 均未覆盖(只读实测:dshs 的 Environment 里只有 DSHS_CLUSTER_LEASE_TTL_MS=30000,无 IDLE/REAP/TTL)⇒ 「进程长期不重启时实例由 idle-reap / 用户访问替换来收」这一前提成立,但收得很慢(7 天)+ 有 每 host 4 个的常驻上限(第 5 个会淘汰 LRU)。

§1 目标

退出路径(SIGTERM → app.onClose / agent.stop())不再停任何实例;重启后实例仍在册且端口可达(TCP 判据),由启动认领接管、由 cleanStaleScopes(uid) 在用户访问时自然替换。

可判定的"做完了没有":§5 的 P1 + P2 + 对冲项全绿 —— 即:重启前后 scope 数守恒(且两侧都 ≥ 1)+ 认领报告 adopted + stopped === scanned 自洽且 scanned > 0 + 三处 teardown() 的"不停实例"断言全绿。


§2 只读前置(执行前必须先核实,逐条给命令与期望输出)

全部为只读;⛔ 不改任何文件、不改任何生产值。任一条与期望不符 ⇒ 停下报告,不许"先改了再看"。

# 核实内容 命令 期望输出
1 代码仓状态 + 三处 teardown 现状 cd D:/github/dsh_shenxian && git status --porcelain src/supervisor/ && grep -n -A6 "async teardown" src/supervisor/orchestrator.ts src/supervisor/remote-spawner.ts src/supervisor/leased-spawner.ts git status = 仅 M src/supervisor/orchestrator.ts(序 ㉕ 留下的改动)|① orchestrator.ts 体 = clearInterval(reapTimer) + for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)|② remote-spawner.ts 体 = 空体(只有注释)|③ leased-spawner.ts 体 = this.stopHeartbeat() + await this.inner.teardown()。🔴 若 ① 已变成空体 ⇒ 本单已被别人做过 ⇒ 停下报告。
2 认领链仍在(⛔ 不许为 0) cd D:/github/dsh_shenxian && grep -c "rehydrateAdoptedScopes" src/supervisor/orchestrator.ts; grep -c "cleanStaleScopes" src/supervisor/orchestrator.ts rehydrateAdoptedScopes ≥ 2(定义 + 构造函数调用)|cleanStaleScopes ≥ 1。🔴 cleanStaleScopes = 0 ⇒ 停下报告 —— 那是档案 30 本体(spawn 前清同 uid),⛔ 不许为了本单把它去掉。
3 两机活跃实例数(决定夹具规模) ssh -p 22 bt-server 'systemctl list-units --type=scope --all --no-legend | grep -c "dsh-"' + ssh -p 22 test106 '…同上…' 本棒读数 = 0 / 0。⚠️ 为 0 不是"没问题",而是"夹具必须先造"(见 §4 S1)。
4 106 worker 侧 idle-reap 是否走缺省 ssh -p 22 test106 "systemctl show dshs-worker -p Environment | tr ' ' '\n' | grep -iE 'IDLE|REAP|TTL'" 无输出(=走代码缺省:60 s 间隔 / 7 d TTL / 4 上限)。⚠️ 若读出 =0 的值 ⇒ 记入 §10("106 上没人收实例"缺一环)。
5 回滚点存在性 + 产物一致性 ssh -p 22 bt-server 'ls -d /opt/dsh/backups/* | tail -3; md5sum /opt/dshs/lib/supervisor/orchestrator.js'(106 同查 /opt/dshs-cluster/lib/…) 至少一个 seq* 目录|两机 orchestrator.js md5 同值(序 ㉕ 铺的 5bfb15e00b592c293548ae17b639399b;⚠️ 若已变 ⇒ 现核取真值再写进 §5/§6 的基线,⛔ 不写死)。
6 基线三件套 + 参数表指纹 cd D:/github/dsh_shenxian && npm run build >/dev/null && npm.cmd test 2>&1 | tail -5;node scripts/overlay-probe.cjs --table;sed '/^## §10 指纹/,$d' "E:/ProgramData/AI技能/aliyun-dsh-server/参数表_覆盖网络_20260917.md" | md5sum npm test = 201 pass / 200 / 0 fail / 1 skip(序 ㉗ 收口值)|overlay-probe --table = 19 PASS / 1 SKIP / 1 FAIL(1 FAIL = OBS-21,是本线已知在册缺口、⛔ 非本单引入)|参数表指纹 = 7fc5889341b99fb26bd05cad0313960b(本棒 00:4x 现核)。⛔ 判据一律用"现核值",本单写的是取数时刻的快照。

§3 范围

3.1 在册文件集

面 文件 说明
改动 src/supervisor/orchestrator.ts LocalSpawner.teardown()(:604)—— 本单唯一语义变动处
改动(授权扩范围) src/supervisor/remote-spawner.ts teardown()(:329)—— 取证已是 no-op;只补"不停实例"断言/注释固化
改动(授权扩范围) src/supervisor/leased-spawner.ts teardown()(:252)—— 拆开"停心跳"与"停实例"的耦合语义
改动 参数表_覆盖网络_20260917.md §6 新增观测项 OBS-22(⛔ 无新生产键)
改动 scripts/overlay-probe.cjs 新增 OBS-22
新增 test/orchestrator-teardown.test.mjs 三处 teardown() 的"不停实例"单测(⛔ 不改 package.json test 列表结构 —— 那是硬编码文件列表,加进去会改 npm test 总数;照序 ㉕ 先例刻意不进)

3.2 🔑 扩范围授权依据(必须写在单里,供第三方复核)

  • 用户原话:「选 B」(2026-09-17 23:3x)。候选 B 的定义原文(交接单_实例逐步拉起_20260917.md §8.3)即 = "三处一起改:LocalSpawner.teardown() + RemoteSpawner.teardown() + LeasedSpawner.teardown()"。
  • ⇒ src/supervisor/remote-spawner.ts 与 src/supervisor/leased-spawner.ts 不在 交接单_实例逐步拉起_20260917.md §3.1 在册集,现已获准纳入。
  • ⚠️ 序 ㉕ 曾按 R7(超出在册集)停下报告、未动手;本单解除该阻塞。⛔ 但**"获准纳入"只覆盖这两个文件** —— 再超 ⇒ 仍按 §9-5 停下报告。

3.3 ⛔ 不动什么(防顺手扩大)

  • ⛔ 不许删 cleanStaleScopes(uid)(spawn 前清同 uid = 档案 30 本体)。
  • ⛔ 不许把启动流程改成"什么都不做"(孤儿会失控)—— rehydrateAdoptedScopes() 的扫描/探活/停孤儿三步一步都不能少。
  • ⛔ 不许"重启后重新 spawn 一遍"(换端口换名字 + 启动风暴)。
  • ⛔ 认领不写回 mains(否则 enter 复用分支会等一个永不出现的 launchToken ⇒ 用户被 503 挡住)。
  • ⛔ token 不落盘(改官方 dsh 取 token 违 R2)。
  • ⛔ 不改 bwrap 参数(🔴 47 是 bubblewrap 0.4.0,--perms 属 0.5+ ⇒ 会起不来)。
  • ⛔ 不动 RELAY_FAILOVER_* / HB_SEC / burst;⛔ 不动 relay / 选路 / 内容分发(本单与覆盖网络数据面无关)。
  • ⛔ 不新增公网监听口、不改 nft / nginx(R5)。
  • ⛔ 不 commit / 不 push(R7)。
  • ⛔ 不改任何生产值(D1)。

§4 步骤(每步自带一次可执行的验证)

三条硬门贯穿全程:D1(不改任何生产值)|R5(不新增公网监听口 / 不改 nft·nginx)|R7(不 commit / 不 push)。

S0 · 开工对账

  • 跑 §2 全部 6 条并原样留档(命令 + 原始输出)。任何一条不符 ⇒ 停下报告。
  • 抢全局执行锁:bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序28单A执行棒";抢不到 = 有会话在跑 ⇒ 只报告并停(R9:⛔ 不删锁 / 不接管 / 不等重试)。
  • 验证:guard 返回「✓ 已持全局执行锁」。

S1 · 造真实形态夹具(⚠️ 因为两机活跃实例 = 0,本步不可省)

  • 照序 ㉕ 的做法:造一个真 systemd scope + 真 argv + 真监听端口的既有实例(systemd-run --scope 起一个监听 127.0.0.1:<区内端口> 的进程,--chdir <dataRoot>/users/<uid>/…,-u dsh-<uid>-<hex>.scope)。
  • 验证:ssh -p 22 bt-server 'systemctl list-units --type=scope --all | grep -c "dsh-"' ⇒ ≥ 1;ss -lntp 'sport = :<端口>' ⇒ 有监听。
  • ⚠️ 夹具必须是真实形态 —— 序 ㉕ 已实证:假夹具(只有 unit 名、无真进程)会让"认领逻辑工作"这个结论站不住。

S2 · 逐处最小改动(三处,⛔ 顺序无关但必须三处都动)

处 位置 改前语义 改后语义(最小差异)
① orchestrator.ts LocalSpawner.teardown() :604 clearInterval(reapTimer) + for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId) 保留 clearInterval(reapTimer);删掉那行循环,改为不做任何实例处置。注释写明:回收责任移交「启动认领 + TCP 探活判孤儿」+ cleanStaleScopes(uid)(访问时替换)+ idle-reap(缺省 60 s / 7 d / 4 上限)。
② remote-spawner.ts :329 空体(只有一行注释) 语义不变;补一句断言式注释 + (可选)在函数体内加一行"可被 grep 的守卫标记",供 §5 对冲项的静态断言使用。
③ leased-spawner.ts :252 this.stopHeartbeat() + await this.inner.teardown() 拆开两件事:stopHeartbeat() 保留(心跳停 ⇒ 租约按 TTL 过期,这是"进程不在就别再续租"的正确语义);await this.inner.teardown() 保留(它只是转发,inner = RemoteSpawner ⇒ no-op)。⇒ 净改动 ≈ 注释 + 守卫标记;⛔ 严禁在此处向 worker 下发 POST /stop(今天没有,将来也不许)。
  • 验证:grep -n -A6 "async teardown" 三处,人工逐字比对"改后 ≠ 改前"仅限上表差异(处②/③ 应为"语义不变")。
  • ⚠️ 处① 的 this.mains.delete/watchdogs.delete/lastActive.delete 不在 teardown() 里(teardown() 只调 stop())—— stop() 内部会做清理,stop() 本身一行不动(它仍是"用户显式停实例"的正路,被路由层调用)。

S3 · 单测(先红后绿)

  • 新增 test/orchestrator-teardown.test.mjs,三组断言:
    • ① LocalSpawner.teardown() 后,假 spawnInstance 造出的实例未被停(stop 桩调用计数 = 0);
    • ② RemoteSpawner.teardown() 对 HTTP 桩的下发计数 = 0;
    • ③ LeasedSpawner.teardown() 后:心跳停(计数 = 1)∧ 实例停(计数 = 0)。
  • 先红:临时把三处改回原语义 ⇒ 3 红(逐个点名是哪一处)⇒ 还原 ⇒ 全绿、md5 逐字一致。
  • 运行:node --test test/orchestrator-teardown.test.mjs(rc=0)。
  • ⛔ 不改 package.json(硬编码文件列表,加进去会改 npm test 总数)。

S4 · 真机先红(新判据对旧生产)

  • 在未部署新 lib 的生产上跑新增的 OBS-22 ⇒ 期望 FAIL,并打印可区分证据(见 §5)。
  • 验证:探针 rc=1 且 FAIL 点名 OBS-22。

S5 · 部署 + 真机后绿

  • npm run build ⇒ 铺 lib/supervisor/{orchestrator,remote-spawner,leased-spawner}.js 到四处:47 /opt/dshs/lib/supervisor/、106 /opt/dshs-cluster/lib/supervisor/(⚠️ 若 relay 侧也有同名副本则一并核;本单不涉及 relay)。
  • 回滚点:/opt/dsh/backups/seq28-<ts>/(先备份旧 .js,md5 记录)。
  • 重启:47 systemctl restart dshs、106 systemctl restart dshs-worker(R8:开发环境服务器,动手前一句话说明在动什么)。
  • 验证:md5sum 各副本同值;重启后 §2-3 的单元 is-active 全 active。

S6 · 主判据(P1 / P2)+ 对冲项

  • 按 §5 执行 P1(scope 数守恒,两侧都 ≥ 1)、P2(认领报告自洽且 scanned > 0)、对冲项(静态 + 动态 + OBS-22)。
  • 验证:三项全绿且 OBS-22 PASS;P1 成立而 P2 不成立时必须点名(见 §5 的"分层判定")。

S7 · 零回归 + 收口

  • npm.cmd test ⇒ 不退化(§2-6 现核基线)|node scripts/overlay-probe.cjs --scene all --table ⇒ 12 PASS / 0 SKIP / 0 FAIL|node scripts/overlay-probe.cjs --table ⇒ 基线 + 本棒新增 1 行(OBS-21 仍 FAIL = 线内在册缺口,非本单引入,报告里点名)。
  • 清理夹具(S1 造的 scope 全部 systemctl stop + 撤销)——⚠️ 夹具不得留在生产。
  • 回填本单 §8;推进 接续入口_覆盖网络线_20260916.md §0 + §2;写工作区日志(append-only)。

§5 验收判据(可机器断言、可被第三方复现)

判据分三层:P0 前提 → P1 主判据 → P2 回收链。🔴 P1 与 P2 必须分开报 —— 这正是序 ㉗ 把「判据成立」与「冗余/链路建成」分开的同一手法:P1 绿而 P2 红 是一个合法且重要的结果,⛔ 不得合并成"全绿"。

P0 · 前提(夹具就位)

ssh -p 22 bt-server 'systemctl list-units --type=scope --all --no-legend | grep -c "dsh-"'
  • 期望:≥ 1。0 ⇒ P0 不成立 ⇒ 停下来造夹具(§4 S1),⛔ 不许直接跑 P1(0 会让判据两边都是 0)。

P1 · 主判据(退出不再杀实例)

N_BEFORE=$(ssh -p 22 bt-server 'systemctl list-units --type=scope --all --no-legend | grep -c "dsh-"')
ssh -p 22 bt-server 'systemctl restart dshs'
sleep $(( N_BEFORE * 1 + 5 ))          # 认领节流 500ms/条 + 单条探活 2000ms ⇒ 留足余量
N_AFTER=$(ssh -p 22 bt-server 'systemctl list-units --type=scope --all --no-legend | grep -c "dsh-"')
echo "before=$N_BEFORE after=$N_AFTER"
  • 判据:N_AFTER == N_BEFORE(rc=0 且 N_BEFORE ≥ 1)。
  • 🔴 反例条款(防空绿):N_BEFORE == 0 && N_AFTER == 0 ⇒ 判 FAIL(不是 PASS)。判据必须能区分"守恒"与"两边都空"。
  • 🔴 反向夹具(证明判据真的在测这件事):把处① 临时改回原语义(或用一个 DSHS_TEARDOWN_STOP=1 的测试专用接线 —— ⛔ 该接线只进单测、不许进生产 env)⇒ 重跑 ⇒ N_AFTER == 0 ⇒ 判据必须变红。先红后绿两腿都要留原文。
  • 同一判据在 106 上复跑一遍(ssh -p 22 test106 '…restart dshs-worker…')—— ⚠️ 两机都要绿:47 走的是 dshs(Manager)的 LocalSpawner/LeasedSpawner→RemoteSpawner,106 走的是 dshs-worker(agent.ts:143 的 LocalSpawner),同一语义三份实现 ⇒ 必须逐台断言。

P2 · 回收链是否真建成(与 P1 分开)

E=$(date -u +%s)
ssh -p 22 bt-server "journalctl -u dshs --since @$E --no-pager 2>/dev/null | grep -c '\[rehydrate\]'"
ssh -p 22 bt-server "journalctl -u dshs --since @$E --no-pager 2>/dev/null | grep -o '\[rehydrate\] summary .*'"
  • 期望:① 行数 ≥ 1;② summary 满足 adopted + stopped === scanned 且 scanned > 0;③ 有 [rehydrate] adopted …(含 scope= / uid= / port=)+ [rehydrate] probe OK … :<port>。
  • 🔴 分层判定(本单最重要的一条):
    • scanned > 0 ∧ adopted ≥ 1 ∧ probeOk ≥ 1 ∧ 自洽 ⇒ P1 + P2 双绿。
    • scanned == 0 ∧ N_AFTER == N_BEFORE > 0 ⇒ P1 绿、P2 红 ⇒ 报告里必须点名:"实例没被杀(P1 成立),但认领没扫到存量 scope(回收链未建成)",⛔ 不得写"全绿"。
    • scanned > 0 ∧ stopped > 0 ⇒ 逐条读 [rehydrate] ⛔ … 原因行,确认"按旧行为停掉"的是端口不通的条目(判据口径 = 孤儿 = 端口不通,⛔ 不是"启动了却不认识")。
  • ⚠️ journalctl 必须用 --since @<epoch>(不吃 date -Is 的时区偏移),且**"查询失败"必须与"确无该行"可分**:把输出的原文字节数一并打印(0 字节 = 读取失败/>0 = 真没有该行)。

对冲项 · "同一语义三份实现"的自动化对冲(B 的专属风险)

  • 静态:
    cd D:/github/dsh_shenxian
    grep -n -A6 'async teardown' src/supervisor/orchestrator.ts src/supervisor/remote-spawner.ts src/supervisor/leased-spawner.ts \
      | grep -nE 'this\.stop\(|inner\.stop\(|killInstance\(|/stop'
    
    期望 = 无命中(rc=1)。🔴 任何一处仍在 teardown() 里停实例 / 向远端下发停止 ⇒ 判据判红。
  • 动态:test/orchestrator-teardown.test.mjs 三组断言全绿(§4 S3)。
  • 探针:新增 OBS-22,三判据: ① 重启前后 scope 数守恒(N_AFTER == N_BEFORE ≥ 1); ② adopted + stopped === scanned ∧ scanned > 0; ③ 三处 teardown() 的静态守卫(上条 grep 无命中)。 ⛔ 缺行即 FAIL,并把 journalctl 的原文字节数一并打出(0 = 读取失败 vs >0 = 真无该行,二者必须可分)。 ⚠️ 编号须现核顺延:grep -oE 'OBS-[0-9]+' scripts/overlay-probe.cjs | sort -u | tail -1(本单立单时 = OBS-21)—— 若已被占用则顺延(序 ㉖ 踩过"编号被前序占用")。

判据自证表(必须原样回填 §8)

层 判据 命令 期望 实测
P0 夹具就位 §5 P0 ≥ 1 待填
P1 47 scope 守恒 §5 P1 N_AFTER == N_BEFORE ≥ 1 待填
P1 106 scope 守恒 §5 P1(test106) 同上 待填
P1 反向夹具变红 临时还原处① 后重跑 N_AFTER == 0 待填
P2 认领行存在 §5 P2① ≥ 1 待填
P2 报告自洽 §5 P2② adopted+stopped==scanned ∧ scanned>0 待填
对冲 静态守卫 §5 对冲-静态 无命中 待填
对冲 动态单测 node --test test/orchestrator-teardown.test.mjs rc=0 待填
对冲 OBS-22 node scripts/overlay-probe.cjs --table PASS 待填
零回归 npm test npm.cmd test 不退化(现核基线) 待填
零回归 场景 --scene all --table 12 PASS / 0 SKIP / 0 FAIL 待填
零回归 探针 --table 基线 +1 行 待填
D1 生产值未改 grep -c COOLDOWN_MS=0 <演练/夹具/回滚路径文件> 0 待填

§6 回滚

回滚 = 恢复三处 teardown 原语义 ⇒ 秒级。 两条路,任选其一(优先路 A:最干净)。

路 A · 语义回滚(推荐,分钟级)

cd D:/github/dsh_shenxian
# ① 三处改回原语义(处① 恢复那行循环;处②/③ 恢复为空体/原写法)
git diff src/supervisor/            # 人工核对只有这三处
npm run build
scp -P 22 lib/supervisor/orchestrator.js bt-server:/opt/dshs/lib/supervisor/
scp -P 22 lib/supervisor/remote-spawner.js bt-server:/opt/dshs/lib/supervisor/
scp -P 22 lib/supervisor/leased-spawner.js bt-server:/opt/dshs/lib/supervisor/
scp -P 22 lib/supervisor/*.js test106:/opt/dshs-cluster/lib/supervisor/
ssh -p 22 bt-server 'systemctl restart dshs'
ssh -p 22 test106  'systemctl restart dshs-worker'
  • 验收:重启后 dsh-*.scope 计数 回落到 0(= 恢复"退出即收")⇒ 这就是"回滚生效"的机器判据。
  • ⚠️ 回滚会带走存活实例(这正是原语义)⇒ 回滚前先看有没有在线用户。

路 B · 产物回滚(秒级,最省事)

ssh -p 22 bt-server 'cp /opt/dsh/backups/seq28-<ts>/{orchestrator,remote-spawner,leased-spawner}.js /opt/dshs/lib/supervisor/ && systemctl restart dshs'
ssh -p 22 test106  'cp /opt/dsh/backups/seq28-<ts>/*.js /opt/dshs-cluster/lib/supervisor/ && systemctl restart dshs-worker'
  • 回滚点:/opt/dsh/backups/seq28-<ts>/(S5 落盘时创建,旧 md5 一并记录在 §8)。
  • ⚠️ 路 B 必须有 seq28-<ts> 存在;没有回滚点不准上线。

🔴 回滚路径硬禁令

⛔ 不许把 RELAY_FAILOVER_COOLDOWN_MS=0 写进任何回滚 / 演练 / 夹具路径 —— 它会自锁(候选链卡在第一个失败候选上,实测 121–123 s 无切换);判定见 §9-1。


§7 决策点 · 影响面与关系

7.1 决策点

  • 已定(用户,2026-09-17 23:3x):执行候选 B(三处同改)|C 登记为架构目标、本阶段不进执行。
  • 已定(本棒,可推翻):
    • 处② 取证已是 no-op ⇒ 只补断言/注释固化,不做语义改动(依据 = §0.3-1,git show HEAD: 逐字比对)。
    • 处③ 保留 stopHeartbeat()(租约语义正确),只把"停实例"剥离。
    • 探针编号 = OBS-22(现核顺延)。
  • 待定 = 空。⛔ 本单无上抛项;执行棒若遇边界外事项(业务优先级 / 花钱 / 合规 / 凭据 / 影响面超出本平台)⇒ 停下报告。

7.2 🔑 架构目标 C(systemd scope 单一真相源)—— 登记,⛔ 不进本阶段执行

是什么:把"实例归属"从 Manager 进程内存(mains / watchdogs Map)搬到 systemd scope 这一单一真相源 ⇒ Manager 退化为"发现 + 代理 + 记账";Manager 重启天然不影响实例,不再需要"退出路径不收 + 启动认领"这对补丁。

为什么现在不做(三条,客观):

  1. 收益随规模兑现,当前规模收益≈0:47 只有 0–2 个实例(本棒实测活跃 scope = 0/0),"归属不一致"的痛点还没出现。
  2. 106 的 provisioner 尚未铺 ⇒ C 的收益(大规模自动编排)在 106 上无处落地。
  3. 改动面最大:C 要动 spawn 路径 + 归属存储 + 路由层,比 B(3 个文件)大一个量级,且与"进程退出"这条路径完全解耦后才能真正验证。

什么信号出现才回头做 C(任一命中即回头):

  • 实例数 ≥ 阈值(建议先与本线参数表对齐,例如每 host 常驻实例数 ≥ 8,即 ≥ 2× 缺省 maxIdleInstances=4);
  • 出现第二种 spawner(今天的实现形态扩到第 4 类 ⇒ "同一语义 N 份实现"的对冲成本超过 C 的一次性成本);
  • 出现"进程被 SIGKILL 后无人回收"的实测案例(B 的认领链只在启动时扫,SIGKILL 后若不重启 → 实例长期无人管;今天靠 idle-reap 兜,但 TTL = 7 天)。

从 B 迁移到 C 的路径(顺序,⛔ 不要跳跃):

  1. 本单(B)先落 ⇒ 拿到"退出不停实例 + 启动认领"这条已验证的容错链(= C 的安全网);
  2. 再把"实例归属"抽成可替换的后端(内存 Map ↔ systemd scope 查询),先并存、以 B 的行为为基线做对照;
  3. 最后把 Manager 的 mains 降级为缓存,归属判定只认 scope;此时 B 的"退出不停实例"就是天然行为(不再是补丁),可回头删掉 teardown() 的守卫断言;
  4. 每一步都保留 B 的回滚路径(§6)作为兜底。

7.3 影响面与前提(必须接受,否则不要做 B)

  • 🔴 影响面 = 所有实例的生命周期语义:实例不再随进程退出而回收。
  • 🔴 前提(明写):进程长期不重启时,实例由 idle-reap / 用户访问替换来收 —— 本棒已量化:maxIdleInstances = 4(每 host)+ instanceIdleTtlSeconds = 604800(7 天)+ idleReapIntervalSeconds = 60(src/config.ts:300-302,两机 env 未覆盖 ⇒ 走缺省)。即:最坏情形下实例会占着端口/内存最长 7 天,或被"第 5 个实例"挤掉。
  • ⚠️ 106 的 provisioner 尚未铺 —— 本单不依赖它:106 侧同样走"dshs-worker 退出不停实例 + 启动认领",idle-reap 也走同一份代码缺省。⚠️ 但"实例长期不重启时谁来收"在 106 上只靠 idle-reap(7 天),记入 §10-3。

7.4 与 lease / 配额记账 / 端口分配 / dsh_hosts / provisioner 的关系

对象 本单下的影响 处置
lease(src/supervisor/lease.ts) LeasedSpawner.teardown() 仍 stopHeartbeat() ⇒ 租约按 TTL(DSHS_CLUSTER_LEASE_TTL_MS=30000,47 实测)过期 ⇒ 归属记录会与"仍在跑的实例"不一致。🔴 这是 B 的真实新增风险。 靠现有的两道兜住:cleanStaleScopes(uid)(spawn 前清同 uid ⇒ 防双实例共 profile)+ 认领时的探活。⚠️ 未实测 ⇒ §10-1 + §9-6 回头条件。
配额记账(dsh_hosts.used_mb / 心跳) Manager 重启后心跳中断 ⇒ host 可能被短暂判离线,但实例仍在 ⇒ 记账漂移。worker 心跳恢复后重报(used_mb 由 worker 本地核算)⇒ 应自愈。 §10-2 未实测。
端口分配(--base / --span,src/supervisor/spawn.ts:79/106 的 findFreePortInRange / findInstancePort) 🔴 端口不再随进程退出而释放 ⇒ 区间占用更久、findFreePortInRange 耗尽风险 ↑。 替换路径(cleanStaleScopes(uid) → 换端口换 token)会释放旧端口。⚠️ 耗尽会抛 实例端口区间已耗尽 ⇒ §9-3 回头条件(⛔ 不许私自扩 --span)。
dsh_hosts 表 不受影响 —— 该表记 host/endpoint/via/容量,与实例生命周期无关(src/db/pg.ts:605)。 无动作。
provisioner(106 未铺) 不阻塞本单(见 §7.3)。 无动作;若 provisioner 落地后出现"自动铺 ⇒ 自动起",须回头核 §9-6。

§8 回报格式(留空给执行棒)

执行棒逐节回填,每节必须给命令 + 原始输出(或原文摘录)+ 判定。⛔ 不许只写结论。

8.1 开工对账(§2 六条)

抢锁:✓ 已持全局执行锁(覆盖网络线-序30执行棒-单A) ✅

# 核实内容 实测(2026-09-18 00:5x) 判定
0 本单 §8 前前缀 sed '/^## §8 回报格式/,$d' <本单> | md5sum = c9aeac82381f569fc8b9effad0fefa99 ✅ 与单里记的一致
1 git status --porcelain src/supervisor/ 仅 M src/supervisor/orchestrator.ts(序㉕ 留下的改动) ✅ 与期望一致
1 三处 teardown() 现状(改前原文) ① orchestrator.ts:604 = if (this.reapTimer !== undefined) clearInterval(this.reapTimer) + for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)|② remote-spawner.ts:329 = 空体,只有一行注释(远端实例的寿命长于任何单个 Manager 副本 ⇒ …)|③ leased-spawner.ts:252 = this.stopHeartbeat() + await this.inner.teardown() ✅ 逐字与 §2 期望一致 ⇒ ⛔ 处① 未被别人做过;处② 的 no-op 取证(§0.3-1)复核成立
1 HEAD 04776af ✅
2 认领链计数 grep -c rehydrateAdoptedScopes src/supervisor/orchestrator.ts = 2(≥2)|grep -c cleanStaleScopes = 4(≥1) ✅ ⛔ 均非 0
3 两机活跃实例数 47 = 0 / 106 = 0 ✅ 与期望一致 ⇒ S1 造夹具不可省
4 106 worker 侧 idle-reap 是否走缺省 ssh -p 22 test106 "systemctl show dshs-worker -p Environment | tr ' ' '\n' | grep -iE 'IDLE|REAP|TTL'" ⇒ 无输出(rc=1) ✅ 走代码缺省(60 s / 7 d / 4 上限)
5 回滚点存在性 + 产物一致性 两机 /opt/dsh/backups/ 下 seq25-… / seq26-… / seq27-20260918-000259 均在册;两机 lib/supervisor/orchestrator.js md5 同值 = 5bfb15e00b592c293548ae17b639399b(=序㉕ 铺的值,未变 ⇒ 未写死) ✅
6 基线三件套 + 参数表指纹 npm.cmd test = 201 tests / 200 pass / 0 fail / 1 skip(=序㉗ 收口值)|参数表指纹(§10 前截断) = 7fc5889341b99fb26bd05cad0313960b|overlay-probe --table 基线 = 19 PASS / 1 SKIP / 1 FAIL(1 FAIL = OBS-21,线内在册缺口)|grep -oE 'OBS-[0-9]+' scripts/overlay-probe.cjs | sort -u | tail -1 = OBS-21 ⇒ 新项编号 = OBS-22 ✅ 全部与期望一致(编号无须顺延)

S0 判定 = 六条全符合期望 ⇒ 开工。零不符、零停下。

8.2 S1 夹具(真实形态)—— 🔴 方法修正:用「平台真路径 spawn」,⛔ 不用合成 scope

为什么改方法(技术自决,可推翻 ⇒ 附理由与证据):单 §4 S1 原写「用 systemd-run --scope 造一个真 argv 的既有实例」。开工后取证发现该夹具不可判别:LocalSpawner.teardown() 的旧语义是 for (user of mains.keys()) await this.stop(user) —— 只停"自己在册(mains)"的实例,而合成 scope 不在 mains 里 ⇒ 旧代码下它也不会被杀 ⇒ N_BEFORE == N_AFTER ≥ 1 在改前改后都成立 ⇒ 判据恒绿(假绿),且 §5「反向夹具必须变红」无法达成。 ⇒ 改为用平台真实路径 spawn(mksess 临时会话 + POST /api/dsh/enter,即 R4 规定的实例面手段)⇒ 得到的实例真 systemd scope + 真 argv + 真监听端口 + 真被 worker 的 mains 追踪 —— 比合成 scope 严格更强,且使 P1 具备判别力(见 8.5 真机先红:旧生产下实测 1 → 0)。

夹具记录:

项 47(w-47 · 用户 admin / uid 114801) 106(w-106 · 用户 guest / uid 100002)
scope 名(首次) dsh-114801-e9bc4a4e.scope dsh-100002-70a193b7.scope
实例端口 20000(本机在听:ss -lntp 有 127.0.0.1:20000) 21000
真实 argv(摘) --chdir /var/lib/dshs/users/cce6d1cd-…/ws -- setpriv --reuid 114801 --regid 114801 --clear-groups /usr/local/bin/dsh --profile web --host 127.0.0.1 --port 20000 --chdir /var/lib/dshs/users/4092b965-…/ws -- setpriv --reuid 100002 … --port 21000
起止 起 00:56:5x(enter 200)|RED 腿 00:58:40 被杀|GREEN 腿 01:0x 重建 起 00:57:0x(enter 200)|RED 腿 00:58:48 被杀|GREEN 腿 01:0x 重建
enter 原文 {"kind":"session","instance":{"id":"a3f6076e-…","port":20000,"status":"running"},"url":"https://admin.alotbuy.com/"} {"kind":"session","instance":{"id":"818a0a27-…","port":21000,"status":"running"}}

⚠️ 一处如实留白:ssh -p 22 bt-server 'enter 时 Host: guest.alotbuy.com ⇒ http=404 not found' —— guest 子域用主域 Host 才路由到 /api/dsh/enter(admin 同)。这是探针/验收脚本的 Host 选择问题,⛔ 不是平台缺陷(会话本身已由 sid cookie 认定,换 Host 后即 200)。

8.3 三处改动(改前 / 改后原文)

处 文件 改前 改后 语义变动量
① src/supervisor/orchestrator.ts LocalSpawner.teardown() clearInterval(reapTimer) + for (…) await this.stop(userId)(逐个杀在册实例) clearInterval(reapTimer);循环整行删除;补 17 行注释(回收责任移交三条:rehydrateAdoptedScopes / cleanStaleScopes(uid) / idle-reap)+ 守卫标记 // ⛔ 退出不停实例(guard: teardown-must-not-stop-instances) 🔴 真改(本单唯一语义变动处)
② src/supervisor/remote-spawner.ts teardown() 空体(一行注释) 空体(guard: 守卫标记 + 原注释 + 5 行"取证已是 no-op / 只固化断言"说明) ≈0(语义不变,只补守卫)
③ src/supervisor/leased-spawner.ts teardown() stopHeartbeat() + await this.inner.teardown() 两行都保留(拆开"停心跳"与"停实例"的耦合语义)+ 守卫标记 + 8 行说明(含"心跳停 ⇒ 租约过期 ⇒ 归属记录与实际不一致"这条如实账) ≈0(语义不变,只补注释/标记)

改动前后逐字核对:grep -n -A7 "async teardown" 三处,差异仅限上表(处①删 1 行;处②/③ 增注释与标记)⇒ ✅ 未越界(⛔ 未碰 stop()、未碰 reapOnce、未碰认领链)。 stop() 仍一行未动(它仍是"用户显式停实例"的正路,被路由层调用)。

8.4 单测先红后绿(原文级)

新增 test/orchestrator-teardown.test.mjs(9 用例;刻意不进 npm test —— package.json 的 test 是硬编码文件列表,加进去会改测试总数,照 序㉕ 先例)。 运行:node --test test/orchestrator-teardown.test.mjs

红腿(临时把三处改回原语义) ⇒ # tests 9 / # pass 6 / # fail 3:

not ok 1 - T1 ⛔ LocalSpawner.teardown() 不得停任何在册实例(stop 桩计数 = 0)
  error: |-        expected: 0        actual: 2        operator: 'strictEqual'
not ok 5 - T5 ⛔ 三处 teardown() 体内均无停实例 / 下发停止(静态守卫,≡ 交接单 §5 对冲项)
  actual:  [ 'orchestrator.ts: for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)' ]
not ok 6 - T6 守卫标记在位(三处都带 guard: teardown-must-not-stop-instances,防被静默改回去)
  以下文件的 teardown() 缺守卫标记(逐处点名)⇒ orchestrator.ts / remote-spawner.ts / leased-spawner.ts

⇒ 🔴 3 红且逐处点名(T1 动态 = stop 桩实际被调 2 次;T5/T6 静态 = 逐文件点名)。 同时如实标注:T4(处③ 的动态断言)改前改后都是绿的 —— 因为处③ 的 inner 是 RemoteSpawner,它的 teardown() 取证本来就是 no-op(§0.3-1)⇒ 候选 B 的唯一语义变动处是 ①,②/③ 是"语义固化 + 机器断言"、⛔ 不是"修 bug"。这条不掩饰,已写进单测文件头。

绿腿(还原后) ⇒ # tests 9 / # pass 9 / # fail 0(rc=0);还原为字节级:三文件 md5 与改动后逐个相同 —— orchestrator.ts c831bc75f6636603411949d43eb84136 / remote-spawner.ts 1279b948c4e2dd6ca021acb5b9fd6f27 / leased-spawner.ts f891797ba8a7ee07d9fdca0d83f07461。

8.5 真机先红(新判据对旧生产)+ 🔴 两种判别器并列

(甲)P1 判据对旧生产 ⇒ 必须 FAIL(判别力证明):未部署新 lib 时(生产 orchestrator.js = 5bfb15e0…,旧语义),带真实例夹具跑 restart dshs-worker:

############ 47 ############  (epoch=1789664319)
N_BEFORE=1   --- restart 前 scope 明细 --- dsh-114801-e9bc4a4e.scope
N_AFTER=0    --- restart 后 scope 明细 --- (none)
--- rehydrate 日志(journalctl --since @1789664319) --- RAW_BYTES=125
Sep 18 00:58:40 iZrj99af19cibck1ge93tqZ node[826743]: [rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)
############ 106 ############  (epoch=1789664328)
N_BEFORE=1   --- restart 前 scope 明细 --- dsh-100002-70a193b7.scope
N_AFTER=0    --- restart 后 scope 明细 --- (none)
--- rehydrate 日志(journalctl --since @1789664328) --- RAW_BYTES=120
Sep 18 00:58:48 VM-0-8-opencloudos node[258630]: [rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)

⇒ 两机 1 → 0 ⇒ 判据 N_AFTER == N_BEFORE ≥ 1 判 FAIL ✅(这就是真机反向夹具:判据确实在测这件事,⛔ 不是恒绿)。同时复现了序㉕ 的真凶现场。

(乙)新 OBS-22 对旧产物 ⇒ 必须 FAIL 并逐处点名(夹具腿,夹具内容 = 真实生产产物字节:旧 = 回滚点 /opt/dsh/backups/seq28-20260918-005909/,新 = 已部署):

⚠️ FIXTURE FAIL OBS-22 …:47 ❌ 缺标记(orchestrator,remote-spawner,leased-spawner) 停实例命中 orchestrator「await this.stop(userId);」
                        |106 ❌ 缺标记(orchestrator,remote-spawner,leased-spawner) 停实例命中 orchestrator「await this.stop(userId);」
⚠️ FIXTURE PASS OBS-22 …:47 静态守卫 ✓|106 静态守卫 ✓

⇒ 红腿 FAIL 并逐处点名 ✅|绿腿 PASS ✅。 ⚠️ 可区分性证据(本线的老坑):红腿那次运行的整体输出是 ❌ 8 项红:OBS-11 , OBS-13 , OBS-14 , OBS-16 , OBS-17 , OBS-19 , OBS-20 , OBS-22 —— 前 7 项是夹具模式未给对应夹具的固有 FAIL(⛔ 不是判据结果),只有 OBS-22 是本项的真实读数;故上面只摘 OBS-22 行。 ⚠️ RAW_BYTES=0 的可区分性:47 dshs 的 [rehydrate] 在窗口内 0 字节,但这不是读取失败 —— 同窗口 journalctl -u dshs --since @<epoch> \| wc -c = 102 字节(-- Logs begin … -- / -- No entries --)⇒ 读取正常、确无该行;且全量历史 journalctl -u dshs --no-pager \| grep -c '[rehydrate]' = 0(详见 8.11 第 1 条)。

8.6 部署对账

本机 npm run build 产物 md5:orchestrator.js 1456d1609f5872c2d635ae7cf4f5c26b|remote-spawner.js 0438afd5738176999d3a4f4f404c9435|leased-spawner.js 12ba045990b2054aaeebeb2c95d70f83 部署四处(md5 逐个相同):

机器 目录 orchestrator.js remote-spawner.js leased-spawner.js
47 /opt/dshs/lib/supervisor/ 1456d160… ✓ 0438afd5… ✓ 12ba0459… ✓
47 /opt/dsh-relay/lib/supervisor/ 1456d160… ✓ 0438afd5… ✓ 12ba0459… ✓
106 /opt/dshs-cluster/lib/supervisor/ 1456d160… ✓ 0438afd5… ✓ 12ba0459… ✓
106 /opt/dsh-relay/lib/supervisor/ 1456d160… ✓ 0438afd5… ✓ 12ba0459… ✓

回滚点 = 两机 /opt/dsh/backups/seq28-20260918-005909/(旧 md5 已记录,含 relay 侧):

5bfb15e00b592c293548ae17b639399b  orchestrator.js        (= 序㉕ 铺的生产值)
0b0e95f284ad39aea17286446f3c07a1  remote-spawner.js
0f473774d116b80c2f57bc22077a0f9c  leased-spawner.js
fad7926494578258f86a91eac613a204  relay-orchestrator.js  ⟵ ⚠️ 见 8.11 第 2 条(relay 侧曾落后 2 个版本)
0b0e95f284ad39aea17286446f3c07a1  relay-remote-spawner.js
0f473774d116b80c2f57bc22077a0f9c  relay-leased-spawner.js

重启(R8:开发环境服务器):2026-09-18 01:0x —— 47 systemctl restart dshs + restart dshs-worker;106 restart dshs-worker。三单元 is-active = active(+ dshs-relay active)。门户 curl -H 'Host: alotbuy.com' 127.0.0.1:3080/ = 200。 ⚠️ ⛔ 未重启 dshs-relay —— 它不加载 supervisor/*(只有 dshs / dshs-worker 用 LocalSpawner)⇒ 不必动数据面。 ⚠️ 动生产前已取证:两机活跃实例 = 0/0(S0 第 3 条)⇒ 本次部署/重启未影响任何在线用户。

8.7 主判据 P0 / P1 / P2 + 对冲项(逐条原文)

照 §5「判据自证表」原样回填:

层 判据 命令 期望 实测 判定
P0 夹具就位 §5 P0 ≥ 1 47 = 1(dsh-114801-507657e0.scope + 127.0.0.1:20000 在听)/106 = 1(dsh-100002-a58dd77b.scope + 127.0.0.1:21000 在听) ✅
P1 47 scope 守恒(Manager) §5 P1(restart dshs) N_AFTER == N_BEFORE ≥ 1 before=1 after=1(⚠️ 非判别腿,理由见 8.11-1) ✅
P1 47 scope 守恒(worker) §5 P1(restart dshs-worker) 同上 N_BEFORE=1 → N_AFTER=1,明细前后均为 dsh-114801-507657e0.scope ✅
P1 106 scope 守恒(worker) §5 P1(test106) 同上 N_BEFORE=1 → N_AFTER=1,明细前后均为 dsh-100002-a58dd77b.scope ✅
P1 反向夹具变红 未部署新 lib 时重跑 N_AFTER == 0 47 1 → 0 / 106 1 → 0(原文见 8.5 甲) ✅ 判据有判别力
P2 认领行存在 §5 P2① ≥ 1 47 RAW_BYTES=443(3 行)/106 RAW_BYTES=428(3 行) ✅
P2 报告自洽 §5 P2② adopted+stopped==scanned ∧ scanned>0 47 {"scanned":1,"adopted":1,"stopped":0,"probeOk":0,…} ⇒ 1+0==1 ✓ ∧ scanned=1 ✓|106 同值 ✅
P2 adopted / probe OK 原文 §5 P2③ 含 scope= / uid= / port= + probe OK … :<port> 47:[rehydrate] adopted dsh-114801-507657e0.scope uid=114801 role=main port=20000 user=cce6d1cd-… + [rehydrate] probe OK dsh-114801-507657e0.scope :20000|106:adopted dsh-100002-a58dd77b.scope uid=100002 … port=21000 + probe OK … :21000 ✅
对冲 静态守卫 §5 对冲-静态 无命中 T5 全绿(三文件 teardown 窗口内 0 命中)+ T9 反向夹具自证判别器有判别力 ✅
对冲 动态单测 node --test test/orchestrator-teardown.test.mjs rc=0 9 pass / 0 fail,rc=0 ✅
对冲 OBS-22 node scripts/overlay-probe.cjs --table <参数表> PASS PASS OBS-22 … 47 静态守卫 ✓|认领面:scanned=1 adopted=1 stopped=0 ✓|106 静态守卫 ✓|认领面:scanned=1 adopted=1 stopped=0 ✓ ✅
零回归 npm test npm.cmd test 不退化 201 / 200 / 0 / 1(=基线逐字一致) ✅
零回归 场景 node scripts/overlay-failover-drill.cjs --scene all --table <参数表> 12 PASS / 0 SKIP / 0 FAIL 12 PASS / 0 SKIP / 0 FAIL(rc=0) ✅
零回归 探针 node scripts/overlay-probe.cjs --table <参数表> 基线 +1 行 21 PASS / 0 SKIP / 1 FAIL = 基线(19P/1S/1F,21 行)+ 新增 OBS-22(PASS);OBS-21 仍 FAIL(线内在册缺口,⛔ 非本单引入);⚠️ OBS-09 由 SKIP → PASS(存量实例在册且码 401 ∈ 集合 = 正常改善) ✅
D1 生产值未改 grep -c 'COOLDOWN_MS=0'(本次全部演练/夹具/回滚路径文件) 0 0 ✅

🔴 P1 与 P2 分层判定(本单最重要的一条):本次两边均为 scanned=1 ∧ adopted=1 ∧ stopped=0 ∧ 自洽 ⇒ P1 + P2 双绿,⛔ 未出现「scanned == 0 而数守恒」那种"P1 绿、P2 红"的混合态 ⇒ 不存在"实例没被杀但回收链未建成"的情形(该口径仍已写进 OBS-22 的判据与参数表 §6,供将来出现时如实点名)。 ⚠️ 另有一条真机数据点(意外收获):--scene all 演练的「幕4 前置」含一次 Manager 重启,演练结束时 47 的 dsh-114801-507657e0.scope 仍在册且端口在听 ⇒ Manager 重启不杀实例这条在真实演练路径上再复现一次。

8.8 零回归三件套

$ npm.cmd test          ⇒ # tests 201 / # pass 200 / # fail 0 / # skipped 1
$ node scripts/overlay-failover-drill.cjs --scene all --table <参数表>
                        ⇒ # 结果:12 PASS / 0 SKIP / 0 FAIL   (rc=0)
$ node scripts/overlay-probe.cjs --table <参数表>
                        ⇒ 21 PASS / 0 SKIP / 1 FAIL(OBS-21 = 线内在册缺口;新增 OBS-22 = PASS)
                          ❌ 1 项红:OBS-21

⚠️ 三段全部同值或更好 ⇒ 零回归成立。🔴 npm test 未把新单测纳入(刻意;见 8.4),故总数 201 与基线逐字相同。

8.9 边界自证

  • ⛔ 未改任何生产值(D1):本次没有改任何 systemd drop-in / EnvironmentFile / nginx·nft 配置;参数表 §3.8 新增的 6 个 TEARDOWN_* 键只被探针读取,⛔ 未写进任何 env。🔴 grep -c 'RELAY_FAILOVER_COOLDOWN_MS=0'(本次全部演练/夹具/回滚路径文件)= 0。
  • ⛔ 未新增公网监听口(R5):PASS OBS-11 ⇒ 必在 7 允许 4 区间 3 派生 2 实际 77 多出 0 缺失 0,relay 口绑定回环=1/1 条。
  • ⛔ 未改 nft / nginx(R5):OBS-11 ⇒ nft accept 0 多出 0;本次零 nft/nginx 读写。
  • ⛔ 未 commit / 未 push(R7):git rev-parse --short HEAD 仍 = 04776af(改动全部留在工作区)。
  • ⛔ 未改 bwrap 参数(🔴 47 是 bubblewrap 0.4.0)|⛔ 未删 cleanStaleScopes(uid) / cleanAllStaleScopes()(T7 断言在册)|⛔ 未把启动流程改成"什么都不做"(rehydrateAdoptedScopes 三步齐备,日志可证)|⛔ 未"重启后重新 spawn 一遍"(认领路径 T18/R18 先例断言:adoptOne 不写 mains、不 spawn)|⛔ 认领不写回 mains(本单未碰认领代码)|⛔ token 不落盘(本单未碰凭据路径)|🔑 判据口径 = 孤儿 = 端口不通(本单未改该口径)。
  • 🔎 一处如实登记的"范围新增":部署面多铺了两处 relay 侧 /opt/dsh-relay/lib/supervisor/(见 8.11 第 2 条)—— 属消除既存不一致、非语义改动。

8.10 指纹

  • 参数表指纹:7fc5889341b99fb26bd05cad0313960b → 3b295a8c43aafc4a62c6f1a59ea0b43a(§3.8 新增 6 键 + §6 新增 OBS-22;§10 内已同步登记,⚠️ §10 不计入指纹)
  • 本单 §8 前前缀 = c9aeac82381f569fc8b9effad0fefa99(⚠️ 回填 §8 后仍不变 —— §8 是截断点,✅ 已验证)
  • 全文 md5 / 行数 = 见 §11.4(⛔ 自指,只能现算)

8.11 如实留档(必填,哪怕"没有")

  1. 🔴 修正了本单 §5 P1 的一条前提(上游假设错):单里写「47 走的是 dshs(Manager)的 LocalSpawner」。实测 47 是 DSHS_DEPLOY_MODE=cluster(/etc/systemd/system/dshs.service.d/cluster.conf)⇒ web/server.ts:931-932 走 LeasedSpawner(new RemoteSpawner(…))、根本不构造 LocalSpawner ⇒ 47 的 dshs 不打 [rehydrate](窗口内 0 字节 + 全量历史计数 0)⇒ 其 LocalSpawner.teardown() 在生产上是死代码。真正会执行 LocalSpawner.teardown() 的只有两台 dshs-worker(agent.ts:143)。⇒ 已把该修正写进参数表 §3.8(TEARDOWN_UNIT_47 行)与 §6 OBS-22。这不影响修复本身(三处同改覆盖两处执行点),但影响判据的落点:⛔ 拿「47 restart dshs」当判别腿会得到恒绿(Manager 从来不停远端实例)⇒ 本单把判别腿放在 两台 worker 上(见 8.5 甲:1 → 0)。
  2. 🔎 一处"范围新增"(如实登记,依据 R8 + R11 + 本线 序㉖ 先例):部署时先核发现 /opt/dsh-relay/lib/supervisor/(两机都在)另有一套 supervisor 副本,且其 orchestrator.js = fad7926494578258f86a91eac613a204 —— 那是 序㉕ 之前的值(落后 2 个版本:既无认领、也无本次改动),remote-spawner.js / leased-spawner.js 同样陈旧。处置 = 一并铺平(四处 md5 全同),并把三份旧值以 relay-*.js 名存进同一回滚点 ⇒ 回滚完整。依据 = 本线 序㉖ 的同型处置("dshs 侧 server.js 比 dsh-relay 侧落后一个版本 ⇒ 两边同铺已消除该既存不一致")。⚠️ ⛔ 未重启 dshs-relay(它不加载 supervisor ⇒ 无行为影响)。
  3. ⚠️ 两条与"本单语义无关"的红,均已归因(§9-8 要求):
    • OBS-08(端点表全在线)曾在替换窗口内 FAIL 一次:实测 端点表 2 条 / 离线 1 条,明细 = w-106:21000 online=false(该实例已换端口、旧条目未回收)。归因 = 暂态:随后复测即 2 条 / 离线 0 条,清理前端点表已自愈(19000 online=true + 21001 online=true,stale 条目消失)。⇒ 判为暂态,与序㉕ §8 的同型记录一致("曾一次 FAIL、重跑即回绿 ⇒ 暂态"),⛔ 非本单引入。⚠️ 但成因值得点名:它出现在 B 的「访问时自然替换」动作之后 —— 见第 4 条。
    • OBS-16(门窗口 ΔstatusHits ≤ 1)曾在一次运行里 FAIL(Δ=2):该次运行与 overlay-failover-drill 长跑并行(同一时段另有演练在打 /status)⇒ 归因 = 测量期间的第三方命中、⛔ 非语义问题;终态复跑 Δ=1 ⇒ PASS。
    • ⚠️ OBS-11 同次 FAIL(实际 79 多出 1)=上述 stale 端点占的 1 个监听 ⇒ 终态 实际 77 多出 0 ⇒ PASS。
  4. ⭐ 一个由"B"直接产生的真实现场(收益边界实证 · 也是本棒最有价值的副产品):做完 S6 后,我用临时 session 走了一次真实用户可见面(curl -H 'Host: guest.alotbuy.com' -b sid=… 127.0.0.1:3080/)⇒ http=200 bytes=62451 真 HTML、502 标记 0(= 序⑬ Q2 的判据再次满足)。同刻 relay 端点表从 {19000 ✓, 21000 离线} 变为 {19000 ✓, 21000 ✗, **21001 ✓**} ⇒ 平台在用户访问时把实例换到了新端口 21001(launchToken 不可恢复 ⇒ 走"自然替换"),旧实例 scope 被 cleanStaleScopes(uid) 收掉。⇒ 🔴 这正是单 §9-9 / §10-4 预告的收益边界,第一次以真机时序证据呈现:「实例没被杀(P1 成立)」+「重启后不能直接进,用户下次访问时自然替换」。⚠️ 附带现象:替换窗口内会短暂留下一条 stale 端点条目(离线 + 占 1 个监听),OBS-08/OBS-11 在该窗口内会红;自愈——但自愈时延未测(⇒ 建议作为独立观测项,⛔ 本单未扩大范围去做)。
  5. ✅ 未发现 adopted + stopped !== scanned(§9-4 回头条件未命中)|未发现双实例共 profile(§9-2 未命中)|未出现端口区间耗尽(§9-3 未命中)|未出现"租约过期后另一 Manager 接管同 uid"(§9-6 未命中)|未改 bwrap(§9-7 未命中)。
  6. ⚠️ summary 里 probeOk 恒为 0(既有观测面缺陷,如实记录、⛔ 未改):真机日志顺序为 adopted … → summary {"…","probeOk":0,…} → probe OK … :<port> ⇒ summary 在探活完成前就打出来了(probeOk/probeFail 计数来不及累加)。⇒ 判据⛔ 不能用 summary 里的 probeOk ≥ 1(会假红);本单 P2 用的是 adopted + stopped === scanned + 独立的 probe OK 行(两者都成立)。属 rehydrateAdoptedScopes 的既有行为、⛔ 非本单引入(本单只动 teardown()),登记为独立后续项(修它 = 把 summary 挪到探活回调之后)。
  7. ⚠️ enter 的 Host 选择:-H 'Host: guest.alotbuy.com' ⇒ 404 not found;-H 'Host: alotbuy.com' ⇒ 200(admin 同)。⇒ 是验收脚本的 Host 写法问题(会话已由 sid cookie 认定),⛔ 非平台缺陷,已写进 8.2。
  8. ✅ 夹具已全部清理:两机 dsh-*.scope 计数 0 / 0(临时 session 亦按 R4 删净:DELETE 0 / 计数 0)⇒ ⛔ 未在生产留任何夹具。
  9. ⚠️ 本单 §2 的"正确排期口径"段经全工作区检索 = 不存在(grep -rn "正确排期口径" .workbuddy/ *.md ⇒ 0 命中)⇒ 该"疑似被误编辑"的线索经取证判为不成立,⛔ 未做任何"修正"(⛔ 不凭印象改文档)。

8.12 回滚路径补正(与 §6 配套;🔴 §6 正文一字未改,以保 §8 前前缀)

🔴 本棒实际部署了 4 处(见 8.6),而单 §6 只写了 2 处(47 /opt/dshs/lib/supervisor/ + 106 /opt/dshs-cluster/lib/supervisor/)⇒ 回滚时若不补 relay 侧两处,/opt/dsh-relay/lib/supervisor/ 会仍留着本次改动 ⇒ 回滚不完整。 ⚠️ §6 正文一字未动(补正放在本节的唯一理由:§6 在 ## §8 回报格式 之前,改它会打穿 §8 前前缀 c9aeac82381f569fc8b9effad0fefa99)。

路 A(语义回滚)· 补全 4 处 —— 在 §6 路 A 命令后追加两行:

scp -P 22 lib/supervisor/{orchestrator,remote-spawner,leased-spawner}.js bt-server:/opt/dsh-relay/lib/supervisor/
scp -P 22 lib/supervisor/{orchestrator,remote-spawner,leased-spawner}.js test106:/opt/dsh-relay/lib/supervisor/

路 B(产物回滚)· 补全 4 处(回滚点两机均为 /opt/dsh/backups/seq28-20260918-005909/):

ssh -p 22 bt-server 'cd /opt/dsh/backups/seq28-20260918-005909 && cp orchestrator.js remote-spawner.js leased-spawner.js /opt/dshs/lib/supervisor/ && for f in orchestrator remote-spawner leased-spawner; do cp relay-$f.js /opt/dsh-relay/lib/supervisor/$f.js; done && systemctl restart dshs'
ssh -p 22 test106  'cd /opt/dsh/backups/seq28-20260918-005909 && cp orchestrator.js remote-spawner.js leased-spawner.js /opt/dshs-cluster/lib/supervisor/ && for f in orchestrator remote-spawner leased-spawner; do cp relay-$f.js /opt/dsh-relay/lib/supervisor/$f.js; done && systemctl restart dshs-worker'

⚠️ 回滚点里的 relay-*.js 是铺平前的旧值(relay-orchestrator.js = 序㉕ 之前的陈旧版)⇒ 路 B 会同时把 relay 侧退回旧版,这正是"回到本棒之前"的完整语义。

✅ 回滚点内容已复核(2026-09-18 05:3x 现读):两机均含 6 个文件 —— orchestrator.js 82401 B / remote-spawner.js 13284 B / leased-spawner.js 9369 B / relay-orchestrator.js 70994 B / relay-remote-spawner.js 13284 B / relay-leased-spawner.js 9369 B。 ✅ 当前生产 md5 已复核(同上时刻):两机 4 处 三文件全同 —— orchestrator.js = 1456d1609f5872c2d635ae7cf4f5c26b / remote-spawner.js = 0438afd5738176999d3a4f4f404c9435 / leased-spawner.js = 12ba045990b2054aaeebeb2c95d70f83 ⇒ 部署后未被任何后续动作漂移;两机 dshs-worker / dshs-relay 均 active。


§9 回头条件(一出现必须回头)

  1. 🔴 ⛔ 任何一棒都不许把 RELAY_FAILOVER_COOLDOWN_MS=0 写进回滚 / 演练 / 夹具路径。 依据:该值看起来合法、实际会自锁 —— num() 的 /^\d+$/ 放行 '0',但归零会让"失败候选必须被排除"一起失效 ⇒ 候选链卡在第一个失败候选上(实测 121–123 s 无切换)。本单的验收里 grep -c 该值必须 = 0。
  2. 出现"双实例共 profile"(档案 30 复现:会话 / settings 写冲突)⇒ 立刻回滚(§6)+ 报告。
  3. 端口区间耗尽(实例端口区间已耗尽)⇒ 停下报告,⛔ 不许私自扩 --span(属参数表改动,须另立单)。
  4. adopted + stopped !== scanned ⇒ 停下报告(自洽性破了 ⇒ 判据不可信)。
  5. 需要超出 §3.1 在册文件集 ⇒ 停下报告(R7)。⚠️ 本单已凭用户「选 B」解除了处②/③ 的阻塞,不含第三次扩范围。
  6. 出现"租约过期后另一 Manager 接管同一 uid"的实测案例 ⇒ 停下报告(双写风险;见 §7.4 lease 行)。
  7. 需要改 bwrap 参数 ⇒ ⛔ 停(🔴 47 是 bubblewrap 0.4.0,--perms 属 0.5+ ⇒ 沙箱起不来 = 所有实例全挂)。
  8. 零回归出现"新"的红(非本单引入)⇒ 先归因(判别器 / 日志原文)再决定是否继续;⛔ 不许"重跑一次就好了"了事。
  9. enter 的真实用户可见面出现 503 时,注意区分:这是 B 下已知且刻意保留的边界(launchToken 不可恢复 —— 序 ㉕ §0 的客观边界),⛔ 不是本单引入的缺陷,但必须如实写进 §8.11**(收益边界 = "不被杀 + 访问时自然替换",⛔ 不是"重启后直接可用")。

§10 未验证项(一个都不许编数)

# 未验证项 为什么重要 怎么验(口径)
1 租约过期 ⇒ 另一 Manager 接管同 uid 的窗口风险 B 下实例不随进程死,归属记录会与存活实例不一致 ⇒ 理论上有"两个 Manager 都以为自己该管这个 uid"的窗口 起第 2 个 Manager 副本(本机多实例,用户 2026-09-17 已授权「本机内存大 可以模拟多台」)⇒ 停其一 ⇒ 等 LEASE_TTL_MS=30 s 过期 ⇒ 观察是否出现双 owner / 是否触发 cleanStaleScopes 替换
2 used_mb 记账在 Manager 重启后是否自愈 记账漂移会让容量决策失准 重启前后各读一次 dsh_hosts.used_mb(47 的 PG:127.0.0.1:15432),观察心跳恢复后是否回到真值
3 106 侧 idle-reap 是否真的在跑 §7.3 的"谁来收"前提在 106 上只靠它 106 上造一个空闲实例 ⇒ 观察 idleReapIntervalSeconds=60 s 的扫描日志;⚠️ TTL 7 天 ⇒ 短窗口内验不到"收",只能验"扫描在跑"(须显式写清这个口径)
4 B 的真实收益边界(用户可见面) 收益是"不被杀 + 访问时自然替换",不是"重启后可直接进"(launchToken 不可恢复 ⇒ enter 仍 503) 用临时 session(R4:mksess 直插、用完即删)走一次 POST /api/dsh/enter,记录 503 与其后"自然替换"(换端口换 token)的全过程
5 认领探活对启动时长的影响 scanned × stagger(500 ms) + 单条 probe(2000 ms);实例多时启动会变慢 用 N = 1 / 4 / 8 个夹具 scope 各跑一次,记录"启动 → [rehydrate] summary 出现"的墙钟
6 性能/成本:端口区间长期占用 见 §7.4 端口行 ���续 5 次 spawn/替换,记录区间占用峰值与是否出现耗尽告警

§11 指纹与状态

11.1 归档号(本棒已原子占号)

  • 现核最大号(⛔ 不写死,取号命令):ls 04-调整方案/ | grep -oE '^[0-9]+' | sort -n | tail -1 ⇒ 本棒实测 = 128。 ⚠️ prompt 写的"归档编号至 112"已过期(现核 128 ⇒ 序号 113–128 是立单时的历史值)⇒ 任何引用都必须复跑取号。
  • 占号动作(已执行):mkdir 04-调整方案/.lock-129 ⇒ 成功 ⇒ 本单归档号 = 129。
  • 🔴 63 是空号,⛔ 不补占(已核:ls 04-调整方案/ | grep -E '^63' = 空)。
  • ⚠️ 本棒不落 04 孪生 —— 取证结论:本线的 交接单_*.md 均无 04-调整方案/ 孪生(逐条比对 16 份单 = 0 命中);带孪生的是方案类文档(如 123-方案规划方法-覆盖网络线提炼.md ↔ 根同名,md5 同值 cf063cc0…)。⇒ 占号窗口在落单后即释放,执行棒收官建档案时必须重新现核取号。

11.2 指纹

  • §8 前前缀(口径:sed '/^## §8 回报格式/,$d' <本单> | md5sum):见下方"落单后实测" —— 每次改本单(含回填 §8)都要重算。
  • ⚠️ §8 一旦回填,前缀指纹不变(§8 在截断点之后);全文 md5 会变。
  • 落单后实测(2026-09-18 00:5x,序 ㉘ 规划棒):
    • §8 前前缀 = c9aeac82381f569fc8b9effad0fefa99(⚠️ 本值稳定 —— §11 在截断点之后,改 §11/回填 §8 都不动它)
    • 全文 md5(§11 本段回填前快照) = dd4f95d5dcae747d63437c8d0ede5fd8
    • 全文 md5(§11 本段回填后最终) = 见下方 §11.4(写完本段后现算;⚠️ 二者必然不同 —— 口径必须写明,⛔ 不许把快照当最终值引用)
    • 行数 = 383(快照时刻;回填 §8 后会增长)

11.3 状态

  • 本单状态:✅ 已执行 · 待归档(2026-09-18 00:5x–01:0x 由「覆盖网络线-序 ㉚ 执行棒 · 单 A」执行完毕;P1 + P2 双绿、零回归三件套全绿、夹具已清理、生产 4 处已上线)。
  • 执行棒回报 = 本单 §8.1–§8.12(全节已回填;§8.12 为回滚路径补正)。
  • 本棒(序 ㉘ 规划棒)边界自证:⛔ 零代码改动 · ⛔ 零服务器改动(只读取证:systemctl list-units / systemctl show -p Environment 两条只读命令 / 两机各一次)· ⛔ 未 commit / 未 push · ⛔ 不改任何生产值。
  • 上游单状态:交接单_实例逐步拉起_20260917.md §8.3 已由本单结清(B 被采纳 ⇒ 该单的"待拍板"项关闭)。
  • 🔴 本单遗留的独立后续项(均已在 §8.11 / §8.12 点名,⛔ 未扩大范围去做):① summary.probeOk 恒 0(既有观测面缺陷)② 「自然替换窗口内 stale 端点条目」的自愈时延未测 ③ §10 的 6 条未验证项(一条未验)④ 架构目标 C(§7.2,登记不执行)。

11.4 全文 md5 的口径(⛔ 不写死)

  • 全文 md5 每改一次本单都会变(回填 §8 尤其)⇒ 权威取数 = 现算: md5sum "E:/ProgramData/AI技能/aliyun-dsh-server/交接单_退出路径不杀实例_20260918.md"
  • 落单快照(§11.4 本段写入前的最后一次现算,2026-09-18 00:5x)= b1b93471081e10d9be9c28aa9b5c1d3c(387 行)。 ⚠️ 本行写入后该值即失效 ⇒ 引用前必须复跑上面的命令(本库已有"写死基线 40 分钟内被打穿"的实证)。
  • §8 前前缀 不受影响(§8 是截断点)⇒ 仍是 §11.2 的 c9aeac82381f569fc8b9effad0fefa99。

11.5 执行后实测(2026-09-18 05:3x · 供第三方复现)

  • §8 前前缀(现算,口径 sed '/^## §8 回报格式/,$d' <本单> | md5sum)= c9aeac82381f569fc8b9effad0fefa99 ⇒ 🔴 回填 §8 + 新增 §8.12 之后仍逐字不变(= "§8 是截断点"这条口径的自证)。
  • 全文 md5 / 行数:⛔ 不写死,权威取数 = md5sum <本单> 现算(本单每改一次都会变)。
  • 生产 md5(两机 4 处、三文件):orchestrator.js = 1456d1609f5872c2d635ae7cf4f5c26b / remote-spawner.js = 0438afd5738176999d3a4f4f404c9435 / leased-spawner.js = 12ba045990b2054aaeebeb2c95d70f83。
  • 两机活跃实例 scope = 0 / 0(夹具已清);dshs-worker / dshs-relay = active。