# 跨机可见性状态回流 · 设计件(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 /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: {: {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 ```