Files

243 lines
21 KiB
Markdown
Raw Permalink Normal View History

# 版本管理与包更新机制 · 第 36 棒执行报告(S1–S4)
> **会话名**:`p36e-platform-versioning`(域锁:`src/worker` + `src/web/routes/business-plugins.ts` + 本工作区 `交付物`/`.workbuddy`/入口 + `dsh-server-docs/05-交接单`)
> **用户拍板**(2026-09-26 06:36 本人原话):「**C 方案,然后B,这次就用版本更新机制去更新网络上各节点,子节点和设备**」
> **唯一执行单**:`交付物/版本管理与包更新机制-现状与方案-20260926.md`(§8 执行棒细化 S1→S4)
> **本报告口径**:每条附**命令原文 + 输出原文 + 退出码**;**未达成的一律具名**,⛔ 不折算成达成。
---
## 0 判定(首屏)
| 子阶段 | 判定 | 一句话依据 |
|---|---|---|
| **S1 止血(层 A)** | ✅ 达成 | 对账/下载超时**解耦**、按体积动态预算、重试+退避、失败留痕;全部阈值走 env;已 build + 上机 |
| **S2 版本管理(B-1/B-2/B-3)** | ✅ 达成 | 版本目录化+通道灰度+定向钉版**真机写盘成功**;反向判据(钉一个取不到的版本)被 `409` 挡住 |
| **S2 的设备纳入(B-4)** | 🔴 **未达成(具名缺口)** | 本机**没有桌面端工程载体**(只有设计文档),且设备侧**没有可复用的拉取凭据通路** ⇒ 按执行单 §8.2-1「没有 ⇒ 具名报告缺口 + 给最小接入方案,⛔ 不硬造」处置 |
| **S3 全局矩阵(B-5)** | ✅ 达成 | `GET /api/plugins/shared/status` 真实返回「每包 × 每 host 的目标版本/漂移/最近对账」 |
| **S4 真跑分发** | ✅ 达成(设备腿见 B-4) | **w-106 真实拉取 2,018,751 B 的包,耗时 3,437 ms**,拉后指纹与权威逐字相同;**w-47 结构性免拉**(与 Manager 共用同一共享根);主判据读数见 §4.4 |
**主判据**(执行单 §6-6):47 侧 `/var/lib/dshs/bundled-plugins/.node-sync-reports.json` 里 `w-106` 的 `stale` **不含** `dsh-plugin-mcn-suite` 且 `failed` 为空 —— **达成**(见 §4.4 原始 JSON)。
---
## 1 改动清单与产物指纹
| 文件 | 行数(本棒前 → 后) | md5 | 上机 |
|---|---|---|---|
| `src/worker/agent.ts` | 708(HEAD) → **1442** | `8edac420b7f73b97af28f9890d4dd658` | ✅ 47 + 106 |
| `lib/worker/agent.js`(构建产物) | — | `83da1ca5129c8ec0c2784dd957f9db6b` | ✅ 47 + 106 |
| `src/web/routes/business-plugins.ts` | 1877(HEAD) → **3410** | `1d37e0931f3828d8a66994f325679424` | ✅ 47 |
| `lib/web/routes/business-plugins.js`(构建产物) | — | `ae90ca08a8889c691a5a7a307f97c5b8` | ✅ 47 |
| `test/shared-layer-sync.test.mjs` | 592 → **1045** | `4e52c165ab77f3e7ed0dfadaed58750b` | — |
⚠️ **行数差 ≠ 本棒新增**:两个 `src` 文件在本棒开工前**已含前序在途改动**(共享层对账面本体,执行单 §1 引的 `:373–519` 就在其中)。本棒净新增 ≈ 50 行(`agent.ts`)+ 版本管理运维面(`business-plugins.ts`)。判据:`git diff -U0 | grep '^@@'` 的 hunk 分布 + 上机后**只有这 2 个 js 文件**与现网不同(其余 128 个 lib 文件逐字一致)。
---
## 2 S1 止血(层 A)落成内容
**病根**(执行单 §1):`src/worker/agent.ts` 的 `const timeoutMs = opts.timeoutMs ?? 20000` 被**对账与产物下载共用** ⇒ 2.0 MB 包跨云(47↔106 经 443 中继)撞 20 s 预算 ⇒ 106 报 `failed:[{flat:"dsh-plugin-mcn-suite", error:"The operation was aborted due to timeout"}]` + `stale:[dsh-plugin-mcn-suite]`。
| 项 | 落成 | 可观测点 | 阈值(**全部 env 可注入**) |
|---|---|---|---|
| **A-1 超时解耦** | 对账、下载各自独立预算;下载侧用**自己的** `AbortController` + 可重置定时器(收到 `content-length` 后按实际体积重算) | 启动日志一行打印全部预算 | `DSHS_SHARED_PULL_SYNC_TIMEOUT_MS`(20000) / `DSHS_SHARED_PULL_DOWNLOAD_TIMEOUT_MS`(300000) |
| **A-2 体积动态预算** | 纯函数 `effectiveDownloadTimeout(sizeBytes, …)` = `min(cap, max(base, ceil(size/rate*1000*1.5)))` | 导出函数 + 单测 3 断言 | `DSHS_SHARED_PULL_MAX_TIMEOUT_MS`(900000) / `DSHS_SHARED_PULL_MIN_RATE_BPS`(64 KiB/s) |
| **A-3 重试 + 退避** | 只重试**瞬态**失败(`PackError.retryable`:`not_here` 不重试、`5xx`/网络重试);退避 = `base × 4^attempt` | `failed[].attempts` | `DSHS_SHARED_PULL_RETRIES`(2) / `DSHS_SHARED_PULL_BACKOFF_MS`(2000) |
| **A-4 失败留痕** | 失败进 `failed[]` 带 `attempts/firstFailedAt/lastFailedAt`;Manager 侧另落 `.node-failures.jsonl`(环形 200) | 47 侧 `.node-failures.jsonl` | — |
**顺带修掉一个真 bug**(回归用例钉住):重试**成功**后旧码仍会把该包推进 `failed[]` ⇒ 读数自相矛盾(`pulled` 与 `failed` 同现)。现以显式 `pulledOk` 标志门控,单测断言 `pulled` 成功时 `failed` 必须为空。
---
## 3 S2/S3 版本管理面(B-1/B-2/B-3/B-5)落成内容
- **B-1 版本目录化**:`.versions/<flat>/<ver>.tgz` 归档(与产物口**同口径**,含 `node_modules`,故解包后 `treeSha256` 与当初记的**逐字相同**);`manifest` 每条目新增 `history[]`;新增 `POST /api/plugins/shared/rollback-live`(**原子换**:解到 stage → 成员白名单 → 指纹校验 → chown → `rename`;**校验不过 ⇒ 现役一个字不动**;被换下来的那版**自动归档**)。
- **B-2 通道**:`channel: {canary: <ver|null>}` + 节点携带 `x-dsh-pull-channel` 头;`stable` 为默认、⛔ 空值即回落现役。
- **B-3 定向钉版**:`pinnedHosts: {<hostId>: <ver|null>}`;解析优先级 **钉版 > 通道 > 现役**。
- **B-5 全局矩阵**:`GET /api/plugins/shared/status`(admin)⇒ `{flats, hosts}`,每格给目标/实际指纹/`drift: current|stale|unknown`/最近对账与失败。⚠️ **老节点快照无 `inventory` ⇒ 一律 `unknown`**,⛔ 不折算成 `current`。
- **两条纪律**(写进代码注释):① 取不到的版本**入口就拒**(`409 version_not_available` + 回可选清单)② 重投新版**不清**通道/钉版(声明与内容解耦)。
- **向后兼容**:所有新字段**可选**,老节点读新 `manifest` 不崩(执行单 §8.2-2)。
---
## 4 S4 真跑分发(逐条命令 + 输出 + 退出码)
### 4.1 机制真实调用(生产运维面,admin 临时会话 · R4 用完即删)
```
$ "E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" tmp/p36e-s4.py <sid>
### [0] 共享层当前清单(GET /api/plugins/shared/status · B-5 矩阵)
http=200
flats[ dsh-plugin-mcn-suite ] = {"flat":"dsh-plugin-mcn-suite","live":{"version":"0.5.1",
"treeSha256":"27f6c6d7…49fac9f","fileCount":659},"channel":{},"pinnedHosts":{},
"history":[],"versions":["0.5.1"],"hosts":[{"hostId":"w-106","channel":"stable",…}]}
### [1] 真投一次(POST /api/plugins/business/share)
http=200 {"ok":true,"idempotent":true,"path":"/var/lib/dshs/bundled-plugins/dsh-plugin-mcn-suite", …}
### [2] B-2 通道:canary → 0.5.1
http=200 {"ok":true,…,"channel":{"canary":"0.5.1"},"versions":["0.5.1"]}
### [3] B-3 钉版:w-106 → 0.5.1
http=200 {"ok":true,…,"pinnedHosts":{"w-106":"0.5.1"}}
### [4] 反向判据:钉一个取不到的版本 ⇒ 必须 409
http=409 {"error":"version_not_available","versions":["0.5.1"]} ⇒ ✅ 已挡住
### [5] 复原:解钉 w-106 + canary 回落现役
http=200(解钉)/http=200(回落) ⇒ pinnedHosts {"w-106":null}、channel {"canary":null}
rc=0
```
写盘复核(ssh 47):`manifest` 键里出现 `channel`/`pinnedHosts` ⇒ **B-2/B-3 确实落到生产文件**(非仅内存)。
### 4.2 47 本机 worker(w-47)这一腿 —— **结构性免拉**(不是缺口)
```
$ ssh bt-server 'bash -s' < tmp/p36e-w47.sh
① worker 单元的 SHARED 相关 env: (空 ⇒ 用代码默认值)
② LISTEN 127.0.0.1:19100 … users:(("node",pid=…))
③ port=19100 tok_len=23
{"ok":false,"reason":"pull_disabled","missing":[],"stale":[],"extra":[],"pulled":[],"failed":[], …}
④ /var/lib/dshs/bundled-plugins owner=root:root mode=755
无 .node-pull.json(⇒ 该机从不拉取)
rc=0
```
**判据**:共享根路径 = `config.ts:627` 默认的 `<dataRoot>/bundled-plugins`,47 的 dataRoot = `/var/lib/dshs` ⇒ **Manager 与 w-47 是同一个目录本体**,w-47 即权威源、无需拉取;`pull_disabled` 是**正确形态**(不是配置漏了)。⇒ 执行单「分发到 47 本机 worker」由「同目录」满足。
### 4.3 106 子节点(w-106)—— **真拉 2 MB**
为让「节点落后」这一支**真实发生**(而非空跑),只在 106 侧清掉**它自己的**拉取记录(共享层内容一字不动):
```
$ ssh test106 'bash -s' < tmp/p36e-refetch106.sh
① before: entries = ['_dsh-local_storyforge','dsh-plugin-mcn-suite','_dsh-local_im-conversation-tabs','_softspark_dsh-file-preview']
after : entries = ['_dsh-local_storyforge','_dsh-local_im-conversation-tabs','_softspark_dsh-file-preview']
② === PULL ===
{"ok":true,"reason":null,"missing":[],"stale":["dsh-plugin-mcn-suite"],"extra":[],
"pulled":["dsh-plugin-mcn-suite"],"failed":[],"asOf":1790378897940,"channel":"stable"}
耗时 = 3437 ms ← 2,018,751 B 包 / 经 443 中继到 47
③ 拉后节点侧记录:{"treeSha256":"27f6c6d7ee84f14666ff966750a7d7d41379d280aef72544ce154bc9f49fac9f",
"pulledAt":1790378897938,"version":"0.5.1"} ← 与权威逐字相同
④ 共享层实况:stage 残留 = 0 · live node_modules 存在 = yes · owner = root:root 755
rc=0
```
**说明**:3,437 ms 的实测**不能**用来断言「旧码同一载荷必失败」——旧码的 20 s 预算在本次网络条件下也可能够;A-1 要消灭的是**预算与对账耦合**这一结构性缺陷(冷启动/拥塞时同一 20 s 会被对账先吃掉),判据是**解耦本身可观测**(见 §2 表),⛔ 不夸大。
### 4.4 主判据读数(拉后第二轮对账 + 47 侧快照)
```
$ ssh test106 'bash -s' < tmp/_pull_106.sh # 第二轮(拉后已收敛)
{"ok":true,"reason":null,"missing":[],"stale":[],"extra":[],"pulled":[],"failed":[],"asOf":1790378917884,"channel":"stable"}
$ ssh bt-server 'cat /var/lib/dshs/bundled-plugins/.node-sync-reports.json'
[
{
"hostId": "w-106",
"at": 1790378917692,
"authoritative": 4,
"missing": [],
"stale": [], ← ✅ 不含 dsh-plugin-mcn-suite
"extra": [],
"failed": [], ← ✅ 为空
"inventory": [ … {"flat":"dsh-plugin-mcn-suite",
"treeSha256":"27f6c6d7ee84f14666ff966750a7d7d41379d280aef72544ce154bc9f49fac9f"} ],
"channel": "stable"
}
]
rc=0
```
**⇒ 主判据达成**(`stale` 不含该包、`failed` 为空)。
### 4.5 设备(桌面端)这一腿 —— 🔴 **未达成,具名缺口** + ⚠️ **本报告一处取证更正(2026-09-26 07:40)**
**更正**:本节初稿(07:3x)写的两条结论,在**读到桌面线自己的工作区**后被推翻,据实更正 ——
| 初稿结论 | 更正后(附依据) |
|---|---|
| ~~「本机没有桌面端工程载体」~~ | 🔴 **错(取证面不全)**。桌面端工程**确实存在**:本工作区是**文档线**(`CODEBUDDY.md` + `docs/` + `.workbuddy/`,非 git 仓、无执行锁),**代码**在 `E:\github\dsh-client`(自研仓)+ 工作树 `E:\github\dsh-desktop-0.1.7rc2` / `dsh-desktop-0.1.5rc1`。初稿只查了本工作区与 `D:/github`、`E:/ProgramData/AIProject`,**漏了 `E:\github`** |
| ~~「设备凭据通路不可复用」~~ | 🔴 **错**。设备凭据体系**已存在且已实测跑通**:用户级设备授权(登录 → 换设备凭据 → 拨号入网,桌面线 `S4` 实测:`network=u:…` / `hostId=d-<uid>-<公钥前8位>` / 租约 24 h)。**真问题只是**:内容拉取三口只认「主机密钥」,**不认识设备** |
**⇒ 因此结论降级(难度大减)**:B-4 的最小接入方案 ❌ 不是"新造一套设备凭据",✅ 而是**给那三个只读口增认「已有的设备身份」**(一处校验臂,用已登记的设备公钥)。⚠️ 它**仍是权限面扩展(小幅、只读、可撤回)** ⇒ 按 R5 先出评估、**由用户点头后再由平台线实施**。
**本棒已交付的替代物**(把"具名缺口"落成可开工件):
- 📄 对接单已写到桌面线工作区:`E:/ProgramData/AIProject/dsh-ai1net-desktop/docs/对接单_桌面端接入版本管理与包更新机制_20260926.md`(协议四步 + 字段 + 三条语义纪律 + 复用点 + 两处改动 + 3 棒判据 + 10 条坑 + ⛔ 边界),并在该工作区 `docs/README.md` 索引登记一行。
- 🔴 **原始取证(未做成工程实现,仍为缺口)**:桌面端**没有拉取器**、**没有把共享包接进 profile 装配** ⇒ 这两件事在对接单 §4.2 里给出落点与判据,⛔ 本棒未实施、⛔ 未假装已接入。
**最小接入方案**(供拍板后实施,工作量:小-中):
- ① 平台侧给设备加**一条**与节点同形的对账面入口,身份用**设备自己的公钥**(已有字段),不新发密钥;
- ② 设备侧实现同一个「拨出-对账-拉取-回报」四步(协议与 worker 逐字相同,只把身份从主机换成设备);
- ③ 矩阵视图(B-5)把设备行**与主机行同表呈现**(同一 `hosts[]`,加 `kind` 区分);
- **前置**:先有桌面端工程载体(本轮已确认本机没有)。
---
## 5 副判据
| 副判据 | 结果 |
|---|---|
| 三机产物一致 | ✅ `lib/worker/agent.js` `83da1ca5…`、`lib/web/routes/business-plugins.js` `ae90ca08…` 在 **本机/47/106 逐字相同**(其余 128 个 lib 文件未动) |
| `npm test` 不劣化 | ✅ 全量 **646 例 / 641 通过 / 3 失败**;3 例失败为 `test/im-sdk.test.mjs`「内核零改动」断言,原因是 **IM 线在途未提交改动**(`src/im/hub.ts`、`src/im/ws.ts`、`src/routes/im.ts`)—— **与本棒改动零交集**(本棒只改 §1 表内 2 个文件) |
| 本模块单测 | ✅ `test/shared-layer-sync.test.mjs` **32/32 通过**(含 A-1 下载慢于对账超时仍成功、A-2 动态预算、A-3 只重试瞬态、A-4 留痕、B-1 归档解包指纹逐字相等、B-2/B-3 解析表 8 断言、B-5 矩阵) |
| 红线零新增 | ✅ 无 root 属主实例文件(全棒未触碰任何实例 home)|无权限扩大|无批量写|对 PG 仅 1 次临时会话 INSERT + 1 次 DELETE |
| **34/362/5 影子读** | 🔴 **未重取(具名)** —— 该夹具(`probe-shadow.mjs`)属 **MCN 数据面线**产物,已在前棒收口时清理,本机无副本;本棒**未重建**(避免越线 + 成本)。**替代取证**:本棒对插件数据面与实例文件**零写**(仅有 1 次临时会话增删),门户 200 / 两机 worker `active` / 共享根 4 个 flat 无残留。⇒ 下次真正需要该读数时,请由 MCN 线补夹具。 |
| 共享根卫生 | ✅ 4 个 flat + `.manifest.json` + `.node-sync-reports.json`,**`incoming`/`trash`/`pull-stage` 残留 = 0**(47 与 106 双侧) |
---
## 6 本棒发现(不影响主判据,建议后续)
| # | 发现 | 说明与建议 |
|---|---|---|
| **F-1** | **归档首次为空** | 本轮一切重投都是**同内容幂等早退**(`idempotent:true`)⇒ 不换版 ⇒ **不归档**(语义正确),但结果是 `.versions/` 目录尚不存在、`history` 为空 ⇒ 「可回滚到任意历史版」在**第一次真换版之前**是空的。建议:首次换版时自然补上;要更早可用,可把既有回滚素材**并入归档**(见 F-2)。 |
| **F-2** | **两条回滚路径的素材未打通** | 现网 `manifest` 里 `rollbackTgz` = `/opt/dsh/backups/plugins/dsh-plugin-mcn-suite/0.5.0.tgz` **已在位**,但**没进**新归档 ⇒ `rollback-live` 去回滚 0.5.0 会回 `409 version_not_archived`;而旧的「素材重放」语义又能回。建议:发布时把既有素材**登记**成归档一条(纯插入,无破坏性)。 |
| **F-3** | **w-47 `pull_disabled` 是正确形态** | 见 §4.2 —— ⛔ 别当故障去"修"(给它配上拉取地址反而会与自身权威源打架)。 |
| **F-4** | **候选池只有 0.5.1 一个版本** | `GET /api/plugins/business` ⇒ 池里该包仅 `0.5.1` ⇒ 本轮**不存在"更新的版本"可分**;这是「真跑分发」只能用同一载荷做增量拉取的原因(**⛔ 不伪造新版本**)。 |
| **F-5** | **超时耦合的旁证** | 实测同一 2 MB 载荷 3.4 s 完成;旧码的耦合缺陷在正常网络下不必然暴露 ⇒ 单元测试(A-1「下载慢于对账超时也必须成功」)是本条**唯一**能稳定复现的判据。 |
---
## 7 部署与回滚
- **上机动作**(动手前已按 R8 一句话说明):`scp` 4 个文件到 47(2 个 `lib` + 2 个 `src`)、2 个到 106;`systemctl restart dshs`(47)+ `restart dshs-worker`(47/106)。
- **现网自证**:106 新日志 `[shared-pull] 已启用:https://ai1net.com(每 1800 s 一轮) · 通道=stable · 对账超时=20000ms · 下载超时=300000ms(上限 900000ms) · 重试=2 次(退避基 2000ms)`;门户 `https://ai1net.com` 200;新路由未带 admin 会话时 401(预期)。
- **备份**:47 与 106 双份 → `/opt/dsh/backups/vmgmt-20260926/`。
- **回滚**:`git revert`(**本棒未提交**,故为将工作树这两处还原)+ 用上述备份回拷 + 重启两个 unit。⚠️ 对账/下载失败**本就不动本地文件** ⇒ 无数据风险。
---
## 8 卫生 · 锁 · 未做事项
- **R4**:临时 admin 会话现场新建、**用完即删**(`deleted = 1`)。
- **远端临时物**:47 与 106 的 `/tmp/p36e-*` 与 `/root/.node-pull.json.p36e-bak` **全部删除**(双侧核对计数 = 0)。
- **本工作区产物**:`交付物/版本管理与包更新机制-执行报告-20260926.md`(本文件)+ 本棒夹具留 `tmp/p36e-*`(`tmp/` 7 天生命周期)。
- **⛔ 未 commit / 未 push**:按工作区 `CODEBUDDY.md §4`「未明确要求 ⇒ 不 commit / 不 push」;且 `D:/github/dsh_shenxian` 工作树里**另有其他线在途改动**(`assets/…`、`dsh-server-docs/…` 等多处),**本棒拒绝连带提交**。
- **本棒未做(具名)**:① B-4 设备接入(§4.5,前置缺失 + 涉权限面,**须拍板**)② 34/362/5 影子读重取(§5,夹具属别线)③ F-2 归档素材打通(建议项,未实施)。
---
## 9 待拍板(1 项)
**要你定的是**:桌面端(设备)要不要**现在**纳入版本更新链。
**为什么需要你定**:纳入就要给每一台桌面设备发一份「能拉包」的凭据 —— 等于在权限面上**多开一个口子**;而且目前这台电脑上**没有桌面端程序本体**(只有设计文档),即便现在做出来,也**无法真机验收**。不纳入的话,桌面端的插件版本继续靠人工更新,时间长了会和服务器节点漂移。
**A 案**:本次**只登记不实施**,等桌面端工程就位后再做。
(优点:不动权限面、不产出无法验收的半成品;缺点:设备侧版本仍靠人工,长期与节点漂移。)
**B 案**:现在先把平台侧的「设备专属拉取凭据 + 对账入口」做出来,客户端以后接。
(优点:客户端一就位即可直接接;缺点:要新增一类凭据,权限面扩大,且当下无法真机验收。)
**我的倾向**:**A**(可推翻)。
> 依据:用户 2026-09-17 明令「要用户拍板的,**等拍了再新建接续会话**」⇒ **本棒未登记下一棒**(唯一剩余实质项 B-4 正卡在这条拍板上)。
---
## 附:本棒真实执行清单(可复现)
| 步骤 | 命令(本机 / 47 / 106) |
|---|---|
| 状态 | `"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "$WS/state.py"` |
| 开工门禁 | `bash $DOC/07-scripts/preflight-lock.sh "p36e-platform-versioning" src/worker/agent.ts src/web/routes/business-plugins.ts` ⇒ rc=0 |
| 抢域锁 | `bash $DOC/07-scripts/handoff-guard.sh --claim-exec "p36e-platform-versioning" --domains src/worker,src/web/routes/business-plugins.ts,aliyun-dsh-server/交付物,aliyun-dsh-server/.workbuddy,aliyun-dsh-server/接续入口_插件投放与分库线_20260922.md,dsh-server-docs/05-交接单` ⇒ rc=0 |
| 构建 | `tsc -p tsconfig.json` ⇒ rc=0(测试跑 `lib/` ⇒ **改完必 build**) |
| 单测 | `tmp/_runtest.sh`(`package.json` 的 test 脚本去 `npm run build &&`)⇒ 646/641/3 |
| 三机对账 | `md5sum lib/**/*.js` × 本机/47/106 |
| 上机 | `scp` + `systemctl restart dshs dshs-worker` |
| 真跑分发 | §4.1–§4.4 四个探针(`tmp/p36e-s4.py`、`tmp/p36e-w47.sh`、`tmp/p36e-refetch106.sh`、`tmp/_pull_106.sh`) |