Files
dsh_ai1net_server/交付物/跨机可见性状态回流-设计件-20260923.md
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

26 KiB
Raw Permalink Blame History

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

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() —— 它已经在做一模一样的定位链:

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,与"零插件"可区分。

⛔ 本件定位不变:本节只是把"待拍板"改成"已定稿",本棒仍未写一行生产代码。执行交执行棒。


附 复核用命令(本件所有代码行号)

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