回收 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)、记忆修复前备份。
153 lines
10 KiB
Markdown
153 lines
10 KiB
Markdown
# 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
|