# 跨机状态回流(③) > **归属**:技能 `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//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 /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 ```