按用户令提交:把此前未纳管的 9 个技能目录一并入库
用户令逐字:「E:/ProgramData/.workbuddy/skills 提交仓库是指的这里」—— 即本目录就是仓库(2026-10-07 已在本目录建仓,见当日日志 §22),本轮把余下未纳管的 9 个技能一并提交。 本次入库(9 个技能,46 个文件): 1、`AI HOT` 2、`draw-ui` 3、`dsh-diagnose` 4、`dsh-knowledge` 5、`dsh-local-env` 6、`dsh-opensource-release` 7、`dsh-workflow` 8、`oil-motion` 9、`skills-security-check` 提交前核对: · **凭据类扫描**(`*.env` / `*token*` / `*.key` / `*secret*` / `*.pem`)⇒ **零命中** ✓; · 体积合计约 20 MB(`draw-ui` 12M + `oil-motion` 6.5M 是大头,形态为配图/素材 —— 仓库 `.gitignore` 里明写「`assets/*.png` 是内容不是产物」⇒ 属刻意入库); · 运行产物仍按既定规则排除(`__pycache__` / `logs/` / `tmp/` / `.venv/` / `*.egg-info` / `uv.lock` / `.workbuddy/`)。
This commit is contained in:
1 parent
d26c844f64
commit
e03465c398
46 files changed
+7558
No files matched your search
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: dsh-diagnose
|
||||
description: DSH 多租户平台(ai1net.com / 47.77.182.89)**故障诊断总入口** —— 覆盖三层:① **服务器上单个用户实例**(内存 / OOM / 崩溃重启 / 打不开 / 起不来 / 挂了 / 一直重启 / 503 / 502 / 会话突然中断 / 响应慢 / 怀疑内存不够)② **业务插件**(插件没 UI / 预览不出现 / 卡片不渲染 / 装了插件但界面没变化·跟没启用一样 / 导入导出报 GLIBC 版本 / 网关崩了但数据其实写进去了 / 改了包上传了还是老行为)③ **跨机状态回流**(某字段跨机恒为 false / 永远是旧值 / admin 看到的启停状态与用户实际不一致 / 命令下去了但状态回不来 / 刷新后状态回跳 / 要不要让 worker 主动上报)。⚠️ **边界**:本技能管**服务器上的实例与插件**;「**本机** Windows 上把官方 dsh 跑起来(web 实例 / 桌面壳)及其取证」属 `dsh-local-env`。核心 = **先按症状定层**(⛔ 别先猜代码)+ 三层各自的第一原则(实例=先读 journal 再分服务/实例/会话三级;插件=**静默失败当默认假设**;跨机=**先判"缺失能力 vs 代码缺陷"**)。
|
||||
version: 1.0.0
|
||||
updated_at: 2026-09-28
|
||||
last_change: 【2026-09-28】由三个同域技能**合并**而成:`dsh-instance-diagnose`(v1.0.0)+ `dsh-plugin-diagnose`(v1.0.0)+ `dsh-distributed-state-readback`(首版)。按 `dsh-knowledge-upkeep §10`「主干 + 详情档」形态:判据实体留在本主干,全文下沉到 `references/`(**内容守恒,逐行未改**)。合并理由:三者都在回答同一问题「**坏了 / 值不对,怎么定位**」,且前两者正文**本就互相声明边界**(同一领域的分层)⇒ 合并后由 §0 分诊表统一入口。⛔ 未删任何判据、命令、事故事实。
|
||||
agent_created: true
|
||||
---
|
||||
|
||||
# dsh-diagnose — DSH 故障诊断(三层入口)
|
||||
|
||||
> ## 🔴 第 0 步:按**症状**定层(⛔ 别先读代码猜)
|
||||
>
|
||||
> | 用户说的 | 层 | 去哪 |
|
||||
> |---|---|---|
|
||||
> | 实例打不开 / 起不来 / 挂了 / 一直重启 / **503** / **502** / 会话突然中断 / 很慢 / 内存不够 | **① 服务器实例** | §1 |
|
||||
> | 插件没 UI / 预览不出现 / 卡片不渲染 / **装了插件但界面没变化** / GLIBC / **改了包还是老行为** / 网关崩了但数据进去了 | **② 业务插件** | §2 |
|
||||
> | **某字段跨机恒为 `false`** / 永远是旧值 / admin 看到的状态与实际不一致 / 命令下去了状态回不来 / 刷新后状态回跳 | **③ 跨机状态回流** | §3 |
|
||||
> | 「**本机** Windows 上把官方 dsh 跑起来」/ 桌面壳 / 本机取证 | ⛔ **不属本技能** | 技能 `dsh-local-env` |
|
||||
>
|
||||
> ⚠️ **②③ 最容易走错方向**:两者的症状都**静默**(不报错、不崩,只是"什么都没有"或"值不对")⇒ ⛔ 别当代码缺陷去 debug,先按各自的**第一原则**判性质。
|
||||
|
||||
---
|
||||
|
||||
## 1. 服务器实例(①)
|
||||
|
||||
**第 0 步永远是「先读 journal 拿真实失败请求」,不要先猜**(报障第一步)。
|
||||
|
||||
**定位顺序(每层可单独结案)**:
|
||||
- **第 0 层 · 跨机日志取证(先做)** —— `dshlog` 把 47/106 的 journald 拉回本机再判(跨机、跨单元、带毫秒时间线)。🔴 两条硬前提:`ssh -C` **必加**(不加约 20 KB/s,**看起来像卡死**);**实例层(`dsh --profile`)的 stdout 不进 journald** ⇒ `dshlog` 拿不到实例自己的日志,实例层证据仍须上机取 cgroup / proc。
|
||||
- **第 1 层 · 服务级** —— `systemctl status dshs` + journal 里 `crash-restart|instance-restart`。🔴 `exitCode 137` = 内核 SIGKILL(128+9),**九成是 OOM**。
|
||||
- **第 2 层 · 实例级(内存三件套)** —— 先现查 scope 名(**每次重启 hash 会变**)⇒ `memory.usage_in_bytes` / `max_usage_in_bytes`(**字节,不是 KB**)/ `memory.stat`(**分 rss 与 cache**,rss 占绝对多数 ⇒ 内核 OOM killer 无路可退)+ `/proc/<pid>/smaps_rollup`。⚠️ **本机是 cgroup v1**(`/sys/fs/cgroup/memory/system.slice/…`),写 v2 路径**静默读到空**。
|
||||
- **第 3 层 · 会话级(谁在吃内存)** —— 按 `session.jsonl.zstd` 的 mtime/size 排序;⚠️ 会话目录中间还有一层 workspace 转义目录,⛔ 别按 `home/sessions/<id>` 找。
|
||||
|
||||
**四条死亡路径(症状不同,别混)**:A · V8 堆限(`Reached heap limit` / `status=ABRT`)|B · cgroup 限(`oom-kill:constraint=CONSTRAINT_MEMCG` / `status=9` → 平台 137)|C · bwrap 挂载点被遮蔽(`Can't chdir to <正常路径>` —— **路径看起来完全正常**,极易误判成"目录没建")|D · cwd 为空(`Can't chdir to :` —— **路径是空的**,一眼可辨)。
|
||||
⇒ **见到 `Can't chdir` 直接分诊**:路径空 ⇒ D(查 `folder` 是不是 NULL);路径正常却报不存在 ⇒ C(看 bwrap 的**中间目录是不是"就近创建"的**;✅ 正解=所有中间目录**统一前置 + 去重 + 由外到内**)。
|
||||
|
||||
**快速分诊(用户看到的状态码决定查哪层)**:**502** ⇒ 先看平台 journal 那条请求有没有 `request completed`(没有 ⇒ **平台接了却没回响应**,⛔ 别先查 nginx/域名/实例死活)|**431** ⇒ Cookie 超限(陈旧 `dsh-auth-*` 堆积;⚠️ 平台 journal 查不到该请求)|白屏 / `Failed to load plugins` ⇒ 先问"**无痕窗口是否同样报错**"。
|
||||
|
||||
**两条铁律**:🔴 **压测走隔离 cgroup**(`systemd-run … -p MemoryHigh/`MemoryMax`),⛔ 不在生产实例上压满配额(会触发 crash-loop / 熔断);⚠️ 复现路径 B 必须用**纯堆外分配**(`Buffer.allocUnsafe(...).fill(1)`),用 JS 对象数组会**先撞 V8 堆限(路径 A)**、永远复现不出 137。
|
||||
|
||||
**账实判据**:成本由「**装了什么**」决定,不是「装了几个」—— 一个重型插件(如 `dsh-univer-office` **+65 MiB**)≈ 无穷多个轻量插件;实测**整份会话数据只 0.9 MB** ⇒ 「减会话长度 / 少用 web_fetch」对内存**几乎无用**。
|
||||
|
||||
📂 **全文 ⇒ `references/00-服务器实例故障.md`**(含平台事实表、内存去向 smaps 拆解、插件内存逐个实测、注入层真机验证配方、9 条踩坑清单)
|
||||
|
||||
---
|
||||
|
||||
## 2. 业务插件(②)
|
||||
|
||||
> **第一原则:静默失败要当默认假设。** 排查顺序永远是「**先证明某一层到底有没有活着**」,而不是先读代码猜。
|
||||
|
||||
**三层归属(先定层)**:**host 半边**(工具能调通?死了=报"未知工具")|**client 半边**(页面 `__DSH_BOOT__` 里**有没有**该插件行?死了=**有无都没痕迹**:无卡片/无 dock、无报错)|**网关 / 原生绑定**(进程在、socket 能连、健康路径 200?死了=进程崩 / reset / `.node` 加载报错)。
|
||||
|
||||
**最隐蔽的一类 · client 半边静默挂死**:机理 = 加载器解析 `package.json` 的 `dsh.client.inject`(**包名**列表),**只要有一条 inject 永远不可满足,`apply()` 永不执行**(连注册都没有,静默)。
|
||||
🔑 **通用尺子 = `inject` 差集**:取实例页面实际下发的客户端插件集合(**权威清单,⛔ 别用 manifest 正则——会漏行**)→ 取目标插件的 `inject` → **求差集**。**差集非空 = 确诊**。
|
||||
⚠️ **别混两个 inject**:`package.json` 的 `dsh.client.inject` 是**包名**(不可满足 ⇒ **整体不 apply**);源码里的 `export const inject = [...]` 是**服务名**(只影响个别 `ctx.inject` 块)。
|
||||
|
||||
**第二类 · 组件挂上了、点击却没反应(外部契约失效)**:dsh 升级会改**外部契约**(槽位名 `conversation` → `main.conversation`;`ctx.locale.getLocale()` 由返回 id 变成返回**快照对象**)⇒ `querySelector` / 取值**静默失效**。
|
||||
|
||||
**桥接类(host 半边连不上 / 静默无限重连)**:⚠️ **先破一个假象** —— 内核 cordis 的默认 exporter **只写内存环形缓冲、不落 stdout** ⇒ 内核插件报错在实例日志里**看不见**,必须先挂 `ctx.logger.exporter(...)`。四层诊断配方(上游直连 → 实例内 fetch → **出站抓包**(能看出"从哪一代请求开始丢凭据")→ 硬编码对照)**逐层排除,⛔ 别跳步**。
|
||||
|
||||
**「注册成功」≠「模型可见」**:`register()` 返回 disposer 只说明"调用没抛错",能不能被模型看见要**另用 `tools.schemas()` 对账**。⚠️ 读法陷阱:⛔ 别用 `getOwnPropertyNames(...tools.data)` 数工具 —— `Map` 属性名是**空数组**,会把"有 N 个工具"误读成 `size=0`;**必须先判 `instanceof Map`**。
|
||||
|
||||
**原生绑定 / glibc**:第一步永远**先直测**(`ldd --version` + `node -e "require('<.node>')"`),⛔ 别读代码猜。两类对策不同:**有 JS 回退开关** ⇒ 资产侧垫片(可用则原样交上游 ⇒ 宿主升级后自动恢复);**无回退** ⇒ 只能宿主/镜像层解决(换 glibc ≥ 2.35)⇒ **上报用户决策,别硬凑**。
|
||||
|
||||
**「改了却没生效」四类**(按可能性):打包顺序把垫片废掉(模块级 `const` 被降级为 `var` ⇒ 静默回退原实现)|插件没真正换上新包(`mine/apply` 对**已启用**插件是 **noop** ⇒ 必须**停用→启用**两步;终态是 `success` 不是 `done`)|客户端包按 `rev` 缓存(**必须硬刷新**)|🔴 **同 `version` 号不会被重装** ⇒ **改代码必升 version** + 装完做**内容门禁**(`grep -q "<新符号>"`)。
|
||||
|
||||
**验证纪律**:每条结论都要有**可复现的命令**;`grep` 会骗人(esbuild 的 CJS 导出用 getter ⇒ 假阴性);业务插件 stdout **不落 journald** ⇒ 找不到"日志"时**别下结论**。
|
||||
|
||||
📂 **全文 ⇒ `references/01-业务插件故障.md`**(含注入差集完整命令、桥接类四层配方、可见性对账脚本、实测真因、平台侧事实)
|
||||
|
||||
---
|
||||
|
||||
## 3. 跨机状态回流(③)
|
||||
|
||||
> **第一原则:先判「缺失能力」还是「代码缺陷」** —— 判错方向会白烧一整轮。
|
||||
|
||||
**第一步 · 三条排除**(在**实例所在那台机**上取证,⛔ 不是 Manager):① 操作本身失败了吗(看**目标机文件系统**:软链/声明实际存在吗)② 执行环境没配好吗(看**目标机进程命令行** `/proc/<pid>/cmdline`)③ 调用链断了吗(**下发是否真走到远端**)。
|
||||
🔑 **判据一句话:原机上成了 + Manager 侧读不到 = 缺失能力(拓扑必然),⛔ 不是缺陷。**
|
||||
|
||||
**第二步 · 分开看读/写两条路径**(⚠️ 最省时间):写路径(Manager→worker 下发)多**已有**|响应路径(任务返回值)多**已有**|**读路径(`GET` 那种"随时查状态")常缺的就是这条**。
|
||||
⇒ 判据:**"操作后立刻返回的值是对的、刷新后变错" ⇒ 缺的只有读路径**;设计目标应写成「让读路径也拿到真值」,⛔ **不是**"加个缓存"(缓存=第二个真相)。
|
||||
|
||||
**第三步 · 三候选用「物理可行性」筛**(⛔ 不是用"哪个更好"筛),照这个顺序问:
|
||||
1. **worker 有入站口吗?** DSH 硬口径是「worker 只拨出、无入站口」⇒ **候选 C(worker 主动上报)物理上走不通**,要新开出站通道/入站端点 ⇒ 撞「扩大可见面」红线门禁。
|
||||
2. **reconcile 谁驱动?** DSH 由 **worker 本机定时器**驱动、**Manager 不在场** ⇒ **候选 B(共用台账)不能单独成立**,只能当缓存。
|
||||
3. **已有一条 Manager→worker 的周期调用吗?** 有(`reportHost()` 每次心跳 `GET /healthz`)⇒ **候选 A 的增量 = 加宽返回值**,成本最低。
|
||||
⇒ 结论通常是 **A 为骨架 + B 降级为可选缓存**;C 需先授权。
|
||||
|
||||
**语义细节(⛔ 不写这三条,回流做出来照样是错的)**:`asOf` 必带且 `stale` **现算不落库**(落库的 `stale` 自己也会陈旧);阈值 = **3 × 心跳周期**且**与心跳周期同源计算**(⛔ 不写两个独立常量);**三层语义别混**(意图=控制面审计/**事实=对端 profile 的 bundles**/不可用=数据面台账)⇒ **`GET` 取「事实」不取「意图」**;🔴 降级时 **⛔ 不许把"未知"显示成"未启用"**(这正是原缺陷的成因)。
|
||||
|
||||
📂 **全文 ⇒ `references/02-跨机状态回流.md`**(含本判据的 DSH 实证、三候选完整优缺点、失败降级场景表、与既有台账的粒度对比、交付件 10 节骨架、9 条反模式、关键文件地图)
|
||||
|
||||
---
|
||||
|
||||
## 4. 详情档索引(跨档引用按此表定位)
|
||||
|
||||
| 档 | 覆盖的原技能 | 原章节 |
|
||||
|---|---|---|
|
||||
| `references/00-服务器实例故障.md` | `dsh-instance-diagnose`(v1.0.0,全文) | 何时用 / 平台事实 / 三层定位 / 快速分诊 / 四条死亡路径 / 隔离复现 / 踩坑清单 / 定量归因 / 内存去向 / 插件内存成本 / 不兼容插件 / 注入层验证配方 / 相关 |
|
||||
| `references/01-业务插件故障.md` | `dsh-plugin-diagnose`(v1.0.0,全文) | §0 第一原则 / §1 三层归属 / §2 静默挂死 / §2.5 桥接类 / §3 glibc / §4 改了没生效 / §5 验证纪律 / §6 平台侧事实 |
|
||||
| `references/02-跨机状态回流.md` | `dsh-distributed-state-readback`(首版,全文) | §0 治什么 / §1 三条排除 / §2 读写路径 / §3 三候选 / §4 语义细节 / §5 与台账关系 / §6 交付件骨架 / §7 反模式 / §8 文件地图 |
|
||||
|
||||
🔴 **合并前的三个技能名已退役**(`dsh-instance-diagnose` / `dsh-plugin-diagnose` / `dsh-distributed-state-readback`)⇒ 正文或别处若出现这三个名字,**按本技能对应章节读**。
|
||||
@@ -0,0 +1,280 @@
|
||||
# 服务器实例故障(①)
|
||||
|
||||
> **归属**:技能 `dsh-diagnose` · 详情档(主干 `../SKILL.md` §1)
|
||||
> **本档覆盖**:原技能 `dsh-instance-diagnose` **全文**
|
||||
> **原行段**:原 `SKILL.md` 全文 265 行,其中 frontmatter 占前 8 行已剥离 ⇒ 本档承载第 9–265 行
|
||||
> **搬运方式**:**逐行未改**(⛔ 未删任何判据 / 命令 / 事故事实)
|
||||
> ⚠️ 本技能已由 `dsh-diagnose` **合并**,原名 `dsh-instance-diagnose` 退役 ⇒ 见到该名按本档读。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# dsh-instance-diagnose — DSH 实例故障诊断
|
||||
|
||||
## 何时用
|
||||
|
||||
用户报「实例打不开 / 会话突然报错 / 聊到一半断了 / 很慢 / 内存不够」,或你看到 `crash-restart` 日志。**报障第一步永远是先读 journal 拿真实失败请求**,不要先猜。
|
||||
|
||||
## 平台事实(硬编码,勿猜)
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务器 | SSH:`ssh -p 22 -i ~/.ssh/id_ed25519 [email protected]`(⚠️ 2026-09-19 更正:别名 `bt-server` 里的 `Port 32022` 已失效 —— sshd 只监听 **22**,用它必 `Connection refused`) |
|
||||
| 用户数据根 | `/var/lib/dshs/users/<uuid>/`(**不是** `/opt/dsh/users`,那里只有 main) |
|
||||
| 已知账号 | guest = `4092b965-2f68-4977-9989-68b3966f7df0`(系统 uid **100002**)|admin = `cce6d1cd-b376-4304-80f0-0e1c58c9ffde`(uid 114801) |
|
||||
| 实例配额 | ⚠️ **2026-09-14 起:基础 MIN、最多浮动到 MAX**(档案 **96**,用户要求:**不受插件开关影响**):cgroup `MemoryHigh = MIN_MEM_MB = 448 MiB`(**软限/基础**,超过即回收·限速)+ `MemoryMax = MAX_MEM_MB = 1024 MiB`(**硬限/上界**,越界 OOM);**不再读 profile 的 bundles**;`heapMbFor()` = 配额 − 96,**cap 256**。其余 `CPUQuota 150%` / `TasksMax 128` 未变。实测(2026-09-14):两 scope 均 `MemoryHigh=448M` + `MemoryMax=1024M`(与各自插件集合无关)。⚠️ **`dsh-univer-office` 这类重插件(gateway ≈390MB + 基座)512 装不下 ⇒ 先抬 MIN**。⚠️ `/etc/dshs.env` 里的 `DSH_INSTANCE_NODE_OPTIONS=--max-old-space-size=160` **已不是生效值**(代码侧 `withHeap()` 会摘掉 `--max-old-space-size` 再按配额补回),查看实际值请直接读 `/proc/<pid>/environ` 与 `systemctl show <scope> -p MemoryMax` |
|
||||
| 宿主 | 物理内存仅 **1.83 GB**(1915896 kB);swap 1 GB |
|
||||
| 会话文件 | `<用户根>/home/sessions/--var-lib-...-ws-<工作区>--/<session-id>/session.jsonl.zstd`(**多帧 zstd**,按 magic `28 b5 2f fd` 切分逐帧解压) |
|
||||
|
||||
> ⚠️ 会话目录名**不是** `home/sessions/<session>`,中间还有一层带名字的 workspace 转义目录。
|
||||
|
||||
## 三层定位(按顺序做,每层都能单独结案)
|
||||
|
||||
### 第 0 层 · 跨机日志取证(**先做这一层** —— 2026-09-18 加)
|
||||
|
||||
**遇到"线上跑着跑着不对"但说不清哪一层时,先用 `dshlog` 把 47 / 106 的 journald 拉回本机再判**:跨机、跨单元、带毫秒时间线,比逐台 `journalctl` 快一个量级。
|
||||
|
||||
```bash
|
||||
N="E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe"
|
||||
$N 07-scripts/dshlog.mjs collect --since 6h # 拉取(之后再用就不带 --since,走 cursor 增量)
|
||||
$N 07-scripts/dshlog.mjs watch --since 1h # 巡检:8 条规则出判据表,有 FAIL ⇒ rc=2
|
||||
$N 07-scripts/dshlog.mjs q "/EADDRINUSE|OOM/" --since 24h
|
||||
$N 07-scripts/dshlog.mjs timeline --from 2026-09-18T20:00 --grep "overlay" --out /tmp/tl.log
|
||||
```
|
||||
|
||||
⚠️ **三条必知**(细节见 `04-调整方案/129-日志采集与巡检-方案C实现.md`):
|
||||
|
||||
- 🔴 **`ssh -C` 是硬前提**:47 出方向未压缩实测 ~20 KB/s(1.6 MB 要 84 s),开压缩后 11 s。不加会**看起来像卡死**。
|
||||
- 🔴 **实例层(`dsh --profile`)的 stdout 不进 journald**(`_SYSTEMD_UNIT=…scope` 与 `_PID=` 均为空,已双重取证)⇒ **`dshlog` 拿不到实例自己的日志**,只有 Worker 转发的部分。要实例层证据仍须按下面第 2 层**上机取 cgroup / proc**。
|
||||
- 📌 归档在 `E:/dsh-logs/`(本机 E 盘,不在仓库内);`prune --keep N` 管保留。
|
||||
|
||||
### 第 1 层 · 服务级
|
||||
|
||||
```bash
|
||||
systemctl status dshs --no-pager | head -20 # 重启过没
|
||||
journalctl -u dshs --since today --no-pager | grep -iE "crash-restart|instance-restart|instance-stable"
|
||||
```
|
||||
|
||||
- `instance-restart` 带 **`exitCode 137`** ⇒ **内核 SIGKILL(128+9),九成是 OOM**
|
||||
- `exitCode 1` + `ModuleLoader.import` ⇒ 插件加载崩(如 `duplicate loader entry id`)
|
||||
- 无 exitCode ⇒ 信号终止,看紧随其后的服务重启
|
||||
|
||||
### 第 2 层 · 实例级(内存三件套)
|
||||
|
||||
**先找 scope 名**(**每次重启 hash 会变**,别写死):
|
||||
|
||||
```bash
|
||||
ls -d /sys/fs/cgroup/memory/system.slice/dsh-*.scope
|
||||
```
|
||||
|
||||
> ⚠️ **本服务器是 cgroup v1**,路径是 `/sys/fs/cgroup/memory/system.slice/<scope>/`,
|
||||
> 文件叫 `memory.usage_in_bytes` / `memory.max_usage_in_bytes` / `memory.limit_in_bytes`。
|
||||
> cgroup v2 才是 `memory.current` / `memory.peak` —— **写 v2 路径会静默读到空**。
|
||||
|
||||
```bash
|
||||
P=/sys/fs/cgroup/memory/system.slice/<scope>
|
||||
cat $P/memory.usage_in_bytes # 当前(字节!不是 KB)
|
||||
cat $P/memory.max_usage_in_bytes # 历史峰值 —— 等于 limit 就是「曾精确打满」
|
||||
cat $P/memory.stat # ★ 关键:分 rss 与 cache
|
||||
cat /proc/<pid>/smaps_rollup # Anonymous / Pss_Anon
|
||||
```
|
||||
|
||||
**判据**:
|
||||
- `rss` 占绝对多数、`cache` 很小 ⇒ **几乎全不可回收,内核 OOM killer 无路可退**
|
||||
- `rss` 接近 `limit` 且 `max_usage == limit` ⇒ 配额**零余量**,任何波动就是终点
|
||||
- `rss_huge` 大 ⇒ 透明大页放大占用
|
||||
|
||||
> ⚠️ **`ps` 的 RSS ≠ cgroup 计费**:实测 `ps` 报 437 MiB 而 cgroup 只用 381 MiB(共享页不计入)。
|
||||
|
||||
### 第 3 层 · 会话级(谁在吃内存)
|
||||
|
||||
```bash
|
||||
find <用户根>/home/sessions -name session.jsonl.zstd -printf "%TY-%Tm-%Td %TH:%TM %10s %h\n" | sort -r | head -12
|
||||
```
|
||||
|
||||
拉回本地用 `dsh-server-docs/07-scripts/sess-analyze.mjs` 解(**注意该脚本同目录的 `sess-list-presets.mjs` 首行曾缺 `/**` 起始符**,如报 `SyntaxError: Unexpected token '*'` 先补)。
|
||||
|
||||
**别忘了对照**:多实例时横向比 `dsh-instance-mem.log`(**时间戳是 UTC,+8 才是本地时间**)。无插件的实例峰值 vs 有插件的实例峰值 = 插件的净增量。
|
||||
|
||||
## ⚡ 快速分诊:用户看到的状态码决定查哪一层(2026-09-19 加)
|
||||
|
||||
| 用户看到 | 真实含义 | 第一动作 |
|
||||
|---|---|---|
|
||||
| **502** | 边缘 nginx 说"上游提前关闭连接" | **先看平台 journal 那条请求有没有 `request completed`**:没有 ⇒ **平台接了却没回响应**(档案 **141**:`proxyHttp` 在**实例冷启动窗口**裸断连接,现已改为 503+Retry-After)。⛔ 别先查 nginx 配置 / 域名 / 实例死活 |
|
||||
| **431** | 请求头(Cookie)超限 | 陈旧 `dsh-auth-*` 堆积(档案 98)。⚠️ 平台 journal 里**查不到**该请求 |
|
||||
| **500** 且正文含 `解析不出落点` | 控制面只读**单台**中继 | 见 `PLAYBOOK-实例与插件坑.md §18`(观测面绿而控制面红) |
|
||||
| 白屏 / `Failed to load plugins` | 客户端 bundle 取不到 | 先问"**无痕窗口是否同样报错**"(区分浏览器侧 vs 服务器侧,PB §3.2) |
|
||||
|
||||
⚠️ **502 的典型时序**(实测):`POST /api/dsh/enter` 返回 200(耗时 11 s)→ scope `ActiveEnterTimestamp`
|
||||
与 enter 同刻 → 用户 **10 余秒后**访问 `<user>.<baseDomain>` → 502。
|
||||
⇒ 实例**就绪后自愈**(`curl 127.0.0.1:<实例端口>` 回 401);**事后同机 curl 复现不出 502** 是正常的,别因此否定结论。
|
||||
|
||||
## 四条死亡路径(症状不同,别混)
|
||||
|
||||
| 路径 | 触发 | 日志指纹 | systemd 结果 |
|
||||
|---|---|---|---|
|
||||
| **A · V8 堆限** | 堆冲到 `--max-old-space-size` | `FATAL ERROR: Reached heap limit` / `Ineffective mark-compacts` | `code=dumped/status=ABRT` |
|
||||
| **B · cgroup 限** | RSS 打满 `MemoryMax` | `kernel: oom-kill:constraint=CONSTRAINT_MEMCG, task=node` | `code=killed/status=9/KILL` → 平台 `exitCode 137` |
|
||||
| **C · bwrap 挂载点被遮蔽**(2026-09-15 新增) | bwrap 参数里**同时绑多个路径且它们嵌套在同一前缀下**(如"用户根" + "共享技能层")。后挂的 `--tmpfs <共同祖先>` 会把**已绑好的挂载点整个遮掉** | `bwrap: Can't chdir to <userRoot>/ws/xxx: No such file or directory` —— ⚠️ **路径看起来完全正常**,极易误判成"目录没建" | 子进程 `exitCode 1` → 平台按崩溃退避重启 → 5 次后**熔断**(10 min 冷却) |
|
||||
| **D · cwd 为空**(2026-09-15 新增) | 启动参数里 `folder` 为空/`null`(**跨机迁移**时最容易:复现不了原启动参数) | `bwrap: Can't chdir to : No such file or directory` —— ⚠️ **路径是空的**(一眼可辨) | 同上 |
|
||||
|
||||
**A/B 拿不到可读的应用层日志**(这正是用户觉得"莫名其妙就断了"的原因);**C/D 反而有明确日志** ⇒ 见到 `Can't chdir` 直接分诊:
|
||||
|
||||
- **路径是空的** ⇒ **D**:查实例记录 / 迁移入参里的 `folder` 是不是 NULL。
|
||||
📌 集群模式下实例行是 `claimInstance` 建的(local 模式不写库),**它必须把 `folder`/`patch` 一并落库**,否则迁移时无处取得启动参数。
|
||||
- **路径正常却报不存在** ⇒ **C**:看 `orchestrator.ts` 的 bwrap 参数里,**中间目录是不是"就近创建"的**(例如插在 `--bind root root` 之后)。
|
||||
✅ 正确做法:**所有挂载点的中间目录统一前置 + 去重 + 由外到内**,禁止插在任何一个 `--bind` 之后。
|
||||
- 最小复现(**别拿生产实例试**):`bwrap <与平台等价的参数> -- /usr/bin/ls -ld <那个路径>`,再逐条增删参数二分。
|
||||
⚠️ 顺带记住:需要给挂载点权限时只能用 `--tmpfs`(自带 0755),**不能用 `--perms`** —— 47 上 bwrap 是 0.4.0,不认该选项。
|
||||
|
||||
## 隔离复现(**唯一允许的压测方式**)
|
||||
|
||||
⛔ **压测走隔离环境**:在**生产实例**上压满配额 = 当场 SIGKILL,会连带把实例带进 crash-loop / 熔断(自找干扰)。⚠️ 注意 **2026-09-13 用户已明确「服务器是开发环境,不用担心中断用户」**(R8 已放宽)—— 但**别因此就去污染实例**:隔离复现能拿到同样的数据而**不留副作用**,仍是首选;只有在需要复现"平台侧联动"(如自动回滚、熔断计数)时才动真实例。
|
||||
|
||||
```bash
|
||||
systemd-run --wait --pipe --collect --unit=memsim-x \
|
||||
-p MemoryHigh=448M \
|
||||
-p MemoryMax=1024M \
|
||||
node /tmp/memsim/probe.mjs
|
||||
```
|
||||
|
||||
- 独立 cgroup ⇒ **撞墙只杀模拟进程**,生产不受任何影响(宿主 available 需 > 400 MiB)
|
||||
- 跑完 `rm -rf /tmp/memsim`,`systemctl list-units "memsim*"` 确认无残留
|
||||
|
||||
**复现路径 B(137)的配方**:必须用**纯堆外分配**才能走到 cgroup 限——
|
||||
|
||||
```js
|
||||
const b = Buffer.allocUnsafe(4 * 1024 * 1024); b.fill(1); bufs.push(b)
|
||||
```
|
||||
|
||||
⚠️ 若改用 JS 对象数组,会**先撞 V8 堆限(路径 A)**,永远复现不出 137。这是本轮踩到的坑。
|
||||
|
||||
**复现路径 A 的配方**:正常 push JS 对象即可,同时打印 `process.memoryUsage()` 看崩在哪个 `heapUsed`。
|
||||
|
||||
## 踩坑清单(全部实测)
|
||||
|
||||
1. **cgroup v1 vs v2 路径不同** —— 写错不报错,静默读到空值。
|
||||
2. **`memory.*_bytes` 单位是字节**,不是 KB(差 1024 倍)。
|
||||
3. **`dsh-instance-mem.log` 时间戳是 UTC**,+8 才对得上本地时间。
|
||||
4. **scope 名带随机 hash**,每次重启都变,必须现查。
|
||||
5. **`ps` RSS ≠ cgroup usage**(共享页不计入 cgroup)。
|
||||
6. **会话目录多一层 workspace 转义目录**,别按 `home/sessions/<id>` 找。
|
||||
7. **`systemd-run` 起服务不继承调用者 env** ⇒ 压测必须 `--setenv=NODE_OPTIONS=...`,否则堆限根本没生效。**自证手段**:脚本里打印 `require('node:v8').getHeapStatistics().heap_size_limit`。
|
||||
8. **同质字符串会被引擎优化**:`'x'.repeat(N) + i` 走 cons string 惰性拼接(不复制前缀)→ 实测「投喂 23 GB 只涨 140 MB」的假数据。**内存类模拟实验极易骗人,优先用真实数据统计 + smaps 实测**。
|
||||
9. **`.cjs` 不支持顶层 await** ⇒ 用 `await import()` 的探针脚本要存成 `.mjs`。
|
||||
|
||||
## 定量归因的经验值(2026-09-12 guest 实例实测)
|
||||
|
||||
| 组成 | 量 | 说明 |
|
||||
|---|---|---|
|
||||
| V8 老生代堆 | ≤`--max-old-space-size` | **这是「许可」不是「上限」**,V8 会主动把水位推满以减少 GC |
|
||||
| RSS / heapUsed 膨胀 | **约 3.4×** | 实测 heapUsed 37 MiB 时 RSS 已 125 MiB(预留未用 + 回收未归还给 OS) |
|
||||
| `dsh-univer-office` | **+65 MiB** | 磁盘 168 MB,含 29 MB + 10 MB 原生绑定 |
|
||||
| 页缓存(node_modules mmap) | ~18 MiB | **唯一可回收的部分** |
|
||||
| V8 不管但计入配额 | ~50 MiB | Buffer / 原生库 / 模块映射 |
|
||||
|
||||
### 内存去向实测(2026-09-12 · 空载实例 smaps 拆解)
|
||||
|
||||
**空载实例(零会话)已占 285 MiB**:`V8 堆/匿名 155.9` + `[heap] 58.1` + `文件映射 69.1`(其中 **node 本体 59.8**)+ `JIT 2.2`。
|
||||
|
||||
⚠️ **`[heap]`(malloc 区)不受 `--max-old-space-size` 约束** ⇒ 压堆限**不会**让总量线性下降。
|
||||
|
||||
### 会话数据几乎不占内存(重要反直觉)
|
||||
|
||||
实测:**整个会话(2546 事件 / 11 轮)的全部字符串只有 0.9 MB**(最大单串 40820 字符 ⇒ 工具有截断)。
|
||||
⇒ **「减会话长度 / 少用 web_fetch」对内存几乎无用**;占用主体是**代码与插件加载**。
|
||||
|
||||
### 插件内存成本(逐个 `import` 实测)
|
||||
|
||||
| 插件 | rss 增量 | 备注 |
|
||||
|---|---|---|
|
||||
| `dsh-univer-office` | **+65.1 MiB** | 单文件 bundle 5400 行 / 6 MB,**顶层静态 import** 拉起重型依赖(连 `@puppeteer/browsers` 都在),全文件仅 2 处动态 import |
|
||||
| `libsql` | +7.8 MiB | 原生绑定 |
|
||||
| 平台自研 ×3(portal-entry / workspace-scoped-picker / business-plugins) | **0.0 MiB** | 自研插件写法是轻的 |
|
||||
| `puppeteer-core` | **0.0 MiB** | 只 `import` 主入口、不启动浏览器 ⇒ **懒加载确实有效** |
|
||||
|
||||
⇒ **成本由「装了什么」决定,不是「装了几个」** —— 一个重型插件 ≈ 无穷多个轻量插件。
|
||||
⇒ 治理优先级:**卸重型插件 > 让插件懒加载 > 折腾堆参数**。
|
||||
⚠️ 插件的 `pnpm remove`(禁用)**必须重启实例**才真释放 —— Node 模块缓存不卸载。
|
||||
|
||||
**诊断结论的落地口径**:算「V8 堆上限 + 插件 + 堆外 + 缓存」总和是否 ≥ `MemoryMax`。若 ≥,就是**配额本身零余量**(配置问题,不是 bug)——此时可调项只有:① 压 `--max-old-space-size`(代价:更易撞路径 A)② 提高配额(受宿主物理内存限制)③ 减少单会话负载。
|
||||
|
||||
## 已知与本平台不兼容的插件
|
||||
|
||||
### `dsh-univer-office`(2026-09-12 定性:**架构级不兼容,配置不可修**)
|
||||
|
||||
两条**独立**的阻碍,缺一都打不开:
|
||||
|
||||
| 阻碍 | 机理 | 证据 |
|
||||
|---|---|---|
|
||||
| **Host → Gateway** | 插件启动 bundled Gateway(默认 `127.0.0.1:9080`),实例内 node 要连它做健康检查;而平台 nft `dsh_egress` 对 `skuid 100000-199999 → 127.0.0.0/8` 是 `reject with tcp reset` ⇒ 永远连不上 | `univer_new` 恒报 `bundled Gateway did not become ready within 10000ms`;实例内 `curl 127.0.0.1:908x` → `000` / Connection refused |
|
||||
| **Browser → Viewer** | **源码硬编码** `gateway = http://127.0.0.1:${port}`、`viewerUrl = ${gateway}/?file=...`,client 拿它当 **iframe src** ⇒ 浏览器去**用户自己电脑**的 127.0.0.1 找 Viewer,那里没有服务 | `grep -o "viewerUrl: [^,]*" lib/index.js`;`grep -oE "http://127\.0\.0\.1:[^\`\"']*"` |
|
||||
|
||||
⇒ 它的 Viewer **假设「浏览器与实例同机」(本地部署场景)**,与托管多租户平台根本不兼容。**解封 loopback 也没用**——第二条拦在浏览器侧。
|
||||
⇒ 只能改插件(viewerUrl 改走平台代理的相对路径)。**治理结论:候选池应下架 / 用户应禁用**(顺带省 **65 MiB**)。
|
||||
|
||||
**替代路径**:让 agent 用 python(openpyxl + python-docx + matplotlib)直接产出 xlsx/docx,已验证可行(2026-09-12)。
|
||||
|
||||
⇒ **评估标准已立档**:`04-调整方案/75`(**托管友好性 H1–H4** + **资源成本**;自研插件改造规范 **R-a~R-e**)。今后候选池导入与自研插件验收按该档的检查项走 —— 核心判据一句话:**「插件的一切对外交互,是否都能走平台已有的那一条入口」**(浏览器侧只用相对路径;实例侧不依赖 loopback 网络服务)。
|
||||
|
||||
## 注入层 / 浮层的**真机验证配方**(2026-09-13 实测,4 轮才摸清)
|
||||
|
||||
**为什么不能用 curl 验**:`src/supervisor/proxy.ts` 的注释写得很明白 —— **「curl 不带 Accept-Encoding,故此前验证是假阳性」**。注入只在「客户端接受 HTML → 代理把上游 `accept-encoding` 改写成 `identity` → 上游回未压缩 HTML」时发生;curl 一不留神就绕过这个判定,于是**"有标记"不代表脚本能在浏览器里跑**,而**"没标记"也可能是 curl 自己造成的**。⇒ **判定注入层是否活着,必须用真浏览器。**
|
||||
|
||||
**前置(R4 允许的临时会话,别用真实账号)**:
|
||||
|
||||
```bash
|
||||
# 服务器上:建一个 10 分钟自过期的 guest 会话(ip=127.0.0.1 / ua=poc-curl2)
|
||||
TOKEN=$(ssh bt-server 'cd /opt/dshs && /usr/local/bin/node mksess-guest.cjs')
|
||||
# 用完立刻删(否则留下真实可用的会话行)
|
||||
ssh bt-server "cd /opt/dshs && /usr/local/bin/node -e \"const c=require('crypto');const D=require('/opt/dshs/node_modules/better-sqlite3');const db=new D('/var/lib/dshs/dshs.db');console.log(db.prepare('DELETE FROM sessions WHERE token_hash=?').run(c.createHash('sha256').update('$TOKEN').digest('hex')).changes);db.close();\""
|
||||
```
|
||||
|
||||
**工具**:`playwright-core` + `channel:'chrome'`(装在 `E:\ProgramData\.workbuddy\binaries\node\workspace`;须 `createRequire` 指向该目录,并在该目录内跑)。把 `sid` cookie 写进 context(`domain:'.ai1net.com'`, `secure:true`, `sameSite:'None'`),再 `goto https://<user>.ai1net.com/`。
|
||||
|
||||
**判定用的哨兵与元素(直接 `page.evaluate` 读)**:
|
||||
|
||||
| 判据 | 说明 |
|
||||
|---|---|
|
||||
| `window.__dshRecover === 1` | **recovery.js 全文执行完毕的哨兵**(脚本第 23 行设)—— **最强证据**,比找 DOM 元素可靠 |
|
||||
| `window.fetch.toString()` 不含 `[native code]` | 证明监控层已装载(脚本包装了 `fetch` / `EventSource` / `WebSocket`) |
|
||||
| `#__dshAssistBar` 存在 | assist.js 跑起来了(它 `mount()` 时建这个容器) |
|
||||
| `#__dshRecover` + 文案 + `#__dshRetryBtn` | 浮层级 |
|
||||
| `document.scripts` 里含 `__dsh` 的 `<script>` 数 = 2 | 两个注入脚本都下发了 |
|
||||
|
||||
**4 个坑(每个都让我误判过一次)**:
|
||||
|
||||
1. **`page.route` 拦不住 WebSocket** ⇒ 想用「拦 `/api/*`」或 `ctx.setOffline(true)` 造断流**逼不出浮层**(dsh 的主链路是 WS/SSE)。`setOffline` 也不会立即掐断已建立的 WS。
|
||||
2. **`probe()` 有 15 秒节流**(`lastProbe`),而心跳每 25 秒跑一次 ⇒ **心跳会把节流窗口占掉**,你手动 `dispatchEvent(new Event('focus'))` 往往被**静默丢弃**。要么按心跳节奏等(`status` 恒 `running:false` → 连续两次软失败 ≈ 50 s),要么确保距上次探针 ≥15 s。
|
||||
3. **`recover()` 成功后 0.7 秒内 `location.replace()` / `location.reload()`** ⇒ 浮层只亮 0.7 s。**任何 ≥2 秒的轮询都会全踩空**,然后你会得出"浮层没出现"的**错误结论**。
|
||||
4. **`#__dshAssistBar` 本身是容器**,里面才是「📁 我的文件」「🧭 能力」两个 button —— **点容器无效**,要用 `page.click('#__dshAssistBar button:nth-child(1)')`。
|
||||
|
||||
**✅ 正解(纯客户端、零生产副作用)**:拦 `status` 仿真未运行 + **把 `/api/dsh/enter` 挂住不回**,浮层就会**停住不下跳**,从容观测:
|
||||
|
||||
```js
|
||||
await page.route('**/api/dsh/status*', r => r.fulfill({ status:200, contentType:'application/json', body:'{"running":false}' }))
|
||||
await page.route('**/api/dsh/enter*', () => { /* 故意永不回包 → 浮层停住 */ })
|
||||
// 之后每 2 秒采样 #__dshRecover 的可见性与文案即可
|
||||
```
|
||||
|
||||
2026-09-13 用这套验证拿到的实测结果:文案「工作区已休眠,正在唤醒…(**已等待 N 秒**)」**倒计时逐秒递增**、`DSH · AUTO RECOVERY`、AI 核心视觉 + 进度条;助手面板点开后正确列出工作区目录。**全程零页面错误。**
|
||||
|
||||
## 相关
|
||||
|
||||
- 排查完整案例:`04-调整方案/55`(会话档位)、`04-调整方案/16`(崩溃循环)
|
||||
- 实例配额与内存优化:`04-调整方案/58`
|
||||
- 会话档位是「会话创建时播种」的,老会话不跟随平台默认:`04-调整方案/33`
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 变更历史(原 frontmatter · 逐字保留)
|
||||
|
||||
```text
|
||||
name: dsh-instance-diagnose
|
||||
description: DSH 多租户平台(ai1net.com / 47.77.182.89)单个用户实例的「故障诊断」技能,重点是内存 / OOM / 实例崩溃重启。当出现「实例打不开」「实例起不来」「实例挂了 / 崩了 / 一直重启」「报 503 / 502」「会话突然报错/中断」「跑着跑着断了」「响应慢」「怀疑内存不够」时触发。⚠️ 与 `dsh-desktop-dev-shell` 的边界:本技能管**服务器上的用户实例(含平台代理层)**;「本机 dsh 实例起不来」(在 Windows 上跑官方 dsh 源码)属对方。核心:先做第 0 层「跨机日志取证」(dshlog),再分「服务级 / 实例级 / 会话级」三层定位,再用 cgroup 内存三件套定量归因,最后在隔离 cgroup 里复现——**绝不在生产实例上做压力测试**。
|
||||
version: 1.0.0
|
||||
updated_at: 2026-09-22
|
||||
last_change: 【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v1.2.0);正文与历史中的版本号为当时记录,未改动。此前 v1.2.0(2026-09-22):补触发词「实例起不来 / 实例挂了·崩了·一直重启 / 报 503·502」—— 实测盲区:用户说「实例起不来」时原描述命不中(只有「实例打不开」),会误落到 `dsh-desktop-dev-shell`;并显式写清与后者的边界(服务器用户实例 vs 本机源码实例)。此前 2026-09-15:死亡路径由 2 条扩到 4 条 —— 新增 C(bwrap 挂载点被嵌套 tmpfs 遮蔽(`Can't chdir to <正常路径>`))与 D(迁移复现不了启动参数(`Can't chdir to :` 空路径)),含分诊判据与「中间目录必须统一前置」的修法。
|
||||
agent_created: true
|
||||
```
|
||||
@@ -0,0 +1,201 @@
|
||||
# 业务插件故障(②)
|
||||
|
||||
> **归属**:技能 `dsh-diagnose` · 详情档(主干 `../SKILL.md` §2)
|
||||
> **本档覆盖**:原技能 `dsh-plugin-diagnose` **全文**
|
||||
> **原行段**:原 `SKILL.md` 全文 186 行,其中 frontmatter 占前 8 行已剥离 ⇒ 本档承载第 9–186 行
|
||||
> **搬运方式**:**逐行未改**(⛔ 未删任何判据 / 命令 / 事故事实)
|
||||
> ⚠️ 本技能已由 `dsh-diagnose` **合并**,原名 `dsh-plugin-diagnose` 退役 ⇒ 见到该名按本档读。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# DSH 业务插件故障诊断
|
||||
|
||||
> 适用对象:**候选池里投放的业务插件**(`dsh-univer-office`、`dsh-plugin-mcn-suite`、`@dsh-local/*` 等)。
|
||||
> 实例本身的故障(打不开 / OOM / 崩溃重启)用 `dsh-instance-diagnose`,不是本技能。
|
||||
|
||||
## 0. 第一原则:**静默失败要当默认假设**
|
||||
|
||||
业务插件最容易「坏得没声音」:host 工具照跑、日志干净、界面就是没有东西。
|
||||
所以排查顺序永远是「**先证明某一层到底有没有活着**」,而不是先读代码猜。
|
||||
|
||||
## 1. 三层归属(先定层,再动手)
|
||||
|
||||
| 层 | 怎么判「它活着」 | 死了什么样 |
|
||||
|---|---|---|
|
||||
| **host 半边**(`lib/index.js`) | 插件的 DSH 工具能调通(`univer_*` / 业务工具返回 `ok:true`) | 工具直接报「未知工具」 |
|
||||
| **client 半边**(`lib/client.js`) | 页面 `__DSH_BOOT__` 里**有**该插件行,且槽位探针能看到它的占位者 | **有无都没痕迹**:无卡片/无 dock/无服务、无报错 |
|
||||
| **网关 / 原生绑定** | 网关进程在、socket 能连、`/[健康路径]` 200 | 进程崩、连接被 reset、`.node` 加载报错 |
|
||||
|
||||
## 2. 客户端半边**静默挂死**(最隐蔽的一类,2026-09-13 实测)
|
||||
|
||||
**症状**:host 工具全好,浏览器里**一个 UI 元素都没有**,Console 无报错。
|
||||
|
||||
**机理**:dsh 客户端加载器(`@deepseek-ai/dsh-client-modules`)对每个插件行解析 `package.json` 的
|
||||
`dsh.client` → `inject`(**包名**列表);**cordis 的 `inject waiting`** 决定 fiber 何时 `apply()`。
|
||||
⇒ **只要有一条 inject 永远不可满足,`apply()` 永不执行**(连注册都没有,静默)。
|
||||
|
||||
**判据:`inject` 差集**(一把尺子,通用):
|
||||
|
||||
1. 取实例页面(**不碰用户浏览器**,用临时会话走平台代理):
|
||||
```bash
|
||||
SID=$(node /opt/dshs/mksess-guest.cjs) # 需对应用户;用完必须删会话
|
||||
curl -s -H "Host: <用户名>.ai1net.com" -H "Cookie: sid=$SID" http://127.0.0.1:3080/ -o page.html
|
||||
```
|
||||
2. 从页面里取**实际下发的客户端插件集合**(权威清单,别用 manifest 正则——会漏行):
|
||||
```python
|
||||
import re; s=open('page.html',encoding='utf-8',errors='ignore').read()
|
||||
served={p.split('/client.js')[0] for u in re.findall(r'/plugins/\?\?[^"\'&]+',s)
|
||||
for p in u.split('??',1)[1].split(',')}
|
||||
```
|
||||
3. 解析 `__DSH_BOOT__` 里目标插件的行 → 取它的 `inject` → **求差集**:
|
||||
```python
|
||||
rows=re.findall(r'\{"id":"([^"]+)","url":"[^"]*","rev":"[^"]*","inject":\[(.*?)\]\}', s)
|
||||
need=re.findall(r'"([^"]+)"', dict(rows)['<插件包名>'])
|
||||
print([x for x in need if x not in served]) # 非空 = 该客户端 fiber 永久挂起
|
||||
```
|
||||
4. **差集非空 = 确诊**。两个常见根因:
|
||||
- **inject 了被平台角色补丁禁用的官方包**。平台对普通用户禁:
|
||||
`@deepseek-ai/dsh-client-ui-settings-models` / `-settings-plugins` / `-settings-plugin-inventory` /
|
||||
`@deepseek-ai/dsh-client-ui-cordis` / `dsh-client-hmr` / `dsh-host-directory-picker-auto`
|
||||
(`ensure-role-profile-patch.cjs`,档案 15;**admin profile 不禁**——A/B 对照一眼能看出来)。
|
||||
- **inject 了根本不存在的包名**(如 `@deepseek-ai/dsh-client-runtime`)。
|
||||
⇒ 直接 `ls /usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/ | grep <名字>` 核一下。
|
||||
5. **修法**:从插件 `package.json` 的 `dsh.client.inject` **移除**该包(或改成真实存在的)。
|
||||
⚠️ **别混两个 inject**:`package.json` 的 `dsh.client.inject` 是**包名**(模块级依赖);
|
||||
客户端源码里的 `export const inject = ['slots','locale','conversation']` 是**服务名**。前者不可满足会**整体不 apply**,后者只影响个别 `ctx.inject` 块。
|
||||
|
||||
**第二类(2026-09-20 实测):组件挂上了、点击却「没反应」—— 外部契约失效**(与上面同族,但更难看出)
|
||||
|
||||
- 症状:侧栏入口**看得见**、Console 无报错,点下去 **UI 毫无变化**(不是"没渲染",是"面板宿主找不到")。
|
||||
- 真因:客户端靠 **DOM 契约(槽位名)** 找宿主 ⇒ dsh 升级**改了槽位名**后 `querySelector` 恒 null。
|
||||
实测(0.1.5-rc.1):`conversation` → **`main.conversation`**;`document.querySelector('[data-slot="conversation"]')` 恒 null
|
||||
⇒ 判据 = `!!document.querySelector('[data-mcn-panel]')` 为 **false**(同时 `#/mcn` 的 hash 已变、入口高亮已生效)。
|
||||
- 同族还有 **官方 API 形态变了**:`ctx.locale.getLocale()` 由「返回 id 字符串」变成「返回**快照对象** `{active,…}`」
|
||||
⇒ `String(obj)` 恒为 `"[object Object]"` ⇒ 受控 `<select>` 只显示第一项 ⇒ 看着像"切语言没反应"(其实界面语言可能是对的)。
|
||||
判据:`document.documentElement.lang` 与控件 `value` **不一致**。
|
||||
- 取证姿势(浏览器实测 · 平台实例):`mksess.cjs` 造临时会话 → `agent-browser`。⚠️ **`set headers` 注 Cookie 无效**,
|
||||
改用 `open https://ai1net.com/login.html` + `eval document.cookie='sid=…; domain=.ai1net.com; path=/'` 再 `open` 实例子域;
|
||||
⚠️ 前台会被沙箱 SIGTERM ⇒ 先 `run_in_background` 起一次守护,后续命令加 `> 文件 2>&1`(**不走管道**)即可前台跑。用完**删会话**。
|
||||
- 详版 ⇒ **`PLAYBOOK-实例与插件坑 §20`** / 档案 **`04-调整方案/143-MCN工作台入口失效与语言切换显示修复.md`**。
|
||||
|
||||
**预期管理(必须一起告知用户)**:
|
||||
- 插件激活**之前**产生的**旧回合不会回溯渲染**(预览卡片由 conversation turn 定义驱动)⇒ 要**新跑一轮**才看得见。
|
||||
- 槽位探针里 `active:false` 常常**不是故障**:很多组件在「没内容可显示」时 `return null`
|
||||
(如 univer dock:`if (operation.worktreeId === null) return null`),同槽里恒有内容的项才是 `true`。
|
||||
- 某类槽(chain 类,如 `conversation.chat.turnTail`)的探针输出**不带名称字段** ⇒ 「按名字找不到」是**探针局限**;
|
||||
改用**注册时写的常量**(如 `priority: -10`)去对号。
|
||||
- **不是所有插件都注册 `tool.*.toolview`** ⇒ 「工具卡片没变样」≠ 插件坏了。
|
||||
|
||||
## 2.5 host 半边「桥接类」插件:**连不上 / 静默无限重连**(2026-09-21 实测 · carbon 插件线)
|
||||
|
||||
**症状**:实例起得来、`dsh web:` 正常、日志干净,但**会话里一个桥接工具都没有**(如 `mcp__*`)。
|
||||
这类插件(MCP 桥 / 外部服务代理 / 远程 API 插件)的 host 半边**只在连上之后才注册工具**
|
||||
⇒ 「连不上」的表现与「插件压根没装」**完全一样**。
|
||||
|
||||
⚠️ **先破一个假象**:内核 cordis 的默认日志 exporter **只写内存环形缓冲、不落 stdout**
|
||||
⇒ 内核插件(含 `@deepseek-ai/dsh-mcp-client`)的报错在实例日志里**看不见**。
|
||||
第一件事就是把日志挂一份出来(公开 API):
|
||||
|
||||
```js
|
||||
ctx.logger.exporter({ colors: 0, levels: { default: 3 }, export: (m) =>
|
||||
process.stdout.write(`[log] ${m.type} ${m.name}: ${(m.args || []).map(String).join(" ")}\n`) });
|
||||
```
|
||||
|
||||
挂上后立刻能看到 `warn mcp-client: connection attempt failed: …`(不挂就永远在猜)。
|
||||
|
||||
**诊断配方(四层,逐层排除,⛔ 别跳步)**
|
||||
|
||||
| # | 尺子 | 判读 |
|
||||
|---|---|---|
|
||||
| 1 | **上游直连**:官方 SDK / curl 直打端点 | 通 ⇒ 上游没问题,问题在 dsh 侧 |
|
||||
| 2 | **实例进程内出站**:插件里 `fetch` 打同一 URL | 通 ⇒ 不是沙箱 / nft / 代理拦;不通 ⇒ 查实例 egress 规则 |
|
||||
| 3 | **出站抓包**:包一层 `globalThis.fetch`,打 `method / url / rpc / status`,失败时打**实发 header**(秘密只留尾 5 位) | 🔴 关键:能看出「**从哪一代请求开始丢凭据**」 |
|
||||
| 4 | **硬编码对照**:把秘密**直接写进配置**(仅测试用) | 立刻全绿 ⇒ **病根 = 凭据交付通道**,与上游 / 协议 / 网络无关 |
|
||||
|
||||
**本线实测真因(值得背下来)**:内核 `@deepseek-ai/dsh-mcp-client` 建传输时用
|
||||
`{ requestInit: { headers: config.headers } }` —— **按引用捕获 headers**;且每代重连都从
|
||||
**同一个 config 对象**重建 ⇒ **第 1 代能带上运行期注入的凭据,第 2 代起每次 `initialize` 都 401**
|
||||
(抓包实发 header 只剩 `accept` / `content-type`)⇒ 无限退避重试、工具永远挂不上。
|
||||
「原地改内存 header」与「每周期重新注入」两种修补**都不收敛**
|
||||
⇒ 结论:**插件自持连接**(自己用 `fetch` 实现 streamable-HTTP:凭据只留内存、每请求自带 header、失败响亮报出)。
|
||||
|
||||
**可见性判据(「注册成功」≠「模型可见」,必须分开对账)**
|
||||
|
||||
```js
|
||||
const list = tools.schemas(); // 省略 scope ⇒ 全局视图
|
||||
const set = new Set(list.map((s) => s.name));
|
||||
const missing = myNames.filter((n) => !set.has(n));
|
||||
console.log(`schemas()=${list.length} 缺=${missing.length} get(首个)=${!!tools.get(myNames[0])}`);
|
||||
```
|
||||
|
||||
- ⛔ **读法陷阱**:⛔ 别用 `Object.getOwnPropertyNames(tools.layers.global.tools.data)` 数工具 ——
|
||||
`new Map()` 的属性名是**空数组**,会把「有 N 个工具」误读成 `size=0`(本线因此误判过一轮)。
|
||||
必须**先判 `instanceof Map`** 再读 `size`。
|
||||
- `register()` 返回 disposer 只说明「调用没抛错」;**能不能被模型看见**要用上面这把尺子另证。
|
||||
|
||||
## 3. 原生绑定 / glibc 门禁
|
||||
|
||||
**第一步永远先直测**(别读代码猜):
|
||||
```bash
|
||||
ldd --version | head -1 # 宿主 glibc(本平台 = 2.32,Alibaba Cloud Linux 3)
|
||||
node -e "try{require('<那个 .node>');console.log('OK')}catch(e){console.log(e.message.split('\n')[0])}"
|
||||
```
|
||||
**两类绑定,对策不同**:
|
||||
|
||||
| 类型 | 例子 | 对策 |
|
||||
|---|---|---|
|
||||
| **有 JS/上游回退开关** | `engine-formula-rust-binding`(`useRustEngine:false` 走 JS 引擎) | **资产侧垫片**:包一层同名导出,**绑定装不上才**强制关掉(可用则原样交上游 ⇒ 宿主升级 glibc 后自动恢复) |
|
||||
| **无回退** | `exchange-node-binding`(Office 导入导出) | **只能在宿主/镜像层解决**(换 glibc ≥ 2.35 的基底)⇒ 上报用户决策,别硬凑 |
|
||||
|
||||
**关键警示(2026-09-13 踩过)**:
|
||||
- 同一条 glibc 门禁可能被**多个进程**撞到 —— 同一插件在 **worker** 与 **gateway** 里可能**都要**建投影;
|
||||
**只给 worker 打垫片不够**:网关崩掉的表现是「**写入报错但数据已落盘**」(提交时崩 → 客户端读不到响应 → 连接 reset ⇒ 假错误 ⇒ 有重复写入风险)。
|
||||
- 打垫片要**同时**处理 ESM(`import.meta.url` 可用)与 **CJS**(esbuild 把 `import.meta` 降级成 `{}` ⇒
|
||||
`createRequire(undefined)` **启动即崩**);CJS 产物里让垫片显式 import 该包的 **CJS 入口**。
|
||||
|
||||
## 4. 「改了却没生效」的四类(按可能性排序)
|
||||
|
||||
1. **打包顺序把垫片废掉**:模块级 `const` 被降级为 `var` 整体提升 ⇒ 垫片跑到时是 `undefined`,
|
||||
比较恒 false ⇒ **静默回退原实现**。判据:在**产物**里插一行探针打印该常量。
|
||||
**修法**:垫片内用**函数内字面量**,不依赖任何模块级常量。
|
||||
2. **插件没真正换上新包**:`mine/apply` 对**已启用**插件是 **noop** ⇒ 必须**停用 → 启用**两步;
|
||||
任务终态是 **`success`**(不是 `done`,轮询别只判 done,否则空转)。改完**核 md5**(实装产物 == 你构建的)。
|
||||
3. **客户端包按 `rev` 缓存**:客户端半边改动后**必须硬刷新**页面;顺带记住**硬刷新会取消在途的
|
||||
`platform: client` 工具查询**(由页面回答),而 **host 侧调用不受影响** —— 别把它当链路故障。
|
||||
4. **同 `version` 号不会被重装**:改完 `lib/` 却忘了升 `package.json` 的 `version` ⇒
|
||||
`dsh plugin add file:<tgz>` 之后跑的**还是旧代码**(本线因此被误导数轮排查)。
|
||||
判据 = 装完立刻做**内容门禁**:`grep -q "<新符号>" <profile>/node_modules/<pkg>/lib/index.js`,
|
||||
未命中就先 `rm -rf` 该包目录再装。⇒ **改代码必升 version**(0.1.0→0.1.1→0.1.2 …)。
|
||||
|
||||
## 5. 验证纪律(每条结论都要有可复现的命令)
|
||||
|
||||
- **结构自检 ≠ 能打开**:`zipfile.testzip()` 只证明「能解压」。文档类产物要上**严格解析器**
|
||||
(`python-docx` / `openpyxl` / `python-pptx`;装进隔离 venv)。本次正是靠它抓到 `w:tbl` 缺必需子元素 `w:tblGrid`。
|
||||
- **改完必须在真机跑一次**,并尽量用**租户 uid**(`setpriv --reuid=<uid>`)+ 真实 socket/真实数据。
|
||||
- 找不到「日志」时别下结论:业务插件的 stdout **不落 journald**,实例 journal 只有 systemd 启停两行。
|
||||
- **`grep` 会骗人**:esbuild 的 CJS 导出用 getter(`__toCommonJS` / `apply: () => apply`),
|
||||
`grep "exports.apply"` 会给你**假阴性** —— 要按打包器形态去找。
|
||||
|
||||
## 6. 平台侧事实(省得反复查)
|
||||
|
||||
- 插件投放:admin `POST /api/plugins/business`(`{filename, file:<base64>, trust?}`)→ 池 `/var/lib/dshs/business-plugins/`;
|
||||
再 `mine/apply` 启用。临时会话:`node /opt/dshs/mksess{,-guest}.cjs`,**用完必须**
|
||||
`DELETE FROM sessions WHERE user_agent='poc-curl2'`。
|
||||
- 技能是**显式清单**注册(`src/host/skills/plugin.ts` 的 `DEFINITIONS`),**不是扫目录** ⇒ 加技能要**同时**改清单并重建 `lib`。
|
||||
- 起自带网关做复现时:`NODE_PATH` 要带 `…/node_modules/.pnpm/node_modules`,否则 `libsql`/`ws` 等外部依赖解析不到,会被误判成插件坏了。
|
||||
- 平台「我的文件」面板 = **浏览 + 下载,没有预览**;**只有 `.univer` 能在线预览**,Office 文件在浏览器里没有原生渲染。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 变更历史(原 frontmatter · 逐字保留)
|
||||
|
||||
```text
|
||||
name: dsh-plugin-diagnose
|
||||
description: DSH 多租户平台(ai1net.com / 47.77.182.89)**业务插件**的故障诊断技能 —— 专治「工具能用但浏览器里什么都没有」「原生绑定装不上」「明明改了却没生效」这类**静默失败**。当出现「插件没 UI / 预览不出现 / 卡片不渲染」「**装了插件但界面没变化 / 界面跟没启用一样**」「导入导出报 GLIBC 版本」「网关崩了但数据其实写进去了」「改了包上传了还是老行为」时触发。⚠️ **与 `dsh-desktop-dev-shell` 的边界**:本技能管**服务器实例里的插件**(投放后没生效 / 启用了没 UI);「**本机**源码构建的壳里挂插件、界面不出现」属对方。核心:先按 **host 半边 / client 半边 / 网关·原生绑定** 三层归属定位,再用「**inject 差集**」「**glibc 直测**」「**产物插探针**」三把尺子取证——**每一次都要有可复现的命令**。
|
||||
version: 1.0.0
|
||||
updated_at: 2026-09-22
|
||||
last_change: 【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v1.3.0);正文与历史中的版本号为当时记录,未改动。此前 v1.3.0(2026-09-22):补触发词「装了插件但界面没变化 / 界面跟没启用一样」并写明与 `dsh-desktop-dev-shell` 的边界 —— 实测:用户说这句时原描述命不中本技能(只有对方声明了同一说法),服务器实例语境下会加载错。此前 v1.2.0(2026-09-21):新增 **§2.5 host 半边「桥接类」插件:连不上 / 静默无限重连** —— 含「cordis 默认 exporter 不落 stdout,必须先挂 logger.exporter 才能看见内核插件报错」这条前提,四层诊断配方(上游直连 → 实例内 fetch → 出站抓包 → 硬编码对照),以及本线实测真因(内核 mcp-client `requestInit.headers` 按引用捕获 ⇒ 第 1 代带凭据、第 2 代起 401);并补「注册成功 ≠ 模型可见」的公开面回读判据与 `Map` 属性名空数组的读法陷阱;§4 从三类扩为四类(新增「同 version 号不会被重装 ⇒ 改代码必升 version + 内容门禁」)。v1.1.0(2026-09-20):§2 新增「第二类:组件挂上了、点击却没反应」—— dsh 升级改了**外部契约**(槽位名 `conversation`→`main.conversation`;官方 `ctx.locale.getLocale()` 由返回 id 变成返回**快照对象**)导致 `querySelector`/取值静默失效;补浏览器实测姿势(`agent-browser` 的 `set headers` 注 Cookie 无效 + 前台会被沙箱 SIGTERM 两个坑)与判据。v1.0.0:首版。由 2026-09-13 一整天 guest 实例 univer 插件四轮排查沉淀(worker socket 垫片被打包顺序废掉 / 宿主 glibc 2.32 < 绑定要求 2.35 / 网关提交 changeset 也建投影致其崩溃 / client 半边因一条不可满足的 inject 永久挂起),含 4 类真因、3 套取证配方与 8 条实测踩坑。
|
||||
agent_created: true
|
||||
```
|
||||
@@ -0,0 +1,224 @@
|
||||
# 跨机状态回流(③)
|
||||
|
||||
> **归属**:技能 `dsh-diagnose` · 详情档(主干 `../SKILL.md` §3)
|
||||
> **本档覆盖**:原技能 `dsh-distributed-state-readback` **全文**
|
||||
> **原行段**:原 `SKILL.md` 全文 209 行,其中 frontmatter 占前 5 行已剥离 ⇒ 本档承载第 6–209 行
|
||||
> **搬运方式**:**逐行未改**(⛔ 未删任何判据 / 命令 / 事故事实)
|
||||
> ⚠️ 本技能已由 `dsh-diagnose` **合并**,原名 `dsh-distributed-state-readback` 退役 ⇒ 见到该名按本档读。
|
||||
|
||||
---
|
||||
|
||||
|
||||
# DSH 跨机状态回流:判缺陷 or 判缺失能力,以及通路怎么选
|
||||
|
||||
## §0 这个技能治什么
|
||||
|
||||
DSH 是 **Manager + worker** 形态:用户的 profile 树、实例、沙箱都长在 **worker** 上,
|
||||
而平台 API、控制面 DB、UI 都在 **Manager** 上。**只要一个状态"真身在 worker、读取在 Manager",就必然出现"读到的是假的"**。
|
||||
|
||||
典型症状(⚠️ 都是**静默**的,不报错、不崩,只是值不对):
|
||||
- `/api/plugins/mine` 的 `enabled` **跨机时恒为 `false`**
|
||||
- admin 面看到的启停状态与用户实例实际不一致
|
||||
- 用户"启用了但刷新就没了"
|
||||
- 某个字段**永远是旧值** / 永远是初始值
|
||||
- 命令(apply / launch / stop)下发成功,但**状态回不来**
|
||||
|
||||
⛔ **最容易犯的错**:把它当**代码缺陷**去修(去查 enable 是不是失败了、是不是权限问题、是不是包没装上)。
|
||||
**先按 §1 判性质**,判错方向会白烧一整轮。
|
||||
|
||||
---
|
||||
|
||||
## §1 第一步:判「缺失能力」还是「代码缺陷」(三条排除)
|
||||
|
||||
**动作**:挑一个现场实例,**独立取证"那件事在原机上到底成没成"**。
|
||||
三条都排除掉 ⇒ **是缺失能力,不是缺陷**(⇒ 该立项做回流,而不是 debug)。
|
||||
|
||||
| # | 要排除的假设 | 怎么证(在**实例所在那台机**上取,⛔ 不是 Manager) |
|
||||
|---|---|---|
|
||||
| 1 | 「操作本身失败了」 | 看**目标机的文件系统**:软链/目录/写入的声明**实际存在吗**?(`ls -la` + 解一下软链能不能落到 `package.json`) |
|
||||
| 2 | 「执行环境没配好」 | 看**目标机的进程命令行**:`tr '\0' '\n' < /proc/<pid>/cmdline` —— 挂载/参数是否真的注入了? |
|
||||
| 3 | 「调用链断了」 | 看**下发是否真走到远端**:调用端有没有"已转发到 X 机"的日志/返回值? |
|
||||
|
||||
**判据一句话**:
|
||||
> **原机上成了 + Manager 侧读不到 = 缺失能力(拓扑必然),⛔ 不是缺陷。**
|
||||
|
||||
✅ 本判据在 DSH 上的实证:客实例 `uid 100002` 在 106(worker)上 `enable storyforge` —— ① 106 上软链与 `package.json` 的 `file:` 声明**都在**;② 沙箱命令含 `--ro-bind-try` 共享层挂载;③ `applyPlugins` 确实转发到了 106。
|
||||
⇒ 三条全排除 ⇒ 性质 = **单向下发、无状态回流**(有去路、没回路)。
|
||||
|
||||
---
|
||||
|
||||
## §2 第二步:定位"缺的到底是哪条路径"(⚠️ 最省时间的一条)
|
||||
|
||||
**别一口气说"回流缺失",先把读/写两条路径分开看**:
|
||||
|
||||
| 路径 | 形态 | 缺不缺 |
|
||||
|---|---|---|
|
||||
| **写路径**(apply / launch / stop) | Manager → worker(下发) | 多数**已有** |
|
||||
| **响应路径**(任务返回值) | 装配返回**对端读出来的真值** | 多数**已有** ✅ |
|
||||
| **读路径**(`GET` 那种"随时查状态") | Manager 读**自己本机盘** | 🔴 **常缺的就是这条** |
|
||||
|
||||
**判据**:若"操作完成后立刻返回的值是对的、但刷新页面后变错" ⇒ **缺的只有读路径**。
|
||||
⇒ 设计目标应写成「**让读路径也拿到真值**」,⛔ **不是**「把返回值再采一遍 / 加个缓存」。
|
||||
|
||||
⚠️ 实测教训:DSH 里 `apply` 的响应**已经是真值**(回的是对端 `bundles`),前端切换后状态正常;只有页面重读 `GET` 时才变 `false`。**若不先做这一步分辨,会把"加缓存"当成解法 ⇒ 造出第三个真相。**
|
||||
|
||||
---
|
||||
|
||||
## §3 第三步:通路三候选,用**物理可行性**筛(⛔ 不是用"哪个更好"筛)
|
||||
|
||||
三条候选的传统比法是"延迟/成本/复杂度",**在 DSH 上先要用"物理上走不走得通"筛**:
|
||||
|
||||
### 候选 A · Manager 主动拉 ✅ 默认首选
|
||||
|
||||
**做法**:复用**已有的** Manager→worker 调用,把它的**返回值加宽**。
|
||||
|
||||
🔴 **先找"已经在跑的调用"** —— DSH 上已有一条:`src/supervisor/leased-spawner.ts` 的 `reportHost()`
|
||||
**每次心跳就 `GET <agentUrl>/healthz` 一次**,却只读回 `{ok, instances}`。
|
||||
⇒ **最小改动 = 把响应体加宽**(加 `perUser`),⛔ 不必新建链路。
|
||||
|
||||
优点:零新增网络口、零新增凭据、**不新增入站面**、失败语义(心跳断了 = 不可达)**白捡**、与既有归属判据天然一致。
|
||||
缺点:非事件驱动(要等心跳周期);响应体随用户数增长(**必须设上限**);`/healthz` 语义被拓宽 ⇒ 防"重响应拖垮存活探测"。
|
||||
|
||||
### 候选 B · 共用台账 ⚠️ **通常不能单独成立**
|
||||
|
||||
**做法**:apply 成功时写控制面台账,读时查台账。
|
||||
|
||||
🔴 **致死点(先查这个)**:**对账(reconcile)是谁驱动的?**
|
||||
DSH 上 reconcile 由 **worker 本机定时器**驱动(`src/worker/agent.ts` 的 20 s 本地定时器),
|
||||
**Manager 不在场** ⇒ "apply 时写台账"**根本收不到那次变更** ⇒ **台账覆盖不了 reconcile 路径**。
|
||||
|
||||
⇒ B **不是独立候选**,只能当 A 或 C 的**缓存**。判据:**台账一旦落表就会有人当权威读 ⇒ 必须明确它只是缓存。**
|
||||
|
||||
优点:事件驱动、零延迟;admin 一次查询看全域;与既有台账同库。
|
||||
缺点:**引入第二个真相**(台账 vs 对端 profile 必漂);**覆盖不到 reconcile**;多 Manager 联邦下"落哪个库"会错(用户迁机后台账残留);不可达时无判据。
|
||||
|
||||
### 候选 C · worker 主动上报 ⚠️ 常撞红线
|
||||
|
||||
**做法**:worker 在状态变更时主动 POST 给 Manager。
|
||||
|
||||
🔴 **先查一条**:**worker 有没有入站口?**
|
||||
DSH 的硬口径是 **「worker 只拨出、无入站口」** ⇒ "worker 推给 Manager"**物理上走不通**,
|
||||
要新开一条 **worker→Manager 出站通道或入站端点** ⇒ 撞 `CODEBUDDY.md §1`「**扩大可见面**」红线门禁。
|
||||
|
||||
优点:纯事件驱动最省资源;状态天然新鲜;多 worker 缩放到更优。
|
||||
缺点:**新增出站通道+新凭据+新信任关系 = 扩大可见面**;Manager 须开入站口(与既有形态冲突);失败/重试/退避全要另造;补历史仍要配一条 A 式的拉取。
|
||||
|
||||
### 筛法总结(照这个顺序问)
|
||||
|
||||
1. **worker 有入站口吗?** 没有 ⇒ **C 直接撞红线**,需用户授权才能谈。
|
||||
2. **reconcile 谁驱动?** 在 worker ⇒ **B 不能单独成立**。
|
||||
3. **已有一条 Manager→worker 的周期调用吗?** 有 ⇒ **A 的增量 = 加宽返回值**,成本最低。
|
||||
|
||||
⇒ 结论通常是 **A 为骨架 + B 降级为可选缓存**,C 需先授权。
|
||||
|
||||
---
|
||||
|
||||
## §4 语义细节(不写这三条 ⇒ 回流做出来照样是错的)
|
||||
|
||||
### 4-1 `asOf` 必带,`stale` **现算不落库**
|
||||
|
||||
| 字段 | 语义 | 要点 |
|
||||
|---|---|---|
|
||||
| `enabled` | 对端**事实** | 取"事实"不取"意图" |
|
||||
| `asOf` | **该读数的采集时刻**(epoch ms,与全库口径一致) | ✅ 必填,否则无法判陈旧 |
|
||||
| `stale` | `now - asOf > 阈值` | 🔴 **派生、现算** —— ⛔ 落库的 `stale` 自己也会陈旧 |
|
||||
| `source` | `'pull' \| 'task'` | 诊断用 |
|
||||
|
||||
**阈值自决 = 3 × 心跳周期**:单跳失败不该立刻报陈旧(网络抖动),连续 3 跳才说明腿真断了。
|
||||
⚠️ 阈值须**与心跳周期同源计算**,⛔ 不写两个独立常量(两套判据必然漂移)。
|
||||
|
||||
### 4-2 三层语义别混用(🔴 这是"永远 false"的根因)
|
||||
|
||||
| 层面 | 真值在哪 | 谁权威 |
|
||||
|---|---|---|
|
||||
| **意图**(用户点了要开) | 控制面审计流水 | 平台 |
|
||||
| **事实**(profile 里真开了) | **对端 profile 的 bundles** | 实例所在那台机 |
|
||||
| **不可用**(能不能开) | 数据面状态台账 | 平台 |
|
||||
|
||||
⇒ **`GET` 的状态字段取"事实",不取"意图"。**
|
||||
⚠️ **失败重试期间应仍显示旧事实**,⛔ 不显示"意图已提交" —— 否则用户看到 `true` 但实例上没装 = 更糟的假象。
|
||||
|
||||
### 4-3 降级:⛔ **不许把"未知"显示成"未启用"**
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 单跳失败 | **不动读数**(阈值内) |
|
||||
| 连续 ≥3 跳失败 | `stale=true` + UI 标"可能滞后" |
|
||||
| 该机 status = down | `stale=true` + 保留**最后一次成功读数** |
|
||||
| **从未成功拉过** | 回落本机快照 + 标 `source` 缺失 ⇒ UI 显**"未知"** |
|
||||
|
||||
🔴 最后一行是重点:**当前缺陷的成因就是"把未知显示成未启用"** ⇒ ⛔ 别在新代码里复制它。
|
||||
|
||||
---
|
||||
|
||||
## §5 与既有台账的关系(别硬塞)
|
||||
|
||||
**先比粒度**:既有台账多是**「插件级」**(每插件一行);
|
||||
用户启停状态是**「用户 × 插件级」** ⇒ **粒度不同,塞不进去**。
|
||||
|
||||
⇒ **A 案不建表**(内存缓存即可);**B 案才建**,且:
|
||||
- 🔴 **必须落控制面**(与既有控制面表同库)—— 按「归属/租约类只有 Manager 能写」的分层判据。
|
||||
- ⛔ **不得落 worker 本地库**(会造出"用户迁机后台账留在旧机")。
|
||||
|
||||
---
|
||||
|
||||
## §6 收尾:交付件骨架
|
||||
|
||||
规划棒出件时按此 10 节写(照 DSH 现有交付物风格):
|
||||
|
||||
1. 问题定义(现象 + **排除项** + 影响面按严重度)
|
||||
2. 事实基础(**现读代码,带行号**;⛔ 不凭记忆)
|
||||
3. 由事实推出的**硬约束**(C1/C2/C3)
|
||||
4. 通路形态三候选(**逐条优点 + 缺点** + 一张对比表)
|
||||
5. 写入时机(T1 操作完成后 / T2 **对账时** —— ⛔ 两个都要)
|
||||
6. 陈旧度语义(`asOf` / `stale` / 阈值)
|
||||
7. 失败重试与不可达降级(场景表)
|
||||
8. 与既有台账的关系(粒度对比 ⇒ 建不建表)
|
||||
9. **"不做会怎样"**(四时间尺度,说明为什么不能长期搁置)
|
||||
10. 落地轮廓(每候选的实现提示 + 验收判据)+ 附复核命令
|
||||
|
||||
---
|
||||
|
||||
## §7 反模式(⛔ 见到就打住)
|
||||
|
||||
1. **当缺陷 debug** —— 原机上已成了 ⇒ 是缺失能力,⛔ 别查权限/装包。
|
||||
2. **想"加个缓存"** —— 缓存就是第二个真相;先修**读路径**。
|
||||
3. **让 worker 主动推** —— 先问"worker 有入站口吗"。
|
||||
4. **只做 apply 时写状态** —— 漏掉 reconcile(实例启动对账会把状态改回去)。
|
||||
5. **落库 `stale`** —— 派生值必须现算。
|
||||
6. **把"未知"显示成"未启用"** —— 这是原缺陷的成因。
|
||||
7. **两个独立常量算阈值** —— 阈值必须与心跳周期同源。
|
||||
8. **台账当权威** —— 台账是缓存;权威在对端 profile。
|
||||
9. **替用户拍形态** —— 三候选各有优有劣 ⇒ **上抛**(候选竖排成段、每条写优点+缺点)。
|
||||
|
||||
---
|
||||
|
||||
## §8 关键文件地图(DSH · ⚠️ 行号会漂,用 `grep` 现找)
|
||||
|
||||
| 要找什么 | 去哪 |
|
||||
|---|---|
|
||||
| 读路径(`GET` 读本机盘) | `src/web/routes/business-plugins.ts` 的 `/api/plugins/mine` |
|
||||
| 写路径(下发+响应真值) | 同文件 `mine/apply` 的 `await supervisor.applyPlugins(user.id, [])` |
|
||||
| **已有周期调用**(A 案落点) | `src/supervisor/leased-spawner.ts` 的 `reportHost()`(GET `/healthz`) |
|
||||
| 免凭据口 | `src/worker/agent.ts` 的 `onRequest` hook(`/healthz` 豁免) |
|
||||
| worker 本地定时器(**reconcile 驱动点**) | `src/worker/agent.ts` 的 `healTunnel` / `reconcileTunnel` |
|
||||
| 归属真相 | 控制面 `dsh_instances.host_id`(`src/db/repo.ts`) |
|
||||
| 主机心跳落点 | `dsh_hosts.last_heartbeat`(`src/db/repo.ts` 的 `setDshHostStatus`) |
|
||||
| 共用纯读盘函数 | `src/supervisor/plugin-assembly.ts` 的 `readProfileManifest` |
|
||||
|
||||
**找"已有周期调用"的命令**:
|
||||
```bash
|
||||
cd D:/github/dsh_shenxian
|
||||
grep -rn "healthz\|setInterval" src/supervisor/ src/worker/ --include=*.ts
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 变更历史(原 frontmatter · 逐字保留)
|
||||
|
||||
```text
|
||||
name: dsh-distributed-state-readback
|
||||
description: DSH 多租户平台(Manager + worker 集群形态)里判断与设计「跨机状态回流」的方法 —— 治「平台侧读到的状态是假的 / 永远是 false / 永远是旧值」这类**静默错误**。当出现「某字段在跨机时恒为 false」「admin 看到的启停状态与用户实际不一致」「命令下去了但状态回不来」「刷新后状态回跳」「要不要让 worker 主动上报」时使用。核心:先用三条判据把「缺失能力 vs 代码缺陷」分开,再按「拉/推/共用台账」三候选的**物理可行性**筛掉走不通的形态。
|
||||
agent_created: true
|
||||
```
|
||||
Reference in new issue
Block a user