回收 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)、记忆修复前备份。
26 KiB
跨机可见性状态回流 · 设计件(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:
worker/agent.ts的/healthz(:354-365):返回体加perUser,数据源 =spawner.listUserInstances()+ 各自readProfileManifest(profileDir(...)) .bundles(F12 共用实现)。保持免凭据(F3),但只返回本机实例的 userId(⛔ 不接受调用方传 userId 列表 —— 那就是开任意读口)。leased-spawner.ts:157-173reportHost():解析perUser,落 Manager 内存 Map(key = userId)+ 记asOf。⛔ 不落 PG(§六)。business-plugins.ts:1648-1670/mine:enabled改从该 Map 读;缺失 ⇒ 回落本机快照 + 标未知(§5-3)。- 前端:
/mine响应多带asOf/stale⇒portal.html在三段式 UI 上按stale显示"可能滞后"。 - 验收:真机(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 入站口须用户明确授权,然后才是传输层实现。
九 本件明确不做的事(⛔ 防后续棒越界)
- ⛔ 未碰装配协议(
file:vslink:仍待拍板 ⇒plugin-assembly.ts零改动、交接单file:描述零改动)。 - ⛔ 未开可见面开关
DSHS_SHARED_CATALOG_PUBLIC。 - ⛔ 未改代码、未动服务器(本棒定位 = 规划棒)。
- ⛔ 不做跨节点内容分发(需单独立项)。
- ⛔ 不替用户拍形态(§三 三候选各有优有劣 ⇒ 上抛,见 §十)。
十 ✅ 形态已定稿(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 的两条硬约束(写码前必须遵守)
- 🔴 新端点必须带 token,⛔ 绝不能挂在免凭据的
/healthz上。/healthz是免凭据口(F3)⇒ 若让它接受userId参数,等于开了一个匿名任意读口。新端点须与/fence同级(post()通路带 token)。 - 🔴 只返回该 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 验收(真机)
- 106 上
enable一个插件 ⇒ 47 侧/mine读到enabled:true(无需等心跳)。 - 停 106 agent ⇒
/mine降级为"未知"(⛔ 不许显示成"未启用")。 - 本机用户的
/mine路径零网络往返(性能不回退)。 - 对不存在的 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