Files
dsh_ai1net_server/交付物/跨机可见性状态回流-设计件-20260923.md
T
admin ce8e6ceed9 chore(工作区): 纳入版本控制基线(回收 411 MB 过程产物)
回收 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)、记忆修复前备份。
2026-09-24 07:51:03 +08:00

331 lines
26 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.
# 跨机可见性状态回流 · 设计件(2026-09-23)
> **线**:插件投放与分库线 · 第 9 棒(规划棒)
> **定位**:**只出设计件** —— 本轮未改任何生产代码、未动服务器、未碰装配协议。设计件 §六 候选不替用户拍板。
> **上游事实来源**:第 8 棒真机取证(`交付物/S5-S6真机E2E验收-20260923.md` §S6-E)+ 本棒现读代码(行号为源码实际行号)。
---
## 一 问题定义(缺失能力,⛔ 不是代码缺陷)
### 1.1 现象
`GET /api/plugins/mine` 的 `enabled` 字段在**跨机**时恒为 `false`。
**代码事实**(`src/web/routes/business-plugins.ts:1648-1650`):
```ts
app.get('/api/plugins/mine', { preHandler: requireAuth }, async (request) => {
const user = request.user!
const { bundles } = readProfileManifest(profileDir(user.id)) // ← 读 Manager 本机盘
const enabled = new Set(bundles)
```
`profileDir()` 拼的是 **Manager 本机的 `userRoot`**(同文件 `:1608`)。集群形态下 profile 树长在**实例所在那台机**上(同文件 `:1592-1595` 已把这条写成注释)⇒ Manager 读的是自己盘上一个**不存在的路径**。
### 1.2 已排除的解释(第 8 棒真机取证)
| 假设 | 反证 |
|---|---|
| enable 本身失败了 | ⛔ 已在 106 上成功:`node_modules/@dsh-local/storyforge` 软链 09:43 建立、可解到 `package.json`、`package.json` 写入 `file:/var/lib/dshs/bundled-plugins/_dsh-local_storyforge` |
| 沙箱没挂共享层 | ⛔ `dsh-100002-9648cef4.scope` 沙箱命令行含 `--ro-bind-try /var/lib/dshs/bundled-plugins`(S1 挂载成立) |
| 装配没走到远端 | ⛔ `RemoteSpawner.applyPlugins` → worker `POST /plugins/apply` 已生效(S3-E ①–⑦ 全绿) |
⇒ **三项全否**。这是**单向下发、无状态回流**的拓扑必然:`POST /api/plugins/mine/apply` 有去路,`GET /api/plugins/mine` 没有回路。
### 1.3 影响面(按严重度)
| # | 影响 | 谁受损 | 严重度 |
|---|---|---|---|
| 1 | **用户自己看到的状态是错的** | 用户 | 🔴 高 —— 已开通的插件仍显"未开通",用户会重复点开通 |
| 2 | **admin 三段式 UI 第 ②③ 段全错** | admin | 🔴 高 —— 第 7 棒 `loadVisibility()` 的「已启用/已停用」两段**数据源就是 `/mine` 的 `enabled`**(`portal.html`)⇒ 跨机用户的这两段会显示成"全部未启用" |
| 3 | `apply` 的"是否全空操作"判定失效 | 平台 | 🟡 中 —— `:1697-1706` 读**本机** profile 快照算 `toEnable`/`toDisable`(跨机时 `enabledSet` 恒空)⇒ 本该 `noop` 直接返回的请求会**真的起一个远端任务**并标 `restarted:true`(`:1833`)。⚠️ **但不会造出错误装配**:装配清单是**全量意图**(`:1709-1711` 原注释:落盘端按全量做**幂等** reconcile,且明写"本机这份读数**不作为装配输入**")⇒ 代价是**无谓任务**,⛔ 不是"重装文件" |
| 4 | 前端切换后状态回跳 | 用户 | 🟡 中 —— `task.enabled` 走的是装配响应(`:1829-1830`,**真值**),但页面刷新后重读 `/mine` 又变回 `false` ⇒ 表现为"启用了但刷新就没了" |
> ⚠️ **第 4 条是本设计件的关键约束**:`apply` 的**响应路径已经是真值**(`last = await app.supervisor.applyPlugins(user.id, [])` 回的是对端读出来的 `bundles`)。缺的**只有读路径**。⇒ 设计目标是"让读路径也拿到真值",⛔ **不是**"把返回值再采一遍"。
---
## 二 事实基础(本棒现读代码,⛔ 全部可复核)
| # | 事实 | 位置 |
|---|---|---|
| F1 | **既有 Manager→worker 拉取通路已存在**:`LeasedSpawner.reportHost()` 每次心跳 `GET <agentUrl>/healthz`,并读回 `{ok, instances}` 落 `dsh_hosts.last_heartbeat` | `src/supervisor/leased-spawner.ts:157-173` |
| F2 | 同一函数里**另有一条到 worker 的 `POST` 通路**(`/fence`,带 token,`agentFor(hostId)` 定位) | `leased-spawner.ts:144-155, 175+` |
| F3 | **`/healthz` 是免凭据口** | `src/worker/agent.ts:344`(`if (request.url === '/healthz') return`) |
| F4 | worker 侧已有 `GET /instances`("一次拿回整机",对账用) | `src/worker/agent.ts:368` |
| F5 | **`/mine` 是每请求现算**,无任何缓存层 | `business-plugins.ts:1648-1670` |
| F6 | `apply` 响应路径已是真值(对端 `bundles`) | `business-plugins.ts:1829-1830`;`spawner.ts:25-26` |
| F7 | 归属真相在 Manager 侧 `dsh_instances.host_id`(v7 租约/归属) | `src/db/repo.ts:71-73`;`remote-spawner.ts:224-225` |
| F8 | PG 控制面已有心跳落点 `dsh_hosts.last_heartbeat` | `src/db/repo.ts:865-881`;`schema.ts:319/338` |
| F9 | v15 台账两表 `plugin_datastores` / `plugin_data_audit` 均**控制面表**,按"只有 Manager 能写"分层 | `src/db/schema.ts:660-727` |
| F10 | 装配本身**幂等**(同清单重跑 = 空操作)⇒ 重试安全 | `plugin-assembly.ts`;`remote-spawner.ts:456-458` |
| F11 | **无入站口**:worker 只拨出(覆盖网络线口径),Manager ⛔ 不能等 worker 主动 POST 自己 | 覆盖网络线 §判据 |
| F12 | `readProfileManifest(dir)` 是纯读盘函数,**两台机共用同一实现** | `plugin-assembly.ts:49-62` |
| F13 | worker agent 已有**本地 20 s 定时器**驱动(自愈/对账),`/healthz` 里顺手再跑一次 | `worker/agent.ts:250-252` |
### 🔴 读事实基础得出的三条硬约束
**C1(最容易被忽略)**:F11 + F5 ⇒ **"worker 主动上报"物理上走不通**。worker 只拨出、无入站 ⇒ 任何"manifest 被改时由 worker 推给 Manager"的方案都要**新开一条 worker→Manager 的出站通道**(或让 worker 主动 POST 一个 Manager 上不存在的新口)。
⛔ 判据:F5 说 `/mine` 是现算的 ⇒ 即使推了,也得先有可推的**入站端点**。
**C2**:F1 + F3 ⇒ **"Manager 主动拉"的骨架已经在跑了**:`reportHost()` 每次心跳就 GET 一次 worker。缺的只是"这个响应里没有 manifest"。
⇒ 形态①的增量 = **把一个已有调用的返回值加宽**,⛔ 不是新建链路。
**C3**:F6 ⇒ 读路径必须**与响应路径同源**(都取自对端 profile)。若走形态②,须保证 `apply` 后落台账与 `applyPlugins` 回读**取的是同一份**;取错源会造出"任务说开了、台账说没开"的新矛盾。
---
## 三 通路形态(三候选 · 逐条优点 + 缺点)
> ⛔ 本节**不替用户拍板**:三条各有优有劣、客观标准分不出高下 ⇒ 属 §1 边界外第 ⑤/⑦ 类,交用户定(见 §六)。
> ✅ 交付件给出**实现级细节**,任一候选被选中都可直接排执行棒。
### 候选 A · **Manager 主动拉**(复用既有心跳调用)
**做法**:把 `reportHost()` 里那条 `GET /healthz`(F1)的**worker 侧返回值加宽** —— 从 `{ok, instances}` 扩成 `{ok, instances, perUser: {<userId>: {bundles, asOf}}}`。Manager 收到后落台账(§四)。
**改动面**:`worker/agent.ts` 的 `/healthz` 返回体 + `leased-spawner.ts:157-173` 的解析与落库。**零新增网络口、零新增凭据、零新增入站面**。
| 优点 | 缺点 |
|---|---|
| **零新增链路**:F1 已在跑、F3 已免凭据 ⇒ 不动传输层、不动覆盖网络、不动 token | **非事件驱动**:manifest 改了要等下一跳心跳(§五 给 5 s 预算) |
| **不新增入站面**(F11 约束下这一点是**硬优势**:其他两案都要新增监听或凭据) | **响应体随用户数线性增长**:单心跳要带全机用户的 bundles ⇒ 需分页/限深(见 §五-4) |
| **与既有归属判据天然一致**:心跳本来就是"这台机报自己有什么",`host_id` 在多 Manager 联邦下天然只上报给自己的 Manager | **广播式**:即使只有一个用户 `enable`,也要整机重报(浪费,但量级可忽略:见 §五-4 的预算) |
| 失败语义**白捡**:心跳断了 = 这台机不可达 ⇒ 陈旧度标记直接复用(§五-3) | **`/healthz` 语义被拓宽**:它从"存活探测"变成"存活 + 状态",须防"重响应拖慢存活探测"(超时预算 §五-4) |
### 候选 B · **共用台账**(apply 时写、读时查)
**做法**:`apply` 成功后在**控制面新表**(或 `plugin_datastores` 侧加列)落"该 user × 该 plugin × 状态",`/mine` 改为查台账。
**改动面**:新迁移(v16+)+ `apply` 写入点 + `/mine` 读取点(F5 现算改查库)。
| 优点 | 缺点 |
|---|---|
| **事件驱动、零延迟**:写完即读得到,无心跳窗口 | **🔴 引入"第二个真相"**:台账 vs 对端 profile 两处,二者漂移就造出更难查的新缺陷(现状 C3 的教训:`apply` 响应取对端、台账取 Manager ⇒ 必漂) |
| **admin 侧一次查询看全域**,天然支持"分段 UI"与"跨节点汇总" | **写时机难题无解**:`reconcile`(实例启动时对账)发生在 **worker 本机**,Manager 不在场 ⇒ 台账**根本收不到**那次变更;要收就得加回流通路(即回到 A 或 C,⛔ 台账不能单独成立) |
| 与 v15 台账同库,运维面统一 | **多 Manager 联邦下错**:台账落在"当前 Manager"的库里,而 F7 的归属真相在 `dsh_instances.host_id` ⇒ 用户迁机后台账残留(与跨区可见性默认"互不可见"的倾向打架) |
| `/mine` 不再是 O(读盘)(当前每请求一次 `readProfileManifest`,F5) | **不可达时无判据**:台账不知道"这台机现在通不通" ⇒ 降级行为要另配 `dsh_hosts.last_heartbeat`(等于又绕回 A 的数据源) |
> 🔴 **B 的关键否证**:`reconcile` 的驱动点在 worker(F13),Manager 不在场 ⇒ "apply 时写台账"覆盖不了 reconcile 路径 ⇒ **必须配一条回流(= A 或 C)**,台账只能当**缓存**,⛔ 不能当权威。这一条足以说明 B **不是独立候选**,而是 A 或 C 的**内存/落库优化**。
### 候选 C · **worker 上报**(worker 侧新增出站口)
**做法**:worker 在 manifest 变更时主动 `POST` 到 Manager 的新端点(或 Manager 的 `/api/hosts/...`)。
| 优点 | 缺点 |
|---|---|
| **纯事件驱动、最省资源**:只在真变更时发一次 | **🔴 新增 worker→Manager 出站通道**:F11 说 worker 只拨出 ⇒ 要么让它反向拨 Manager(新凭据 + 新信任关系),要么在现有隧道里加反向 RPC ⇒ **等于扩大可见面/攻击面**(§1 红线门禁) |
| 状态天然新鲜(`asOf` = 发出时刻) | **Manager 必须开入站口**:⛔ 与"worker 只拨出"的既有形态冲突,要重开一条被刻意关掉的腿 |
| 缩放到多 worker 更优(各报各的) | **失败语义要另造**:worker 上报失败 ⇒ 谁重试、重试几次、退避怎么算,全是新代码(A 案这些白捡) |
| — | **补历史难**:Manager 重启/新增 worker 时,不知道该向谁要全量 ⇒ 仍要配一条 A 式的拉取 |
### 形态对比(一张表收敛)
| 维度 | A 主动拉 | B 共用台账 | C worker 上报 |
|---|---|---|---|
| 新增网络口 | **0** | 0(但需 A/C 兜 reconcile) | **≥1(含新凭据)** |
| 新增入站面(红线) | **否** | 否 | **是** 🚫 |
| 事件驱动 | 否(心跳周期) | 是 | 是 |
| 覆盖 `reconcile` 路径 | **是** | **否(须配 A/C)** | 是 |
| 与 F7 归属判据一致 | **天然** | 需另维护 | 天然 |
| 与 F11「只拨出」冲突 | **否** | 否 | **是** |
| 可否单独成立 | **是** | **否** | 是 |
| 改动量(估) | 小(2 文件) | 中(迁移 + 2 处) | 大(传输层 + 2 处) |
⇒ **本设计件的技术倾向 = A 为骨架 + B 降级为可选缓存**(B 单独不成立,见上否证);**C 需用户先接受"新开一条 worker→Manager 入站口"这条红线**(§1「扩大可见面」)。
⛔ 但最终选哪条**由用户定**(§六),执行棒 ⛔ 不得自行改档。
---
## 四 写入时机(两处,⛔ 必须都覆盖)
> 🔴 只做 `apply` 完成时 ⇒ **漏掉 `reconcile`**(实例启动时对账把 profile 改回来)。两条都要。
| # | 时机 | 触发方 | 为什么必须 | 频率 |
|---|---|---|---|---|
| T1 | **apply 完成后** | Manager(`business-plugins.ts:1829` 那条 `applyPlugins(user.id, [])` 已是真值读取) | 用户点开通/停用后的即时可见 | 每次 apply 1 次 |
| T2 | **实例启动时 reconcile** | **worker 本机**(F13 的 20 s 定时器 + 启动流程) | profile 会被 reconcile 改动(`reconcileBundles` 摘掉不在 deps 的项)⇒ 只信 T1 会漂 | 每次拉启 + 20 s 对账 |
**T1 的落地**:不新增调用 —— 复用 `:1829` 那次回读,把 `last.bundles` **除了**写 `task.enabled` 之外,**同时**落状态(A 案下 = 落 Manager 侧内存缓存;B 案下 = 落台账)。
**T2 的落地**:A 案下**不需要专门做** —— 心跳周期拉取天然覆盖(F1 每次心跳即一次全量对账)。这是 A 相对 B/C 的**实质优势**:`reconcile` 这条路径在 A 案里"白覆盖",在 B 案里"根本覆盖不到"。
---
## 五 语义细节(陈旧度 / 降级 / 预算)
### 5-1 `asOf`:**必须带**,⛔ 不是可选项
| 字段 | 语义 | 必填 |
|---|---|---|
| `enabled` | 对端 profile 的 `dsh.profile.bundles` 是否含该 id | ✅ |
| `asOf` | **该读数的采集时刻**(epoch ms,与全库时间戳口径一致) | ✅ |
| `stale` | `now - asOf > <阈值>` 的**派生标记**(⇒ 现算,⛔ 不落库,避免"标记本身也陈旧") | ✅(派生) |
| `source` | `'pull' \| 'task'`(来自心跳拉取 / 来自 apply 响应)⇒ 诊断与排障用 | 建议 |
**`stale` 阈值自决 = 3 × 心跳周期**。理由:单跳失败不该立刻报"陈旧"(网络抖动),连续 3 跳丢失才说明这条腿真断了。⚠️ 阈值须**与心跳周期同源计算**,⛔ 不写两个独立常量(旧教训:两套判据必然漂移)。
### 5-2 🔴 `enabled` 的语义澄清(本设计件最容易被误读的一条)
第 8 棒已记:跨机时 `/mine` 的 `enabled` = **本机快照**。本设计件补充**语义分层**,避免后续棒继续混用:
| 层面 | 真值在哪 | 谁权威 |
|---|---|---|
| **意图**("用户点了要开") | 控制面台账(`plugin_data_audit` 的 `enable_requested`) | 平台 |
| **事实**("profile 里真的开了") | **对端 profile 的 `bundles`** | 实例所在那台机 |
| **不可用**("这个插件现在能不能开") | `plugin_datastores.state`(v15 台账) | 平台 |
⇒ **`/mine` 的 `enabled` 取"事实",不取"意图"**(现状已是如此,C3 也要求如此)。回流通路只解决"事实从哪读"。
⚠️ 由此得一条硬判据:**失败重试期间 `enabled` 应仍显示旧事实**(⛔ 不显示"意图已提交")—— 否则用户看到 `true` 但实例上没装 = 更糟的假象。
### 5-3 失败重试与不可达降级
| 场景 | 行为 | 判据 |
|---|---|---|
| 单跳心跳失败 | **不动读数**,`stale` 现算后可能仍为 `false`(阈值 3 跳) | `now - asOf <= 3×心跳周期` |
| 连续 ≥3 跳失败 | `stale=true` + UI 标注"状态可能滞后" | — |
| 该机 `dsh_hosts.status='down'` | `stale=true` + 保留**最后一次成功读数** | F8 已有 status 列 |
| **从未成功拉过**(新机 / 新用户) | `enabled` 回落**本机快照**(= 当前行为),并标 `source:'pull'` 缺失 ⇒ UI 显"未知" | ⛔ **不许把"未知"显示成"未启用"**(这是当前缺陷的成因,别在新代码里复制它) |
| apply 响应路径失败 | 按现状:`task.status='failed'` + 友好错误(`:1842-1851`)—— **⛔ 本设计件不改这条** | — |
**重试策略自决**:A 案下**不做专门重试** —— 心跳本身就是重试(每周期一次,天然退避到周期粒度)。⛔ 不引入独立重试队列(会造出第三个真相)。
### 5-4 响应体预算(A 案的关键工程约束)
- 单个 userId 的 bundles 列表:实测量级 **≈ 6 项 × ~40 字符**(106 客实例回退后为 4 项)⇒ 单用户 ≈ 0.3 KB。
- 单机 100 用户 ⇒ 单心跳 ≈ **30 KB**。心跳周期量级为秒级(`heartbeatMs`)⇒ 30 KB/跳 × 100 台 worker **在覆盖网络上属可接受**,但**必须设上限**。
- **自决的护栏**:① 响应体硬上限(超限 ⇒ 截断 + 标 `truncated:true`,⛔ 不静默)② 只为**该 Manager 认领的用户**返回(F7 的 `host_id`)⇒ 天然把"全机用户"收窄成"我的用户" ③ 上限与心跳超时分开算(⛔ 不许让状态拉取拖垮存活探测 —— 见 A 案缺点栏)。
---
## 六 与既有 v15 台账的关系
| 台账 | 现状职责 | 本设计件要不要动 | 理由 |
|---|---|---|---|
| `plugin_datastores`(v15) | 插件**数据面**状态(库名/state/schema_version/plan_hash) | **⛔ 不动** | 它是"插件级"(每插件一行),本件是"**用户 × 插件**"(每用户每插件一行)⇒ **粒度不同,塞不进去** |
| `plugin_data_audit`(v15) | DDL/迁移审计流水 | **⛔ 不动**;新路可选追加一类 action | 它是**追加型流水**(`id` 自增、`ts`/`actor`/`plugin_id`/`action`/`detail`)⇒ 若 B 案被选中,可复用为"意图"记录,⛔ 但**权威读数仍在对端 profile**(C3) |
| **要不要新表** | — | **A 案 ⇒ 不要**(内存缓存即可,无常驻必要)|**B 案 ⇒ 要**(新迁移 v16+) | 判据:**A 案下台账只是缓存,缓存不该有持久 schema**;一旦落表就会有人当权威读 ⇒ 正是 F9/C3 要防的 |
🔴 **F9 的一致性要求**:若 B 案被选中,新表**必须落控制面**(与 `plugin_datastores` 同库),因为"用户 × 插件的启停状态"属**跟着用户走**与**归属**之间的灰色地带 —— 按 `CODEBUDDY.md` 的分层判据,**归属/租约类只有 Manager 能写** ⇒ 落控制面;⛔ **不得落 Worker 本地库**(那会造出"用户迁机后台账留在旧机")。
---
## 七 "不做会怎样"(为什么这条不能长期搁置)
| 时间尺度 | 后果 | 可逆性 |
|---|---|---|
| **立刻** | 跨机用户的 `/mine` 恒显 `enabled:false`;admin 三段式 UI 第 ②③ 段对跨机用户**全错**(§1.3-2) | ✅ 可逆(纯显示) |
| **短期** | 用户重复点"开通" ⇒ `noop` 判定失效(§1.3-3)⇒ **本该零成本的重复操作会真的起一个远端装配任务**(标 `restarted:true`)。⚠️ 装配清单是全量 + 对端幂等(`:1709-1711`)⇒ **不会重装文件**,代价是**无谓任务与状态抖动**。正确性不受损 | ✅ 可逆(但仍消耗实例稳定性) |
| **中期** | admin **不敢信**启停状态 ⇒ 管理动作失去依据;出故障时无法回答"这个用户到底开了什么" | 🟡 半可逆 |
| **长期** | 本线要扩到**多 Manager 联邦**(`架构设计/覆盖网络-顶层架构全貌.md`)⇒ 每个 Manager 都得独立回答"我这个区的用户开了什么"。**不建回流 ⇒ 每个 Manager 都要各造一套**(联邦下没有全局读权限 ⇒ 更贵) | 🔴 越晚越贵 |
⇒ **结论:应当立项**(不是"可以不做")。**但形态选择是用户的决定**(§六),⛔ 本件不替拍。
---
## 八 落地轮廓(供执行棒直接照做 · 细节随形态选择而变)
> ⚠️ 本节是**实现提示**,⛔ 不是已生效的设计。**执行棒须等用户定形态**(§六)后才开工。
**若选 A**:
1. `worker/agent.ts` 的 `/healthz`(`:354-365`):返回体加 `perUser`,数据源 = `spawner.listUserInstances()` + 各自 `readProfileManifest(profileDir(...)) .bundles`(F12 共用实现)。**保持免凭据**(F3),但**只返回本机实例的 userId**(⛔ 不接受调用方传 userId 列表 —— 那就是开任意读口)。
2. `leased-spawner.ts:157-173` `reportHost()`:解析 `perUser`,落 **Manager 内存 Map**(key = userId)+ 记 `asOf`。⛔ **不落 PG**(§六)。
3. `business-plugins.ts:1648-1670` `/mine`:`enabled` 改从该 Map 读;缺失 ⇒ 回落本机快照 + 标未知(§5-3)。
4. 前端:`/mine` 响应多带 `asOf`/`stale` ⇒ `portal.html` 在三段式 UI 上按 `stale` 显示"可能滞后"。
5. 验收:真机(106 客实例)`enable` → **≤3 跳心跳** 后 47 侧 `/mine` 读到 `enabled:true`;杀掉 106 agent → 连续 3 跳后 `stale:true`。
**若选 B**(⚠️ 须同时配 A 或 C,见 §三否证):新增迁移 v16+ + 控制面表(须带 `user_id`/`plugin_id`/`enabled`/`as_of`/`source`)+ 同步写点。⛔ **B 单独执行必然造出第二个真相**。
**若选 C**:先解决 `§1「扩大可见面」` 红线 —— 新开 worker→Manager 入站口**须用户明确授权**,然后才是传输层实现。
---
## 九 本件明确不做的事(⛔ 防后续棒越界)
1. ⛔ 未碰装配协议(`file:` vs `link:` 仍待拍板 ⇒ `plugin-assembly.ts` 零改动、交接单 `file:` 描述零改动)。
2. ⛔ 未开可见面开关 `DSHS_SHARED_CATALOG_PUBLIC`。
3. ⛔ 未改代码、未动服务器(本棒定位 = 规划棒)。
4. ⛔ 不做跨节点内容分发(需单独立项)。
5. ⛔ 不替用户拍形态(§三 三候选各有优有劣 ⇒ 上抛,见 §十)。
---
## 十 ✅ 形态已定稿(2026-09-23 18:3x · 用户拍板)
> **用户原话**:「可以 worker 上报,具体要管理那个用户的时候 manager 针对性的去查」
> ⇒ **拍板结果 = 形态 D**,⛔ 原有的 A / B / C 三候选**全部作废**(§三 保留为决策过程记录,⛔ 不再作为候选)。
### 10-1 形态 D = **按需定向拉(主)+ worker 变更上报(辅·后置)**
用户这句话同时解决了两件事:
| 用户的话 | 解掉的是 | 结论 |
|---|---|---|
| 「**可以 worker 上报**」 | §1 红线「扩大可见面」⇒ **用户已授权**新开 worker→Manager 的报告腿 | 上报**允许做**,但按用户语境定位为**辅助**(缓存失效信号),⛔ 不是主数据源 |
| 「具体要管理那个用户的时候 manager **针对性的去查**」 | §三 A 案最大缺点「每次心跳广播全机用户」 | **主路径 = 按需拉单个用户**,⛔ 不广播、⛔ 不周期全量 |
### 10-2 🔴 关键发现:**"针对性去查"的骨架已经存在,不需要新建任何通路**
本棒现读 `src/supervisor/leased-spawner.ts:144-155` 的 `fenceOnAgent()` —— 它已经在做**一模一样的定位链**:
```ts
const inst = await this.db.findUserInstance(userId, 'main') // ① userId → hostId(F7 归属真相)
const target = inst?.hostId == null ? 本机
: (this.options.agentFor?.(inst.hostId) ?? 本机) // ② hostId → {agentUrl, token}
await this.post(`/fence`, { userId, epoch: … }, target) // ③ 带 token 定向 POST 到那台 worker
```
⇒ **形态 D 主路径 = 把 ③ 的目标从 `/fence` 换成一个新的只读端点**(建议 `/profile-bundles`),其余三件(定位、凭据、方位)**全部复用**。
**由此得到的四条硬优势**(相对原 A/B/C):
| 维度 | 形态 D |
|---|---|
| 新增监听端口 / 新凭据体系 | **0** —— 复用 worker agent 既有入站口与既有 token(与 `/fence` 同级权限) |
| 广播浪费 | **0** —— 只查"要管理的那个用户" |
| 覆盖 `reconcile` 路径(原 B 案死结) | **✅ 天然覆盖** —— 按需查的是**对端现算真值**,不是历史快照 |
| 与 F7 归属判据一致 | **✅ 同源** —— 走的就是 `findUserInstance(userId).hostId` |
### 10-3 ⛔ 形态 D 的两条硬约束(写码前必须遵守)
1. 🔴 **新端点必须带 token,⛔ 绝不能挂在免凭据的 `/healthz` 上**。`/healthz` 是免凭据口(F3)⇒ 若让它接受 `userId` 参数,等于**开了一个匿名任意读口**。新端点须与 `/fence` 同级(`post()` 通路带 token)。
2. 🔴 **只返回该 worker 上"确有实例"的 userId**;查不到 ⇒ 返回 `not_here`,⛔ 不返回空 bundles。区分「这台机上没有该用户」与「该用户一个插件都没开」—— 二者**语义完全不同**,混用即复制现有缺陷(§5-3 末行判据)。
### 10-4 落地轮廓(执行棒照此做)
| 步 | 动作 | 文件 |
|---|---|---|
| 1 | worker agent 新增只读端点 `POST /profile-bundles`(带 token),入参 `{userId}`,出参 `{found:boolean, bundles:string[], asOf:number}`;数据源 = `readProfileManifest(profileDir(userId))`(F12 共用实现) | `src/worker/agent.ts` |
| 2 | Manager 侧新增 `fetchUserBundles(userId)`:复刻 `fenceOnAgent` 的三步定位链,改为 `post('/profile-bundles', …)` | `src/supervisor/leased-spawner.ts`(或同目录新文件) |
| 3 | `/mine` 改造:**先判实例是否在本机** —— 本机 ⇒ 直接读盘(零网络);非本机 ⇒ 走第 2 步定向查 ⇒ 带 `asOf` / `source` 返回 | `src/web/routes/business-plugins.ts:1648-1670` |
| 4 | 内存缓存(key = userId,带 `asOf`)+ 陈旧度与降级语义按 §5-1 / §5-3 执行 | 同上 |
| 5 | **辅(后置)**:worker manifest 变更时发轻量失效通知 ⇒ Manager 丢对应缓存。⚠️ **无此步也对**(只是每次查多一次往返)⇒ ⛔ 不得因它阻塞 1–4 | 待定 |
### 10-5 验收(真机)
1. 106 上 `enable` 一个插件 ⇒ 47 侧 `/mine` 读到 `enabled:true`(**无需等心跳**)。
2. 停 106 agent ⇒ `/mine` 降级为"未知"(⛔ **不许显示成"未启用"**)。
3. 本机用户的 `/mine` 路径**零网络往返**(性能不回退)。
4. 对不存在的 userId ⇒ 返回 `not_here`,与"零插件"可区分。
> ⛔ **本件定位不变**:本节只是把"待拍板"改成"已定稿",**本棒仍未写一行生产代码**。执行交执行棒。
---
## 附 复核用命令(本件所有代码行号)
```bash
cd D:/github/dsh_shenxian
sed -n '1648,1652p' src/web/routes/business-plugins.ts # /mine 读本机 profile
sed -n '157,173p' src/supervisor/leased-spawner.ts # reportHost:既有拉取通路
sed -n '354,365p' src/worker/agent.ts # /healthz 现状返回体
sed -n '343,352p' src/worker/agent.ts # /healthz 免凭据
sed -n '660,727p' src/db/schema.ts # v15 两表(控制面)
```
**本棒三门复验(基线记录 · 未改代码)**:
```
npm run build → tsc 无输出,rc=0
npm test → 449 tests / 447 pass / 0 fail / 2 skip,rc=0
node scripts/check-layering.mjs → ✅ 无新增违规(现存 5 条全在基线内),rc=0
```