回收 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)、记忆修复前备份。
77 KiB
PLAYBOOK — 实例与插件坑(DSH 平台)
为什么单独一个文件:
MEMORY.md有注入上限(超了会被截断)⇒ 把「出手前 10 秒必须看到」的判据留在MEMORY.md §四,把详版(命令 / 日志口径 / 恢复手法)挪到这里,由MEMORY.md指过来。 读法:MEMORY.md是索引与状态;本文件是排障手册。两边冲突时以实测为准,并回头修这里。
1. 插件机制的能力边界
- 插件机制 = 扩展,无「替换官方内置文案」扩展点;官方硬编码中文的 20+ 包只能在注入层改 DOM;替换官方同名包 ⇒ 不做。
- ⚠️ 第三方客户端插件不得把官方 UI 包写进
dsh.client.inject(-settings-models/-settings-plugins/-cordis等被角色补丁禁用 ⇒ 该插件浏览器半边永久静默挂起)。 - 判据(客户端插件没 UI):解析
__DSH_BOOT__→ 各inject与实际插件集求差集:非空 = 该 fiber 永久挂起(静默)。
2. 原生绑定 / GLIBC
- 宿主 glibc = 2.32 ⇒ 要求 ≥2.35 的原生绑定(univer 的 rust / node binding)默认装不上。
- ✅ 2026-09-14 已解并上线:给指定进程挂一份自带 glibc 2.35(
/usr/local/dsh-runtime/glibc-2.35/;放/usr/local是为 bwrap 沙箱内可见)⇒ 插件 0.2.28 起 worker/gateway 经ld.so --library-path启动(src/host/processes/glibc-runtime.ts自探测;UNIVER_DSH_GLIBC_RUNTIME_DIR可覆盖 / 置空关闭;平台侧零改动)。 - ⚠️ 必须整套 20 个 so 一起给 —— 只给 libc/libm + 宿主
/lib64会撞GLIBC_PRIVATE/__libc_siglongjmp。 - 删目录即完全回退。⛔ 仍然不要动宿主系统 glibc。仍未解:截图 / PDF。
3. Failed to load plugins 的三种真因
3.1 真因之三 = 431 Request Header Fields Too Large(Cloudflare 拒的,不是 nginx)
成因(proxy.ts 注释原文):实例每次 (重)启动都会换 launch token 和 dsh-auth-<随机后缀> 的 cookie 名;浏览器把旧名字全留着(平台只在转发时丢弃:mergeCookies 的 !/^dsh-auth-/,从不回写浏览器)⇒ Cookie 头单调增长 ⇒ 超 CF 上限 ⇒ 431。
⚠️ 不是 nginx:本机 vhost 已配 large_client_header_buffers 4 32k。
⇒ 那条 11 MB 合并脚本取不到 ⇒ 报 bundle script … failed to load。
判据(三条):① 平台日志里没有那条 bundle 请求 ② 无痕正常(无 cookie ⇒ 头小)③ F12 里状态是 431、Remote Address 是 CF。
恢复:清 alotbuy.com 的 cookie(只删 dsh-auth-*,保留 sid)。⚠️ 频繁重启实例会加速它。
✅ 已治本(档案 98):平台在响应里对陈旧 dsh-auth-* 回写 Set-Cookie: …; Max-Age=0(只在"本次响应确实下发了 dsh-auth"时才动手)⇒ jar 不再增长。⚠️ 但它救不了已 431 的当下(那时请求进不来)。
3.2 浏览器侧(扩展 / 缓存)
🔴 报障排查第一步就问:「普通窗口」与「无痕窗口」是否同样报错?(09-14 实测教训:我绕了十几轮才问到) 无痕默认禁用全部扩展 + 全新缓存 ⇒ 一步区分浏览器侧(扩展/缓存)与服务器侧。
⚠️ 客户端报 Failed to load plugins / bundle script … failed to load 时:dsh 用 document.createElement("script") 加载那条 /plugins/??…&rev=… 合并脚本(官方 dsh-client-modules)⇒ 被扩展拦掉时浏览器根本不发请求 ⇒ 平台日志里查不到任何失败请求(实测:用户刷新只留 7 条请求,bundle 那条一次都没出现)。
⛔ 此时别再往服务器侧挖(OOM / 配额 / 插件表 / 启动日志都会是"干净"的)。
3.3 cgroup OOM 杀实例
🔴 「实例起不来 / 加载插件报错」先跑三日志判据,别猜客户端(实测:我围着客户端 bundle 转了 6 轮,真因是 cgroup OOM 杀实例):
journalctl -k --since -6h | grep -E "oom-kill|CONSTRAINT_MEMCG"——oom_memcg=/system.slice/dsh-<uid>-*.scope就是配额太小 (实测:admin 上限 384 而 0.1.5 基座吃 ~312 ⇒ 只剩 70 MiB 给 mcn 7 插件 ⇒ 一分钟被 OOM 杀 4 次 ⇒ 熔断)journalctl -u dshs --since -1h | grep -o "crash-restart.*"—— 看平台记的exitCode+lastError栈tail -5 /var/log/dsh-instance-mem.log—— 每 10 分钟一行「当前 / 峰值 / 上限」,一眼看出配额够不够
⛔ 别把 OOM 之后的 cordis-plugin-loader … Entry._init 栈当成"插件不兼容" —— 那是饿死时的表象。
⚠️ 实例「起不来」先查属主 / EACCES(见 MEMORY.md §三 R10),不要先怀疑 OOM。
4. 铺插件 / 换产物的两个静默陷阱
- ⚠️ 换共享 tgz 的瞬间 + 跑
ensure-biz-plugins.cjs= 静默卸载:它的pruneBrokenFileDeps()会把「指向不存在文件」的file:依赖直接摘掉 ⇒ 先放新产物、再跑脚本,跑完核对bundles(guest 的 univer 就这样消失过,见档案 95)。 - 🔴 铺插件 / 重启
dshs后,用户「已打开」的页面会报Failed to load plugins:dsh 把全部client.js拼成一个脚本, URL =/plugins/??<模块列表>&rev=<hash>是严格契约(实测:原样 200 | 只改rev→ 404 | 少一个模块 → 404) ⇒ 页面持有的 rev/列表一过期就 404。硬刷新即恢复。(纪律见根CODEBUDDY.mdR8 —— 09-14 已放宽为「开发环境直接做 + 动手前一句说明」。)
5. /plugins/ 的 ETag / 304(档案 97)
- ✅ 那条把全部客户端插件拼起来的脚本实测 11,172,365 B、未压缩(官方
dsh-client-modules的 combo URL), 原先只有no-cache且无校验器 ⇒ 每次打开页面全量重下 11 MB(经 CF 实测 114.87 s)。 - 现按 URL 派生 ETag、命中
If-None-Match直接 304(0 字节)。 - ⚠️ 首次访问仍是全量 11 MB(压缩未确认);⚠️ 实例每次重启换 nonce ⇒ 旧页面的 rev 必 404。
6. 待修 / 待办(技术债)
- 门户
web/portal.html的readAsBase64()缺await⇒ 三个上传入口恒传空数据(待同修)。 - 档案 82 自身严重重复(§一–§八 6 份)⇒ 待单独立项去重。
- MCN 插件:
mcn-task重复建组未解(需产品决策);/mcn/api/accounts500 为既有缺陷。
7. 开源导出 · K8s 残留清理(09-14~15)
工作根
E:\ProgramData\AI技能\dsh-laijing-github(不在受保护根 ⇒ 写它不需要锁)。
- 判据不是字面量,而是语义:「这段描述在单机形态下还成立吗」⇒ 清
Pod(K8s Pod ≠ DSH 子进程)/sidecar(已移除的 per-user file sidecar)/a Linux Pod。 - 三类扫(只扫注释必漏):① 字面量
k8s② 语义措辞(Pod/sidecar/Headless)③ 标识符名(LivePod/K8sSpawner/stampFencing)—— 第三类最易漏(注释改了、名字还在;实测spawner.ts的export interface LivePod0 引用死代码,首轮cf8b7b1漏掉、68c0a32补删)。扫法:拿 K8s 词汇表捞非注释行里的interface/type/class/函数名。 - 工具:
_k8s_comment_scan.py [--wide](按「注释 / 代码」分类输出;os.walk—— bashgrep -r不遍历隐藏目录,会漏.github/)。实测注释命中 0、tscexit 0。 - ⛔ 噪音不要清:
--profile headless(dsh 自己的 profile 名)·headless-univer(第三方插件包名)·manifest(npm 包清单,非 K8s manifest)·egress(nftables 出网护栏,保留能力)。 - ⚠️ 本机跑不了
_build_export.py --force:shutil.rmtree(DST)被安全删除层拦下(SAFE_DELETE_FAIL_CLOSED),且它会连导出物的.git一起删。 替代法 = 新规则抽成具名表(K8S_SEMANTIC_RULES,仍REGEX_RULES += …)→_apply_k8s_semantics.py --write就地热应用(不删任何东西;与整目录重建逐字节一致;幂等,复跑命中 0)。 真要整目录重建:先mv dsh-users-platform/.git <导出根>/_keep_git_tmp→ 重建 →mv回来。 - ✅ 依赖已清:走「源仓先下线 K8s 后端 → 再
npm uninstall @kubernetes/client-node」⇒ 源仓 6 模块 + 2 测试 + 1 脚本移入D:\github\_dsh_shenxian_K8s后端备份_20260915\(README 含还原命令 + 集群化方案要复用的模板清单)。 ⚠️ 刻意保留(不是遗漏):deploy/·poc/01–04·Dockerfile.dsh·docs/k8s*.md(已加状态横幅)·config.ts的 K8s 配置字段与DeployMode联合类型。 - ⚠️ 连带判据 =「产物不在,引用它的步骤也不该在」:CI 里 3 条步骤依赖由已撤
Dockerfile.dsh构建的dsh:ci镜像(Smoke — dsh resolves runtime plugin/Trivy — dsh/Push dsh to ACR)⇒ 整块删除(新增_drop_block():逐行re.escape拼接,避免手写正则静默失配)。 - 留档:
_K8s清理影响评估与回归说明_20260915.md(影响评估 / 回归命令 / 继续开发四纪律 / 工具陷阱 —— 接手先读)|_K8s排除台账.md §五·补|_待清理_K8s残留清单_20260914.md(顶部已挂 ✅ 完成横幅)。 - 未清:ACR 推送段用占位符
registry.example.com⇒ master 推送会失败(与 K8s 清理无关)。
8. 兼容判定收口(档案 93)
- ⚠️ 「单测长期红」≠ 代码坏:真因常是服务器源码落后于 git。判断法:两侧各自
git ls-files src test+tr -d '\r' | sha256sum→ 排序 diff(两侧都要行尾归一化;diff <(a) <(b)在本机不可用 ⇒ 落临时文件)。 - ⚠️ 伞包
@deepseek-ai/dsh必须单独解析(它不在自己的node_modules里)⇒ 收口到dsh-install.platformPackageVersion(),plugin-compat与plugin-dsh-compat共用版本表。 - ⚠️ 兼容闸门判据 A 必须带 prerelease 兜底:平台是 prerelease,semver 默认语义下声明
*也会被判不兼容(抽样 300 实测假阳性 ≈39%,含在跑的dshmarket)⇒ 改成「默认语义不满足时再看includePrerelease,能满足的只计数不阻断」。判据 B 未动。 - 🔑 复刻官方 UI 的取证入口 = 官方包
lib/client.js头部\0dsh-css:区块(CSS 模块,类名带哈希)+ 同文件zh/en(全部文案)+README.zh.md(语义)。
9. 实例侧取数·验收四坑(06 §7 三段式的实操,2026-09-15 实测全部踩过一遍)
场景:不靠浏览器验证「UI 改动真的上线了」——抓实例壳页 → 取 combo URL → 请求 bundle → grep 新特征。 四坑都在同一条链路上,按顺序踩完才拿到绿。
| # | 现象 | 根因 / 正确姿势 |
|---|---|---|
| 1 | 抓实例页只拿到 10.7 KB 的登录页(title「DeepSeek」、只有 /i18n.js) |
实例子域代理按会话判归属 ⇒ curl 必须带上门户 sid cookie(只有 launch token 不够)。正确:303 → set-cookie: dsh-auth-* → 200,壳页 ≈58 KB(title「DeepSeek Harness」) |
| 2 | /plugins/??… 404(2702 B) |
两点同时要对:① 站点必须是实例子域(/plugins/ 是 dsh 自己的路由;打门户域只会得到门户 404);② 仍要带 sid(否则被当"无租户"请求 → 假 404,与「fetch 不能设 Host」同族) |
| 3 | 壳页 combo 里没有要验的包(如 @dsh-local/business-plugins;58 条 /plugins/?? 里 0 条命中) |
新用户 profile 是首次进实例时才创建的 ⇒ ensure-biz-plugins 对它直接「无 profile → 跳过」。正确顺序:先 enter 起一次实例(生成 profile)→ 跑 ensure-biz-plugins.cjs --all → POST /api/dsh/restart(client bundle 只在实例启动时加载)→ 再 enter。⚠️ 另:生产里**"审批"会顺带铺 bundle**(档案 36),直改 DB 置 active 会绕过该钩子 —— 这也是坑 3 的另一个成因 |
| 4 | POST /api/dsh/restart 400 |
restartSchema 要求 command 是必填 string(handoff 停写后恒传空串)⇒ 必须发 {"command":""} + Content-Type: application/json |
配套事实(R4 一次性用户模板):role 取值是 active(CHECK 约束 admin|pending|active|disabled),不是 approved;清理要按 DELETE /api/admin/users/:id 同一套 —— 顺次删 credential_vault / domains / sessions / dsh_instances / folder_plugins / workspaces / audit_log / users,再 rm users/<id> 整棵(platform 侧的 users/<id>/home 是 dsh 的 watch 域),最后 userdel dsh-<id 去横线前 20 位>。
⚠️ 停实例只准按 uid 过滤该用户自己的 scope(systemctl list-units --type=scope | grep <uid> 再 stop)—— ⛔ 绝不能 stop 'dsh-*.scope',那会连别人的实例一起停。
验收基线(可复用):壳页 combo 含目标包 + 壳页字节数变大 + rev 变了;bundle 请求 200 且体积 ≈11.36 MB(全量拼接脚本量级)并含新特征串。
9.1 ⛔ 集群化之后的两个必踩(2026-09-15 晚实测)
- 临时用户的审批必须写 PG,写 SQLite 等于没写
- 平台已
DSHS_DEPLOY_MODE=cluster,权威库 =postgres://dshs:[email protected]:15432/dshs(单元dshs-pg,数据目录/var/lib/dshs-pg)。 /var/lib/dshs/dshs.db(SQLite)是回滚用的旧库、平台根本不读 ⇒UPDATE users SET role='active'写进去后login仍 403pending_review(脚本表现:"登录失败",很误导)。- ⚠️ 列名也不同:PG 里
users的主键是id(不是user_id)⇒ 清理脚本别照抄 SQLite 时代的列名。 - 连接方式二选一:
psql "$DSHS_DB_URL" -tAc "…"或/opt/dshs/node_modules/pg(pg模块在服务器上现成可用)。
- 平台已
- 新用户不再落在 47 上 ⇒ 「起实例 → 抓壳页 → 取 combo」这条验收路走不通
- 集群按容量分配:存量用户锚定
w-47(47 本机)、新用户落w-106(106 经反向隧道连到 47 ⇒ 47 侧 ssh 不到 106)。 - 实测表现:新建的一次性用户
enter成功、但 47 上没有它的users/<id>目录、壳页 combo 不含@dsh-local/business-plugins(因为新用户的 profile 在 106 上,ensure-biz-plugins --all在 47 跑不到它)⇒ 别据此误判"插件没生效"。 - ⇒ 想给 UI 改动做实例侧/浏览器验收,先解决「新用户落哪台 Worker + 怎么到那台机」,否则只能做到磁盘层(profile 里的
client.js有 grep 命中)为止。
- 集群按容量分配:存量用户锚定
10. 本机 Python 编码坑(2026-09-15 实测 · 害我误判一整天)
现象:stop-dialog-guard.py 装了 UserPromptSubmit 钩子,但全盘找不到任何日志,看起来"宿主从没调用过它"。
真因:安装形态是 python -S -E <脚本>,而 -E 会忽略 PYTHONUTF8=1 / PYTHONIOENCODING=utf-8(本机环境确实设了这俩)
⇒ sys.stdin.encoding 回退 cp936;钩子 payload 必然含中文(用户的提示词)⇒ 文本模式读出乱码
⇒ json.loads 抛 ValueError ⇒ 原代码 except ValueError: return 静默返回 ⇒ 每次调用都空转。
| 判据 | 结论 |
|---|---|
python -S -E |
stdin.encoding = gbk、utf8_mode=0 ⇒ 中文 payload 必炸 |
python -S(无 -E) |
stdin.encoding = utf-8、utf8_mode=1 ⇒ 正常 |
对照脚本 lock-guard-hook.py |
安装时没加 -E ⇒ 一直正常(但同样依赖环境变量,是潜在风险) |
正解(与 flag / locale 解耦)
raw = sys.stdin.buffer.read().decode('utf-8', 'replace') # 读
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8')) # 写
- 原
sys.stdin.read()/sys.stdout.write(...)在 cp936 下会解码失败 / UnicodeEncodeError - 钩子类脚本必须「入口即留痕」(记 event / cwd / in_scope / transcript 尾部)—— 否则"宿主没调用"与"调用了但被静默 return"分不开,而两者处置完全相反(前者卸载、后者放宽作用域判据)
- ⛔ 安装命令不要给这类脚本加
-E
同类风险(未改,待定):lock-guard-hook.py 目前可用,但若环境不再提供 PYTHONUTF8=1 会静默 fail-open(守卫失效)。
10.1 🔴 md 文件里几个非法字节 ⇒ 整份读出来全乱码(2026-09-16 实测)
现象:Read 工具读 .workbuddy/memory/2026-09-16.md 全文乱码(浠d环 = "代价"),文件本身看起来"有内容"。
真因:该文件是 UTF-8,但开头有 2 个非法字节(b5 84,半个汉字残渣)⇒ 自动编码检测(Read / 编辑器 / chardet 类)判定"不是 UTF-8" ⇒ 走偏为 GBK ⇒ 整份文件按 GBK 解码 ⇒ 全乱。
⚠️ 关键:只有 2 字节坏,却毁掉 212 KB 文件的可读性(其余 212,337 字节全合法)。
判据(一条命令,别靠肉眼看)
b = open(p, 'rb').read()
try: b.decode('utf-8'); print('OK') # 合法
except UnicodeDecodeError as e: print('BAD', e.start) # 非法 ⇒ 必须修
- 逐字节
b[i:i+1].decode('utf-8')统计"非法字节数"是错的(所有续接字节都会报错,得 147426 这种荒谬值)⇒ 必须整块 decode。 - 坏字节在开头 ⇒ 检测必走偏;在中间 ⇒ 检测多半仍判 UTF-8(但严格读会抛)⇒ 两种都要修。
处置:字节级删掉那 2 个字节(先备份),再整块 decode('utf-8') 自证。⛔ 不要"按 GBK 重读再写回"——那会把整份文件真的转成 GBK。
防线:日志/笔记追加一律 open(p,'ab').write(blk)(Python 字节级),⛔ 不用 cat X >> Y。
本机 cat >> 有两个独立毛病:① 内容被写到文件开头(同 §10 上文,2026-09-16 实测)② 可能留下非法字节。
⇒ 每次追加后必做两件事:grep -n 核章节在末尾 + 整块 decode('utf-8') 核编码合法。
排查记录:坏字节不是 fix_log.py 造成的(它是纯 rb/wb 字节级搬移 rest + block,不可能新增字节)⇒ 判据 = 先看那个脚本有没有"文本模式读写";纯字节级脚本一律排除。
11. 「去查」索引(原 MEMORY.md §四,2026-09-15 迁入 —— 避免与 CODEBUDDY.md §2 重复占 MEMORY 的 12 KB 额度)
| 要做什么 | 去查 |
|---|---|
| 改权限档位 / 内存配额 / 插件启停 / web provider | 04/33|04/58·74(V8 堆 256→160 反证)|04/16·64·65 |
| 改 UI / 改完「看不到变化」|univer / GLIBC / worker 故障 | 06-工作台UI规范 §7 + 04/82|04/76 §追加 |
| 用户自传个人技能(上传/启停/删) | 04/11·04/41|已上线,UI = 档案 100 |
| 实例内要「AI 生成 docx/xlsx/pptx」 | univer 插件 office-file-generation 技能。⚠️ 技能是显式清单注册 ⇒ 加技能必须同时改 src/host/skills/plugin.ts 的 DEFINITIONS 并重建 lib |
升级 business-plugins |
npm pack(先删 lib/*.bak)→ scp /opt/dsh/artifacts/ → ssh bt-server 'cd /opt/dshs && node scripts/ensure-biz-plugins.cjs --all --restart'。不走候选池 |
| admin 管任意用户的实例 / 文件 | 04/86:/api/admin/users/:id/{fs,dsh}/*(全 requireAdmin)+ 插件「实例管理」页用户切换器 |
| 要给平台加新能力(文件 / UI 类 —— 别先想着自己写) | github.com/web-casa/Awesome-DeepSeek-Harness-Plugins + npm keywords:dsh-plugin。采纳前三关:inject 的官方包存在吗 / 被角色补丁禁了吗(⇒ 静默挂死)/ peerDependencies 覆盖 dsh 版本。✅ 已采纳 @softspark/[email protected](04/89) |
| dsh 版本(现行) | 0.1.5-rc.1(04/90)|升级照档案 07 走(R1)|易错三处:① 自研插件的行由 profile 层单点插入 ② 角色补丁要禁用官方 auto picker ③ 确认目标版本原生预览包是否已下发 |
| 开源导出 / 发版 / 脱敏 | 技能 dsh-opensource-release(16 条事故清单 + 验证六件套)|工作根 E:\ProgramData\AI技能\dsh-laijing-github\(不在受保护根 ⇒ 不需要锁) |
9.2 「某个 UI 分区是谁提供的」—— 三步定位(别再绕 30 次工具调用)
- 只在"活着的 profile"里找(不是官方树、不是备份、不是文档库快照):
grep -l settings.section <profile>/node_modules/@dsh-local/*/lib/client.js - 🔴 中文 UI 文案要同时搜 UTF-8 与
\uXXXX转义 ——portal-entry的 label 就是"\u7528\u6237\u8bbe\u7f6e"(用户设置)⇒ 只搜 UTF-8 会全域 0 命中(2026-09-15 实测绕了十几轮)。 反推转义:python3 -c 'print("用户设置".encode("unicode_escape").decode())'。 - 登记表:
dsh-server-docs/07-实例UI分区登记表.md(id/order/label → 提供者 → 源码 → 可改性)。 ⛔ 改分区必须同步改那张表。
⚠️ 自研 bundle 的源码必须在仓内 poc/<name>/ —— portal-entry 曾只有服务器上的 tgz(源码不在仓)
⇒ 想改只能"从产物反推源码"(2026-09-15 已补入 poc/portal-entry/)。约定:产物只从仓内源码构建。
9.3 🔴 部署平台的两条硬规矩(2026-09-15 实测,两条都能搞出事故)
- 平台部署(2026-09-15 已修根) —— 曾有个大坑:
/opt/dshs/src是集群化之前的旧源码 (server.ts里还是K8sSpawner、git 停在8390eb3),而线上lib/是集群版产物 ⇒ 源码与产物不一致,在那边 build 会把线上编译回旧版。 ✅ 已修:以工作树(不是 HEAD)打包src scripts test web poc package.json tsconfig.json覆盖- 删掉 9 个 K8s 时代遗留(
tcp-bridge/file-service/k8s-user-fs/reconcile/leader/k8s-spawner+ 两个对应 test)⇒ 服务器npm run build通过,且产物 57/57 与本机逐字节一致。 备份在/opt/dsh/backups/dshs-tree-20260915-210559/。 ⇒ 现在的正确姿势(两条都行):(a) 送源码 →cd /opt/dshs && npm run build && systemctl restart dshs; (b) 只送编译产物(本机 build →scp lib/**→ restart),送前 diff 线上lib/xxx.js确认只差本次改动。 ⚠️ 判据工具:_tmp_t01/compare-tree.py <manifest> <工作树根>(逐文件比对"一致/不一致/仅基线有/服务器独有")。
- 删掉 9 个 K8s 时代遗留(
- 同版本号不能覆盖重发 ——
pnpm会按版本复用同名 tgz ⇒ 改完必须升号; 而从未安装过的新版本(例如改了内容但谁都没装过)覆盖同号是安全的(判据:看 profile 里已装版本)。 另:npm pack前先rm -f *.tgz,且每个包都要有.npmignore排除*.tgz(0.2.5 与 0.5.4 两次都把上一版 tgz 打进了产物)。
🔧 定位实例 UI 分区:node scripts/find-ui.mjs [关键词] [--md] [--grep](自研在前、官方在后;
label 会解码 \uXXXX;同时给"能不能改")。别再靠逐个包 grep(2026-09-15 绕了约 30 次)。
12. 会话成本的实测依据(为什么有"会话预算"这条规矩)
- 某会话实测:上下文 5.2 万 → 59.2 万 token,累计 input 1.93 亿 vs output 51.9 万(371 : 1)。
- 其中 33 次缓存失效,每次把 ~50 万 token 按全价重算 ⇒ 缓存失效是单项最大成本(不是"读了多少文件"本身)。
- ⇒ 由此定了三条硬操作:① 少读大文件 ② 单条工具输出 >4K 字符落盘、只回摘要 ③ 同文件禁重复 Read、多条只读检查合并成一条 Bash。
- ⇒ 还有一条最容易被忽略的:常驻注入文件(
CODEBUDDY.md/MEMORY.md/ 技能 / hooks)攒批改 —— 每改一次会让所有活跃会话的缓存失效一次。 - 阈值:~15 万 token 或 ~120 次工具调用 ⇒ 收口开新会话(
stop-dialog-guard.py已自动告警)。
13. 跨 worker 用户迁移(47 → 106,2026-09-16 实测走通)
迁移规则(读代码得到,src/web/routes/admin.ts:205-245):
POST /api/admin/users/:id/dsh/migrate {targetHost} = drain 源机 → 目标机承租约拉起 → 归属 epoch+1。
⚠️ 代码注释原文「数据不搬家」⇒ 该路由只改归属,数据一致性由调用方保证(前提 = 两机 dataRoot 同路径 + 用户数据已同步)。
本平台两机路径完全相同(/var/lib/dshs/users/<uuid>/)⇒ 绝对路径与符号链接(pnpm 的 99 个 fallback 链接)全部直接可用。
🔴 判据:worker agent 在跑 ≠ 该节点能跑实例。 目标机必须查这四件套:
/var/lib/dshs/users的权限 —— 47 是drwx--x--x;若目标机是drwx------⇒setpriv --reuid <uid>无法穿越该路径 ⇒ 实例必崩(现象是"实例起不来",不会报权限错)。修:chmod 711(/var/lib/dshs也应对齐 711)。- dsh 主程序 —— 106 上原本完全没有(
/usr/local/bin/dsh不存在)。装法:npm i -g @deepseek-ai/dsh@<与 47 同版本>(会落到/usr/lib/node_modules,bin 在/usr/bin/dsh),再补/usr/local/bin/dsh符号链接(平台以dsh命令解析;worker env 里也可能有DSHS_DSH_BIN)。 /usr/local/dsh-runtime—— python3.12.x(python-build-standalone,自包含可整体搬)+bin/{jq,rg,ffmpeg,ffprobe};缺失则实例内 python 全废。ffmpeg/ffprobe 各 ~170 MB,可暂缓。- OS 账号 —— 从
DSHS_BASE_UID起;106 没有 provisioner(dsh-provision.path只在 47)⇒ 需手工useradd -u <uid> -M -s /usr/sbin/nologin dsh-<uuid>。
慢链路搬运(47 出口单流仅 ~16 KB/s):
- 并行确实有效:8 路 ≈ 98 KB/s、16 路 ≈ 140 KB/s(瓶颈是单连接 BDP,不是总带宽);
- 本机中转最优:
47 →(8 路并行拉)→ 本机 →(实测 4.2 MB/s)→ 106; - ⚠️ scp 并发 >10 会被 47 的 sshd 拒(
Connection closed);即便降到 6 路,分片仍会静默中断(数量齐、内容缺)⇒ 必须逐片比对大小 + 重拉(脚本已存.workbuddy/tools/relay-transfer.sh,本次第 1 轮补 5 片、第 2 轮归零); - 口径:源机
tar czf - | split -b 8m分片 → 本机xargs -P8 scp拉取并校验 →scp到目标机合并tar xzf→chown -R <uid>:<uid>。
能重建就别搬(省下的都是 47 出口带宽):
- dsh 主程序 → npm 在目标机装(295 MB,本地网络);
- MCN venv → 按
mcn-req-no4.txt在目标机python -m venv+pip install(免传 819 MB); node_modules→ 本次仍走搬运(压缩后仅 89.6 MB)——因为 pnpm 的 fallback 符号链接是绝对路径,而两机路径恰好相同,直接搬最稳、且保证与源机逐字节一致;whitelist-cache(28 MB)不传 —— 只被 Manager 侧src/web/routes/whitelist.ts读,worker 用不到。
验收(本次实测判据):migrate 返回 {"ok":true,"from":"w-47","to":"w-106","epoch":N+1,"port":…};目标机 systemctl list-units 'dsh-*.scope' 出现该 uid 的 scope 且 running;curl 127.0.0.1:<port> 得 401(存活);源机 scope 消失;插件磁盘层版本在位(本次 0.3.23 / 0.5.5)、journal 0 error。
收尾:源目录先 tar czf 到 /opt/dsh/backups/migrated/ 并验证条目数,再删源。
本机(Windows/WorkBuddy)坑:安全删除层同时拦 rm、mv 和 Python shutil.move(trash 失败即 FAIL_CLOSED)⇒ 搬迁本机临时产物只能用 PowerShell Move-Item。
14. 删目录前的引用检查(2026-09-16 事故教训)
事故:在 106 上删掉旧控制面 /root/dsh-users-platform 后,同机的集群 worker 起不来 —— 报
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'fastify' imported from /opt/dshs-cluster/lib/web/server.js。
根因:/opt/dshs-cluster/node_modules 与 /usr/local/dshs-cluster/node_modules 都是符号链接 →
/root/dsh-users-platform/node_modules。删掉被指向的目录 ⇒ 依赖链断裂。而删前的
grep -rl '<path>' /etc/systemd/system/ 只显示"它自己的单元引用自己",看起来没人依赖。
📌 纪律:删任何目录前,两个方向都要查
- 服务引用:
grep -rl '<path>' /etc/systemd/system/ 2>/dev/null - 符号链接引用:
find / -lname '<path>*' -not -path '/proc/*' 2>/dev/null | head⚠️ 第 2 步最容易被跳过 —— 而「依赖目录被符号链接共享」在本项目里是常态 (集群目录的node_modules就链接到/root/dsh-users-platform/node_modules)。
配套动作
- 删前先备份可控数据(本例数据根仅 176 K,备份成本≈0);
- 删后立即验证同机其他服务仍 active —— 本次就是靠
systemctl is-active dshs-worker才发现事故的。
依赖被误删时的修复套路
拿一份完好的 package.json + package-lock.json(从另一台健康节点取)→ 目标机
npm ci --omit=dev --ignore-scripts。
⚠️ 必须 --ignore-scripts:npm ci 默认会跑 prepare(= npm run build),
而纯运行目录通常没有源码 ⇒ 会中断,且只装了一半(表现为"装了 109 个包但仍报缺包")。
15. 后台任务「迟到通知」的处置(2026-09-16 实例)
现象:一个早已被 pkill 杀掉的后台搬运任务(_relay.sh 版),在 2 小时 9 分后才回报 completed,
并附上一份"看起来像它跑完了"的输出。
为什么不用慌:看它的输出就能自证没造成损害 ——
Read from remote host … Connection reset by peer(我杀它导致的)→ 本机: 0 片 0 字节(源分片已被移走)
→ 上传106: 0 片 → 最后那条 ls 列出的目标目录完好如初。
📌 纪律:收到后台任务的迟到 completed,不要直接采信"它做完了",先按三步核实副作用:
- 读输出里实际计数(拉了几片 / 传了几片 / 耗时);
- 对账目标目录(两侧
ls -l逐项比大小); - 确认无残留进程/临时文件、且同机服务仍 active。
⚠️ 典型教训:这类任务若在"源分片已被移走"后才继续跑,scp 会因通配符无匹配而失败 ⇒ 反而不会破坏目标;
但若源分片仍在,它就会把不完整的分片解包覆盖到目标 ⇒ 所以核实动作不能省。
16. 🔴 碰新 HTTPS git 主机会弹「授权窗」= 那是 git 凭据管理器(GCM),不是 WorkBuddy 闸门(2026-09-18 实测)
现象:对新的 HTTPS 远端(例:git ls-remote https://cnb.cool/...)执行 git 命令后,桌面弹出登录/授权窗,
命令无任何输出(挂着等凭据);用户看到会以为"平台又要我授权",质问「不是已经授权了吗」。
真因(本机 ~/.gitconfig 实录):
[credential] helper = !"…/mingw64/bin/git-credential-manager.exe"⇒ 全局挂 GCM;[credential "https://work.alotbuy.com"] provider = generic⇒ 只为这一台主机配过 provider;[http]/[https] proxy = http://127.0.0.1:10800。
⇒ work.alotbuy.com 一直走 SSH([email protected]:…)⇒ 用 SSH key、根本不进 GCM、从不弹窗;
而 cnb.cool 走 HTTPS、又是没配过凭据的新主机 ⇒ GCM 必弹。"授权过 SSH key" ≠ "所有 git 主机都授权了"。
判据(区分"平台拦了"还是"git 在要凭据"):
E:\ProgramData\.workbuddy\audit-log\spool\audit-spool-<pid>-<date>.jsonl(⚠️ 当日未 flush,只在这里;正式分片 YYYY-MM-DD.N.jsonl 会滞后)
—— 若该命令有 command-safety.sandbox-executed | allowed 记录 ⇒ 平台已放行,弹窗来自 git 自己。
(对照:今天 00:04 那条 file-safety.bulk-delete.approved 才是平台弹的,且只对那一条命令生效,不具传染性。)
修法:① 新主机优先用 SSH;② 一次性命令显式关掉 GCM ⇒ 加 -c credential.helper= + GIT_TERMINAL_PROMPT=0;
③ 必须用 HTTPS ⇒ 先给 [credential "<host>"] 配 provider / 存一次 token,把交互收敛成一次性。
⛔ 别用 git config --global credential.helper "" 全局关 —— 会连带打断 work.alotbuy.com 的现有链路。
✅ 本轮已修:给 cnb.cool 补 credential.https://cnb.cool.provider = generic + git credential approve 存入 GCM
⇒ 之后 ls-remote 不再弹窗(实测不带任何显式凭据也可通过)。
⚠️ 反向坑:凭据已入库后 ⛔ 不要再写 git -c credential.helper= —— 它把"已存好的凭据"也一起禁掉,
报 could not read Username for 'https://cnb.cool'。该参数只在"绕开 GCM、显式传 token"时才用。
17. 🔴 rm -rf / shutil.rmtree 超过 50 文件被闸门拦住时,脚本不会停 —— 会静默产出「假绿」(2026-09-18 实测)
现象:脚本里"先删旧目录、再重建",执行后脚本继续往下跑,只有一行
[safe-delete][SAFE_DELETE_BULK_CONFIRM_REQUIRED] {"count":474,"threshold":50,...}
—— 看起来像警告,其实删除根本没执行。
后果链(本轮真实发生过一次):
删除被拦 ⇒ 目标目录还在 ⇒ os.makedirs(..., exist_ok=True) 使老文件原封不动保留(或 makedirs 直接抛错 ⇒ 复制整段被跳过)
⇒ rm -rf .git 同样被拦 ⇒ git init 变成 re-init(会提示 reusing existing repository)
⇒ 新提交叠在旧内容之上 ⇒ 产出「commit message 写着"原样",内容其实还是上一版」的假绿。
判据:脚本里凡有 rmtree / rm -rf,执行后必须复检目录是否真没了([ -d "$D" ] && echo 仍在);
⛔ 不能以"命令没报错"推定删除成功。
解法(首选):换一个全新的目录名,别删。删除只在"确实要回收空间"时单独做一步、单独复检。
同理(同一类误判):TaskStop 报 killed 不代表撤销了已发出的网络动作 —— 本轮后台任务被 kill 之后,
远端 sha 依然从 8e88cd2 变成 d83ed62 ⇒ 终止后必须重新核对远端状态(git ls-remote 取裸 sha),别只看 kill 回执。
18. 🔴 实例子域 500 host "w-NN" 声明 via=relay 但解析不出落点(2026-09-19 实测 · 观测面绿而控制面红)
现象:某用户打开 <user>.<baseDomain> ⇒ HTTP 500,正文
{"statusCode":500,"error":"Internal Server Error","message":"host \"w-106\" 声明 via=relay 但解析不出落点:拒绝回落到 endpoint (relay 语义下 endpoint 是落点,回落会打到本机同号端口)"}。
⛔ 不是域名问题、不是 nginx 问题、不是实例挂了 —— 报障时别往那三个方向查。
定位(三步,别绕):
- 看是哪台 host:正文里的
host "w-106"即dsh_hosts.id;dsh_instances.host_id可查"谁归属哪台" (select u.username, i.host_id, i.status from users u left join dsh_instances i on i.user_id=u.id)。 - 看 manager 读的那份
/status里该 host 的端点是不是online:false:读DSHS_RELAY_STATUS_URL(本平台 =http://127.0.0.1:20080/status= 47 中继)⇒endpoints[].online。 ⚠️ 它online:false有两种可能,必须分开:(a) 该 worker 真的离线;(b) 它挂到另一台中继上去了(online是中继本地的健康判定,不是全局事实)。 - 拿另一台中继的
/status对照(106:ssh test106 'curl -s http://127.0.0.1:20080/status')⇒ 看online[]与sessions[]。 实测形态:47 说w-106 … online:false(陈旧条目),106 说online: ["w-106(session=… ports=19000/21001 … hbAge=…ms …)"]⇒ worker 活得很好,只是挂在 106 那台。
根因(文件级):lib/net/relay/rendezvous.js#RelayRendezvous.resolve()
= const pushed = opts.presence?.(name); const online = pushed ?? opts.online?.(name); if (online === false) return undefined;
⇒ online 只来自单一中继的状态快照;undefined ⇒ lib/net/reachability.js#agentBaseUrlOf() 按设计抛错
(via===relay 时不许回落 agentUrl:那会打到控制面本机同号端口,属 P0-3 刻意失败)。
解法(按代价从小到大):
- ① 让 worker 回到 manager 正在读的那台中继上(= 两机
SEEDS的主入口必须落在同一台):DSHS_OVERLAY_BOOTSTRAP_SEEDS的首位决定主入口(参数表语义「主入口首位」)。 ⚠️ 106 的 worker 曾因候选链首位是自己的裸 IP 中继而长期不注册到 47 ⇒ 表现为"实例面 500 但探针全绿"。 - ② 修代码:可达性/在线态改为「两台中继并集」 —— 与
scripts/overlay-probe.cjs序㊾ 的并集口径同源 (eps键 =network:hostId:port、online取或、used按network/hostId去重)。 ⛔ 这是独立立项(改web/server.js的online/presence提供者),别顺手改。
配置坑(同族 · 2026-09-19 踩过):DSHS_OVERLAY_BOOTSTRAP_SEEDS 的唯一载体 = drop-in
(/etc/systemd/system/dshs.service.d/overlay-443fb.conf)。
🔴 在 /etc/dshs.env 里写同名键会静默压掉 drop-in(EnvironmentFile 后应用)⇒ 入口列表被窄化、
丢掉第二中继 ⇒ 直接诱发本节的 500。⛔ 改这个键只改 drop-in;改完用
tr '\0' '\n' < /proc/$(systemctl show dshs -p MainPID --value)/environ | grep SEEDS 复核进程实际值。
判据(可机读):journalctl -u dshs | grep -c "解析不出落点" 的首现时间 = 该 500 的真实起点;
[relay-client] registered host=w-NN network=ops session=… + [overlay-candidates] … urls= 的首位 是修复是否生效的直接证据。
⚠️ 本条最值钱的教训:"探针全绿" ≠ "实例面可用" —— 序㊾ 让探针按两台中继并集看,所以 worker 漂到哪台它都绿;
而控制面仍只读一台。凡"用户报某个域/某台机器打不开"而探针全绿 ⇒ 先查 dsh_hosts.via 与归哪台,别信探针的绿。
19. 🔴 报「实例子域 502」先查这一条:平台到底有没有回响应(2026-09-19 实测)
现象:用户打开 <user>.<baseDomain> 得到 502(浏览器一张无信息错误页),
而门户正常、平台进程正常、实例进程也正常。别往 nginx 配置 / 域名迁移 / 实例挂了这三个方向查。
判据(两条,10 秒定案):
- nginx error log 里该请求是
upstream prematurely closed connection while reading response header—— ⛔ 不是connect() failed (111)、也不是no live upstreams; - 平台 journal 里那条请求只有
incoming request、没有request completed⇒ 平台接了请求却没写出任何响应(Fastify 的 completed 日志不打 = 响应从未发出)。
根因(档案 141):src/supervisor/proxy.ts#proxyHttp() 在转发重试两次都失败时
reply.raw.destroy() —— 不写响应头、不写 body,直接断连。
触发场景 = 实例冷启动窗口:/api/dsh/enter 已返回、scope 才建立 10 余秒,
实例进程已在、端口已分配,但 dsh 还没开始监听;
而 resolveSubdomainAccess() 走 supervisor.endpointFor()(不读 dsh_instances.status)⇒ 拿得到 endpoint
⇒ 进转发分支,不会落到档案 49 的 404 not_running → 302 /wake.html 过渡页分支。
修复:新增 replyUpstreamUnavailable() —— 响应头未发出时回 503 + Retry-After: 2
(导航请求给一页会自动 location.reload() 的极简 HTML;其余回 JSON instance_starting),
只有已 headersSent/writableEnded 才允许 destroy。
回滚:/opt/dsh/backups/proxy.js.pre141-20260919-161444 → systemctl restart dshs。
⚠️ 两个别踩:
- 🔴
dsh_instances.status可能长期停在stopped且pid/port为空,而 scope 一直在跑 —— 它是控制面视图,endpointFor()不读它,⛔ 别拿它判"实例到底在不在跑"(也别据此判"平台会重复拉起")。 - ⚠️ 该 502 只在冷启动窗口内,实例就绪后自行恢复(
curl 127.0.0.1:<实例端口>会回 401)。 复现差异大:事后同机curl -H "Host: <user>.<baseDomain>"打 3080 只会拿到 401, 复现不出 502 —— 别因"我打不开复现"就否定结论,判据看上面两条。
20. 🔴 插件「点了没反应」先查槽位名这类外部契约(2026-09-20 实测 · MCN工作台 + 语言下拉)
症状:实例里点左侧「MCN工作台」毫无反应;设置 → 用户设置里切「界面语言」看着也没反应。 同一根因类别:dsh 升级改了插件依赖的外部契约,插件里写死的老契约静默失效 —— 不是「改不生效」,也不是用户操作问题。
| 症状 | 真因(dsh 0.1.5-rc.1 实测) | 10 秒判据 |
|---|---|---|
| 工作台点了不出现 | 对话区槽位名 conversation → main.conversation ⇒ querySelector('[data-slot="conversation"]') 恒 null ⇒ 面板宿主一直 null、createPortal 不渲染 |
!!document.querySelector('[data-mcn-panel]') = false;页面 data-slot 列表里只有 main.conversation |
| 切语言「没反应」 | ctx.locale.getLocale() 返回快照对象 {active,locales,revision}(老版返回 id 字符串)⇒ String(obj) = "[object Object]" ⇒ 受控 <select> 匹配不上任何 option,浏览器只显示第一项 English |
document.documentElement.lang 与 select 的 value 不一致(界面中文 / 下拉 English)= 命中 |
- ⚠️ 别按「界面语言对不对」判:它可能是对的,错的是下拉显示 —— 选「中文」时本就已是中文 ⇒ 看着毫无变化。
- 🔧 浏览器实测姿势:
mksess.cjs造 admin 临时会话 →agent-browser:set headers注 Cookie 无效,改用open https://ai1net.com/login.html+eval document.cookie='sid=…; domain=.ai1net.com; path=/'→ 再open实例子域。 ⚠️agent-browser前台会被沙箱 SIGTERM;用run_in_background起一次(守护进程常驻)后,后续命令加> 文件 2>&1(不走管道)即可前台跑。用完删会话(user_agent='poc-curl2')。 - 🔧 重打包前先做「新产物 vs 线上在跑的那份」
diff:本次正是靠它抓到 3 处「一构建就负向回归」 (已摘除的计划任务被装回 / 死链「自动任务」菜单项复活 / 1.9 MB 历史 tgz 被打进包)。详见 档案04-调整方案/143-MCN工作台入口失效与语言切换显示修复.md。 - 🔧 手动跑平台脚本(
ensure-*.cjs)必须带运行环境:/etc/dshs.env+dshs.service.d/*.conf的Environment=(缺DSH_PLATFORM_DIR⇒artifactDir()落到/root/.dshs/platform/artifacts);模板见.workbuddy/tmp/run-with-platform-env.sh。
21. 🔴 浏览器侧「注入 UI」验收的两个假阴性(2026-09-21 实测 · 差点误报成产品缺陷)
场景:改
assets/inject/recovery.js(浮层)后要证明"动画真的在跑、观感对了"。两条都会让正确的实现看起来是坏的。
21.1 --virtual-time-budget 会冻结 CSS 动画时钟 ⇒ 假"动画卡死"
- 现象:
chrome --headless=new --virtual-time-budget=6000 --dump-dom采样计算样式,transform恒为起始帧(__dshr-in的 0% =scale(.8)),在 150/400/900/1800/3200ms 一模一样 ⇒ 看着像动画不动。 - 判据(10 秒分真假):读 Web Animations API ——
document.querySelector('.__dsh-hub-icon').getAnimations()[0]的currentTime/playState:currentTime恒 0 且playState = running⇒ 时钟被冻结,是夹具假象(本轮命中);playState = idle / paused或getAnimations()为空 ⇒ 才是动画真坏了。
- 加
--run-all-compositor-stages-before-draw无效,假象照旧。 - ✅ 正解 = 拿真时钟证据:本机
playwright / selenium / websockets / pyppeteer一个都没装,但 Node 22 自带全局WebSocket⇒ 直接走 CDP: 起chrome --headless=new --remote-debugging-port=9225 --user-data-dir=<临时>→GET /json/list取 page 的webSocketDebuggerUrl→Page.enable/Runtime.enable/Page.navigate→Runtime.evaluate({awaitPromise:true})里用真setTimeout采样 →Page.captureScreenshot抓帧。脚本:tmp/anim_final.cjs。 - 加餐(比真时钟更硬):
anim.pause()后手设currentTime扫关键帧 ⇒ 零时钟依赖的确定性验证。 本轮实测:ct=0 → 0.8,ct≈367ms → 1.05(60% 过冲),ct=633ms → finished / 1.0,opacity全程1。
21.2 抠底图配 box-shadow ⇒ 又长出一圈方形光晕
box-shadow沿元素矩形边界绘制;filter: drop-shadow()跟随 alpha 轮廓。- ⇒ 透明底 / 抠底图做"发光"只能
drop-shadow;用box-shadow会在图外画出一圈方框,观感 = "图外面又套了个矩形"。 - 自查:
getComputedStyle(img).boxShadow应为none。 - 同类:同一元素上两个 CSS 动画抢同一属性 ⇒ 后声明的覆盖前者(曾致
opacity=0、图已加载却看不见)⇒ 原则是一个元素只挂一个动画。
21.3 静态资源取图通路(浮层/注入 UI 通用)
- 实例子域只放行
/favicon.svg一个路径,其余静态资源一律 401 ⇒ 注入 UI 不能用子域相对路径取图。 - 改取门户根域(
https://ai1net.com/<asset>= 200,无 CORP / 无 CORS 限制 ⇒ 跨域<img>可加载), 根域由location.hostname去首段推出(IP 或两级以内域名原样用),配onerror兜底。 - ⚠️ 取图通路必须显式验:
curl -A "<浏览器 UA>" -o 文件 -w "%{http_code}|%{content_type}|%{size_download}", 再比 md5 与本地一致(本机urllib会被 UA 拦成 403)。
21.4 本机工具路径坑(本轮定位 · 会让脚本静默整段无输出)
- PortableGit
1.2.0的mingw64/bin里没有curl.exe(usr/bin也没有) ⇒ 照抄旧脚本的路径调 curl ⇒subprocess抛FileNotFoundError(未捕获)⇒ 该步之后全静默,看着像"没输出/没命中"。 - ✅ 本机可用:
C:\Windows\System32\curl.exe、D:\Program Files\Git\mingw64\bin\curl.exe;ssh/scp 用 PortableGitusr/bin。 - ⚠️
scp的端口是大写-P(小写-p是保时间戳,会静默什么也不传)。 - 🔴
GIT_SSH_COMMAND在 Windows 上必须用正斜杠:传E:\...\ssh.exe(反斜杠)⇒ git 转手时反斜杠被吃掉 ⇒ 先报E:ProgramData...ssh.exe: command not found,紧接着报fatal: Could not read from remote repository.+Please make sure you have the correct access rights and the repository exists.—— 这句极具误导性(看着像密钥/权限/仓库不存在,实为路径被吃)。 ✅ 正解:别设GIT_SSH_COMMAND,靠 PATH 里的 PortableGitusr/bin/ssh.exe即可(裸git ls-remote也是这么通的)。 - 🔴 无人值守会话里 PATH 可能只剩「壳」 —— 实测(2026-09-21 automation 轮)本机 bash 首条命令就报
shell-runtime-bash-env.sh: line 3: dirname: command not found+bash.exe: line 1: head: command not found,| head/$(dirname …)全废、脚本看着像"无输出"。✅ 每条命令首行前置即可(与state.py用的同一套):export PATH="/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/bin:$PATH"⛔ 不要改前置/c/Windows/System32(那里的bash是 WSL 启动器 ⇒ 撞 Program Blacklist,整条命令被拦)。 - 🔴 批量字符串替换时,
python - << 'PYEOF'内联的old极易 MISS(实测 2026-09-21): 路径、含单引号/反斜杠/中文引号的old一旦与文件里的字节不完全一致,str.replace静默什么都不做, 脚本照样print成功 ⇒ 看着像"改了却没生效"(像 bug,实为 MISS)。 ✅ 判据:每次都打印替换前后的字符数变化(len(t)前后对比),长度没变就是 MISS; ✅ 更稳的做法:先把替换逻辑写成.py文件再跑(用 Write 工具写,避免 shell 转义),并assert old in t。 - 🔴 改
MEMORY.md前先数字符数(实测 2026-09-21):项目MEMORY.md有 ≈7,800 字符注入上限, 超出的部分不会被注入(=等于不存在)。本轮因新增一行涨到 ≈8,340 已超, 不得不回头压缩四处旧行才降下来。✅ 铁律:只减不增;要加内容先想好"哪一段下沉到04/<NN>"。
21.5 提交/推送前必做(⛔ 防"把别人的在途活一起提交")
- 🔴 绝不用
git add -A/git add .。本仓常态是「多线并行、工作树常驻 30+ 已改文件 + 60+ 未跟踪」, 全量 add 会把别条线的在途改动一起卷进你的提交。 - ✅ 顺序:① 逐个文件
git diff -- <path>确认归属 → ② 用显式路径git add -- <p1> <p2> …→ ③git status --short -- <路径>复核暂存清单 → ④ 提交。 - ✅ 推前分叉判定(比
git log可靠):git ls-remote <url> refs/heads/master取裸 sha, 与本地提交前的基线比;相等 ⇒ 纯快进可推;不等 ⇒ 先fetch比对,⛔ 别强推。 再用git merge-base --is-ancestor <基线> HEAD(rc=0)交叉验证。 - ✅ 推后复核:两端裸 sha 应 = 本地 HEAD(三方一致)才算闭环;只报「push 成功」不算。
- ⚠️ 本仓
origin配了两条 pushurl(work.alotbuy.comSSH +cnb.coolHTTPS)⇒ 一次push即双推。 CNB 令牌会过期,401 会被 git 泛化成「仓库不存在 / 权限不足」 ⇒ ⛔ 别据此改远端 URL,先查 helper。
21.6 🔴「这三类禁止入库」+判定它的两个 git 语义坑(2026-09-21)
禁令(用户明令):tmp/ · 交接单/ · 中间产物 —— 禁止提交。已由根 .gitignore 机械兜住:
/tmp/、/_tmp*/、/_中间产物*/、dsh-server-docs/交接单/(⛔ 一律根锚定,否则会误吞 dsh-server-docs/archive/_tmp_r6_s8.md 这类既有跟踪文件)。
🔴 坑 1:git check-ignore -v <目录带斜杠> 会假阳性。
实测 git check-ignore -v tmp/ → rc=0,且指向 .gitignore 的某一行(当时指向第 39 行,而那是个空行,文件里根本没有 tmp/ 规则)。
✅ 判据一律用「目录内的文件路径」:git check-ignore -v tmp/x.txt;并与 git status --porcelain、
git ls-files -o --exclude-standard 交叉验证(这两条才是权威口径)。
🔴 坑 2:status / ls-files 输出里的中文路径会被 C-quoted。
"_\\344\\270\\255\\351\\227\\264\\344\\272\\247\\347\\211\\251_..." ⇒ 用中文字面量做 in 过滤会全部漏掉(表现为"没有未跟踪的交接单",其实是过滤失效)。
✅ 判据:输出里看到 "\\3xx 形式的转义 ⇒ 说明路径非 ASCII 被引号包起来了,改用转义后的形式匹配,或干脆只数总数、别按字面量筛。
🔴 坑 3:改 .gitignore 前先确认它的行尾。 本仓根 .gitignore 是 CRLF(dsh-server-docs/.gitignore 是 LF)。
按 LF 追加 ⇒ 混合行尾(露馅信号:追加后 CR 计数没变)。✅ 复核判据:CR 数 == 总行数 才算全 CRLF。
⚠️ 被忽略的预期副作用:这些路径从此不再出现在 git status ⇒ 别把它当"没生成"。已跟踪文件(如 交接单/README.md)不受影响,仍可正常更新提交。
§21.4 补 · INDEX.md 类台账的字节级单行插入:三处断言纪律(2026-09-21 实测)
场景:往 dsh-server-docs/INDEX.md 这类按行组织的大台账里插一行(档案登记)。
⚠️ 第一条坑:CR == CRLF 配对数 这个断言是错的(本次连中两次):
写插入脚本时想当然断言 out.count(b'\r\n') == cr_before。实测 INDEX.md:
bytes 62292 CR 5 LF 273 CRLF 0 ← CR=5 但 CRLF=0
⇒ 5 个 CR 全是孤立 CR(不是 CRLF,是历史残渣)。既然 CRLF 本来就是 0,任何「CR==CRLF」的断言必炸。
✅ 正确断言:cr_after == cr_before + lf_after == lf_before + 1 + out.count(b'\r\n') == b.count(b'\r\n')(配对数不变)。
⚠️ 第二条坑:孤立 CR 的绝对偏移必然右移,⛔ 不能断言位置相等:
那 5 个孤立 CR 位于插入点之后(byte 55550 > 148 行所在处)⇒ 插一行后整体右移。
断言 lone_after == lone_before 必炸。✅ 正确断言(三选一,本次用前两条):
assert len(lone_after) == len(lone_before) # 数量不变
gap = [x - lone_after[0] for x in lone_after] # 相对间距不变
assert gap == [x - lone_before[0] for x in lone_before]
shift = lone_after[0] - lone_before[0] # 右移量 == 新行字节数 + 1
assert shift == len(new_line.encode('utf-8')) + 1
右移量那一条最有价值:它同时验证了「新行确实被插进了文件」(若插失败,shift 会是 0)。
🔴 第三条(教训):assert 报的是「我的期望错了」还是「文件坏了」,必须分清。
本次两次 AssertionError 都是我的断言写错,文件本身是对的。
⛔ 不要一报错就 git checkout -- 或改写文件 —— 先 print 实际值,判明是期望错还是数据坏。
✅ 先跑只读诊断(数 CR/LF/CRLF、用 re.finditer(rb'\r(?!\n)') 定位孤立 CR 并打印上下文),再决定改断言还是改文件。
顺带发现(未处理):INDEX.md byte 55550 起有 5 个连写孤立 \r(\r\r\r\r\r),
夹在「2026-09-14 完成 |」与「|T09–T21」之间。docs-audit.py 只数 CR 总数、不区分孤立/配对 ⇒ 未报此问题。
⇒ 审计脚本有盲区:CR 计数正常不代表行尾干净。属「只做被明确要求的事」,未擅自修。
§21.5 · 改「表格类」文件时 Edit 的两个静默破坏(2026-09-21 实测)
背景:给 MEMORY.md 压字数,连续用 Edit 替换整行。两次出问题,都不报错、都静默。
坑 1 —— 把行尾的换行带进 old_string,两行黏成一行。
new_string 以 | 结尾(表格行),若把上一行的 old_string 尾写成 …|\n| 而 new_string 尾只写 …|,
则分隔两行的 \n| 被吃掉 ⇒ 这一行与下一行黏成一行、表格行数少 1。
MEMORY.md 因此从 49 行 变 48 行(字符总数只少了几个,看总数发现不了)。
⇒ 🔴 判据:改表格类文件后,必须核对「行数」与「每行长度表」,⛔ 不能只看字符总数。 行数掉 1 而字符数只微降 = 黏行的典型指纹。
坑 2 —— old_string 结尾跨到下一个 ## 标题,把标题删掉。
为省事把 old_string 一路写到紧邻的下一个标题处,new_string 却没带上该标题 ⇒ 标题消失。
MEMORY.md 的 ## 三、成本与自动化 就这样丢过一次。
⇒ 🔴 判据:old_string 的结尾不要跨过任何 ## 标题;要跨小节就分两次 Edit。
共性:两者都是局部成功、整体破坏 —— 工具返回 Successfully edited,文件也能正常读,
只有结构级校验(数行数、比对标题清单、比对表格行数)才发现。
⇒ ✅ 定式:改结构性文件 → 收尾必须跑一次「结构校验」(行数 + 标题清单 + 每行长度),
脚本量完即删,⛔ 别省这一步。
§21.6 · git status | grep <中文路径> 恒为空 —— 别据此判「文件没生成/被忽略」(2026-09-21)
现象:新建 dsh-server-docs/架构设计/README.md 后,
git status --short | grep 架构设计 返回 0 行;git check-ignore -v 也 rc=1(未忽略)。
看起来像「文件根本没被 git 看到」。实际文件是好的。
根因:git 默认 core.quotepath=true,把非 ASCII 路径转义成八进制输出
("dsh-server-docs/\346\236\266\346\236\204\350\256\276\350\256\241/..."),
所以 grep 中文 永远匹配不到。⚠️ 与本仓 grep/ls 无关,纯粹是输出转义。
✅ 正解:git -c core.quotepath=false status --short --untracked-files=all | grep <中文>
判据(别搞反):
- ① 中文路径一律加
-c core.quotepath=false再看;⛔ 别用「grep 没命中」当判据。 - ② 新增目录首次要
--untracked-files=all,否则只显示目录名、看不到里面文件。 - ③ 想确认「有没有被忽略」用
git check-ignore -v <path>,退出码 1 = 未忽略(不是出错)。 - ④ 与 §21.4 同源:「我 grep 不到」≠「东西不存在」 —— 先分辨是「工具口径」还是「真没有」。
22. 🔴 共享只读层(bundled-*)落地与验收的四个坑(2026-09-22 实测 · 插件投放与分库线执行棒①)
22.1 部署真源 = lib/,⛔ 不是 /opt/dshs 的 git HEAD
- 47:
ExecStart=/usr/local/bin/node lib/cli.js(WorkingDirectory=/opt/dshs);106:/opt/dshs-cluster/lib/cli.js。 - ⚠️
git -C /opt/dshs log -1是噪声:曾在8390eb32(main) 而src/全量显示 modified —— 那棵树是 scp 铺出来的,与本地仓3d8f50e(master) 完全不同代。 - ✅ 正确判据 = 与本地构建产物比字节(跨平台先
tr -d '\r'再 md5):开工时抽 4 个未改文件比,全等 ⇒ 部署形态 =npm run build+scp lib/**+systemctl restart dshs。 - ⛔ 别 scp
src/上去再在服务器 build —— 服务器的src/是另一代,会混出不一致产物。
22.2 ⛔ 两节点 lib/ 会静默分叉 ⇒ 部署后必须比一次全量
实测:106 /opt/dshs-cluster/lib 缺 14 个 .js、33 个共有文件内容不同(逐次 scp 拼出来的旧树)。
后果:新代码 config.js import './platform-paths.js',而 106 没这个文件 ⇒ dshs-worker 崩溃循环(ERR_MODULE_NOT_FOUND,restart counter 9+)。47 正常不代表 106 正常。
必修姿势:
本地:tar czf /tmp/lib-sync.tgz lib
远端:cd <部署根> && tar xzf … && chown -R root:root lib && systemctl restart <unit>
对账:find lib -type f | 逐文件 tr -d '\r' | md5sum → 两节点 diff 行数 = 0(`*.bak-*` 历史残留除外)
⚠️ 判据:systemctl is-active 看一次不算 —— 崩溃循环期间它会短暂显示 activating;必须 journalctl -u <unit> --since @<epoch> | tail 看有没有 ERR_MODULE_NOT_FOUND / status=1/FAILURE。
22.3 🔴 沙箱内验收:nsenter 挑错 PID = 读的是宿主(假阳性)
实测坑:nsenter -t $(pgrep -f '^/usr/bin/bwrap' | head -1) -m 取的是宿主命名空间 —— bwrap 会留一个父监控进程在外面,只有它的子进程在沙箱里。
用错 PID 时:ls /var/lib/dshs/bundled-plugins 能看到东西(那是宿主同名路径!)、touch 报 Permission denied(宿主权限,而非 Read-only file system)⇒ 看起来"验证通过",其实什么都没验。
正确姿势:
N=$(ps -eo pid,comm,args --no-headers | awk '$2=="node" && /dsh --profile/ {print $1}' | head -1)
grep -E "bundled-(skills|plugins)" /proc/$N/mountinfo # ← 前提:必须能看到既有 bundled-skills 那一条,否则 PID 还是错的
nsenter -t $N -m -- setpriv --reuid <uid> --regid <uid> --clear-groups -- /bin/ls -l <路径>
nsenter -t $N -m -- setpriv --reuid <uid> --regid <uid> --clear-groups -- /bin/sh -c "touch <路径>/x" # 期望 Read-only file system
判据三条(缺一不可):① /proc/<pid>/mountinfo 里目标路径带 ro,nosuid,nodev ② 实例内能看到内容 ③ 实例内写 = Read-only file system(不是 Permission denied)。
⚠️ R10:对用户实例的一切验证必须 setpriv 到该 uid。
22.4 共享层物化(pnpm install 到只读共享目录)的四个必修
| # | 坑 | 现象 | 修法 |
|---|---|---|---|
| 1 | peer 自动安装 | ERR_PNPM_NO_MATCHING_VERSION @deepseek-ai/dsh-client-runtime@^0.1.2-rc.1(公共源只有 0.0.1-rc.1 / next 0.1.1-rc.2) |
dsh 插件的 @deepseek-ai/dsh-client-* 是 optional peerDependencies(宿主提供)⇒ 加 --config.auto-install-peers=false。⛔ 别去掉 |
| 2 | scoped 包名路由 404 | POST /api/plugins/business/@scope/name/share → 404 Route … not found(Fastify :id 不吃 /) |
候选池 4 个里 3 个带 scope ⇒ 必须再加一条 body 形态({id});既有 DELETE …/:id 有同一限制 |
| 3 | tar 以 root 解包按归档 uid 还原属主 | 共享层里出现 197108:197121(开发机 uid)—— 若该 uid 本机存在就能改写共享只读层 |
解包后强制 chown -R root:root <dest>;验收 `find -not -user root |
| 4 | 回滚素材对存量包缺失 | 首次 share 回 backupTgz:""(上传钩子的备份只对新上传生效) |
在 share 时兜底补落一份 tgz 到备份面(幂等:已存在则跳过) |
22.5 🔴 候选池记录字段不可信,版本只认内容指纹(2026-09-22 实测)
现象:dsh-plugin-mcn-suite 三处读数互相矛盾 ——
| 来源 | 读数 |
|---|---|
business_plugins 记录 |
version=0.3.9 · file_size=1953267 · updated_at=2026-09-13 00:02:16 |
| 池内 tgz(磁盘) | 0.3.13(包内 package/package.json)· 1989680 B · mtime=2026-09-20 22:13:45 |
共享层 .manifest.json |
version:"0.3.9" · tgzSha256=d14cd5fe… ← sha 与磁盘那份相同 ⇒ 同一份文件 |
| 备份面 | /opt/dsh/backups/plugins/dsh-plugin-mcn-suite/**0.3.9**.tgz(sha 同上面)⇒ 文件名带错版本号 |
根因判据:记录 updated_at(09-13)≠ 文件 mtime(09-20)⇒ 该 tgz 在 09-20 被平台之外的方式(直接 cp/scp)替换过,记录从未同步;平台没有"文件被外部替换"检测(无 sha 台账)。
对照:@dsh-local/storyforge 记录 0.1.0 / 1754963 与磁盘逐项一致 ⇒ 走上传 API 的路径本身没问题。
由此得出的硬判据(写更新/回滚逻辑时必守):
- 判「有没有新版本」= tgz 内容 sha256 或包内
version,⛔ 不用池记录字段; - 上传钩子须回读校验(记录 version == 包内 version),不一致 ⇒ 写审计告警;
- B 档回滚素材文件名改用
<包内版本>-<sha256 前 8 位>.tgz—— 现名(0.3.9.tgz)会让"取上一版"取错; - 凡"平台外可写"的目录(候选池、共享层、备份面)都要有 sha 台账 + 定期对账,否则指纹与标签会静默脱钩。
22.6 本机 bash 包装器 PATH:export 有效,且必须每条命令前置
⚠️ 修正一条旧口径:工作区 MEMORY 曾写「若包装器整体起不来(ls/cat 也 not found)⇒ export 救不回,改走 PowerShell」—— 不成立。
2026-09-22 复测:确实出现了 dirname / head / grep / ls / which: command not found(shell-runtime-bash-env.sh: line 3: dirname: command not found + bash.exe: line 1: head: command not found),而
export PATH="/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/bin:$PATH"
一次就恢复(dir/head/grep/sed/ssh 全部可用)。
🔴 关键补充:工具每次调用不共享 shell 状态 ⇒ 这个 export 必须前置在每一条 bash 命令里,否则下一条又会 127。PowerShell 只在 export 也救不回时才需要(本机至今未复现该情形)。
只读共享层的正确布局(B 档 D-i = 只留最新一版):
- 目录 = 扁平化包名(
sanitizeFileName,@scope/name→_scope_name),⛔ 路径里不带版本号 —— 带版本号则每次替换都让用户 profile 里写死的file:悬空。 file:引用 =file:<bundledRoot>/<flatName>(pnpm 对目录型file:建软链 ⇒ 实体一份、链接多份,天然满足"不复制")。- 版本/哈希/备份路径落在
<bundledRoot>/.manifest.json;pnpm的 store 指到共享层内部(--config.store-dir=<bundledRoot>/.store)⇒ 同一文件系统、硬链成立、不依赖任何用户 HOME。 - 替换用
rename换目录(先.trash-*再进),保证引用路径不变。
22.7 🔴 PG CREATE DATABASE 不能在函数内执行 —— 「SECURITY DEFINER 建库」这条路根本不存在(2026-09-23 实测)
结论:PG 对 CREATE DATABASE 有两层独立禁止,SECURITY DEFINER 也绕不过:
ERROR: CREATE DATABASE cannot run inside a transaction block (PL/pgSQL 函数体恒在事务内)
ERROR: CREATE DATABASE cannot be executed from a function (PG 显式禁止从函数发起)
实测对照(同一私有 schema、同一 SECURITY DEFINER 姿势,只换语句):
| 函数内语句 | 结果 |
|---|---|
CREATE SCHEMA |
✅ 成功 |
CREATE ROLE |
✅ 成功 |
CREATE DATABASE |
❌ 内核拒绝 |
⇒ 凡"想靠 DB 侧函数封住建库权限、又不想给角色 CREATEDB"的方案,在 PG 上不可实现,不要在方案阶段就写进设计(本例已因此白做一版 Runbook)。可行路径只剩:① 给角色 CREATEDB(权限扩大,白名单只能靠代码自觉)② 平台侧持超管凭据直连、单语句 -c 建库(免事务,实测可建可删)。
旁路一并探明(⛔ 别重复试):dblink / postgres_fdw 均不可用(pg_available_extensions 无条目、调用报 function does not exist);本机只装了 plpgsql。
⚠️ 两个易错前提:
su postgres -c 'psql …'必须先cd /tmp,否则su切目录被拒(could not change directory to "/root"),报错长得像"连不上 server"。- 平台的
dshs是 systemd 服务名,不是系统用户(su: user dshs does not exist);但它以 root 运行(systemctl show dshs -p User⇒ 空)。 dshsPG 角色在dshs库has_database_privilege(...,'CREATE') = t⇒ 建 schema 零扩权,别误报成需要授权。
验证姿势:跑这种"函数能不能干某事"的探测时,⛔ 别把 SQL 写在 ssh 'bash -s' <<'REMOTE' 里用 \$\$ 转义 —— $$ 会被 shell 展开成 PID,报 syntax error at or near "1057744"(本轮踩过)。✅ 本地 Write 一个 .sql 文件 → ssh 'cat > /tmp/x.sql' < 本地文件 → 远端 psql -f /tmp/x.sql。
23. 🔴 真机取证(建库这类不可回滚动作)的三个坑(2026-09-23 实测 · 插件投放与分库线第 4 棒)
背景:本棒要在 47 上真建一个测试库、核 pg_database × 台账双写一致,再清理。
三个坑全部命中,且前两个会直接产出错误结论或残留。
23.1 🔴 清理必须写进 catch(只清成功路径 = 留残留)
首跑探针失败在台账步骤(47 控制面停在迁移 v13,无 v15 两张表)⇒ 异常抛出、
后面的清理块根本没执行 ⇒ 测试库 dshs_pl_bselftest4 留在了生产 PG 上。
第二跑把 cleanup() 同时放进 catch 才回到零残留(pluginDbs: [])。
判据:凡是「建了东西要删」的取证脚本,cleanup() 必须在 finally 或 catch 里被调用;
⛔ 只在 try 尾部写 await cleanup() 是假安全。收工前独立再查一次残留(⛔ 不信探针自报的 cleaned)。
23.2 🔴 source /etc/dshs.env 取到的 DSHS_DB_URL 是错的
47 上 source /etc/dshs.env 得到的连接串指向 5432,而真 PG 在 15432 ⇒ 探针首跑
ECONNREFUSED ::1:5432。真值只在 drop-in /etc/systemd/system/dshs.service.d/cluster.conf
(Environment="DSHS_DB_URL=…:15432/dshs")。
正确取数:grep -h '^Environment="DSHS_DB_URL=' <drop-in> | sed -E 's/^Environment="(.*)"$//' | sed -E 's/^DSHS_DB_URL=//'
⇒ 与既有口径一致并再次实证:「drop-in 才是唯一载体」。⚠️ 别用 /proc/<pid>/environ 之外
的"读配置文件"姿势 —— /etc/dshs.env 里压根没有这个键(首跑我还被一个残留的 shell 变量误导过一次)。
23.3 ⚠️ 47 的部署根 = /opt/dshs,⛔ 不是 /opt/dsh
systemctl cat dshs ⇒ WorkingDirectory=/opt/dshs、ExecStart=/usr/local/bin/node lib/cli.js。
/opt/dsh 只放 artifacts/ 与 backups/。要跑真实编译产物做取证时,把 lib/ 拷到 /tmp/xxx/pd/
再 sed 改 import 路径 —— 并在 /tmp/xxx 下 ln -s /opt/dshs/node_modules node_modules
(ESM 不认 NODE_PATH ⇒ 只设环境变量会 ERR_MODULE_NOT_FOUND: Cannot find package 'pg')。
23.4 附带一条:CREATE DATABASE 的姿势(B 档落地)
平台侧走 pool.query() 的隐式自动提交(单语句、⛔ 不 BEGIN)。
outcome='exists'(PG 42P04 duplicate_database)是幂等成功,⛔ 不是失败 ——
把它记成 ok:false 会让"重复点建库"被误报成故障。权限不足 42501 ⇒ 503 PG_CREATEDB_MISSING。
24. 🔴 业务插件投放(候选池 → 共享层)与三条伴随坑(2026-09-23 实测 · IM 线第 14 棒)
24.1 「候选池」≠「已发布」——link: 协议下这是两步
装配协议 = link:(2026-09-23 拍板)⇒ profile 里写的是 link:/var/lib/dshs/bundled-plugins/<flat>,
目标是共享层目录。因此投放 = ① POST /api/plugins/business(入池)+ ② POST /api/plugins/business/share(发布到共享层),
缺 ② ⇒ 用户点启用必撞 plugin_bundle_missing(business-plugins.ts 的 friendlyError 把它翻成
「该插件尚未发布到共享包库,请先联系管理员完成发布」)⇒ 那是半成品投放。
一条完整的投放命令链(47 就地;admin 途径,⛔ 不铺 profile):
# 0) 打包(package/ 布局;findPackageJson 递归 ≤3 层,npm pack 同形)
mkdir -p /tmp/pack/package && cp -r <插件目录>/{package.json,cordis.patch.yml,lib} /tmp/pack/package/ \
&& (cd /tmp/pack && tar -czf /tmp/out.tgz package)
# 1) 入池(body = {filename, file:<base64 tgz>},cookie 名 sid,端口 3080,需 -H 'Host: alotbuy.com')
TK=$(ssh bt-server 'node /opt/dshs/mksess.cjs admin') # 临时 admin 会话,TTL 600 s,用完必删
# 2) 发布(带 scope 的包名走 body 形态,`:id` 形态吃不下 '/')
curl -X POST -H "Host: alotbuy.com" -H "Cookie: sid=$TK" -H 'Content-Type: application/json' \
-d '{"id":"@scope/name","confirm":true}' http://127.0.0.1:3080/api/plugins/business/share
判据:入池回 replaced/trustedOverride/compat.level;发布回 action:"created" + fileRef + fileCount;
两处都写 audit_log(upload_business_plugin / share_business_plugin)。回滚素材自动落
/opt/dsh/backups/plugins/<flat>/<ver>.tgz。
⚠️ 池目录真身 = /var/lib/dshs/business-plugins、共享层 = /var/lib/dshs/bundled-plugins
(dataRoot 实际是 /var/lib/dshs,⛔ 不是 /opt/dsh)—— 别按 dataRoot=/opt/dsh 去猜路径。
24.2 🔴 沙箱里看不见共享层 ⇒ link: 软链悬空(47 实测)
实例的 bwrap 命令行里必须有 --ro-bind-try /var/lib/dshs/bundled-plugins 才读得到 link: 的目标。
实测 47 上在跑的实例缺这一项(scope 启动于 09-21,早于该绑定上线;代码 orchestrator.ts 已含该绑定,
106 在跑的实例 cmdline 里有)。判据:
systemctl show dsh-<uid>-<hash>.scope -p ExecStart | grep -c 'bundled-plugins' # 0 = 缺
⇒ 凡改了实例可见的绑定,必须让实例"走一次重启"才生效(插件启用动作自带重启,所以通常不用额外动作)。 ⇒ 判据写法:光看共享层目录存在不够,要同时确认实例沙箱 cmdline 里绑了它。
24.3 🔴 Windows 上往文本文件"追加"必须用二进制模式(差点污染文档库行尾)
Python 文本模式 open(p, 'a', encoding='utf-8') 在 Windows 会把 \n 静默翻成 \r\n
(newline=None ⇒ 按 os.linesep 翻译)⇒ 往文档库 md 追加一段,就把那一段变成 CRLF,与仓库 LF 规范混行。
本棒实测:E 单被追加出 60 个 CR、当日日志 17 个 CR。
- ✅ 判据(字节级,⛔ 别信肉眼):追加前后各算一次
open(p,'rb').read().count(b'\r')—— 必须不变。 - ✅ 做法:
open(p, 'ab').write(text.encode('utf-8'));或先把要追加的内容写成独立.md(Write 工具走 LF),再二进制追加。 - ✅ 修法(只回滚自己那一段):定位自己块的首个 CR 行(
min(i+1 for i,l in enumerate(parts) if l.endswith(b'\r'))), 断言它 ≥ 我的起始行,再从那里起把行尾\r剥掉。⛔ 不做全文件 CRLF↔LF 批量转换(用户级记忆硬禁令)。 - ⚠️ 陷阱:用文本模式读做差分算字节数会被骗(读取时
\r\n已被归一成\n)⇒ 前后字节差会"恰好等于"源文件长度, 让你误判"没加 CR"。一律走rb算。
24.4 🔴 协议类取证:IM 链路服务端零日志
src/im/** / src/web/routes/im.ts / src/supervisor/proxy.ts 三处 process.stderr / console.*
零命中;reject()(ws.ts:227)只回一帧 error 再 destroy(),不落日志。
⇒ journalctl -u dshs | grep bad-op 恒空,而"空"会被读成"没发生"(同类坑 PB §21/§23 已记)。
⇒ 协议/帧类判据只能在客户端取:DevTools → Network → WS → /api/im/ws → Messages 看帧流水
(出站只应出现 {"op":"subscribe"|"ping"|"resume"};入站首帧 {"op":"hello"};不出现 {"op":"error","error":"bad-op"})。
24.5 ⚠️ 本机 tmp/claim_lock.py 的 ME 是写死的 ⇒ --release-exec 会报「非本人持有」
tmp/claim_lock.py 里 env["ME"] = "aliyun-dsh-server-IM架构对齐"(硬编码)⇒ 用它放锁时,
handoff-guard.sh 的 _my_owner() 拿到的会话名与 OWNER 文件对不上 ⇒ 回 「· 无可释放的锁(或非本人持有)」
(rc 仍 = 0 ⇒ 极易被当成"已释放")。本棒实测踩到一次。
- ✅ 判据:放锁后必须复核
交接单/.locks/为空(ls -la),⛔ 别只看返回文案。 - ✅ 放锁姿势(绕开该脚本):直接给足
OWNER=与ME=两个 env:
export PATH="/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/usr/bin:/e/ProgramData/.workbuddy/binaries/PortableGit/versions/1.2.0/bin:$PATH"
cd D:/github/dsh_shenxian/dsh-server-docs
OWNER="<会话名>" ME="<会话名>" bash scripts/handoff-guard.sh --release-exec "<会话名>" # ⇒ ✓ 已释放域锁
⚠️ 该脚本仅用于抢锁没问题(抢锁不看 ME 对不上);放锁一律按上面来。
🗒 待办(⛔ 超出本棒范围,未改):把 claim_lock.py 的 ME 改成从 argv 的 --claim-exec/--release-exec 派生。
24.4 🔴 反过来的一种更坏情形:不设 ME 时 --release-exec = 清空所有锁(2026-09-23 21:1x 实测 · 插件投放线即席应答棒)
handoff-guard.sh:250-262 的 _who="${ME:-}",随后判 if [ -z "$_who" ] || [ "$o" = "$_who" ] ⇒ ME 为空时条件恒真 ⇒ 遍历 交接单/.locks/* 全删。本次因此误删了别的线(IM 线第 15 棒)正在持有的域锁 —— 违反 R9 精神(锁只能由持有者自己放)。
- 🔴 两种坏法不是一回事:
ME写错 ⇒ 报「无可释放的锁」rc 仍 0(§24 上文,假绿);ME没设 ⇒ 静默全删(本次,更坏且不可逆 —— OWNER 文件一起没了,救不回)。 - ✅ 判据:放锁前必须确认
[ -n "$ME" ];放锁后ls -la 交接单/.locks/—— 若为空而你只应放自己那把 ⇒ 已误删(有别人的锁残留才是正常态)。 - ✅ 姿势:
ME="<会话名>" bash scripts/handoff-guard.sh --release-exec(⚠️ 位置参数<会话名>脚本根本不读,只当文档看)。 - 🗒 修法(⛔ 未擅改 —— 机制层需独占 + 当时 IM 线在跑):
_who为空 ⇒ 直接报错退出,⛔ 不得进全删分支;顺带把位置参数当第二来源(_who="${ME:-${2:-}}",两者都空即拒)。 - 💡 事故处置(本次采用):⛔ 不能"替别人重造一把同名锁"(名字对、
session_id对不上 ⇒ 那个棒下次抢锁会把自己挡在门外);正确处置 = 如实上报 + 让该线收口时自行核对/重领。