交接单按归属约定(正文落文档库、工作区只放指针)归档至 归档/交接单-20260924-归档/,含逐件判定 README: - 16 件已被文档库正式版取代(T09–T21 + 覆盖网络-24/25/26) - 4 件主题已被覆盖网络线入口汇总 - 7 件历史接续包/规划件 CODEBUDDY §9:收口清本棒 tmp、tmp 保留期 7 天、禁「待清理」中间态、 工作区入库只放文档与文件、不保留脚本副本、>60 KB 单文件须逐个判。
64 KiB
交接单 · 退出路径不杀实例(打通 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 🔬 本棒三条取证事实(均为本棒现场实测,⛔ 与上游结论冲突时以本棒为准)
- 🔴 处②
RemoteSpawner.teardown()已经就是 no-op ——src/supervisor/remote-spawner.ts:329-331与 HEAD 版逐字相同(git show HEAD:…已核),函数体只有一行注释:远端实例的寿命长于任何单个 Manager 副本 ⇒ 由 Manager 的归属/租约管理,不在关闭时清。⇒ 用户口径"三处一起改"与"处② 已符合"不矛盾:三处都纳入在册范围、都加"退出不停实例"的机器断言,但处② 语义改动量 ≈ 0(只补守卫/注释固化,防将来被改回去)。 - 🔴 两机当前活跃实例 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 的反例条款)。 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、106systemctl restart dshs-worker(R8:开发环境服务器,动手前一句话说明在动什么)。 - 验证:
md5sum各副本同值;重启后 §2-3 的单元is-active全active。
S6 · 主判据(P1 / P2)+ 对冲项
- 按 §5 执行 P1(scope 数守恒,两侧都 ≥ 1)、P2(认领报告自洽且
scanned > 0)、对冲项(静态 + 动态 +OBS-22)。 - 验证:三项全绿且
OBS-22PASS;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 的专属风险)
- 静态:
期望 = 无命中(rc=1)。🔴 任何一处仍在
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'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(现核顺延)。
- 处② 取证已是 no-op ⇒ 只补断言/注释固化,不做语义改动(依据 = §0.3-1,
- 待定 = 空。⛔ 本单无上抛项;执行棒若遇边界外事项(业务优先级 / 花钱 / 合规 / 凭据 / 影响面超出本平台)⇒ 停下报告。
7.2 🔑 架构目标 C(systemd scope 单一真相源)—— 登记,⛔ 不进本阶段执行
是什么:把"实例归属"从 Manager 进程内存(mains / watchdogs Map)搬到 systemd scope 这一单一真相源 ⇒ Manager 退化为"发现 + 代理 + 记账";Manager 重启天然不影响实例,不再需要"退出路径不收 + 启动认领"这对补丁。
为什么现在不做(三条,客观):
- 收益随规模兑现,当前规模收益≈0:47 只有 0–2 个实例(本棒实测活跃 scope = 0/0),"归属不一致"的痛点还没出现。
- 106 的 provisioner 尚未铺 ⇒ C 的收益(大规模自动编排)在 106 上无处落地。
- 改动面最大:C 要动 spawn 路径 + 归属存储 + 路由层,比 B(3 个文件)大一个量级,且与"进程退出"这条路径完全解耦后才能真正验证。
什么信号出现才回头做 C(任一命中即回头):
- 实例数 ≥ 阈值(建议先与本线参数表对齐,例如每 host 常驻实例数 ≥ 8,即 ≥ 2× 缺省
maxIdleInstances=4); - 出现第二种 spawner(今天的实现形态扩到第 4 类 ⇒ "同一语义 N 份实现"的对冲成本超过 C 的一次性成本);
- 出现"进程被 SIGKILL 后无人回收"的实测案例(B 的认领链只在启动时扫,SIGKILL 后若不重启 → 实例长期无人管;今天靠 idle-reap 兜,但 TTL = 7 天)。
从 B 迁移到 C 的路径(顺序,⛔ 不要跳跃):
- 本单(B)先落 ⇒ 拿到"退出不停实例 + 启动认领"这条已验证的容错链(= C 的安全网);
- 再把"实例归属"抽成可替换的后端(内存 Map ↔ systemd scope 查询),先并存、以 B 的行为为基线做对照;
- 最后把 Manager 的
mains降级为缓存,归属判定只认 scope;此时 B 的"退出不停实例"就是天然行为(不再是补丁),可回头删掉teardown()的守卫断言; - 每一步都保留 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 如实留档(必填,哪怕"没有")
- 🔴 修正了本单 §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行)与 §6OBS-22。这不影响修复本身(三处同改覆盖两处执行点),但影响判据的落点:⛔ 拿「47restart dshs」当判别腿会得到恒绿(Manager 从来不停远端实例)⇒ 本单把判别腿放在 两台 worker 上(见 8.5 甲:1 → 0)。 - 🔎 一处"范围新增"(如实登记,依据 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 ⇒ 无行为影响)。 - ⚠️ 两条与"本单语义无关"的红,均已归因(§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。
- ⭐ 一个由"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在该窗口内会红;自愈——但自愈时延未测(⇒ 建议作为独立观测项,⛔ 本单未扩大范围去做)。 - ✅ 未发现
adopted + stopped !== scanned(§9-4 回头条件未命中)|未发现双实例共 profile(§9-2 未命中)|未出现端口区间耗尽(§9-3 未命中)|未出现"租约过期后另一 Manager 接管同 uid"(§9-6 未命中)|未改 bwrap(§9-7 未命中)。 - ⚠️
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 挪到探活回调之后)。 - ⚠️
enter的 Host 选择:-H 'Host: guest.alotbuy.com'⇒404 not found;-H 'Host: alotbuy.com'⇒200(admin 同)。⇒ 是验收脚本的 Host 写法问题(会话已由sidcookie 认定),⛔ 非平台缺陷,已写进 8.2。 - ✅ 夹具已全部清理:两机
dsh-*.scope计数 0 / 0(临时 session 亦按 R4 删净:DELETE 0/ 计数 0)⇒ ⛔ 未在生产留任何夹具。 - ⚠️ 本单 §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 回头条件(一出现必须回头)
- 🔴 ⛔ 任何一棒都不许把
RELAY_FAILOVER_COOLDOWN_MS=0写进回滚 / 演练 / 夹具路径。 依据:该值看起来合法、实际会自锁 ——num()的/^\d+$/放行'0',但归零会让"失败候选必须被排除"一起失效 ⇒ 候选链卡在第一个失败候选上(实测 121–123 s 无切换)。本单的验收里grep -c该值必须 = 0。 - 出现"双实例共 profile"(档案 30 复现:会话 / settings 写冲突)⇒ 立刻回滚(§6)+ 报告。
- 端口区间耗尽(
实例端口区间已耗尽)⇒ 停下报告,⛔ 不许私自扩--span(属参数表改动,须另立单)。 adopted + stopped !== scanned⇒ 停下报告(自洽性破了 ⇒ 判据不可信)。- 需要超出 §3.1 在册文件集 ⇒ 停下报告(R7)。⚠️ 本单已凭用户「选 B」解除了处②/③ 的阻塞,不含第三次扩范围。
- 出现"租约过期后另一 Manager 接管同一 uid"的实测案例 ⇒ 停下报告(双写风险;见 §7.4 lease 行)。
- 需要改 bwrap 参数 ⇒ ⛔ 停(🔴 47 是 bubblewrap 0.4.0,
--perms属 0.5+ ⇒ 沙箱起不来 = 所有实例全挂)。 - 零回归出现"新"的红(非本单引入)⇒ 先归因(判别器 / 日志原文)再决定是否继续;⛔ 不许"重跑一次就好了"了事。
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 后会增长)
- §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。