Files
workbuddy_skills/dsh-diagnose/references/02-跨机状态回流.md
T
admin e03465c398 按用户令提交:把此前未纳管的 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/`)。
2026-10-08 22:29:08 +08:00

225 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 跨机状态回流(③)
> **归属**:技能 `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
```