Files
dsh_ai1net_server/交付物/S3跨机装配落地-20260923.md
admin ce8e6ceed9 chore(工作区): 纳入版本控制基线(回收 411 MB 过程产物)
回收 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)、记忆修复前备份。
2026-09-24 07:51:03 +08:00

153 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` |
### 关键实现点(三条最容易写错的)
1. **单腿装配**:worker agent 复用 `LocalSpawner`,因此**两台机跑的是同一段 `applyProfileChanges`** ——
结构上消除"两份判据漂移"(这正是抽公用模块的目的)。
2. **`RemoteSpawner.call()` 4xx 重试 bug(既有)**:4xx 的 `throw` 原先在 `try` 内被 `catch` 吞掉,
循环跑满 4 次(注释写"重试没意义"但从未成立)。已用 `noRetry` 标记重抛修掉;新测试断言 `calls === 1`。
3. **回滚靠"重发全停用清单"**:探活失败时不再用本机快照还原(跨机后快照**不代表远端磁盘**),
改为重发 `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