Files
workbuddy_skills/dsh-diagnose/references/02-跨机状态回流.md
T

224 lines
13 KiB
Markdown
Raw Normal View History

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