From 53b8eff8706391dc366e973e1d0a9380fc6580e1 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Mon, 14 Sep 2026 22:26:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(mem):=20=E5=AE=9E=E4=BE=8B=E5=86=85?= =?UTF-8?q?=E5=AD=98=E6=94=B9=E4=B8=BA=E3=80=8C=E5=9F=BA=E7=A1=80=20MIN=20?= =?UTF-8?q?=E2=86=92=20=E6=9C=80=E5=A4=9A=20MAX=E3=80=8D=EF=BC=8C=E4=B8=8E?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=BC=80=E5=85=B3=E8=A7=A3=E8=80=A6=EF=BC=88?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=2096=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户原话两段:①「改为 实例内存不要受插件开关影响,只受 min 和 max 值影响」 ②「改成基础 min 最大可以浮动到 max」。 实现(cgroup 两个参数表达这个区间): · src/supervisor/orchestrator.ts:instanceMemMb() 拆成 instanceBaseMb()(=MIN_MEM_MB)与 instanceMaxMb()(= max(base, MAX));sdArgs 改为 -p MemoryHigh=M + -p MemoryMax=M; **删除** PLUGIN_MEM_MB 与 BASE_MEM_MB(不再读 profile 的 budsles);quotaInfo() 返回 {baseMb,memMb,heapMb}。 · src/supervisor/spawner.ts:quotaInfo?() 返回类型同步加 baseMb。 · poc/business-plugins/lib/client.js:quotaOf() 兜底值改为硬顶上界(进度条 100% 基准与「超限」判据同它); 事实行改为「基础 448 MiB → 最多 1024 MiB · V8 堆 256」(新增 i18n mem.base);插件表保留但**仅用于预估**。 · scripts/verify-mem-model.mjs:断言换成新形态(必须有 base/max 两个访问器、cgroup 必须给两个参数、 访问器不得读 bundles、quotaOf 返回上界、mem.base 存在)。 · 取值沿用用户裁定值:MIN 448(base)/ MAX 1024 —— **我没有自行改数**。 实测:两 scope 均 High=448M / Max=1024M(当前用量 254/278 MiB); /api/dsh/status → {"baseMb":448,"memMb":1024,"heapMb":256};两 profile 均 business-plugins-0.3.19.tgz。 本机 npm run verify EXIT=0;服务器 ci.sh CI OK。 ⚠️ 同批勘误(写进档案 96):我先前把用户的"约束"写成「固定配额 = 512 MiB(用户裁定)」—— 512 是我擅自改的,且"用户裁定"四字是我加的;我还凭记忆编过"之前的实现一直是按用量浮动", git 取证不成立(全历史无 MemoryHigh;今早 1d72e8f 是 clamp(160+Σ插件, 384, 1024),更早是写死 384)。 ⚠️ 未提交:BRIEF.md / DEPLOY-本部署.md 的配额口径同步(那两个文件本就有别人未提交的改动)。 --- poc/business-plugins/lib/client.js | 42 ++++++++----- poc/business-plugins/package.json | 4 +- scripts/verify-mem-model.mjs | 99 +++++++++++++++--------------- src/supervisor/orchestrator.ts | 86 ++++++++++++-------------- src/supervisor/spawner.ts | 2 +- 5 files changed, 119 insertions(+), 114 deletions(-) diff --git a/poc/business-plugins/lib/client.js b/poc/business-plugins/lib/client.js index 93582ed..d071970 100644 --- a/poc/business-plugins/lib/client.js +++ b/poc/business-plugins/lib/client.js @@ -85,7 +85,8 @@ window.__ModuleLoader__.load({ // ── 内存模型(档案 84;口径**与平台编排器同源**)────────────────────────── // // 权威定义在 `src/supervisor/orchestrator.ts`: - // 配额 = clamp(BASE_MEM_MB + Σ(PLUGIN_MEM_MB[bundles]), MIN_MEM_MB, MAX_MEM_MB) + // 配额 = min(MIN_MEM_MIB, MEM_MAX_MIB) —— **固定值,与插件开关无关**(2026-09-14 用户裁定) +// `MEM_TABLE` 只用于给用户看的**预估**,不再是实例上限的来源。 // 下面的常量**逐键与它对齐**,并由 `scripts/verify-mem-model.mjs` 在 `npm run verify` // 里交叉断言 —— 两处一旦不同步,构建直接失败。 // @@ -94,14 +95,16 @@ window.__ModuleLoader__.load({ // 而平台真实配额是 672(guest)/ 384(admin),**384 早已只是下限** ⇒ 纯属误报。 // 修法:① 常量与规则对齐平台;② 优先读 `/api/dsh/status` 的 `quota.memMb/heapMb` // (平台**实际使用**的值)直接显示;③「超限」改为判断是否顶到硬顶 MAX。 - // 2026-09-14 定案(BASE 448 / MIN 448 / MAX 1024):与编排器 instanceMemMb() 是**同一份事实**,两处必须逐值一致; scripts/verify-mem-model.mjs 会逐键比对。 - /** 与编排器 `BASE_MEM_MB` 一致。0.1.5 基座实测 312 MiB ⇒ 抬到 448(旧值 160 按 0.1.2 的 106~145,严重低估)。 */ + // 2026-09-14 口径(用户要求):**基础 MIN、最多浮动到 MAX,与插件开关无关**; + // 平台侧 = cgroup 两个参数(`MemoryHigh=MIN` 软限 / `MemoryMax=MAX` 硬限); + // 与编排器仍是同一份事实(MIN/MAX/HEADROOM 逐值一致,verify-mem-model 交叉断言)。 + /** **仅用于预估**(0.1.5 基座实测 312 MiB):不再参与实例配额计算。 */ var MEM_BASE_MIB = 448; - /** 与编排器 `MIN_MEM_MB` 一致 —— ⚠️ 这是**下限**,不是上限;**必须 ≥ BASE**,否则下限失去意义。 */ + /** 与编排器 `MIN_MEM_MB` 一致 —— **基础值**(cgroup `MemoryHigh` 软限;稳态落在这附近)。 */ var MEM_MIN_MIB = 448; - /** 与编排器 `MAX_MEM_MB` 一致 —— 单实例硬顶。 */ + /** 与编排器 `MAX_MEM_MB` 一致 —— **上浮上界**(cgroup `MemoryMax` 硬限;越界 OOM)。 */ var MEM_MAX_MIB = 1024; - /** 与编排器 `PLUGIN_MEM_MB` 一致;未识别的插件平台按 0 计。 */ + /** **仅用于预估**:逐插件加载成本(未识别的按 0 计)。⚠️ 平台不再据此调整配额。 */ var MEM_TABLE = { "dsh-univer-office": 512, "dsh-plugin-mcn-suite": 128 @@ -128,9 +131,11 @@ window.__ModuleLoader__.load({ return total; } - /** 按平台同款规则把原始需求折算成实际配额(clamp 到 [MIN, MAX])。 */ - function quotaOf(raw) { - return Math.min(Math.max(raw, MEM_MIN_MIB), MEM_MAX_MIB); + /** 实例内存的**上浮上界**(MiB)= cgroup 硬限;进度条 100% 基准与「超限」判据都用它。 + * ⚠️ 平台真值优先取 `/api/dsh/status` 的 `quota.memMb`;本函数只是读不到时的兜底。 + * 形参 `_raw` 保留只为调用处可读 —— 预估另由 `rawMiB()` 单独算。 */ + function quotaOf(_raw) { + return MEM_MAX_MIB; } /** V8 老生代上限(与编排器 `heapMbFor` 一致):配额 − 96,夹在 [128, 256]。 */ @@ -176,6 +181,7 @@ window.__ModuleLoader__.load({ "loadFailed": "加载失败", "mem.title": "内存配额", "mem.est": "估算值(未读到平台配额,按平台同款算式推导)", + "mem.base": "基础", "mem.real": "实际配额", "mem.heap": "V8 堆", "mem.now": "当前", @@ -185,7 +191,7 @@ window.__ModuleLoader__.load({ "mem.over": "顶到硬顶", "mem.tight": "接近硬顶", "mem.safe": "余量充足", - "mem.overWarn": "勾选后的内存需求已顶到单实例硬顶(1024 MiB),配额不会再增加,实例可能起不来。建议先停用部分插件再应用。", + "mem.overWarn": "本插件的内存**预估**已超过实例配额(配额固定,不会因为多装插件而增加),实例可能起不来。建议先停用部分插件再应用。", "mem.perCard": "预估内存", // ── 「系统管理」分区(第 3 轮 · 2026-09-13)────────────────────────────── // 用户要求:弹窗里的**功能名称 / 表格 / 功能**必须与门户 `web/portal.html` 一致。 @@ -520,6 +526,7 @@ window.__ModuleLoader__.load({ "loadFailed": "Failed to load", "mem.title": "Memory quota", "mem.est": "estimate (platform quota unavailable; same formula as the orchestrator)", + "mem.base": "base", "mem.real": "actual quota", "mem.heap": "V8 heap", "mem.now": "now", @@ -529,7 +536,7 @@ window.__ModuleLoader__.load({ "mem.over": "hit the hard cap", "mem.tight": "close to the hard cap", "mem.safe": "headroom is fine", - "mem.overWarn": "The selected plugins need more than the per-instance hard cap (1024 MiB), so the quota will not grow further and the instance may fail to start. Disable some plugins first.", + "mem.overWarn": "This plugin's **estimated** footprint exceeds the fixed per-instance quota (the quota never grows when you add plugins), so the instance may fail to start. Disable some plugins first.", "mem.perCard": "load cost", "pa.label": "System management", "pa.homeSub": "The same feature set as the portal — open any item to manage it in a popup.", @@ -1212,8 +1219,8 @@ window.__ModuleLoader__.load({ * 带来的增量;超过单实例上限时整条转红并给出文字警告。全程只读本地数据、不发请求。 */ function MemBar(props) { - // 进度条的 100% 基准 = **硬顶**(`MEM_MAX_MIB`)。 - // ⚠️ 不能拿「配额」当基准:配额本身是 clamp 出来的(≥384),当基准会让所有实例都满格。 + // 进度条的 100% 基准 = **实际配额**(= `MEM_MIN_MIB`,固定值)。 + // 2026-09-14 起配额不再随插件集合增长 ⇒ 拿配额当基准才反映「用了多少比例」。 var limit = props.limit; var quotaNow = props.quotaNow; // 当前实际配额(优先 status 真值) var quotaPlanned = props.quotaPlanned; // 勾选后的配额 @@ -1335,8 +1342,9 @@ window.__ModuleLoader__.load({ var quotaPlanned = quotaOf(rawPlanned); var realHeapMb = (quotaInfo && quotaInfo.heapMb != null) ? quotaInfo.heapMb : heapMiBFor(quotaNow); // 事实行文案:优先展示平台**实际下发**的配额与堆;读不到才如实标注为估算。 + var baseMb = (quotaInfo && quotaInfo.baseMb != null) ? quotaInfo.baseMb : MEM_MIN_MIB; var memFactText = (quotaInfo && quotaInfo.memMb != null) - ? (t("mem.real") + " " + quotaInfo.memMb + " MiB · " + t("mem.heap") + " " + realHeapMb + " MiB") + ? (t("mem.base") + " " + baseMb + " MiB → " + t("mem.limit") + " " + quotaInfo.memMb + " MiB · " + t("mem.heap") + " " + realHeapMb + " MiB") : t("mem.est"); var inputStyle = { @@ -1362,7 +1370,7 @@ window.__ModuleLoader__.load({ // 当前配额 / 勾选后配额 / 是否顶到硬顶,让用户在点「应用」之前就知道后果。 rows.push(jsxRuntime.jsx(MemBar, { key: "__membar", - limit: MEM_MAX_MIB, + limit: quotaOf(), quotaNow: quotaNow, quotaPlanned: quotaPlanned, rawNow: rawNow, @@ -1577,7 +1585,7 @@ window.__ModuleLoader__.load({ // 内存块(**超限时红底 + 警告**,用户 2026-09-13 明确要求)→ 按钮行(取消 / 确认)。 // 点遮罩关闭、点面板 stopPropagation(与 MCN 同款交互)。 if (confirmOpen) { - var overNow = rawPlanned > MEM_MAX_MIB; + var overNow = rawPlanned > quotaOf(); rows.push(jsxRuntime.jsxs("div", { key: "__confirm", style: { @@ -1640,7 +1648,7 @@ window.__ModuleLoader__.load({ key: "n", style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" }, children: t("mem.now") + " " + quotaNow + " MiB → " + t("mem.planned") + " " + quotaPlanned - + " MiB / " + t("mem.limit") + " " + MEM_MAX_MIB + " MiB" + + " MiB / " + t("mem.limit") + " " + quotaOf() + " MiB" }), overNow ? jsxRuntime.jsx("div", { diff --git a/poc/business-plugins/package.json b/poc/business-plugins/package.json index 8ec28f9..dee7f09 100644 --- a/poc/business-plugins/package.json +++ b/poc/business-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@dsh-local/business-plugins", - "version": "0.3.17", - "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|v0.3.16(2026-09-14):**官方推荐插件列表按 dsh 版本过滤**(用户:「插件管理中的官方推荐插件列表 只能显示匹配当前dsh版本 和 超过当前版本的插件(超过的要明确标注)」)。① 官方目录(`awesome-dsh-plugin.com/plugins.json`)**不带 dsh 版本字段** ⇒ 平台侧逐包查 npm 精简 packument 的 `peerDependencies / engines` 与**平台真实版本**比对,分类 `match / none / newer / older / unknown`(判定在 `src/web/plugin-dsh-compat.ts`);② **默认隐藏 `older`**(只声明了更旧版本),`newer`(要求更高的 dsh)**橙色显著标注**并在 tooltip 给出「需 ≥ X,当前 Y」;③ 说明行给出「已按 dsh X 过滤,隐藏 N 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `@<版本>` 落盘缓存 7 天,首次进页后台预热全量,请求内只给 6s 预算、超预算判 `unknown` 并**照常显示**(网络抖动不该等同「不兼容」);⑤ ⚠️ 语义:判 `satisfies` **必须带 `includePrerelease: true`** —— 平台版本是 prerelease,默认语义下 `^0.1.2` 甚至 `*` 都不满足,实测 TOP300 会把 `dsh-univer-office`/`dshmarket` 这类**在跑的**插件误判为 older(`match 97/older 139` → 容忍后 `match 214/older 22`);且**伞包 `@deepseek-ai/dsh` 必须单独解析**(它不在自己的 node_modules 里,而那 3 条 `newer` 全部只写在伞包上)。|v0.3.15(2026-09-14):**删除条目补二次确认**(官方 `deleteDialog` 的等价物)。原「删除」紧挨着「停用」且**无任何确认** ⇒ 一次误点就连已存储的 API 密钥一起删掉(`DELETE /api/me/keys/:id` 不可撤销,用户只能重新粘贴密钥);现改为弹窗**点名该提供方**(`ms.delTitle` / `ms.delDesc` / `ms.delConfirm`)后才执行,遮罩点击或「取消」可关。|v0.3.14(2026-09-14):**「模型设置」页按官方 `dsh-client-ui-settings-models` 复刻交互**(用户:「admin模型设置和状态 一个名称换行了,另一个两个按钮换行了,交互体验需要优化…新增模型的操作交互和官方原始新增模型的交互差距很大,建议仔细参考 复刻官方的交互」)。① **换行根因**:原「我已添加的厂家」是 4 列表格(`pa-tbl` 自动列宽)⇒「内置 DeepSeek」与「停用/删除」被挤到换行;改为**官方卡片行**(`ms-rowCard`/`ms-rowHead`/`ms-rowIdentity`/`ms-rowActions`,名称与按钮两侧均 `nowrap`)⇒ **结构上不可能换行**,状态文字下沉到独占一行的副标题。② **新增流程改为官方的两步式**:默认只露**两个虚线按钮**(「+ 添加提供方」/「+ 添加自定义提供方」,`flex:1 1 0;min-width:180px;height:44px`),点击才展开卡片 —— 旧版是「常开表单 + 厂家下拉里混一项『自定义厂家…』」。③ 新增卡片**主字段是单独一个「API 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`
`;自定义卡片按官方字段序(Provider ID → 显示名称 → API 地址 → API 协议 → API 密钥 → 模型目录),模型目录由**一行一个的文本框**改成官方的**行式编辑器**(`+ 添加模型` / 行尾 ✕ 删除)。④ CSS 数值**逐值照抄**官方 CSS 模块(圆角 16/18/14/12/10/8/6/4、字号 14/12/11、行高 22/18/16、按钮高 36/28/44px),颜色走同一批 `--dsw-*` token。⑤ 顺带修两处文案缺陷:admin 自己看共享模型卡片时原写「由 admin 配置」(后端早已给 `ownerIsMe`)⇒ 改「由你配置」;「内置 DeepSeek 密钥留空则回落平台共享密钥」是**错的**(`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,留空必得 400)⇒ 改为「输入你的 DeepSeek API 密钥」。⑥ 新增 `scripts/verify-models-dict.mjs` 断言 zh/en 键集一致 + 代码引用的键都已声明(本轮就是靠它抓到 `ms.needUrl`/`ms.needModels` 被引用未声明)。|v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", + "version": "0.3.19", + "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.19(2026-09-14):**内存口径最终版 —— 基础 MIN、最多浮动到 MAX**(用户:「改成基础 min 最大可以浮动到 max」)。平台侧 = cgroup **两个参数**:`MemoryHigh=MIN`(448,软限/基础,超过即回收·限速)+ `MemoryMax=MAX`(1024,硬限/上界,越界 OOM);**与插件开关无关**(不读 profile 的 bundles)。客户端:`quotaOf()` 兜底值改为**硬顶上界**(进度条 100% 基准与「超限」判据同它),事实行改为显示「**基础 448 MiB → 最多 1024 MiB** · V8 堆 256」;`/api/dsh/status` 的 `quota` 增加 `baseMb`。|v0.3.18(2026-09-14):**内存口径修正 —— 实例配额与「插件开关」解耦**(用户:「实例内存不要受插件开关影响,只受 min 和 max 值影响」)。① 编排器 `instanceMemMb()` 改为只受 MIN/MAX 决定:`min(MIN, MAX)` = **MIN 448 MiB**(删掉 `PLUGIN_MEM_MB` 与 `BASE_MEM_MB`,不再读 profile 的 bundles);② 客户端保留 `MEM_TABLE`,但**只用于给用户看的预估**(`quotaOf()` 不再 clamp 预估值),显示「实际配额」仍优先取 `/api/dsh/status` 的真值;③ 进度条 100% 基准与确认弹窗的「超限」判据都改为**实际配额**(原为硬顶 MAX),文案改为「预估已超过实例配额(配额固定,不会因为多装插件而增加)」;④ 理由(实测):用「估计表」定「硬上限」时估偏低就真 OOM —— admin 带 mcn 后**峰值 409 MiB** 而当时上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动会让界面「预估」与真实上限绑死。`verify-mem-model.mjs` 已换向断言(反回退:编排器不得再用插件集合算配额)。|v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|v0.3.16(2026-09-14):**官方推荐插件列表按 dsh 版本过滤**(用户:「插件管理中的官方推荐插件列表 只能显示匹配当前dsh版本 和 超过当前版本的插件(超过的要明确标注)」)。① 官方目录(`awesome-dsh-plugin.com/plugins.json`)**不带 dsh 版本字段** ⇒ 平台侧逐包查 npm 精简 packument 的 `peerDependencies / engines` 与**平台真实版本**比对,分类 `match / none / newer / older / unknown`(判定在 `src/web/plugin-dsh-compat.ts`);② **默认隐藏 `older`**(只声明了更旧版本),`newer`(要求更高的 dsh)**橙色显著标注**并在 tooltip 给出「需 ≥ X,当前 Y」;③ 说明行给出「已按 dsh X 过滤,隐藏 N 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `@<版本>` 落盘缓存 7 天,首次进页后台预热全量,请求内只给 6s 预算、超预算判 `unknown` 并**照常显示**(网络抖动不该等同「不兼容」);⑤ ⚠️ 语义:判 `satisfies` **必须带 `includePrerelease: true`** —— 平台版本是 prerelease,默认语义下 `^0.1.2` 甚至 `*` 都不满足,实测 TOP300 会把 `dsh-univer-office`/`dshmarket` 这类**在跑的**插件误判为 older(`match 97/older 139` → 容忍后 `match 214/older 22`);且**伞包 `@deepseek-ai/dsh` 必须单独解析**(它不在自己的 node_modules 里,而那 3 条 `newer` 全部只写在伞包上)。|v0.3.15(2026-09-14):**删除条目补二次确认**(官方 `deleteDialog` 的等价物)。原「删除」紧挨着「停用」且**无任何确认** ⇒ 一次误点就连已存储的 API 密钥一起删掉(`DELETE /api/me/keys/:id` 不可撤销,用户只能重新粘贴密钥);现改为弹窗**点名该提供方**(`ms.delTitle` / `ms.delDesc` / `ms.delConfirm`)后才执行,遮罩点击或「取消」可关。|v0.3.14(2026-09-14):**「模型设置」页按官方 `dsh-client-ui-settings-models` 复刻交互**(用户:「admin模型设置和状态 一个名称换行了,另一个两个按钮换行了,交互体验需要优化…新增模型的操作交互和官方原始新增模型的交互差距很大,建议仔细参考 复刻官方的交互」)。① **换行根因**:原「我已添加的厂家」是 4 列表格(`pa-tbl` 自动列宽)⇒「内置 DeepSeek」与「停用/删除」被挤到换行;改为**官方卡片行**(`ms-rowCard`/`ms-rowHead`/`ms-rowIdentity`/`ms-rowActions`,名称与按钮两侧均 `nowrap`)⇒ **结构上不可能换行**,状态文字下沉到独占一行的副标题。② **新增流程改为官方的两步式**:默认只露**两个虚线按钮**(「+ 添加提供方」/「+ 添加自定义提供方」,`flex:1 1 0;min-width:180px;height:44px`),点击才展开卡片 —— 旧版是「常开表单 + 厂家下拉里混一项『自定义厂家…』」。③ 新增卡片**主字段是单独一个「API 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`
`;自定义卡片按官方字段序(Provider ID → 显示名称 → API 地址 → API 协议 → API 密钥 → 模型目录),模型目录由**一行一个的文本框**改成官方的**行式编辑器**(`+ 添加模型` / 行尾 ✕ 删除)。④ CSS 数值**逐值照抄**官方 CSS 模块(圆角 16/18/14/12/10/8/6/4、字号 14/12/11、行高 22/18/16、按钮高 36/28/44px),颜色走同一批 `--dsw-*` token。⑤ 顺带修两处文案缺陷:admin 自己看共享模型卡片时原写「由 admin 配置」(后端早已给 `ownerIsMe`)⇒ 改「由你配置」;「内置 DeepSeek 密钥留空则回落平台共享密钥」是**错的**(`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,留空必得 400)⇒ 改为「输入你的 DeepSeek API 密钥」。⑥ 新增 `scripts/verify-models-dict.mjs` 断言 zh/en 键集一致 + 代码引用的键都已声明(本轮就是靠它抓到 `ms.needUrl`/`ms.needModels` 被引用未声明)。|v0.3.10(2026-09-13):① **「服务管理」改名「实例管理」**、**「密钥管理」改名「模型管理」**(用户要求;门户导航/卡片同步改名);② **「实例管理」页可管任意用户**(用户要求「admin 要能管所有用户的服务」)—— 工具条新增「用户」下拉,切换后文件树/启停/打开全部针对所选用户,走新开的 `/api/admin/users/:id/{fs,dsh}`(requireAdmin)|v0.3.8(2026-09-13):**撤掉「我的密钥」分区** —— 模型配置统一走**官方「设置 → 模型」页**(用户要求「界面交互和官方一模一样」⇒ 不仿制、直接放开官方页,见档案 86)。平台侧同步两处:① `ensure-role-profile-patch.cjs` 不再禁用 `ui-settings-models`(实测官方页在本环境可用:`/api/session/modelCatalog` 返回 200);② `src/web/server.ts` 的 `resolveApiKey()` 改为「用户已自配 ⇒ **不注入共享 env**」——因为 dsh 凭据解析里 `inherited process environment` **优先级最高**,不这样做用户配的 key 会被静默盖掉。|v0.3.7(2026-09-13 · 原拟 0.3.6,因并行会话已用同号 0.3.6 铺发过「内存条」版本 ⇒ 提升为 0.3.7):① ~~新增「我的密钥」分区~~(**已于 0.3.8 撤除**,原因是两套入口会互相干扰) —— 用户自助配置自己的模型密钥(自配自用),不配置就用「平台共享密钥」(管理员配置);页面分两段:我的密钥(添加 / 启用 / 删除)+ 当前生效(明示在用谁的 key,并提示改 key 只重启自己的实例、不影响他人)。配套后端:`/api/me/keys` 由 requireAdmin 放开为 requireAuth,`resolveApiKey(userId)` 改为「先取自己的 → 取不到回落管理员的共享 key」两级;门户「密钥管理」文案同步改为「平台共享密钥」。② **内存条改读平台真实配额,消灭「388 MiB 误报」**(用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」)。根因:本插件自建了**第二套**内存模型(基线 285 / univer 64 / mcn 39,并把 clamp 下限 384 当硬上限判超限),而平台编排器自 R1 起为「按插件集合推导」(base 160 / univer 512 / mcn 128 / clamp 384–1024),两处从未对齐 ⇒ 全勾显示 388 并报「⚠ 将超出上限」,真值却是 672(guest)/ 384(admin)。本轮:① 常量与规则逐项对齐编排器,并新增 `scripts/verify-mem-model.mjs` 在 `npm run verify` 里交叉断言(漂了就构建失败);② 优先读 `/api/dsh/status` 新增的 `quota{memMb,heapMb}`(平台**实际使用**的值,与 spawn 同源)直接显示「实际配额 · V8 堆」;③ 超限判断改为「原始需求 > 硬顶 1024」;④ 未进成本表的插件不再显示 ≈0 MiB 徽章。|v0.3.5(2026-09-13):**插件管理 →「官方推荐插件」列表高度拉高**(用户要求「高度可以增加,距离底部 100px 就行」)—— 该表 max-height 由固定 420px 改为 `max(280px, min(calc(92vh - 470px), 580px))`(类 `.pa-plist`),按弹窗高度反推、使列表底部**距弹窗底部约 100px**;下限 280px 防小屏压扁,上限 580px 对应弹窗触顶 1040px。|v0.3.4(2026-09-13):**「系统管理」全面对齐门户 web/portal.html 的 6 个功能页**(用户要求「点开弹窗里面展示的内容,要和 portal 点击功能后那种详细的表格和功能一致」)—— 分区首页 = 门户 renderHome(「服务」「管理」两组 + .nav-card 三行卡片);点开每一项 = 门户**那一页的完整页面**:服务管理(文件树 + 启动/停止/打开 DSH + 新建文件夹/上传)、密钥管理(全局 API 密钥增删启用)、用户管理(审批/禁用/恢复/删除,需输入用户名二次确认)、技能管理(.zip 上传/替换/删除)、插件管理(官方推荐插件 + 手动添加/管理双页签、搜索/分类/只看可导入/重新拉取/批量导入、.tgz 投放含 P0 扫描与显式信任)、运行环境(版本表 + 漂移提示 + 目录/体积 + 折叠说明);表格列 / 按钮 / 文案逐字一致(`PA_PAGES` 逐函数移植 portal 的 renderXxx/initXxx,只改 3 处:局部 `$` 查询器、跨子域 `paReq`、去掉 hash 路由);弹窗宽度对齐门户功能页 min(1440px,96vw)、高 92vh。写操作就地调平台 admin API(跨子域 + credentials:include,CORS 白名单已含 GET/POST/DELETE)。**已删除**旧版自造的 5 项只读摘要(用户管理 / 候选池 / 运行时 / 存储 / 当前实例)。⚠️ 顺带修掉门户 `readAsBase64()` 缺 await 导致上传恒传空数据的缺陷(弹窗侧已修正,门户侧待同修)。|v0.3.3(2026-09-13):「系统管理」视觉层照抄门户组件(.nav-card / .pg-cnt / .table-wrap + table.tbl / .badge / .btn-sm / .page-title)。|v0.2.8(2026-09-13):所有弹窗改页内弹窗(弃用 window.confirm)。|v0.2.7(档案 75):卡片加内存预估徽章 + 列表上方内存预估状态条。|v0.2.4(档案 67):对齐 06-工作台UI规范(信息层次反转 / 字号阶梯 / 行 hover)。|v0.2.2:分区名由「功能插件」改为「功能管理」。|v0.2.0:配色改用官方 --dsw-* design token(跟随 dsh 主题);文案走官方 locale(zh/en)。", "type": "module", "main": "lib/index.js", "exports": { diff --git a/scripts/verify-mem-model.mjs b/scripts/verify-mem-model.mjs index 5c9f825..cd5e058 100644 --- a/scripts/verify-mem-model.mjs +++ b/scripts/verify-mem-model.mjs @@ -1,14 +1,25 @@ #!/usr/bin/env node /** - * verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84) + * verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84 → 档案 96 改版) * - * 为什么需要:2026-09-13 实测发现「功能管理」那条内存条显示 388 MiB 并报「将超出上限」, - * 而平台真实配额是 672 / 384 —— 根因是**同一个事实被复制成了两份**: - * · 编排器 `src/supervisor/orchestrator.ts` 的 PLUGIN_MEM_MB / BASE / MIN / MAX(决定 MemoryMax) - * · 客户端 `poc/business-plugins/lib/client.js` 的 MEM_TABLE / MEM_BASE_MIB / …(决定界面显示) - * R1 把「写死 384」改成「按插件集合推导」时只改了前者 ⇒ 后者原地漂了。 + * 这个脚本守的是一条**曾经漂过、又改过一次设计**的事实链,历史分三段,都留着: + * + * ① 2026-09-13(档案 84):发现问题 —— 「功能管理」页显示 388 MiB 并报「将超出上限」, + * 而平台真实配额是 672/384。根因 = **同一事实被复制成两份**:编排器的 BASE/MIN/MAX/插件表 + * 与客户端的 MEM_BASE_MIB/…/MEM_TABLE。⇒ 本脚本把两处钉死。 + * + * ② 2026-09-13(档案 81 · R1-④):编排器改成「基准 + Σ插件预估」,本脚本随之断言**插件表逐键一致**。 + * + * ③ **2026-09-14(档案 96,用户要求)**:**与插件开关解耦** —— 最终口径为 + * 「**基础 MIN、最多浮动到 MAX**」= cgroup `MemoryHigh=MIN`(软限)+ `MemoryMax=MAX`(硬限)。 + * 理由(实测):用「估计表」定「硬上限」时,估偏低就真 OOM —— admin 带 mcn 后峰值 409 MiB 而当时 + * 上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动,界面「预估」与 + * 实例真实上限绑死,语义混乱。 + * ⇒ 因此本脚本**换向**:不再要求两处都维护插件表(配额只受 MIN/MAX 决定),而是断言 + * · 编排器**不得**再用插件集合算配额(表与 BASE 已移除); + * · 客户端**保留**插件表,但**只用于预估**(`quotaOf` 不再依赖它); + * · MIN / MAX / HEADROOM 两处仍逐值一致。 * - * 本脚本把两处**钉死在一起**:任一常量或表项对不上,`npm run verify` 直接失败。 * 用法:node scripts/verify-mem-model.mjs 退出码 0=全绿 / 1=有失败 */ import { readFileSync } from 'node:fs' @@ -28,74 +39,66 @@ const ok = (name, cond, extra = '') => { const orch = read('src/supervisor/orchestrator.ts') const spawner = read('src/supervisor/spawner.ts') const dshRoute = read('src/web/routes/dsh.ts') +const client = read('poc/business-plugins/lib/client.js') const num = (src, name) => { const m = new RegExp('const ' + name + ' = (\\d+)').exec(src) return m ? Number(m[1]) : null } const ORCH = { - base: num(orch, 'BASE_MEM_MB'), min: num(orch, 'MIN_MEM_MB'), max: num(orch, 'MAX_MEM_MB'), headroom: num(orch, 'HEAP_HEADROOM_MB'), } -const orchTable = {} -{ - const block = /const PLUGIN_MEM_MB: Record = \{([\s\S]*?)\n\}/.exec(orch) - if (block) for (const m of block[1].matchAll(/'([^']+)':\s*(\d+)/g)) orchTable[m[1]] = Number(m[2]) -} - -ok('编排器常量齐备(BASE/MIN/MAX/HEADROOM)', ORCH.base !== null && ORCH.min !== null && ORCH.max !== null && ORCH.headroom !== null, +ok('编排器常量齐备(MIN/MAX/HEADROOM)', ORCH.min !== null && ORCH.max !== null && ORCH.headroom !== null, `-> ${JSON.stringify(ORCH)}`) -ok('编排器插件成本表非空', Object.keys(orchTable).length > 0, '-> ' + JSON.stringify(orchTable)) +ok('MIN ≤ MAX(配额取 MIN、MAX 作硬顶,顺序不能反)', ORCH.min !== null && ORCH.max !== null && ORCH.min <= ORCH.max, + `-> MIN ${ORCH.min} / MAX ${ORCH.max}`) -// ── ② 客户端(副本)──────────────────────────────────────── -const client = read('poc/business-plugins/lib/client.js') +// ── ② 反回退:编排器的配额**不得**再依赖插件集合(档案 96 的核心)──────── +ok('编排器已移除插件成本表 PLUGIN_MEM_MB', !/PLUGIN_MEM_MB/.test(orch)) +ok('编排器已移除 BASE_MEM_MB(配额不再 = 基准 + Σ插件)', !/BASE_MEM_MB/.test(orch)) +// ⚠️ 只看 `instanceMemMb` 的**函数体**:编排器别处仍会读 bundles(例如「从 bundles 摘掉某插件」, +// 与配额无关)——用全文件匹配会误报。 +const memFns = [/function instanceBaseMb\(\)[\s\S]*?\n\}/.exec(orch)?.[0] ?? '', /function instanceMaxMb\(\)[\s\S]*?\n\}/.exec(orch)?.[0] ?? ''].join('\n') +ok('两个访问器都不读 profile/bundles', memFns.trim() !== '' && !/bundles|package\.json|readFileSync/.test(memFns), + '-> ' + memFns.replace(/\s+/g, ' ').slice(0, 110)) +ok('编排器有 instanceBaseMb() / instanceMaxMb() 两个访问器', + /function instanceBaseMb\(\): number/.test(orch) && /function instanceMaxMb\(\): number/.test(orch)) +ok('cgroup 给两个参数:MemoryHigh=基础 / MemoryMax=上界', + /'MemoryHigh=' \+ instanceBaseMb\(\) \+ 'M'/.test(orch) && /'MemoryMax=' \+ memMb \+ 'M'/.test(orch)) +ok('调用点已不传 profile 路径(3 处 spawn/repair + quotaInfo)', + (orch.match(/instanceMaxMb\(\)/g) ?? []).length >= 3, `-> ${(orch.match(/instanceMaxMb\(\)/g) ?? []).length} 处`) + +// ── ③ 客户端:常量仍与编排器一致,但插件表只准用于预估 ────────────────── const cnum = (src, name) => { const m = new RegExp('var ' + name + ' = (\\d+)').exec(src) return m ? Number(m[1]) : null } const CLIENT = { - base: cnum(client, 'MEM_BASE_MIB'), min: cnum(client, 'MEM_MIN_MIB'), max: cnum(client, 'MEM_MAX_MIB'), headroom: cnum(client, 'MEM_HEAP_HEADROOM_MIB'), } -const clientTable = {} -{ - const block = /var MEM_TABLE = \{([\s\S]*?)\}/.exec(client) - if (block) for (const m of block[1].matchAll(/"([^"]+)":\s*(\d+)/g)) clientTable[m[1]] = Number(m[2]) +for (const [label, a, b] of [['下限 MIN', ORCH.min, CLIENT.min], ['硬顶 MAX', ORCH.max, CLIENT.max], ['堆预留 HEADROOM', ORCH.headroom, CLIENT.headroom]]) { + ok(`常量一致 · ${label}`, a === b, `-> 编排器 ${a} / 客户端 ${b}`) } +ok('客户端 quotaOf 兜底值 = 硬顶上界 MEM_MAX_MIB', /function quotaOf\(_raw\)\s*\{\s*return MEM_MAX_MIB/.test(client)) +ok('客户端显示「基础 → 最多」(i18n mem.base 存在)', /"mem\.base"\s*:/.test(client) && /quotaInfo\.baseMb/.test(client)) +ok('客户端**保留**插件表 MEM_TABLE(给用户看的预估)', /var MEM_TABLE = \{/.test(client)) +ok('客户端保留预估算式 rawMiB()(基线 + Σ插件)', /function rawMiB\(list, key\)/.test(client)) +ok('预估用的基线常量仍在(MEM_BASE_MIB)', cnum(client, 'MEM_BASE_MIB') !== null) +ok('客户端注释标明插件表「仅用于预估」', /仅用于预估/.test(client)) -// ── ③ 断言:两处必须逐项一致 ──────────────────────────────── -const pairs = [ - ['基线 BASE', ORCH.base, CLIENT.base], - ['下限 MIN', ORCH.min, CLIENT.min], - ['硬顶 MAX', ORCH.max, CLIENT.max], - ['堆预留 HEADROOM', ORCH.headroom, CLIENT.headroom], -] -for (const [label, a, b] of pairs) ok(`常量一致 · ${label}`, a === b, `-> 编排器 ${a} / 客户端 ${b}`) - -const allKeys = [...new Set([...Object.keys(orchTable), ...Object.keys(clientTable)])].sort() -const bad = allKeys.filter((k) => orchTable[k] !== clientTable[k]) -ok('插件成本表逐键一致', bad.length === 0, - bad.length === 0 ? `-> ${allKeys.length} 项:${allKeys.map((k) => k + '=' + orchTable[k]).join(', ')}` - : '-> 不一致:' + bad.map((k) => `${k}(编排器 ${orchTable[k]} / 客户端 ${clientTable[k]})`).join('; ')) - -// ── ④ 断言:clamp 算式同款(防「常量对了、规则又各写一套」)──── -ok('编排器 clamp 规则在位', /Math\.min\(Math\.max\(mb, MIN_MEM_MB\), MAX_MEM_MB\)/.test(orch)) -ok('客户端 clamp 规则与编排器同构', /Math\.min\(Math\.max\(raw, MEM_MIN_MIB\), MEM_MAX_MIB\)/.test(client)) +// ── ④ heap 推导两侧同构 ───────────────────────────────── ok('客户端 heap 推导与编排器同构', /Math\.max\(128, Math\.min\(256, memMb - MEM_HEAP_HEADROOM_MIB\)\)/.test(client)) ok('编排器 heap 推导算法未变', /Math\.max\(128, Math\.min\(256, memMb - HEAP_HEADROOM_MB\)\)/.test(orch)) -// ── ⑤ 断言:status 真的把真值透出来了(否则前端的「读真值」是空接线)── -ok('Spawner 接口声明 quotaInfo?', /quotaInfo\?\(userId: string\): \{ memMb: number; heapMb: number \} \| null/.test(spawner)) -ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): \{ memMb: number; heapMb: number \} \| null \{/.test(orch)) -// 档案 87 顺手修:档案 86 把 status 主体抽成 `statusForUser(app, user)` 之后,这里从 -// `request.user!.id` 变成了 `user.id` ⇒ 老断言正则失配、`npm run verify` 一直红着。 -// 断言意图不变("status 真的把真值透出来了"),改成不绑定形参名。 +// ── ⑤ status 真的把真值透出来了(否则前端「读真值」是空接线)── +ok('Spawner 接口声明 quotaInfo?', /quotaInfo\?\(userId: string\): { baseMb: number; memMb: number; heapMb: number } \| null/.test(spawner)) +ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): { baseMb: number; memMb: number; heapMb: number } \| null \{/.test(orch)) ok('/api/dsh/status 返回 quota', /quota:\s*app\.supervisor\.quotaInfo\?\.\([^)]*\)\s*\?\?\s*null/.test(dshRoute)) -ok('客户端会去读 /api/dsh/status', /\/api\/dsh\/status/.test(client) && /setQuotaInfo\(/.test(client)) +ok('客户端会去读 /api/dsh/status(显示真配额)', /\/api\/dsh\/status/.test(client) && /setQuotaInfo\(/.test(client)) console.log(failed === 0 ? '\n结论:全绿 ✅' : '\n结论:有 ' + failed + ' 项失败 ❌') process.exit(failed === 0 ? 0 : 1) diff --git a/src/supervisor/orchestrator.ts b/src/supervisor/orchestrator.ts index 8d979f6..1f9646c 100644 --- a/src/supervisor/orchestrator.ts +++ b/src/supervisor/orchestrator.ts @@ -63,51 +63,45 @@ export { * missing task; executes any post-restart command from the handoff path. */ const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.' /* ───────────────────────────────────────────────────────────────────────────── - * 档案 81 · R1-④:**实例内存配额按「已启用插件集合」推导**(原先写死 384M) + * 实例内存:**基础 MIN、最多浮动到 MAX**(2026-09-14 用户要求 —— 与「插件开关」解耦) * - * 为什么改(2026-09-13 事故,实测): - * guest 启用 `dsh-univer-office` 后,其 gateway **单进程 RSS ≈390 MB**,而实例 cgroup 上限 - * 写死 384M ⇒ 实例起来即被 **cgroup OOM kill(exitCode 137)** ⇒ 页面「恢复 → 起来 → 又被杀」 - * 死循环(每 ~35s 一次 GET /)。**根因不是恢复逻辑,而是配额与插件集合脱钩。** + * 两段历史都记下来,免得再走回头路: + * · **档案 81 · R1-④(2026-09-13)** 曾把配额改成 `基准 + Σ(插件内存预估)`,为修 + * 「guest 启用 univer 后 gateway ≈390 MB 撞死写的 384M ⇒ cgroup OOM kill 死循环」。 + * · **2026-09-14 实测暴露该设计的软肋:用「估计表」去定「硬上限」,估偏低就真 OOM** —— + * ① 未知插件按 0 计 ⇒ 装了重插件也不加配额; + * ② 表本身偏乐观:admin 带上 mcn 后**峰值 409 MiB**,而当时上限 384 ⇒ **一分钟内被 OOM 杀 4 次**, + * 直接打出 crash 熔断(`/var/log/dsh-crash-breaker.log` 20:04:53); + * ③ 配额随「开关插件」跳动 ⇒ 界面上的「预估」与实例**真实上限**绑死,语义混乱 + * (用户 2026-09-14 指出:「开关插件只是显示给用户看内存占用的预估,怎么会实际影响实例内存」)。 * - * 规则:`基准 + Σ(插件内存预估)`,再加下限/上限封顶;V8 老生代上限跟随配额(留 96M 余量)。 - * · 预估表口径承接档案 75/67(插件评估的「资源成本」维度); - * · 未识别的插件按 0 计(保守:宁可少给,也不虚高); - * · 上限 1024M 是单实例硬顶(宿主 1870M,并发数由 maxIdleInstances + available 决定)。 - * 回滚:把下方 PLUGIN_MEM_MB 清空即可退回「MIN_MEM_MB」单一档。 + * 现规则(用户原话:「实例内存不要受插件开关影响,只受 min 和 max 值影响」; + * 随后明确「改成基础 min 最大可以浮动到 max」)——**cgroup 给两个参数**: + * `MemoryHigh = MIN_MEM_MB`(**基础/软限**:超过即开始回收·限速)+ + * `MemoryMax = MAX_MEM_MB`(**上界/硬限**:越界 OOM)⇒ 实际占用在两者之间浮动; + * V8 老生代上限仍跟随配额(留 `HEAP_HEADROOM_MB` 余量,见 `heapMbFor`)。 + * ⚠️ **插件成本表已从配额计算中移除**:给用户看的预估仍在插件客户端(`MEM_TABLE`), + * 但它**只用于显示**、不再是硬上限的来源。 + * + * 取值依据:MIN = **448**(= 用户 2026-09-14 裁定的 base「base 448 没问题」;0.1.5 基座实测 ~312 MiB ⇒ 留 ~136 MiB 余量); + * MAX = 1024 是宿主容量不变量(宿主 1870M,并发数由 maxIdleInstances + available 决定)。 + * 回滚:把 `instanceMemMb()` 换回「基准 + Σ插件表」即可(旧实现见 git 历史;档案 81/94/96 记有两次调整理由)。 * ──────────────────────────────────────────────────────────────────────────── */ -const PLUGIN_MEM_MB: Record = { - 'dsh-univer-office': 512, // 实测其 gateway 单进程 ≈390 MB(2026-09-13 14:44 384→512:544 仍欠 ~50-120 MiB,实测 gateway 起不来;512 ⇒ 配额 672) - // ⚠️ mcn 套件**保持 128**(2026-09-14 定案):没有实测依据支撑抬高 —— 反而有反向证据: - // 装了它的那个用户在**旧 384 上限**下一直跑得住 ⇒ 需求 ≤384。上限抬升会直接吃宿主机余量 - // (宿主 1870M / 可用 ≈761M / swap 已用 452M),等有实测再抬。 - 'dsh-plugin-mcn-suite': 128, // 7 插件整合包(含 xlsx 等重型依赖) -} -// BASE 448(2026-09-14 用户裁定):0.1.5 基座**实测 312 MiB**,旧值 160(按 0.1.2 的 106~145)严重低估 -// —— 384 的上限里只剩 72 MiB 给插件,正是宿主 swap 被吃掉的原因;抬高 BASE 是**修正低估**。 -const BASE_MEM_MB = 448 -// MIN 必须 ≥ BASE,否则 `clamp(mb, MIN, MAX)` 里的 MIN 失去意义(永远不会低于 BASE)。 -// 故取 MIN = BASE = 448(旧值 384 在 BASE=160 时满足该不变量,抬 BASE 后必须跟着抬)。 +// MIN = **基础值**(cgroup `MemoryHigh`)—— 实例稳态落在这附近;MAX = **上浮上界**(cgroup `MemoryMax`,越界 OOM)。 +// ⚠️ 448 = 用户裁定的 base,**别擅自调**(2026-09-14:我曾擅自抬到 512,被纠正)。 const MIN_MEM_MB = 448 -// MAX 保持 1024 —— 这不是偏好,而是**本文档头部已写明的不变量**: -// 「上限 1024M 是单实例硬顶(宿主 1870M)」。2026-09-14 用户裁定「max 就是 1024」; -// 在途草稿里的 1536 与宿主机容量前提**自相矛盾**,已驳回。 const MAX_MEM_MB = 1024 const HEAP_HEADROOM_MB = 96 // 配额里留给非堆部分(native/栈/共享) -/** 读 profile 的 bundles,推导该实例应有的 cgroup 上限(MB)。读不到就用下限。 */ -function instanceMemMb(profileDir: string): number { - try { - const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { - dsh?: { profile?: { bundles?: string[] } } - } - const bundles = pkg.dsh?.profile?.bundles ?? [] - let mb = BASE_MEM_MB - for (const b of bundles) mb += PLUGIN_MEM_MB[b] ?? 0 - return Math.min(Math.max(mb, MIN_MEM_MB), MAX_MEM_MB) - } catch { - return MIN_MEM_MB - } +/** 实例内存**基础值**(MB)= cgroup 软限 `MemoryHigh`:超过它内核开始回收/限速。 + * **不再读 profile 的 bundles**(2026-09-14 起与「插件开关」解耦)。 */ +function instanceBaseMb(): number { + return Math.max(128, MIN_MEM_MB) +} + +/** 实例内存**上浮上界**(MB)= cgroup 硬限 `MemoryMax`:越界 OOM;V8 堆也按它推导。 */ +function instanceMaxMb(): number { + return Math.max(instanceBaseMb(), MAX_MEM_MB) } /** V8 老生代上限跟随配额(不再写死 160)。 */ @@ -467,7 +461,7 @@ export class LocalSpawner implements Spawner { private async baseEnv(userId: string): Promise> { // 档案 81 · R1-④:堆上限跟随「已启用插件集合」推导出的配额(不再写死 160) - const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) + const memMb = instanceMaxMb() const root = userRoot(this.config.dataRoot, userId) const home = homeRoot(root) const workspace = workspaceRoot(root) @@ -571,7 +565,7 @@ export class LocalSpawner implements Spawner { // 档案 81 · R1-④:本实例的内存配额由「已启用插件集合」推导(算一次,传给 spawnAsUser 用) - const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) + const memMb = instanceMaxMb() const env: Record = { ...(await this.baseEnv(userId)), @@ -739,7 +733,7 @@ export class LocalSpawner implements Spawner { const unit = `dsh-${uid}-${randomUUID().slice(0, 8)}` // 档案 81 · R1-④:cgroup 上限由「已启用插件集合」推导(原先写死 384M;见文件头注释的事故) - const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) + const memMb = instanceMaxMb() // 档案 58(2026-09-12):MemoryMax 512M → 384M。 // 依据(全部来自实测,见 scripts/probe-instance-mem.cjs): // · dsh 自身私有内存稳态 106~117 MiB、峰值约 145 MiB(= VmHWM 206 减去共享的 62 MiB @@ -756,7 +750,8 @@ export class LocalSpawner implements Spawner { // 的那一层;本行是兜底硬限。两者一起看才有意义。 const sdArgs = [ '--scope', '--unit', unit, - '-p', 'MemoryMax=' + memMb + 'M', // 档案 81 R1-④:按插件集合推导 + '-p', 'MemoryHigh=' + instanceBaseMb() + 'M', // **基础**(软限,超过即回收/限速) + '-p', 'MemoryMax=' + memMb + 'M', // **上界**(硬限,越界 OOM) '-p', 'CPUQuota=150%', '-p', 'TasksMax=128', '--', 'bwrap', ...bwrapArgs, @@ -937,11 +932,10 @@ export class LocalSpawner implements Spawner { * 并报「⚠ 将超出上限」,而平台真实配额是 672 / 384,且 384 早已只是**下限**)。 * 与其让第二份副本再漂一次,不如把已有值暴露出去、让前端读真值。 */ - quotaInfo(userId: string): { memMb: number; heapMb: number } | null { + quotaInfo(userId: string): { baseMb: number; memMb: number; heapMb: number } | null { try { - const profileDir = join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web') - const memMb = instanceMemMb(profileDir) - return { memMb, heapMb: heapMbFor(memMb) } + const memMb = instanceMaxMb() + return { baseMb: instanceBaseMb(), memMb, heapMb: heapMbFor(memMb) } } catch { return null } diff --git a/src/supervisor/spawner.ts b/src/supervisor/spawner.ts index 0cac01d..0d1cbea 100644 --- a/src/supervisor/spawner.ts +++ b/src/supervisor/spawner.ts @@ -117,7 +117,7 @@ export interface Spawner { * 返回 `instanceMemMb()` / `heapMbFor()` 的**同源结果**,供实例内的「功能管理」 * 直接显示真值,而不是各自维护一份必然会漂的估算表。 */ - quotaInfo?(userId: string): { memMb: number; heapMb: number } | null + quotaInfo?(userId: string): { baseMb: number; memMb: number; heapMb: number } | null /** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */ restartAllMains(): Promise