Files

302 lines
25 KiB
Markdown
Raw Permalink Normal View History

# S4「接进既有的分发与更新机制」· 落地与回执(2026-09-26 · 第 42 棒)
> **线 / 棒次**:插件投放与分库线 · 第 42 棒(执行棒)|会话 `插件投放与分库线-42`
> **执行依据**:规格书 `D:/github/dsh_shenxian/dsh-server-docs/05-交接单/交接单_包规格书_20260926.md`(§〇 / §五 A / §五 C)
> + 方案 `交付物/功能打包到基础插件-分步实施方案-20260926.md` **§3.10.6**(S4 逐步照做唯一权威)+ 方案 S4 段 + §4 C 类坑 C1–C6
> **前置**:第 41 棒 S3「数据面」已落地(建库 `dshs_pl_ai1net` → 迁移 M1→M5 → 对账差集双空,三轮全绿)
> **产物**:拉取器 `tmp/p42/puller.mjs`(照抄版)+ 沙箱 Manager `tmp/p42/mgr-mock.mjs` + 编排 `tmp/p42/run-s4.mjs` + 真机锚点 `tmp/p42/anchor-real.mjs`
> **读数**:`tmp/p42/读数-20260926.txt`(S4 编排,53 ✅ / 0 ❌)|`tmp/p42/尺读数-S4-20260926.txt`(尺子)|`tmp/p42/real-anchor/`(真机包)
> **包内改动**:**零**。S4 全部落在「分发侧 + profile 装配侧」,⛔ 未改 `E:/ProgramData/AIProject/dsh-plugin-ai1net/` 任何文件。
---
## 一、一句话结论
**包能被平台「对账 + 增量拉取 + 原子换上」,落地后两端指纹逐字一致;三条反例判据全过(清单拿不到 ⇒ 本地零改动/单包失败 ⇒ 其余照常更新/`extra` ⇒ 只告知不删)。**
判据表(**退出码即结论**):
| # | 判据 | 期望 | 实测 | 判 |
|---|---|---|---|---|
| S4-1 | 与权威实现**逐段对得上**(⛔ 不自创第二套) | 逐字 | 11 段逐字照抄(见 §二) | ✅ |
| S4-2 | 四步 happy path 跑通 | rc=0 | 对账→取包→原子换→回报 全通 | ✅ |
| S4-3 | 依赖写 `link:<绝对路径>`,两端**逐字同形** | 逐字相同 | 两端字符串 100% 相同 | ✅ |
| S4-4 | 补 `@deepseek-ai/*` 软链 | 指向本机 scope | linked=1 / kept=3 / relinked=1(负控) | ✅ |
| S4-5 | **三条反例判据** | 全过 | 全过(见 §五) | ✅ |
| S4-6 | 装配后实例页「能力管理」栏出现 | 需真壳 | ⚠️ **未取到(rc=3)** | ⚠️ |
| — | 尺子:装配端A / 端B + 取数 + 回调 + v-baseline | 全绿 | 5/5 `rc=0` + baseline `rc=0` | ✅ |
| — | 包内门禁 `build.mjs --check-only` | rc=0 | `rc=0` | ✅ |
| — | **真机锚点**:本份指纹实现 vs 真机权威 `treeSha256` | 逐字相同 | **两包都相等**(storyforge 117 / mcn-suite 659,§六) | ✅ |
---
## 二、S4-1「照抄」逐段对照表(⛔ 判据 = 逐段对得上)
权威 = `D:/github/dsh_shenxian/src/worker/agent.ts`(节点侧)+ `src/supervisor/plugin-assembly.ts`(软链自足)+ `src/web/routes/business-plugins.ts`(Manager 侧)。
| # | 段 | 权威位置 | 落到本棒哪里 | 改了什么 |
|---|---|---|---|---|
| 1 | `hashTreeAt()` | `agent.ts:241-260` | `puller.mjs` | 逐字(仅去 TS 类型) |
| 2 | `envNumOr()` + `SHARED_PULL_*` 7 个常量 | `agent.ts:194-227` | `puller.mjs` | 逐字(默认值一字未改:20 000 / 300 000 / 900 000 / 64 KiB/s / 2 / 2 000 / `stable`) |
| 3 | `NODE_PULL_STATE_NAME` / `MAX_PACK_BYTES` / 拉取记录读写 | `agent.ts:262-298` | `puller.mjs` | 逐字 |
| 4 | `errText` / `PackError` / `isRetryable` / `defaultSleep` / `backoffOf` | `agent.ts:365-393` | `puller.mjs` | 逐字 |
| 5 | `effectiveDownloadTimeout()` | `agent.ts:412-421` | `puller.mjs` | 逐字 |
| 6 | **`pullOnePack()`**(原子换六动作) | `agent.ts:423-525` | `puller.mjs` | 逐字(含 `tar` 用 cwd+相对名的纪律、gzip 魔数、成员白名单、`chown` 仅 Linux、graveyard 回滚) |
| 7 | **`syncSharedLayerOnce()`** | `agent.ts:539-733` | `puller.mjs` | 逐字 + **新增 1 个测试缝** `__dumpPullStateTo`(只多写一份记录,⛔ 不改算法/默认值) |
| 8 | `resolveDshPackageRoot()` / `resolveHostDshScopeDir()` | `plugin-assembly.ts:1185-1233` | `puller.mjs` | 逐字同源副本(权威文件是 `.ts`,Node 跑不了;两文件均纯 Node 零框架依赖) |
| 9 | `ensureSharedScopeLink()` | `plugin-assembly.ts:1265-1311` | `puller.mjs` | 逐字 |
| 10 | `healSharedLayerSelfSufficiency()` | `plugin-assembly.ts:1330-1365` | `puller.mjs` | 逐字 |
| 11 | `sharedDirNameOf()`(flat 名口径) | `plugin-assembly.ts:871-873` | `puller.mjs` | 逐字 |
**沙箱 Manager 侧**(`mgr-mock.mjs`):`hashTree()` ← `business-plugins.ts:242-259` 逐字|`sharedLayerDiff()` ← 同 :789-798 逐字|产物口打包口径 ← 同 :2528 起(`tar -czf - --owner=0 --group=0 --numeric-owner <flat>`,`cwd=root`)|对账端点口径 ← 同 :2432 起(inventory 指纹**只认 64 hex**,其余 `null`="不知道";`pull = missing + stale`)。
✅ **自查**:本棒产出**没有第二套机制** —— 全部是照抄 + 一个测试缝。
---
## 三、S4-2 四步实现(happy path)
**对账 → 取包 → 落地(原子换六动作)→ 回报**,全部走既有链路口径:
1. **对账**:`POST /api/plugins/shared/node/sync`(头部带 `x-dsh-agent-token` / `x-dsh-host-id` / `x-dsh-pull-channel`;body 带 inventory)。清单拿不到 ⇒ 早退,**⛔ 什么都不动**。
2. **取包**:`GET /api/plugins/shared/node/pack/<flat>?version=<v>`;**对账 20 s 与下载 300 s 两套超时分开**(`syncTimeoutMs` / `downloadTimeoutMs`),并带 `packBytes` 量级提示动态放宽、封顶 900 s、最低速率 64 KiB/s(§4-C2)。
3. **落地(原子换六动作)**:① 落 `.pull-stage-<hex>` ② `tar -xzf` ③ 成员白名单(必须全在 `<flat>/` 下,挡 `../` 与绝对路径)④ **自算 `hashTreeAt` 与平台告知的 `treeSha256` 逐字比** ⑤ `chown root:root`(仅 Linux)⑥ `rename` 原子换(旧目录挪 graveyard → 新的就位 → 成了再删旧的;**换失败把旧的挪回来**);`finally` 只清 stage ⇒ 最坏结果 = "没更新",⛔ 绝不留下半包。
4. **回报**:有失败才 `POST /api/plugins/shared/node/result`(⛔ 不参与任何判定)。
### happy path 原始输出(节选自 `tmp/p42/读数-20260926.txt`)
```
════ B · happy path(四步:对账 → 取包 → 落地原子换 → 回报) ════
✅ ok=true —— reason=null
✅ missing = 两个包(本地全空) —— ["_dsh-local_ai1net","_dsh-local_probe2"]
✅ stale 全空(本地无记录 ⇒ 只能是 missing,⛔ 不会被折算成"一致") —— []
✅ extra 如实告知本地自有目录(⛔ 平台不据此删 —— 见 C-③) —— ["_dsh-local_leftover"]
✅ pulled = 两个包 —— ["_dsh-local_ai1net","_dsh-local_probe2"]
✅ failed 空 —— []
✅ channel = stable —— stable
✅ 回报端点:无失败 ⇒ 平台侧零回报 —— resultCalls=0
✅ 产物口调用 2 次(每包一次) —— ["_dsh-local_ai1net","_dsh-local_probe2"]
✅ 落地后指纹逐字相同 · _dsh-local_ai1net —— node=6f9e6fb121433adc2b29d6f58c2ff9b130b796f3fa70810fd99d4a384ca13d09 mgr=6f9e6fb121433adc2b29d6f58c2ff9b130b796f3fa70810fd99d4a384ca13d09
✅ 落地后指纹逐字相同 · _dsh-local_probe2 —— node=cd3bd919577b85e09c810cb3ff2ef511eda45504136eadb518c8c2bec19445d5 mgr=cd3bd919577b85e09c810cb3ff2ef511eda45504136eadb518c8c2bec19445d5
✅ .node-pull.json 记下了两包的 treeSha256(形状同真机 NodePullEntry) —— {"_dsh-local_ai1net":"6f9e6fb12143","_dsh-local_probe2":"cd3bd919577b"}
✅ 第二轮:ok=true 且 pull 清单为空 —— pulled=[] missing=[] stale=[]
✅ 第二轮:产物口零调用(不重拉 = C1 判据的正面) —— packCalls=2
```
🔴 **「指纹逐字相同」是本节的核心判据**:节点侧解包后**自算**的 hex 与沙箱 Manager **独立实现**算出的 hex **逐字相等**(36 字节全串,不是前 12 位摘要)⇒ 这同时是 §4-C1 的正面证明。
### §4-C1 的机验(两侧算法必须同规则)+ 三条负控
```
════ A · 指纹算法两侧逐字一致(§4-C1) ════
✅ 两侧 hex 逐字相同 · _dsh-local_ai1net —— fileCount 8=8
✅ 两侧 hex 逐字相同 · _dsh-local_probe2 —— fileCount 5=5
✅ 负控①:不归一化 `\` ⇒ 与权威 hex 不等(判据能变红)
✅ 负控②:"只哈希文件名清单" ⇒ 与权威 hex 不等 —— C1 点名的近似物之一
✅ 负控③:改一个字节内容 ⇒ 权威 hex 变(指纹对内容敏感) —— 并已还原 ⇒ 指纹可复现
```
⚠️ 首版负控写的是"**去掉排序** ⇒ 应不等",**实测失败**(该 fixture 只有 5 个文件,`readdirSync` 顺序恰好等于排序序)⇒ 已换成 C1 点名的两类真近似物(**不归一化 `\`** / **只哈希文件名清单**)⇒ 均能变红。**教训:负控要挑"真会翻车的写法",不是随便改一刀。**
---
## 四、S4-3 profile 装配(`link:` 两端逐字同形)
- 端A = **桌面 profile 实件副本**(`E:/github/dsh-desktop-0.1.7rc2/.../home-adapt/profiles/desktop/package.json`,**只读引用,未改真件**)+ 追加本包;
- 端B = **平台侧形态**(`profiles/web` 键位:`bundles` + `dependencies` 的 `link:`,`patchReload: live`);
- 🔴 两端 `dependencies["@dsh-local/ai1net"]` **逐字相同**,且指向 **拉下来的那一份**(不是源包目录)⇒ 闭环成立:
```
✅ 两端 `link:` 值**逐字相同**(§4-C3)
A=link:E:/ProgramData/AIProject/aliyun-dsh-server/tmp/p42/node-shared/_dsh-local_ai1net
B=link:E:/ProgramData/AIProject/aliyun-dsh-server/tmp/p42/node-shared/_dsh-local_ai1net
✅ 端A / 端B 链接均解析到落下来的那一份
```
- **flat 名口径实测**:`sharedDirNameOf('@dsh-local/ai1net')` = `_dsh-local_ai1net`;与**真机既有包实读**同形(`_dsh-local_storyforge` / `_dsh-local_im-conversation-tabs` / `_softspark_dsh-file-preview`)⇒ ✅ 口径一致。
- ⚠️ **真机平台侧的真值形态** = `link:/var/lib/dshs/bundled-plugins/_dsh-local_ai1net`(`/var/lib/dshs/bundled-plugins` 为真读值)。本沙箱用**本地等价绝对路径**代替 —— **这是建模**,⛔ 不等于"真机装配已通"。
---
## 五、S4-5 三条反例判据(⛔ 不验不算完成)
### ① 清单拿不到(黑洞)⇒ 本地**零改动**
```
════ C-① 反例:清单拿不到(黑洞)⇒ 本地文件**零改动** ════
✅ ok=false 且 reason 指明"取不到" —— reason=sync_unreachable: The operation was aborted due to timeout
✅ pulled 空(一个包都没拉) —— []
✅ 本地文件零改动(全树 sha256 前后逐字一致) —— 23b0ab26f2788194 → 23b0ab26f2788194(31 项)
留痕条数 31 ⇒ 31;含软链快照,⛔ 不是只看文件
```
**做法**:黑洞服务收请求**永不响应**(比"连接被拒"更狠,连 HTTP 层都不给);实测走的是 `AbortSignal.timeout(syncTimeoutMs)` 那条真实路径。
⚠️ **"零改动"口径**:用**自写强留痕**(每个文件的 `size + mtimeMs + 内容 sha256` + 每条软链的目标)算全树单值 —— ⛔ 不能只用 `hashTreeAt`,因为它**刻意不计软链**,会漏掉"软链被动了"这一类改动。
### ② 单个包失败 ⇒ 其余包**照常更新**
```
════ C-② 反例:单个包失败 ⇒ 其余包**照常更新**(不是整轮放弃) ════
✅ stale 含两包(都对不上权威) —— ["_dsh-local_ai1net","_dsh-local_probe2"]
✅ 主包**照常更新**(pulled 含它) —— pulled=["_dsh-local_ai1net"]
✅ 主包新指纹=权威新指纹(逐字)
✅ 失败只记备包一项(不是整轮放弃) —— ["_dsh-local_probe2"]
✅ 失败原因是**永久性**(错误页打进产物口 ⇒ pack_not_gzip) —— pack_not_gzip
✅ 永久性错误**不重试**(attempts=1;§A-3 语义) —— attempts=1
✅ ok=false 且 reason = partial:…(部分失败语义) —— reason=partial: _dsh-local_probe2=pack_not_gzip
✅ 备包**旧版仍在、零改动**(半包绝不落地) —— 5356194d200f01fb → 5356194d200f01fb
✅ 失败已回报平台侧(/node/result 1 次) —— resultCalls=1 failed=["_dsh-local_probe2"]
```
**加演(同族、语义相反)**:把坏体换成**截断 gzip** ⇒ `tar` 抛裸异常 ⇒ 判为**瞬态** ⇒ **会重试**:
```
✅ 加演:截断 gzip ⇒ 判为**瞬态**并重试(attempts=3) —— attempts=3 err=Command failed: tar -tzf pack.tgz
```
⇒ **永久性 vs 瞬态的分流是真的在起作用**(同一个 flat,两种坏体,两套行为),⛔ 不是"一律重试"也不是"一律放弃"。
### ③ `extra`(本地多出来的包)⇒ 平台只告知,本地**一个文件没被动过**
```
════ C-③ 反例:`extra`(本地多出来的包)⇒ 平台只告知,本地**一个文件没被动过** ════
✅ 对账告知了 extra 含它 —— ["_dsh-local_leftover"]
✅ 该目录**仍存在**(⛔ 没被删)
✅ 该目录内容与 mtime **零改动** —— 92cc4a9d4bb63109 → 92cc4a9d4bb63109
✅ extra ⛔ 不进 pull 清单(不会去拉它)
```
---
## 六、🔴 真机锚点:本份指纹实现 **=** 真机权威指纹(⛔ 不是近似物)
| flat | 真机台账 `treeSha256`(读 `/var/lib/dshs/bundled-plugins/.manifest.json`) | 本份 `hashTreeAt` 实算 | 文件数 | tgz 字节 | 判 |
|---|---|---|---|---|---|
| `_dsh-local_storyforge` | `1c45c8418875df50a687359806b5b44e5125ffaf7f54c405a566a17d90b1ac3c` | **同串** | 117 | 1,766,902 | ✅ |
| `dsh-plugin-mcn-suite` | `27f6c6d7ee84f14666ff966750a7d7d41379d280aef72544ce154bc9f49fac9f` | **同串** | 659 | 7,689,968 | ✅ |
```
════ 真机锚点 · _dsh-local_storyforge ════
取包命令:ssh [email protected] 'tar -czf - -C /var/lib/dshs/bundled-plugins _dsh-local_storyforge'
{"flat":"_dsh-local_storyforge","tgzBytes":1766902,"fileCount":117,
"got":"1c45c8418875df50a687359806b5b44e5125ffaf7f54c405a566a17d90b1ac3c",
"want":"1c45c8418875df50a687359806b5b44e5125ffaf7f54c405a566a17d90b1ac3c","equal":true}
rc=0
════ 真机锚点 · dsh-plugin-mcn-suite ════
{"flat":"dsh-plugin-mcn-suite","tgzBytes":7689968,"fileCount":659,
"got":"27f6c6d7ee84f14666ff966750a7d7d41379d280aef72544ce154bc9f49fac9f",
"want":"27f6c6d7ee84f14666ff966750a7d7d41379d280aef72544ce154bc9f49fac9f","equal":true}
rc=0
```
(原始读数:`tmp/p42/真机锚点-20260926.txt`)
**做法(全程只读)**:`ssh 47 'tar -czf - -C /var/lib/dshs/bundled-plugins <flat>'`(**与产物口逐字同口径**的打包)→ 本机解开 → 本份 `hashTreeAt()` 算 → 与真机台账逐字比。⇒ **这一步把"照抄"从"看起来一样"变成"算出来一样"**,是本节最强的一条证据。两个包(117 / 659 文件)**都逐字相同**。
⚠️ **两处如实**(都不影响上述判据):
1. **首版清场把判据的退出码污染了**:解包暂存目录带 659 个文件,本机的**安全删除护栏**会拦"一次删 >50 个文件"(`SAFE_DELETE_BULK_CONFIRM_REQUIRED`)⇒ `rmSync` 抛错 ⇒ 脚本在 `equal:true` 之后仍以 `rc=1` 退出。
⇒ 已把清场改成 **best-effort**(清场失败只打日志,**退出码只由 `equal` 决定**)。**教训:辅助动作(清场)不许影响判据的退出码。**
2. **中途一次误读已被纠正**:护栏那次"拦截"实际**已部分删除**了暂存树 ⇒ 事后想复用那份解包物复算,算出 415 文件 / 指纹不符。那不是指纹问题(当时比对是 659 文件 / `equal:true`),而是**素材已被动过** ⇒ 已改为**重新解包**再算,结论以上表为准。**教训:"复用旧素材复算"前先确认素材没被动过。**
---
## 七、尺子回归(⛔ 未改尺子一行)
| 面 | 靶子 / 入口 | rc | 说明 |
|---|---|---|---|
| 装配 · 端A 客户端形态 | `--profile-json tmp/p42/端A-客户端形态/package.json` | **0** | 解析到 `tmp/p42/node-shared/_dsh-local_ai1net/lib/index.js` 8405 B |
| 装配 · 端B 平台形态 | `--profile-json tmp/p42/端B-平台形态/package.json` | **0** | 同上(**两端解析到同一份落下来的产物**) |
| 取数(via 端B) | 同上 | **0** | 真实 import + 真实模块求值;`inject=['connection']` ✅ |
| 取数(via 端A) | 同上 | **0** | 两端加载同一份 ⇒ 一致 |
| 回调(via 端B) | 同上 | **0** | 真实 `apply(ctx)`,`routeCalls` 逐字记录 `{"path":"/api/ai1net/ping","methods":["GET"]}` |
| `v-baseline.sh` | 47 共享层 + 本机 desktop profile | **0** | 平台侧 4 包;客户端侧 4 bundle |
| 包内门禁 | `node scripts/build.mjs --check-only`(包目录) | **0** | 「✅ 门禁通过」(含数据面 ①d 段,`DECL_DIGEST=88c752a3e12597b3…`) |
⇒ **S2 / S3 的绿没被本棒弄红**(尺子一行未改,用 `P39_OUT` 把读数引到 `tmp/p42/ruler/`,⛔ 未覆盖 p40/p41 读数)。
### ⚠️ 未取到 / 不成立(如实)
| 项 | 结果 | 原因 |
|---|---|---|
| **S4-6 实例页「能力管理(插件/技能)」栏开始出现** | ⚠️ **未取到** | 需**真壳在跑**;启动器属**客户端线**(`dsh-ai1net-desktop/.workbuddy/_devkit/launch-desktop-dev-017.mts`),本线 ⛔ 不代启 |
| `v-ui.sh`(浏览器级) | ⚠️ **rc=3 未取到** | CDP `127.0.0.1:9330` 不可达 ⇒ 本机无运行中的真壳(与前两棒同因) |
| `v-plugin.sh 运行` 面 | ⚠️ **不成立(空真)** | 可用启动日志 mtime = **2026-09-25T09:19:43**,**早于本包存在** ⇒ "无失败条目"不构成证据 |
| **真机投放**(把包真投到 47/106) | ⛔ **刻意不做** | 属硬边界「⛔ 不代用户起实例 / 不碰把包装进去」;且包尚在沙箱验证阶段 |
---
## 八、🔴 平台既有链路的真实行为 vs 沙箱建模(逐条标注 · ⛔ 不许把建模当"已通")
| 项 | 平台既有链路的**真实行为**(读码 + 真机只读取证) | 本棒做法 | 是否 = 真实 |
|---|---|---|---|
| 对账/产物/回报三个端点 | 真端点存在且**在跑**:真机 `.node-sync-reports.json` 有 `w-106` 的汇报行(`authoritative:4 missing:[] stale:[] extra:[]`),时间 09-26 | 沙箱同口径建模(本机 http) | ⚠️ **建模** |
| 指纹算法 | `hashTree`(Manager)与 `hashTreeAt`(节点)**两份实现、同算法**,靠 `test/shared-layer-sync.test.mjs` 钉住 | 两份都照抄,互相对照 + **真机锚点**验证 | ✅ **真实**(见 §六) |
| flat 名/`link:` 目标 | `/var/lib/dshs/bundled-plugins/<sanitizeFileName(pkg)>`,真机实读 4 个 flat | 口径照抄,路径本地等价 | ⚠️ **口径真实、路径建模** |
| `@deepseek-ai` 软链 | 真机 4 个包**全部**已有:`→ /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai` | 本机真 scope 目录(见 §九) | ✅ **形态与目标形状一致** |
| 鉴权 | 真平台查 `dsh_hosts.agent_token` 表(`nodeIdentity`) | 沙箱只比固定令牌 | ⚠️ **建模** |
| 版本 / 通道 / 钉版 | 真平台有 `.versions/` 归档 + `channel{stable,canary}` + `pinnedHosts`(真机台账实读 `dsh-plugin-mcn-suite` 两个键都在) + `resolveSharedTarget`(钉版 > 通道 > 现役) | 沙箱**只做"现役一版"** | ⚠️ **未覆盖 B-1/B-2/B-3 三条路** |
| 审计落库 | 真平台写 `app.db.audit`(`shared_layer_report`) | 沙箱只写文件 | ⚠️ **建模** |
| 传输 | 真平台走公网 / relay(47↔106) | 回环 | ⚠️ **建模** |
🔴 **结论纪律**:本棒能断言的只有 —— 「**照抄的那段拉取逻辑,在既有口径下四步跑通、三条反例成立、指纹与真机权威同源**」。⛔ **不能**据此断言「真机投放已通」「门户链路已通」「跨机对账已通」。真机投放属 S9/部署面。
---
## 九、S4-4 `@deepseek-ai/*` 软链(缺链现象 = "包在但加载不了")
```
════ S4-4 ════
✅ 解析到**本机真实** dsh scope 目录(⛔ 不是造一个)
scope=E:\github\dsh-desktop-0.1.7rc2\apps\desktop\.desktop-build\development\project-adapt\node_modules\@deepseek-ai\dsh\node_modules\@deepseek-ai
✅ scope 的父链确是 `<dsh 包根>/node_modules/@deepseek-ai`(用权威 isDshPackageDir 的同一判据)
dsh 包根 package.json name=@deepseek-ai/dsh
✅ scope 目录**名字层**含 dsh 宿主子包(dsh-base / dsh-agent) —— 共 119 项
✅ (预置后)三包链都在 ⇒ 本轮全 kept,一个字节没动
✅ 软链指向本机 scope · _dsh-local_ai1net / _dsh-local_probe2 / _dsh-local_leftover
✅ 缺链 ⇒ 补齐(linked=1),即"包在但加载不了"会被自愈
✅ 幂等:第二轮全 kept,一个字节没动
✅ 负控:指歪的链被纠正(relinked=1)
✅ 软链不计入 treeSha256(补链不改变指纹 ⇒ ⛔ 不会因此永远 stale)
```
🔴 **为什么"必须每轮全量补"**(照抄段注释):软链**不进指纹** ⇒ 缺链的老包**永远不会**被判 `stale` ⇒ 只在铺装那一步补的话,老包会**永远**缺这一条。
⚠️ **一处如实**:本机这份 dev 副本的 scope 里,这些条目是 **pnpm 软链指向 monorepo `packages/**`**,而该路径在**本机不存在**(悬空)—— 那是**客户端线 dev 构建的现状**,与本棒无关。本棒验证的不变量是"**链指向本机解析出的 scope**",⛔ 不验证 scope 内部是否可用。
---
## 十、§4-C1–C6 坑对表(逐条)
| # | 现象 | 本棒怎么处理 | 证据 |
|---|---|---|---|
| **C1** | 永远 stale / 每轮全量重拉(指纹算法两侧不一致) | 两份实现都照抄;**跨实现互算** + **真机锚点**;3 条负控证明判据能变红 | §二 / §三 / §六;第二轮 `packCalls` 停在 2(**不重拉**) |
| **C2** | 对账与下载共用一个超时 | `syncTimeoutMs` / `downloadTimeoutMs` **分开**,`packBytes` 提示动态放宽、封顶、最低速率 —— 全部可被环境变量覆盖、⛔ 不写死 | 逐字照抄;黑洞反例走的是同步超时那条路径 |
| **C3** | `link:` 目标两机不同 ⇒ 装配失配 | 两端 `link:` 字符串**逐字相同**;flat 名与权威 `sharedDirNameOf` 同口径 | §四 |
| **C4** | `ERR_MODULE_NOT_FOUND`(包在但加载不了) | 补链 + **缺链补齐 + 指歪纠正 + 幂等** 四条判据 | §九 |
| **C5** | 「不知道」被折算成「一致」 | 节点侧无记录 ⇒ 报 `null`(⛔ 不报假 hex);平台侧指纹**只认 64 hex**,其余当 `null` ⇒ 判 `stale` 重拉一次自愈 | 逐字照抄;实测第二轮 `stale=[]` 且 `packCalls` 未增 |
| **C6** | 失败一轮全放弃 / 删了不该删的 | 三条语义纪律**逐条实测**:① 取不到 ⇒ 零改动 ② 单包失败 ⇒ 只记 `failed`,其余照常 ③ `extra` ⇒ 只告知、⛔ 不删 | §五 |
---
## 十一、硬边界自查 / 遗留 / 回滚
- ⛔ **未改官方 dsh 主程序与缓存**(R2):本棒只**读** `D:/github/dsh_shenxian/src/**`(照抄来源)与本机 desktop 构建的 dev 副本,**零写**。
- ⛔ **未重造分发与更新机制**:全部照抄(§二 对照表 11 段 + Manager 侧 4 段)。
- ⛔ **未代用户起实例**;⛔ **未把包装进任何 profile**(装配用的是 **沙箱 profile**,真件 profile 只读引用)。
- ⛔ **未改客户端工作区** `dsh-ai1net-desktop` / `dsh-desktop-0.1.7rc2` 任何文件(只读引用;`端A` 是 profile **副本**)。
- ⛔ **包内零改动**:`E:/ProgramData/AIProject/dsh-plugin-ai1net/` 一字未动。
- ⛔ **未 commit / 未 push**;真机侧**零写**(只读 `tar` 取包 + 读 `.manifest.json` / `.node-sync-reports.json` / `ls -l` 软链)。
- ⚠️ **红线门禁**:本棒**未触发**任何一项 —— 未重启控制面、未中断在线用户、改动文件数 < 10、**无不可逆操作**(沙箱目录可整体 `mv` 归档)。
- 🔴 **停点**:**S4 的 P1 已解除**(09-26 13:30 用户拍板 = A「放开桌面只读读包权」);**S9 的 P2 仍待拍板,⛔ 本棒未跨过**。
- **遗留(如实)**:① **S4-6 / `v-ui.sh` 仍欠一笔真壳取证**(客户端线窗口)② 沙箱**未覆盖**版本/通道/钉版三条路(真平台有,属 S9/发布面)③ `tmp/p42/real-anchor/_x-*` 是**真机锚点的解包暂存**(含 mcn-suite 659 文件树),因**安全删除护栏**(一次删 >50 文件需确认)**保留未删** ⇒ 随 `tmp/` 7 天口径处理,⛔ 未绕过护栏。
- **回滚**:本棒 **无生产副作用** ⇒ 回滚 = `mv tmp/p42/ 归档/`(按 §七 规格书回滚口径,⛔ 不用 `rm`)。沙箱外部唯一变化 = 沙箱共享层里的软链与 profile 副本,全在 `tmp/p42/` 内。
---
## 十二、出处
- 规格书:`D:/github/dsh_shenxian/dsh-server-docs/05-交接单/交接单_包规格书_20260926.md`
- 方案:`交付物/功能打包到基础插件-分步实施方案-20260926.md §3.10.6 / S4 段 / §4 C 类`
- 前置回执:`交付物/数据面-S3落地与三轮回执-20260926.md`
- 权威实现(只读):`D:/github/dsh_shenxian/src/worker/agent.ts`|`src/supervisor/plugin-assembly.ts`|`src/web/routes/business-plugins.ts`
- 本棒产物(⛔ 不入库):`tmp/p42/{puller,mgr-mock,run-s4,anchor-real}.mjs`|`tmp/p42/读数-20260926.txt`|`tmp/p42/尺读数-S4-20260926.txt`|`tmp/p42/real-anchor/`