回收 411 MB(470 M → 58.8 M),全部经回收站,可恢复: - 待清理/(146.2 M,含 relay 分片 128 M 与 42 项过程目录) - tmp/(32.4 M,按接续棒命名的过程临时区) - .workbuddy/tmp/(39.5 M) - 4 份 workbuddy.db 冗余副本(101 M,09-23 事故的坏副本 / 抢救产物) - tmp/im16/gw/centrifugo 二进制(63.9 M,可重下)+ 缓存残留 入库范围:常驻规则(CODEBUDDY.md / README.md / state.py)、在途接续入口与 接续包、docs/、交付物/、交接单/、归档/、scripts/、.codebuddy/、 .workbuddy/memory/;共 398 件,其中 >60 KB 的 26 件全为文档。 排除(.gitignore):tmp/、待清理/、运行态日志与缓存、*.db 与 DB 备份整目录、 打包二进制(*.tar.gz / *.tgz)、记忆修复前备份。
10 KiB
S3 跨机装配 · 落地件 — 2026-09-23
归口:插件投放与分库线 · 第 6 棒 · 执行棒(automation
a0558df7)。 唯一执行依据:接续入口接续入口_插件投放与分库线_20260922.md§0 最新行 + §5「⏭️ 本轮动作(第 6 棒)」; 交接单交接单/插件投放与分库线-①共享只读包库与插件数据面.md §五 S3(含 S3-E)。 状态:✅ 代码面已落地 + 106 真机 S3-E 全过 + 两机已部署重启 + 发现 1 条 D2 口径级缺陷(已取证、待拍板)。
一 一句话结论
S3「把装配动作修到实例所在那台机」已落地并真机验证:装配走 applyPlugins 一条腿,
Manager 不再在本机跑 pnpm;抽出的公用模块在两台机跑同一份 applyProfileChanges。
S3-E ①–⑦ 全过,47 的 users/ 下该用户零包实体副本(D2 主判据)。
⚠️ 但真机取证另抓出一条口径级缺陷:D-h 拍板的 file: 协议在 pnpm 9 下会把插件包
整份复制进每个用户的 .pnpm/(实测 6.5 M/用户/包),与 D2「实体一份、链接多份」相悖。
已实测出可替换方案(link: 协议 ⇒ 4 KB 纯软链、且抗 reconcile),因涉及已拍板项 D-h,交用户拍板(见 §六)。
二 落地清单(逐条对 §5 本轮动作)
| # | 要求 | 落点 | 状态 |
|---|---|---|---|
| 1 | POST /plugins/apply(装配入口) |
src/worker/agent.ts(同族于 /restart-probe,⛔ 不新增入站口) |
✅ |
| 2 | remote-spawner.applyPlugins(跨机那条腿) |
src/supervisor/remote-spawner.ts(按 hostIdFor 定位机器) |
✅ |
| 3 | mine/apply 改为只做清单裁决 |
src/web/routes/business-plugins.ts(⛔ 不再本机 runPnpmAs) |
✅ |
| 4 | 抽公用模块(⛔ 不许两份判据) | 新建 src/supervisor/plugin-assembly.ts(约 400 行) |
✅ |
附带修正(真机取证暴露,属"只做被明确要求的事"边界外的必要修复)
| 缺陷 | 性质 | 处置 |
|---|---|---|
pnpm install --ignore-workspace-root-check |
pnpm 9 不认该 CLI 选项(只当 config 项)⇒ Unknown option 直接失败。旧代码一直裸写(2026-09-13 07:43:58 曾因此把平台带崩,当时只加 try/catch 兜住进程、⛔ 没修参数) |
改 -w(与 scripts/ensure-biz-plugins.cjs 既有正确姿势逐字一致)+ 回归测试钉住 |
| 启用项先落盘再 pnpm | pnpm 失败时 package.json 留下"依赖已声明、实际未安装"的半装状态 ⇒ 下次冷启动崩、用户却看到"装过了" |
改为先解析全部包路径(失败即抛、此时 profile 未改);pnpm 失败回滚 package.json |
关键实现点(三条最容易写错的)
- 单腿装配:worker agent 复用
LocalSpawner,因此两台机跑的是同一段applyProfileChanges—— 结构上消除"两份判据漂移"(这正是抽公用模块的目的)。 RemoteSpawner.call()4xx 重试 bug(既有):4xx 的throw原先在try内被catch吞掉, 循环跑满 4 次(注释写"重试没意义"但从未成立)。已用noRetry标记重抛修掉;新测试断言calls === 1。- 回滚靠"重发全停用清单":探活失败时不再用本机快照还原(跨机后快照不代表远端磁盘),
改为重发
enabled:false全清单 —— 落地方按全清单 reconcile,幂等且跨机重试安全。
三 S3-E 真机取证(106 · w-106 · 非 Manager 的 worker)
机位:106(/opt/dshs-cluster · worker dshs-worker active)。探针:/root/probe-s3e-106.mjs
(调真实编译产物 lib/supervisor/plugin-assembly.js,⛔ 不手写 pnpm 命令)。
被测用户:4092b965-2f68-4977-9989-68b3966f7df0(uid 100002,实例 host_id = w-106)。
被测包:@dsh-local/storyforge 0.1.0(在候选池 + 在共享层)。
| # | 判据 | 命令 / 读数 | 期望 | 实测 |
|---|---|---|---|---|
| ① | 装配 task → success、stage 无"跳过" |
applyProfileChanges 返回 |
enabled 含该包、无静默跳过 |
✅ applyOk:true,enabled:["@dsh-local/storyforge"] |
| ② | package.json 的 file: 指向共享层 |
读 profile package.json |
file:/var/lib/dshs/bundled-plugins/… |
✅ file:/var/lib/dshs/bundled-plugins/_dsh-local_storyforge |
| ③ | node_modules/<pkg> 是软链 |
lstat().isSymbolicLink() |
true | ✅ true,指向 .pnpm/@dsh-local+storyforge@file+…/ |
| ④ | 实例内可见共享层(ro) | nsenter -t <pid> -m -- ls /var/lib/dshs/bundled-plugins/ |
见到两个包 | ✅ 见 _dsh-local_storyforge dsh-plugin-mcn-suite |
| ④b | 只读断言 | nsenter … -- touch …/__probe |
失败 | ✅ Read-only file system |
| ④c | mountinfo | grep bundled-plugins /proc/<pid>/mountinfo |
ro,nosuid,nodev |
✅ 完全匹配 |
| ⑤ | 反证:不存在的包 ⇒ 报错可读、实例不崩 | 传 dsh-plugin-nonexistent-probe |
抛错带 code | ✅ code:"plugin_bundle_missing",报错文案「共享层里没有该插件的包:…」 |
| ⑥ | 47 的 users/ 不新增包实体副本 |
find /var/lib/dshs/users/<id> -name '*storyforge*' |
0 | ✅ 0(连软链都没有),该目录 du = 8.0K(空壳) |
空/停用腿同样真机验证:对同一用户跑 enabled:false ⇒ disabled:["@dsh-local/storyforge"],
bundles 回到基线 6 项;清理后该用户 profile 与测试前逐项一致(deps/bundles 复原、storyforge 残留 0、node_modules 回到 51M)。
⛔ 用户面零残留:测试期间的临时目录已全清;被测用户 profile 已复原。
四 验证汇总(回归三件套)
| 项 | 命令 | 结果 |
|---|---|---|
| 构建 | npm run build |
rc=0 |
| 回归 | npm test |
439 tests / 437 pass / 0 fail / 2 skipped(基线 426/424 ⇒ +13) |
| 分层 | node scripts/check-layering.mjs |
✅ 无新增违规(基线 5 条不变,added: 0) |
⚠️ check-layering 报 未归类 1:src/platform-paths.ts —— 非本棒引入(既有文件),⛔ 未动。
部署(两机同批)
| 机 | 落点 | 备份 | 状态 |
|---|---|---|---|
| 47 | /opt/dshs/lib(md5 plugin-assembly.js = 134b5704…) |
/opt/dsh/backups/lib/lib-pre-s3-20260923-075200.tgz |
dshs active;v15 两张台账表随 restart dshs 已落 |
| 106 | /opt/dshs-cluster/lib(同 md5) |
/opt/dsh/backups/lib/lib-pre-s3-20260923-075419.tgz |
dshs-worker active |
两机 plugin-assembly.js md5 逐字一致;共享层内容已同步到 106(storyforge + mcn-suite + .manifest.json)。
relay-dialer 实测已连到 ops/w-106 ⇒ 跨机通路在线。
五 🔴 真机抓出的口径级缺陷(本棒最重要的发现)
现象
S3-E ③ 只断言"node_modules/<pkg> 是软链" ⇒ 会假绿。深挖实测(106,同一份共享层源):
| 协议 | node_modules/<pkg> |
.pnpm 中间层 |
实测占用 |
|---|---|---|---|
file:(D-h 现拍板) |
软链 ✓ | 整份实体复制(116 文件、links=1、inode 与源不同) |
6.5 M |
link: |
直接软链到共享层 | 无中间层 | 4.0 K |
⇒ 现实现满足"① 共享层是唯一来源",但不满足 D2 的"实体一份、链接多份":
每个启用该插件的用户各占一份 6.5 M(mcn-suite 为 24 M 级 ⇒ 每用户 24 M)。
根因(已定位到 pnpm 行为)
pnpm 对 file:(目录型依赖) 走 packageImportMethod 的复制路径,⛔ 不硬链;
对 registry tarball 依赖才硬链(同 profile 实测:普通依赖 links=9,storyforge 全部 links=1)。
⇒ 不是代码写错,是 file: 协议的固有语义。
已实测的替换方案(link:)
- 占用:4.0 K(vs 6.5 M)—— 纯软链直达共享层;
- 抗 reconcile(D-h 选
file:的唯一理由):实测link:在「再加一个依赖」与pnpm remove后 软链仍在 —— 因为它是由package.json声明的依赖,⛔ 不是手工软链(A1 的失败原因); - 符合规划棒原文:
§3技术方向首选实现即「node_modules走硬链/符号链接 ⇒ 实体一份、链接多份」。
六 ⏭️ 待拍板(1 项 · 涉及已拍板项 D-h,⛔ 不替拍)
问题:装配协议是维持 file: 还是换 link:?
候选 A —— 维持 file:(现状)
- 优点:零改动,S3-E ①–⑦ 已全过;
file:在旧 profile 里已有先例(交接单 §9.3 实测 5 条里 1 条已指共享池)。 - 缺点:每个启用用户各复制一份整包(实测 storyforge 6.5 M/人,mcn-suite 24 M 量级)⇒ 与 D2「⛔ 不给每个用户复制一份」直接相悖;用户数一上来磁盘线性增长。
候选 B —— 换 link: 协议
- 优点:实测占用 4.0 K(纯软链),真正兑现 D2「实体一份、链接多份」;抗 reconcile 已实测通过
(加依赖 / remove 后软链仍在)⇒ 同时满足 D-h 当初选
file:的全部理由(耐久性); 与规划棒 §3 首选实现一致。 - 缺点:需改公用模块 1 处 + 交接单 §五 等 8 处文档里的
file:描述; 存量用户file:写法要迁移(可复用既有 snapshot/restore + 幂等重装机制); ⚠️link:是否被 dsh 侧 bundle 解析完全接受尚未在实例内端到端验证(本棒只验了 pnpm 层)。
本棒的处置:⛔ 未擅自改。已按 A(现状)交付并验证通过;B 的证据(占用对比、抗 reconcile、inode 读数)已备齐, 拍板即可落地。倾向 B —— 它是唯一同时满足 D2 与 D-h 耐久性的形态。
七 本轮未做(⛔ 勿误解为已做)
- S5-a 兼容矩阵 · 迁移前结构备份(
pg_dump --schema-only) - S6 门户三段式 UI(平台共享只读 / 已启用 / 已停用)
- 跨节点内容分发(106 的共享层本棒是手工同步的,尚无平台侧分发通路)
- ⛔ 未 merge / 未引入新依赖 / ⛔ 未 commit / 未 push