范围 = 本线(插件投放与分库线)产物 + 记忆类,共 27 件:
- 接续入口_插件投放与分库线(1 件):§0 新增 22:44 拍板行;§2 第 36 棒范围改 5 步、条件步转无条件
- 交付物(22 件):MCN 数据面接入阶段一/阶段二系列(含 B 案落地与 shadow 读数)、
P0 修通与移动端真机验收、pnpm-EPERM、两机 lib 差异、共享层台账语义、
基础插件身份与回滚、插件接入验收、插件数据面取数口、移动端迁包与字号扩面、跨机错误消息
- 记忆(4 件):2026-09-24 / 2026-09-25 日志、MEMORY.md、本棒 automation 执行记录
⛔ 未含他线在途改动(只报告、不代提交):机制层 CODEBUDDY.md / state.py /
.codebuddy/rules/server-ops.md、接续入口_IM线、接续入口_StoryForge验收线、
其余 29 个 automation 目录、docs/规则与载体/、归档/、接续包_*。
10 KiB
共享层台账两条语义缺陷修法(插件投放与分库线 · 第 21 棒)
日期:2026-09-25 11:35–12:2x|棒次:第 21 棒 · 执行棒|工作区:
E:/ProgramData/AIProject/aliyun-dsh-server代码基线:D:/github/dsh_shenxian(生产口径dshs)|真机:47.77.182.89(Managerdshs+ workerdshs-worker)+ 106.54.21.172(workerw-106) 本轮唯一动作:共享层台账两条语义缺陷(§5-10 ㈠㈡)——「清单与磁盘不同步」+「回滚素材名实不符」。
一、结论(三句话)
- 两条缺陷同源:该写进台账的值,此前来自"别处的副本",而不是盘上那份实体 —— ㈠
version取自候选池行;㈡ "回滚素材"落的是新包自拷贝。 - 修法共用一条纪律:要写进台账的值,必须从盘上那份实体现读。落地 = 三个真值口径函数 +
doShare两处接线 + 一个显式自愈口(㈠ 的存量只能靠"拿盘上真值回写清单"收口)。 - 四条判据真机全绿:清单 ≡ 磁盘真值(含"篡改 → 自愈 → 回到真值"端到端)|节点连续两轮
current|回滚素材名实相符(且不含node_modules)|单测 5 例钉住口径。47 已上线并重启;⛔ 未 commit/push。
二、判据①:清单字段与磁盘真值逐字相同
2.1 端到端(走平台新口,非直跑函数)
在 47 上制造一次真实的脱钩(㈠ 的现象),再看能不能修回来:
| 步 | 动作 | 真机读数 |
|---|---|---|
| 0 | 备份清单 | /opt/dsh/backups/lib-p21/manifest-pre-p21.json |
| 1 | 把 _dsh-local_storyforge 的 version 改成 0.2.0(盘上是 0.3.0) |
TAMPERED flat=_dsh-local_storyforge version 0.3.0 -> 0.2.0 |
| 2 | 只读干跑 reconcileSharedManifest() |
DRYRUN checked=4 fixed=1;version ["0.2.0","0.3.0"](后值 = 盘上真值) |
| 3 | 建临时 admin 会话(R4) | MKSESS_USER=cce6d1cd-… / admin |
| 4 | POST /api/plugins/shared/reconcile |
RECONCILE_HTTP=200;body fixed:[{flat:_dsh-local_storyforge, version:["0.2.0","0.3.0"]}] |
| 5 | GET /api/plugins/shared 复核 |
storyforge version=0.3.0 tree=1c45c8418875 files=117(回到真值) |
| 6 | 与备份逐字 diff |
唯一差异 = updatedAt(预期:自愈也是一次写入) |
| 7 | 清理 | SESSIONS_DELETED=1(临时会话用完即删) |
⚠️ 诚实记录:第 6 步不是"零差异" —— 除 updatedAt 外逐字相同。updatedAt 的语义是"该条目最后一次写入",自愈改了三个描述字段 ⇒ 更新它是对的(且第二步起幂等:见 2.3)。
2.2 四个包的真值复核(管理面读数)
_dsh-local_storyforge version=0.3.0 tree=1c45c8418875 files=117
dsh-plugin-mcn-suite version=0.3.15 tree=939206c76abb files=657
_dsh-local_im-conversation-tabs version=0.1.0 tree=678e0f36e100 files=5
_softspark_dsh-file-preview version=2.0.0 tree=5cc4bdf7d1a9 files=881
与盘上 package.json 逐个核对一致(_dsh-local_storyforge :: version=0.3.0 等,见 §四 取证命令)。
2.3 幂等(真机)
MANIFEST_MD5_BEFORE=73435d1f81052604528537fa8064074b
IDEMPOTENT_DRYRUN checked=4 fixed=0
MANIFEST_MD5_AFTER =73435d1f81052604528537fa8064074b
⇒ 真值已对齐后一处不动、字节不变(⛔ 不制造无谓写盘 ⇒ 不抖 mtime、不诱发节点重拉)。
2.4 判据①的字面动作未做(如实声明)
判据原文含「真机跑一次 share 后清单字段与磁盘真值逐字相同」。本棒没跑那次 share,原因:
- 跑
share只有两种走向 —— ① 幂等分支(池内 tgz 与清单tgzSha256相同)⇒ 一个字节都不动,验不到写入路径;② 替换分支 ⇒ 必须真发一个新版本,那会改线上共享层内容(影响面 = 全平台已可见该插件的用户)。 - 等价替代(证明力不低于字面动作):篡改 → 自愈 → 与原值逐字相同(§2.1)+ 单测钉住写入侧口径(§五)。
三、判据②:节点对账连续两轮 current
在 106 上以节点身份触发两轮拉取(POST /shared-layer/pull,带该机自己的节点凭据;⛔ 凭据不出本机):
PULL=200
--- round1 --- {"ok":true,"missing":[],"stale":[],"extra":[],"pulled":[],"failed":[],"asOf":1790308081562}
--- round2 --- {"ok":true,"missing":[],"stale":[],"extra":[],"pulled":[],"failed":[],"asOf":1790308084957}
⇒ 连续两轮判 current,⛔ 不再 stale/反复重拉(两轮间隔 3.4 s)。Manager 侧对账面同步复核:
NODE_REPORT={"hostId":"w-106","at":1790308084767,"authoritative":4,"missing":[],"stale":[],"extra":[],"failed":[]}
四、判据③:回滚素材名与内容自洽
| 检查 | 真机读数 |
|---|---|
盘上既有素材(mcn-suite/0.3.14.tgz)内容版本 |
ASSET_CONTENT_VERSION=0.3.14 ⇒ 文件名 = 内容版本 ✓ |
packSharedTree() 在真共享层包上跑(产物落 /tmp,⛔ 共享层一字未动) |
SRC_TRUTH_VERSION=0.3.15 → PACKED_CONTENT_VERSION=0.3.15 ⇒ 名实相符 ✓ |
素材是否夹带 node_modules |
ARCHIVE_NODE_MODULES_HITS=0 ✓(--exclude=node_modules 生效) |
| 体积 | PACK_SIZE=1996116(≈ 池内 tgz 1982036,同量级 ⇒ 未把依赖树打进去) |
接线审计(部署后的 lib/ 里,证明新函数真的被 doShare 调用):
1787: const asset = rollbackAssetPath(app.config.backupDir, flat, oldVersion);
1790: packSharedTree(dir, asset);
1806: version = packageVersionAt(incoming) ?? version;
1815: backupTgz = rollbackAssetPath(app.config.backupDir, flat, version);
五、判据④:单测钉住两处口径
test/shared-layer-sync.test.mjs 新增 5 例(第 ③ 组「台账两条语义缺陷」):
| 用例 | 钉住什么 |
|---|---|
台账口径(㈠) |
version 取盘上实体现读:最浅 package.json 优先;读不到 / 无 version / 非法 JSON ⇒ null(⛔ 不编造、⛔ 不抛) |
台账自愈(㈠) |
脱钩 ⇒ 三件(version/treeSha256/fileCount)一起修;其余字段原样保留;再跑幂等 |
台账自愈: 双向只报告 |
清单有盘上无 ⇒ missingOnDisk(⛔ 不删条目);盘上有清单无 ⇒ extrasOnDisk(⛔ 不新增);旁挂文件不算 extra |
回滚素材(㈡) |
命名 = 上一版版本号;内容 = 旧实体;⛔ 不含 node_modules |
反证:不许落进被归档目录 |
落点在被归档树内 ⇒ 抛 pack_into_self |
六、门禁与部署
| 项 | 读数 |
|---|---|
npm run build |
rc=0 |
npm test |
589 tests / 587 过 / 0 败 / 2 跳过(第 20 棒 584 ⇒ +5 用例) |
npm run check:layering |
✅ 无新增违规(现存 5 条全在基线内) |
| 部署 | 47 一件(lib/web/routes/business-plugins.js,md5 3a752e01e4f16b4e6d6c47c305362a51 与本地逐字一致) |
| 重启 | systemctl restart dshs ⇒ active · NRestarts=0 · 门户 PORTAL_HTTP=200 |
| 新口注册 | POST /api/plugins/shared/reconcile 无凭据 ⇒ 401(⇒ 路由已注册且门禁生效;404 才是没注册) |
| 备份 | /opt/dsh/backups/lib-p21/47-pre-p21-20260925-114554.tgz + manifest-pre-p21.json |
⛔ 未做:commit / push|改 src/web/routes/im.ts(零改动)|手改用户 home 下的 profile / .pnpm|清理 .dsh-module-fallback 人工软链(§5-18 待拍板)。
七、未闭合 / 本棒新发现
- 🟡 106 侧的
lib/web/routes/business-plugins.js与 47 不一致(本棒顺带读数):47 投前4109ba34…(124222 B)/ 投后3a752e01…(新),而 106 =70d5c0f2…(114041 B,mtime 09-25 00:28) ⇒ 106 上该文件落后好几个版本。本棒只投 47(改动是 Manager 侧台账逻辑,106 是 worker;⛔ 不擅自改别处的部署面)。⇒ 建议下一棒做一次两机lib差异面核对。 - 🟡 106 的新代码仍未生效(§5-19 延续):
dshs-worker未重启 ⇒ 重启前的 106 用户仍会撞第 20 棒修掉的EPERM。⛔ 重启会中断 106 在线实例 ⇒ 待用户给窗口。 - 🔴 ㈡ 的"完整替换路径"未在真机跑通:47 现有包在备份面都已有同名素材(上传钩子时代落的)⇒ 新逻辑走"已存在则跳过"⇒ 真机上打包分支未被触发(其正确性由单测 + §四 的等价复现保证)。要真机跑通需真发一个新版本 ⇒ 会改线上共享层内容 ⇒ 本棒未做。
- 🔴 待拍板(延续):§5-18 历史人工兜底软链是否清理|§5-14
@softspark/dsh-file-preview可见性(用户 11:0x 口径已收口标记问题,但它已在共享层 ⇒ 全员可见)。 - ⚠️ 已知边界(写进代码注释):新旧同版本号重投时,"上一版素材"与"当前版本快照"同名 ⇒
backupTgz会指向旧内容;该场景"回到上一版"本就无意义,故不造名区分。
八、清理与卫生
- 47
/tmp:p21-e2e.sh/p21-e2e.ok/p21-verify.sh/p21-mksess*⇒ 全部已删(TMP_P21_CLEAN)。 - 临时 admin 会话:
SESSIONS_DELETED=1⇒ 残留 0(R4)。 - 备份面保留(回滚素材):
/opt/dsh/backups/lib-p21/(2 件)。 - 服务状态:
dshs=active·dshs-worker=active。 - 本工作区中间产物 ⇒
tmp/p21-*.sh(收口清理,见 §0 收口行)。
九、复现命令(第三方可照跑)
# ① 清单 ↔ 磁盘真值(只读)
ssh bt-server 'cd /opt/dshs && node --input-type=module -e "
const m=await import(\"/opt/dshs/lib/web/routes/business-plugins.js\");
const fs=await import(\"node:fs\");const root=\"/var/lib/dshs/bundled-plugins\";
const {report}=m.reconcileSharedManifest(root, JSON.parse(fs.readFileSync(root+\"/.manifest.json\",\"utf8\")));
console.log(\"checked=\"+report.checked+\" fixed=\"+report.fixed.length, JSON.stringify(report.fixed));"'
# ② 节点两轮对账(在 106 上,用本机凭据)
ssh test106 'set -a; . /etc/dshs-worker.env; set +a; for i in 1 2; do
curl -s -X POST -H "x-dsh-agent-token: $DSHS_CLUSTER_AGENT_TOKEN" \
http://127.0.0.1:19000/shared-layer/pull -w " HTTP=%{http_code}\n"; done'