mingdao-harness 0.6.13 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@ import { mingdaoHome, ensureHome } from './config.js';
12
12
  import { PROVIDERS, MODELS } from './models.js';
13
13
  import { getStoredKey } from './credentials.js';
14
14
  import { isLocalBaseUrl, resolveModelCaps } from './model-caps.js';
15
- import { resolveProviderConfig } from './providers/index.js';
15
+ import { resolveProviderConfig, acquireLocalSlot, localQueueTimeoutMs } from './providers/index.js';
16
16
 
17
17
  const TTL_MS = 60 * 60 * 1000;
18
18
 
@@ -35,7 +35,119 @@ const TTL_MS = 60 * 60 * 1000;
35
35
  // ---------------------------------------------------------------------------
36
36
 
37
37
  /** 预检结果缓存 TTL(10 分钟):端点能力/延迟变化不快,但也不能一辈子不重测。 */
38
- const PROBE_TTL_MS = 10 * 60 * 1000;
38
+ export const PROBE_TTL_MS = 10 * 60 * 1000;
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // v0.6.13(探针误报修复):预检的**超时/重试/退避**与**失败原因分类**
42
+ //
43
+ // 真机证据(web-server.log:799,2026-10-09T10:13:58.900):
44
+ // `chat 预检 tf91d8ab0ddfec0bd unreachable ❌ 不可达:fetch failed(本地引擎没起来?先确认端口/进程)`
45
+ // ——同一回合随后完全可用(web-server.log:800 预告、:803 首帧 8.7s、:844 已跑 21m)。用户照这句话
46
+ // 去重启了引擎。三种形态都必须**分开判、分开说**(桩服务复现见 test/smoke.js §141):
47
+ // ① 端口没人监听 → 连接被拒(ECONNREFUSED):确实是"引擎没起来"
48
+ // ② 只实现 chat 的网关 → 可用(探针只打 POST {base}/chat/completions,**一个 GET 都不多发**)
49
+ // ③ 首帧很慢(prefill/启动中)→ 超时:端口是通的,不能说"引擎没起来"
50
+ // ④ HTTP 404/405 → 端点没实现这条探针请求(探针被拒绝,不是"不可达")
51
+ //
52
+ // 三条硬约束(都是真机踩出来的):
53
+ // · **单次尝试必须短**(1.5s):本地 35B 的"极小请求"实测 0.2~3.0s,1.5s 足够"活着"的引擎答出来;
54
+ // 此前单次 15s、最坏 3 次 + 参考 prefill ≈ 35s,而它在**回合发起之前**被 await(server.js:840),
55
+ // 等于把回合发起拖住(更糟:被 abort 的请求还占着引擎 slot)。
56
+ // · **超时必须覆盖到"首帧"**:此前 `once()` 在响应头到达时就 clearTimeout,而探针是 stream:true ——
57
+ // 引擎"回了头、卡在 prefill"时探针**没有任何超时**(实测 ③b:给了 1.5s 超时,6s 后仍在跑),
58
+ // 把回合发起无限期拖住。现在计时一直盖到首帧/响应结束。
59
+ // · **必须退避重试**:此前 3 次尝试背靠背,全部在同一个瞬间撞上"端口还没 bind"的引擎
60
+ // (实测 ④:端口 800ms 后才监听 → 1ms 内连撞 3 次 → 判"不可达")。退避 300/700ms 后同一现场能测通。
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** 单次探针请求的超时(秒级:短,避免与首帧抢资源)。 */
64
+ export const PROBE_ATTEMPT_MS = 1500;
65
+ /** 工具探针的样本数(N/3 三态:稳定/不稳定/不支持——这是既有判据,不得为了"更快"减样本)。 */
66
+ export const PROBE_ATTEMPTS = 3;
67
+ /** 尝试之间的退避(第 i 次失败后用 BACKOFF[i-1],最后一项复用):给"正在启动"的引擎留出 bind 时间。 */
68
+ export const PROBE_BACKOFF_MS = [300, 700];
69
+ /** 参考 prefill 的单独超时:本地 35B 实测 6.6~7.8s/2000 tokens,用 1.5s 会永远测不到
70
+ * (§140 的"预告由预检实测推导"就会失去依据)。它只有成功连通后才发一次。 */
71
+ const PREFILL_ATTEMPT_MS = 20000;
72
+ /** **失败**结论的保鲜期:失败可能是"引擎正在启动"的瞬时态,绝不能长期复用。 */
73
+ export const PROBE_FAIL_TTL_MS = 60 * 1000;
74
+
75
+ /** 这条预检结论是不是"失败结论"(纯函数):失败结论不得长期缓存/备忘(见 PROBE_FAIL_TTL_MS)。
76
+ * **只**把"没打通/没结论"算失败;`ok-textonly`、`unstable-tools` 是**结论**(端点答了,只是不/不稳定
77
+ * 支持工具调用),按完整 TTL 缓存——否则每个回合都要再往慢引擎上打 4 个探针请求,正好是反效果。
78
+ * @param {any} v */
79
+ export function probeVerdictFailed(/** @type {any} */ v) {
80
+ if (!v) return true; // 压根没拿到结论(抛错/无端点)——同样不能长期当"可用"
81
+ return v.state === 'unreachable';
82
+ }
83
+
84
+ /** 服务端"进程内预检备忘"是否还能用(纯函数,便于断言/变异)。
85
+ *
86
+ * 真机误报的**主因**就在这里:v0.6.13 的备忘**没有任何 TTL**——
87
+ * 进程启动时引擎还没起来,留下一条 `❌ 不可达:fetch failed(本地引擎没起来?…)`,
88
+ * 之后**每一个新回合**都把它原样重放(web-server.log:413 07:03:02 的真实探测 → :416 07:08:13、
89
+ * :799 10:13:58 两次重放,间隔 7 小时 10 分钟),而同一回合其实完全可用(首帧 8.7s)。
90
+ * 所以:成功结论按 PROBE_TTL_MS 复用,**失败结论只按 PROBE_FAIL_TTL_MS(60s)**复用。
91
+ * @param {{ at?: number, value?: any }|null} memo @param {number} [now] */
92
+ export function probeMemoUsable(/** @type {any} */ memo, /** @type {number} */ now = Date.now()) {
93
+ if (!memo || !Number.isFinite(Number(memo.at))) return false;
94
+ const ttl = probeVerdictFailed(memo.value) ? PROBE_FAIL_TTL_MS : PROBE_TTL_MS;
95
+ return now - Number(memo.at) < ttl;
96
+ }
97
+
98
+ /** 探针失败的分类(纯函数,便于断言/变异)——**不许**把超时、拒连、HTTP 4xx 混成一句"fetch failed"。
99
+ * @param {any} err
100
+ * @returns {{ kind: 'refused'|'reset'|'timeout'|'other', detail: string, label: string, hint: string }}
101
+ */
102
+ export function classifyProbeError(/** @type {any} */ err) {
103
+ const code = String(err?.cause?.code || err?.code || '');
104
+ const msg = String(err?.message || err || '');
105
+ if (code === 'ECONNREFUSED' || /ECONNREFUSED/i.test(msg)) {
106
+ return { kind: 'refused', detail: 'ECONNREFUSED', label: '连接被拒(端口没人监听)', hint: '本地引擎没起来/端口不对:先确认端口与进程。' };
107
+ }
108
+ if (code === 'ECONNRESET' || code === 'UND_ERR_SOCKET' || /socket hang up|other side closed|terminated/i.test(msg)) {
109
+ return { kind: 'reset', detail: code || msg.slice(0, 80), label: '连接被对端断开', hint: '引擎可能刚崩/正在重启(连接是通的,只是被断开)。' };
110
+ }
111
+ if (err?.probeTimeout === true || err?.name === 'AbortError' || code === 'UND_ERR_HEADERS_TIMEOUT' || code === 'UND_ERR_BODY_TIMEOUT' || /超时|timed?\s?out|aborted/i.test(msg)) {
112
+ return { kind: 'timeout', detail: code || 'timeout', label: '超时', hint: '端口是通的,但没在探针上限内等到首帧:引擎在 prefill,或**正在启动**——不代表主请求会失败。' };
113
+ }
114
+ return { kind: 'other', detail: code || msg.slice(0, 120) || '未知错误', label: '其它错误', hint: '不是"引擎没起来"的形态,按这个错误码排查(探针只打 POST {base}/chat/completions)。' };
115
+ }
116
+
117
+ /** 探针拿到 HTTP 响应但不是 2xx 时的分类(纯函数)。 */
118
+ export function classifyProbeHttp(/** @type {any} */ status) {
119
+ const s = Number(status) || 0;
120
+ if (s === 404 || s === 405 || s === 501) {
121
+ return { kind: 'http-unimplemented', detail: `HTTP ${s}`, label: `HTTP ${s}(端点未实现该探针请求)`, hint: `该端点没有实现 POST {base}/chat/completions(探针打的与主请求是同一个 URL)。` };
122
+ }
123
+ if (s === 401 || s === 403) {
124
+ return { kind: 'http-auth', detail: `HTTP ${s}`, label: `HTTP ${s}(鉴权被拒)`, hint: '端口与端点都在,是 API Key/鉴权的问题(检查 ⚙ 设置里的密钥)。' };
125
+ }
126
+ return { kind: 'http', detail: `HTTP ${s || '(无响应)'}`, label: `HTTP ${s || '(无响应)'}(探针请求被拒绝)`, hint: '端口是通的,但这条请求被端点拒绝:按返回值排查。' };
127
+ }
128
+
129
+ /** 失败结论的**一行文案**(纯函数):判据(kind)先定,文案跟着判据走。
130
+ * 既要说清"是什么",也要说清"下一步怎么办",并且**永远**写明"预检失败不阻断本回合"。
131
+ * @param {{ kind: string, detail?: string, label?: string, hint?: string }} failure
132
+ * @param {{ attemptMs?: number, tries?: number }} [ctx]
133
+ */
134
+ export function probeFailureNote(/** @type {any} */ failure, /** @type {any} */ ctx = {}) {
135
+ const kind = String(failure?.kind || 'other');
136
+ const detail = failure?.detail ? `(${failure.detail})` : '';
137
+ const label = failure?.label || kind;
138
+ const hint = failure?.hint || '按上面的原因排查。';
139
+ const attemptSec = Number(ctx.attemptMs) > 0 ? (Number(ctx.attemptMs) / 1000).toFixed(1) : '1.5';
140
+ const tries = Number(ctx.tries) > 0 ? Number(ctx.tries) : PROBE_ATTEMPTS;
141
+ // 前缀本身就要分开:只有"连接被拒/被断开"才配说"不可达";超时是"端口通、没等到首帧",
142
+ // HTTP 4xx 是"端点拒绝了这条探针请求"——三者混为一谈正是真机误报的文案根源。
143
+ const head =
144
+ kind === 'refused' ? `❌ 不可达:${label}${detail}` :
145
+ kind === 'reset' ? `❌ 连接被断开:${label}${detail}` :
146
+ kind === 'timeout' ? `⏱ 探针超时:${attemptSec}s 内没有首帧${detail ? `(${failure.detail})` : ''}` :
147
+ kind.startsWith('http') ? `❌ 探针被拒绝:${label}${detail ? '' : ''}` :
148
+ `❌ 探针失败:${label}${detail}`;
149
+ return `${head}——${hint}(探针:${tries} 次尝试 × ${attemptSec}s、含退避;**预检是尽力而为,失败只降级、不阻断本回合**)`;
150
+ }
39
151
  /** 无进展看门狗的自适应下限/上限(见 adaptiveTimeouts)。
40
152
  * 上限与 agent.js 的 DEFAULT_NO_PROGRESS_TIMEOUT_MS 相同——由冒烟断言钉住两者一致(单源守卫)。 */
41
153
  export const PROGRESS_MIN_MS = 600000;
@@ -145,16 +257,25 @@ export function modelNameMismatch(/** @type {any} */ configured, /** @type {any}
145
257
  *
146
258
  * 返回(关键字段):
147
259
  * state 'ok-tools' | 'unstable-tools' | 'ok-textonly' | 'unreachable'(粗三态,兼容旧调用)
260
+ * reason 失败原因(v0.6.13 修复新增):'refused'(连接被拒)/ 'reset'(被断开)/
261
+ * 'timeout'(超时)/ 'inconclusive'(样本不可判定)/ 'http-404' 等 / null(可用)
148
262
  * toolState 'stable-tools' | 'unstable-tools' | 'no-tools' | 'unknown'(细三态,N/3)
149
263
  * toolCallHits / toolCallRuns 命中次数 / 实际样本数
150
264
  * ttftMs 三次小请求的首帧中位数(ms)
151
265
  * prefillMs 参考 prefill(prefillTokens 个 token)的耗时(ms)
152
266
  * baseline / slowdown 历史基线(持久化在 <home>/model-probe.json)与本次倍数
153
267
  * nameMismatch / loadedModel 引擎实际加载的模型名与配置项名是否对不上
154
- * note 一行给人看的结论(UI + web-server.log 直接用)
268
+ * note 一行给人看的结论(UI + web-server.log 直接用)——**文案跟着 reason 走**,
269
+ * 不得把超时/HTTP 4xx/拒连一律写成"本地引擎没起来"
270
+ *
271
+ * 语义(v0.6.13 修复):
272
+ * · `timeoutMs` 是**整个探针的时间预算**(默认 10s;server.js 传 15000),**不是**单次尝试超时;
273
+ * 单次尝试超时 = `attemptMs`(默认 PROBE_ATTEMPT_MS = 1.5s),并且**覆盖到首帧**(不只是响应头)。
274
+ * · `repeats` 默认 3(工具调用三态需要 N/3,不得减);尝试之间有退避(PROBE_BACKOFF_MS)。
275
+ * · 探针是**尽力而为**:任何失败都只降级为"没有预检数据",绝不影响主请求/回合发起。
155
276
  *
156
277
  * @param {any} cfg @param {string} modelName
157
- * @param {{ timeoutMs?: number, force?: boolean, fetchImpl?: any, repeats?: number,
278
+ * @param {{ timeoutMs?: number, force?: boolean, fetchImpl?: any, repeats?: number, attemptMs?: number,
158
279
  * prefillTokens?: number, budgetMs?: number, persist?: boolean }} [opts]
159
280
  */
160
281
  export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} */ modelName, opts = {}) {
@@ -162,7 +283,8 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
162
283
  timeoutMs = 10000,
163
284
  force = false,
164
285
  fetchImpl = null,
165
- repeats = 3,
286
+ repeats = PROBE_ATTEMPTS,
287
+ attemptMs = PROBE_ATTEMPT_MS,
166
288
  prefillTokens = 2000,
167
289
  budgetMs = 35000,
168
290
  persist = true,
@@ -171,12 +293,19 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
171
293
  const base = String(pc.baseUrl || '').replace(/\/+$/, '');
172
294
  const key = `${base}|${modelName}`;
173
295
  const hit = probeCache.get(key);
174
- if (!force && hit && Date.now() - hit.at < PROBE_TTL_MS) return hit.value;
296
+ // v0.6.13 修复:**失败**结论的保鲜期只有 60s——失败可能是"引擎正在启动"的瞬时态,
297
+ // 用 10 分钟 TTL 复用会把"当时打不通"当成"现在也不行"(真机 07:03 的失败结论被 10:13 的
298
+ // 新回合原样重放,见 web-server.log:413 → :799)。成功结论仍按 10 分钟。
299
+ if (!force && hit && Date.now() - hit.at < (probeVerdictFailed(hit.value) ? PROBE_FAIL_TTL_MS : PROBE_TTL_MS)) return hit.value;
175
300
  if (!force && probeInflight.has(key)) return probeInflight.get(key);
176
301
  const doFetch = fetchImpl || globalThis.fetch;
177
302
  const run = (async () => {
178
303
  const startedAt = Date.now();
179
- const left = () => Math.max(1500, Math.min(timeoutMs, budgetMs - (Date.now() - startedAt)));
304
+ // v0.6.13 修复:`timeoutMs` 是**整个探针的时间预算**(不是单次尝试的超时——此前语义正是单次,
305
+ // 于是"3 次 × 15s + prefill"最坏 35s 都堆在回合发起之前)。单次尝试用 attemptMs(1.5s)。
306
+ const budget = Math.min(Number(timeoutMs) > 0 ? Number(timeoutMs) : 10000, Number(budgetMs) > 0 ? Number(budgetMs) : Infinity);
307
+ const deadline = startedAt + budget;
308
+ const left = () => Math.max(0, deadline - Date.now());
180
309
  const info = /** @type {any} */ ({
181
310
  at: Date.now(),
182
311
  endpoint: endpointLabel(base),
@@ -210,22 +339,60 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
210
339
  }
211
340
  })(),
212
341
  state: /** @type {string} */ ('unreachable'),
342
+ // v0.6.13 修复:失败**原因**(判据)与文案分开记——日志/界面都能看出"是哪一种失败"
343
+ reason: /** @type {string|null} */ (null),
344
+ attemptMs,
213
345
  error: /** @type {string|null} */ (null),
214
346
  note: '',
215
347
  partial: false,
216
348
  });
217
349
  if (!base) {
218
- return { ...info, error: '该模型没有可用的 baseUrl(未配置端点)', note: '端点未配置:请先在 ⚙ 设置里填 baseUrl/API Key。' };
350
+ return { ...info, reason: 'no-base-url', error: '该模型没有可用的 baseUrl(未配置端点)', note: '端点未配置:请先在 ⚙ 设置里填 baseUrl/API Key。' };
219
351
  }
220
352
  const headers = { 'Content-Type': 'application/json', ...(pc.apiKey ? { Authorization: `Bearer ${pc.apiKey}` } : {}) };
221
- /** 单次 HTTP(带超时);返回 { ok, status, text } 或抛错 */
222
- const once = async (/** @type {any} */ url, /** @type {any} */ init, /** @type {any} */ ms) => {
353
+ /**
354
+ * 单次 HTTP:返回 `{ res, cleanup, timeoutErr }`——**计时不在这里清**,由调用方"读完首帧"之后清。
355
+ * 为什么(v0.6.13 修复):探针是 `stream: true`,`fetch` 在**响应头**到达时就 resolve;此前
356
+ * clearTimeout 就在那时执行,于是"引擎回了头、卡在 prefill"这种情况**没有任何超时**
357
+ * (桩复现:给 1.5s 超时,6s 后探针仍在跑),而它在回合发起之前被 await —— 把回合发起拖死。
358
+ *
359
+ * v0.6.15(§3.59):**预检也必须过本地端点并发闸门**——探针打的是与主请求同一个
360
+ * `/chat/completions`,父子回合长 prefill 的同时再插一个探针请求,抢的是同一份内存。
361
+ * 排队发生在计时器启动**之前**(排队是内核自己的调度,不能报成"引擎 1.5s 没给首帧"),
362
+ * 但排队本身也有上限(取 min(config.localQueueTimeoutMs, 本探针剩余预算));
363
+ * 名额在 cleanup(读完首帧/失败)时释放,覆盖整个流式读取窗口。
364
+ */
365
+ const open = async (/** @type {any} */ url, /** @type {any} */ init, /** @type {any} */ ms) => {
223
366
  const ac = new AbortController();
224
- const t = setTimeout(() => ac.abort(new Error(`预检超时(${Math.round(ms / 1000)}s)`)), ms);
367
+ const terr = /** @type {any} */ (new Error(`预检超时(${(ms / 1000).toFixed(1)}s)`));
368
+ terr.probeTimeout = true;
369
+ terr.probeTimeoutMs = ms;
370
+ let releaseLocal = /** @type {null | (() => void)} */ (null);
225
371
  try {
226
- return await fetchImplOr(doFetch, url, { ...init, signal: ac.signal });
227
- } finally {
372
+ releaseLocal = await acquireLocalSlot(base, {
373
+ cfg,
374
+ signal: ac.signal,
375
+ endpoint: `预检 ${endpointLabel(base) || base}`,
376
+ queueTimeoutMs: Math.max(1, Math.min(localQueueTimeoutMs(cfg), left())),
377
+ });
378
+ } catch (e) {
379
+ // 排队超时/中断也归入探针超时语义(端口是通的,只是本地端点正被其他请求占着)
380
+ if (e && typeof e === 'object' && /排队超时/.test(String(/** @type {any} */ (e).message || ''))) {
381
+ /** @type {any} */ (e).probeTimeout = true;
382
+ }
383
+ throw e;
384
+ }
385
+ const t = setTimeout(() => ac.abort(terr), ms);
386
+ const cleanup = () => {
228
387
  clearTimeout(t);
388
+ try { releaseLocal?.(); } catch {}
389
+ };
390
+ try {
391
+ const res = await fetchImplOr(doFetch, url, { ...init, signal: ac.signal });
392
+ return { res, cleanup, timeoutErr: terr };
393
+ } catch (e) {
394
+ cleanup(); // 失败路径立刻清计时器并释放名额(成功路径由调用方读完后清)
395
+ throw e;
229
396
  }
230
397
  };
231
398
  // ① 引擎"实际加载的是什么"——**从 chat 响应的 `model` 字段读**,不额外打 `GET /models`。
@@ -247,13 +414,24 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
247
414
  },
248
415
  };
249
416
  const chunkHasTool = (/** @type {string} */ s) => /"tool_calls"\s*:/.test(s);
250
- let lastError = /** @type {string|null} */ (null);
417
+ /** @type {any[]} 每次尝试的失败(已分类)——用来判"是哪一种失败" */
418
+ const failures = [];
251
419
  let connected = false;
420
+ let attemptsMade = 0;
252
421
  for (let i = 0; i < Math.max(1, repeats); i++) {
253
- if (Date.now() - startedAt > budgetMs) { info.partial = true; break; }
422
+ // 退避:引擎"正在启动"(端口还没 bind)时立刻重试 = 3 次全撞在同一个瞬间 → 误判"不可达"。
423
+ // 桩复现(test/smoke.js §141 ④):端口 800ms 后才监听,无退避时 1ms 内连撞 3 次判不可达。
424
+ if (i > 0) {
425
+ const backoff = PROBE_BACKOFF_MS[Math.min(i - 1, PROBE_BACKOFF_MS.length - 1)];
426
+ if (left() > backoff) await new Promise((r) => setTimeout(r, backoff));
427
+ }
428
+ const slice = Math.min(attemptMs, left());
429
+ if (slice <= 0) { info.partial = true; break; }
430
+ attemptsMade += 1;
254
431
  const t0 = Date.now();
432
+ let opened = /** @type {any} */ (null);
255
433
  try {
256
- const res = await once(
434
+ opened = await open(
257
435
  `${base}/chat/completions`,
258
436
  {
259
437
  method: 'POST',
@@ -270,18 +448,20 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
270
448
  stream: true,
271
449
  }),
272
450
  },
273
- left()
451
+ slice
274
452
  );
275
453
  connected = true;
454
+ const res = opened.res;
276
455
  if (!res || !res.ok) {
277
- const status = Number(res?.status) || 0;
456
+ const f = classifyProbeHttp(Number(res?.status) || 0);
457
+ failures.push(f);
278
458
  let detail = '';
279
459
  try { detail = String(await res.text()).slice(0, 300); } catch {}
280
- lastError = `HTTP ${status || '(无响应)'}${detail ? `:${detail}` : ''}`;
281
- info.toolCallFinishes.push(`http-${status}`);
460
+ info.error = `${f.detail}${detail ? `:${detail}` : ''}`;
461
+ info.toolCallFinishes.push(`http-${Number(res?.status) || 0}`);
282
462
  continue;
283
463
  }
284
- const r = await readProbeStream(res, { hasTool: chunkHasTool, t0 });
464
+ const r = await readProbeStream(res, { hasTool: chunkHasTool, t0, timeoutErr: opened.timeoutErr });
285
465
  if (r.model && !info.loadedModel) {
286
466
  info.loadedModel = String(r.model);
287
467
  info.loadedModels = [String(r.model)];
@@ -299,14 +479,36 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
299
479
  info.toolCallInconclusive = (info.toolCallInconclusive || 0) + 1;
300
480
  }
301
481
  } catch (/** @type {any} */ e) {
302
- lastError = String(e?.name === 'AbortError' || /aborted/i.test(String(e?.message)) ? `预检超时(${Math.round(left() / 1000)}s 内没有任何响应)` : e?.message || e);
303
- info.toolCallFinishes.push('error');
482
+ const f = classifyProbeError(e);
483
+ failures.push(f);
484
+ // "超时上限"必须写**实际**等的时间(此前在 catch 里用当时的 left() 反推,会印出错的秒数)
485
+ info.error = f.kind === 'timeout' ? `预检超时(${(slice / 1000).toFixed(1)}s 内没有${connected ? '首帧' : '任何响应'})` : `${f.label}(${f.detail})`;
486
+ info.toolCallFinishes.push(f.kind === 'timeout' ? 'timeout' : 'error');
487
+ } finally {
488
+ // 计时器等"读完首帧"再清;失败路径 open() 已清(重复 clear 无害)
489
+ try { opened?.cleanup?.(); } catch {}
304
490
  }
305
491
  }
492
+ /** 多数票:同因 ≥2 次取它,否则取最后一次;混合失败也如实写进 error */
493
+ const tally = () => {
494
+ const by = new Map();
495
+ for (const f of failures) by.set(f.kind, (by.get(f.kind) || 0) + 1);
496
+ let best = /** @type {any} */ (failures[failures.length - 1] || null);
497
+ for (const f of failures) if ((by.get(f.kind) || 0) > (by.get(best.kind) || 0)) best = f;
498
+ return { best, by: Object.fromEntries(by) };
499
+ };
306
500
  if (!connected) {
501
+ // v0.6.13 修复:**不connected 不等于"引擎没起来"**——超时(端口通、没等到首帧)、被断开、
502
+ // 未知错误都必须各自成句。判据(reason)与文案都由分类函数给出,这里只负责落账。
503
+ const { best, by } = tally();
504
+ const f = best || classifyProbeError(null);
505
+ const kinds = Object.entries(by);
307
506
  info.state = 'unreachable';
308
- info.error = lastError || '连接失败';
309
- info.note = `❌ 不可达:${info.error}${info.isLocal ? '(本地引擎没起来?先确认端口/进程)' : ''}`;
507
+ info.reason = f.kind;
508
+ info.error = info.error || `${f.label}(${f.detail})`;
509
+ // 混合原因(例如"拒连 1 次 + 超时 2 次")必须如实写出来,否则用户没法判断该做什么
510
+ const mix = kinds.length > 1 ? `(${failures.length} 次尝试的失败构成:${kinds.map(([k, v]) => `${k}×${v}`).join('、')})` : '';
511
+ info.note = probeFailureNote(f, { attemptMs, tries: failures.length }) + mix;
310
512
  return info;
311
513
  }
312
514
  // ⑤ 引擎自述上下文(v0.6.13 问题 2):**本地端点**额外读一次 `GET /props`。
@@ -326,14 +528,18 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
326
528
  const propsUrls = strippedBase !== base ? [`${strippedBase}/props`, `${base}/props`] : [`${base}/props`];
327
529
  for (const u of propsUrls) {
328
530
  try {
329
- const res = await once(u, { method: 'GET', headers }, Math.min(5000, left()));
330
- if (!res || !res.ok) continue;
331
- const raw = String(await res.text()).slice(0, 65536);
332
- const ctx = engineContextFromProps(JSON.parse(raw));
333
- if (ctx != null) {
334
- info.engineCtx = ctx;
335
- info.engineCtxSource = u;
336
- break;
531
+ const got = await open(u, { method: 'GET', headers }, Math.min(5000, Math.max(1, left())));
532
+ try {
533
+ if (!got.res || !got.res.ok) continue;
534
+ const raw = String(await got.res.text()).slice(0, 65536);
535
+ const ctx = engineContextFromProps(JSON.parse(raw));
536
+ if (ctx != null) {
537
+ info.engineCtx = ctx;
538
+ info.engineCtxSource = u;
539
+ break;
540
+ }
541
+ } finally {
542
+ got.cleanup();
337
543
  }
338
544
  } catch {
339
545
  /* 读不到就跳过(端点没实现 /props、返回非 JSON、超时)——结论里会如实写"未读到" */
@@ -344,13 +550,31 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
344
550
  const n = info.toolCallRuns;
345
551
  info.toolState = n === 0 ? 'unknown' : info.toolCallHits === n ? 'stable-tools' : info.toolCallHits === 0 ? 'no-tools' : 'unstable-tools';
346
552
  info.state = info.toolState === 'stable-tools' ? 'ok-tools' : info.toolState === 'unstable-tools' ? 'unstable-tools' : info.toolState === 'unknown' ? 'unreachable' : 'ok-textonly';
553
+ // v0.6.13 修复:**连上了、但一个有效样本都没拿到**时必须分清是哪一种——
554
+ // · 每一次尝试都以失败告终(HTTP 4xx 拒掉 / 首帧超时 / 连接被拒)→ 用**分类后的失败原因**出结论
555
+ // (404 → "探针被拒绝:HTTP 404(端点未实现该探针请求)";首帧超时 → "探针超时:1.5s 内没有首帧"),
556
+ // **不许**再说成"工具调用未能判定(多为被 max_tokens 截断)",更不许一律说"引擎没起来";
557
+ // · 拿到过响应但样本"不可判定"(被 max_tokens 截断等)→ reason=inconclusive,保留既有文案。
558
+ const failedAll = n === 0 && failures.length > 0 && (info.toolCallInconclusive || 0) === 0 && failures.length >= attemptsMade;
559
+ if (failedAll) {
560
+ const { best, by } = tally();
561
+ const f = best || failures[failures.length - 1];
562
+ info.reason = f.kind;
563
+ const kinds = Object.entries(by);
564
+ const mix = kinds.length > 1 ? `(${failures.length} 次尝试:${kinds.map(([k, v]) => `${k}×${v}`).join('、')})` : '';
565
+ info.note = probeFailureNote(f, { attemptMs, tries: failures.length }) + mix;
566
+ return info;
567
+ }
568
+ if (n === 0) info.reason = 'inconclusive';
347
569
  // ③ 参考 prefill:固定 ~2000 tokens 的**每次不同**前缀(防引擎前缀缓存把耗时抹平)
348
570
  {
349
571
  const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
350
572
  const filler = Array.from({ length: Math.max(8, Math.round(prefillTokens / 4)) }, (_, i) => `第${i}段:${nonce}`).join(';');
351
573
  const t0 = Date.now();
352
574
  try {
353
- const res = await once(
575
+ // 参考 prefill 单独放宽(PREFILL_ATTEMPT_MS):本地 35B 实测 6.6~7.8s/2000 tokens,
576
+ // 用 1.5s 会永远测不到 → §140 的"预告由预检实测推导"就失去依据。它只在连通后发一次。
577
+ const got = await open(
354
578
  `${base}/chat/completions`,
355
579
  {
356
580
  method: 'POST',
@@ -362,18 +586,22 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
362
586
  stream: true,
363
587
  }),
364
588
  },
365
- Math.min(20000, left())
589
+ Math.min(PREFILL_ATTEMPT_MS, Math.max(1, left()))
366
590
  );
367
- if (res && res.ok) {
368
- const r = await readProbeStream(res, { hasTool: chunkHasTool, t0 });
369
- if (r.model && !info.loadedModel) {
370
- info.loadedModel = String(r.model);
371
- info.loadedModels = [String(r.model)];
372
- info.nameMismatch = modelNameMismatch(modelName, [String(r.model)]);
591
+ try {
592
+ if (got.res && got.res.ok) {
593
+ const r = await readProbeStream(got.res, { hasTool: chunkHasTool, t0, timeoutErr: got.timeoutErr });
594
+ if (r.model && !info.loadedModel) {
595
+ info.loadedModel = String(r.model);
596
+ info.loadedModels = [String(r.model)];
597
+ info.nameMismatch = modelNameMismatch(modelName, [String(r.model)]);
598
+ }
599
+ info.prefillMs = r.ttftMs ?? Date.now() - t0;
600
+ } else {
601
+ info.prefillMs = null;
373
602
  }
374
- info.prefillMs = r.ttftMs ?? Date.now() - t0;
375
- } else {
376
- info.prefillMs = null;
603
+ } finally {
604
+ got.cleanup();
377
605
  }
378
606
  } catch {
379
607
  info.prefillMs = null;
@@ -408,7 +636,7 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
408
636
  : info.toolState === 'no-tools'
409
637
  ? `❌ 未返回 tool_calls(0/${n},finish_reason=${info.toolCallFinishes.join('/')})——该端点很可能不做 function calling`
410
638
  : `⚠ 工具调用**未能判定**(${info.toolCallInconclusive || 0} 次样本不可判定:finish_reason=${info.toolCallFinishes.join('/')})` +
411
- `——多为"被 max_tokens 截断"(思考型模型先输出思考)或请求异常${lastError ? `:${lastError}` : ''};` +
639
+ `——多为"被 max_tokens 截断"(思考型模型先输出思考)或请求异常${info.error ? `:${info.error}` : ''};` +
412
640
  `agent 任务可能步数为 0,**不要**据此判定"不支持工具调用"`;
413
641
  const ttftNote = info.ttftMs != null ? `首帧 ${(info.ttftMs / 1000).toFixed(2)}s` : '首帧未测到';
414
642
  const prefillNote = info.prefillMs != null ? `参考 prefill ${prefillTokens} tokens ${(info.prefillMs / 1000).toFixed(2)}s` : `参考 prefill 未测到`;
@@ -426,7 +654,15 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
426
654
  (info.slowdown?.ttftRatio != null && info.slowdown.ttftRatio >= 3) || (info.slowdown?.prefillRatio != null && info.slowdown.prefillRatio >= 3)
427
655
  ? `\n ⚠ 与上次预检相比明显变慢(首帧 ${info.ttftBaselineMs != null ? (info.ttftBaselineMs / 1000).toFixed(2) + 's → ' : '?'}${info.ttftMs != null ? (info.ttftMs / 1000).toFixed(2) + 's' : '?'}):该端点**可能仍在处理上一个请求**(引擎 slot 被占/排队),建议重启引擎后再跑长任务。`
428
656
  : '';
429
- info.note = `${toolNote} · ${ttftNote} · ${prefillNote}${info.partial ? ' ·(预检超过时间预算,样本未跑满)' : ''} · ${nameNote} · ${engineNote}${slowNote}`;
657
+ // v0.6.13 修复:三态是"N/3"判据,样本没跑满就必须写明(真机现场:引擎正在启动,
658
+ // 前两次尝试被拒、只有第 3 次拿到样本 → 结论会是"(1/1)",看着像"测全了")。
659
+ const wantSamples = Math.max(1, repeats);
660
+ const fewSamples = n > 0 && n < wantSamples;
661
+ if (fewSamples) info.partial = true;
662
+ info.note =
663
+ `${toolNote} · ${ttftNote} · ${prefillNote}` +
664
+ (fewSamples ? ` ·(工具样本只跑满 ${n}/${wantSamples}:前面的尝试失败过——三态按现有样本计,可复跑预检复核)` : '') +
665
+ `${info.partial && !fewSamples ? ' ·(预检超过时间预算,样本未跑满)' : ''} · ${nameNote} · ${engineNote}${slowNote}`;
430
666
  return info;
431
667
  })();
432
668
  probeInflight.set(key, run);
@@ -441,13 +677,23 @@ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} *
441
677
 
442
678
  /** 读一次探针的流式响应:取首帧时延 + 是否出现 tool_calls + finish_reason;拿到结论就主动关流(省 token)。
443
679
  * t0 必须由调用方传入(= **请求发起**时刻):TTFT 的定义是"发出→第一帧",在这里取 Date.now()
444
- * 会把"引擎排队 + prefill"整段抹掉(实测过:真值 120ms 会被量成 0ms)。 */
445
- async function readProbeStream(/** @type {any} */ res, /** @type {{ hasTool: (s: string) => boolean, t0: number }} */ { hasTool, t0 }) {
680
+ * 会把"引擎排队 + prefill"整段抹掉(实测过:真值 120ms 会被量成 0ms)。
681
+ *
682
+ * v0.6.13 修复两点:
683
+ * · `timeoutErr` 是本次请求的首帧超时错误——**在 body 读取阶段触发时必须抛出去**
684
+ * (此前一律吞掉,于是"引擎回了头、卡在 prefill"会退化成"样本不可判定",
685
+ * 最终被归成"不可达",也就是把"引擎在启动/prefill"说成"引擎没起来");
686
+ * · 关流必须 `.catch()`——`reader.cancel()` 的 rejection 此前无人接
687
+ * (桩复现:把引擎在探针读流中途杀掉 → 泄漏一个 `TypeError: terminated` unhandledRejection,
688
+ * 长驻的 web 进程会因此被判为未处理异常)。
689
+ * @param {any} res @param {{ hasTool: (s: string) => boolean, t0: number, timeoutErr?: any }} opts */
690
+ async function readProbeStream(/** @type {any} */ res, /** @type {{ hasTool: (s: string) => boolean, t0: number, timeoutErr?: any }} */ { hasTool, t0, timeoutErr = null }) {
446
691
  let ttftMs = /** @type {number|null} */ (null);
447
692
  let toolCalls = false;
448
693
  let finish = /** @type {string|null} */ (null);
449
694
  let model = /** @type {string|null} */ (null);
450
695
  const reader = res?.body?.getReader?.();
696
+ const close = () => { try { const p = reader?.cancel?.(); if (p && typeof p.catch === 'function') p.catch(() => {}); } catch {} };
451
697
  if (!reader) return { ttftMs: null, toolCalls: false, finish: null };
452
698
  const dec = new TextDecoder();
453
699
  let buf = '';
@@ -467,10 +713,13 @@ async function readProbeStream(/** @type {any} */ res, /** @type {{ hasTool: (s:
467
713
  if (toolCalls || finish) break; // 已能判定:立刻收手
468
714
  if (buf.length > 65536) buf = buf.slice(-4096); // 防无界增长(异常端点狂吐)
469
715
  }
470
- } catch {
471
- /* 读失败按"没拿到结论"处理(上层按 unreachable/unknown 归类) */
716
+ } catch (/** @type {any} */ e) {
717
+ close();
718
+ // 首帧超时(我们自己的 abort)必须上报:它是"超时"判据,不是"样本不可判定"
719
+ if (timeoutErr && (e === timeoutErr || e?.probeTimeout === true)) throw timeoutErr;
720
+ /* 其它读失败按"没拿到结论"处理(上层按 unreachable/unknown 归类) */
472
721
  }
473
- try { reader.cancel(); } catch {}
722
+ close();
474
723
  return { ttftMs, toolCalls, finish, model };
475
724
  }
476
725
 
package/src/presets.js CHANGED
@@ -6,6 +6,14 @@
6
6
  // 2. 用户级 <mingdao-home>/presets/*.json
7
7
  // 3. 内置 随 npm 包分发的 presets/ 目录(只读参考实现)
8
8
  //
9
+ // ---------------------------------------------------------------------------
10
+ // 内置预设的定位(v0.6.16,负责人产品决策):
11
+ // **只提供参数类默认值——不携带人格、不限制工具、不涉及权限。**
12
+ // 发行版面向**普通大众**,不为某种任务做定制:用户可能执行各种任务、不一定用本地模型、
13
+ // 也不一定用来审计代码。因此内置只保留 `presets/local-model.json`
14
+ // (contextBudget / maxOutputTokens / maxRounds 这三个"让本地模型跑得动"的参数)。
15
+ // 需要"只读"的用户请自行组合**权限档**(readonly)与**工具白名单**(tools),而不是依赖内置预设。
16
+ //
9
17
  // 预设字段(全部可选,缺省时保持当前配置不变):
10
18
  // name 唯一名(必填,字母/数字/-/_,1-64)
11
19
  // label 展示名(可选,默认 name)
@@ -15,8 +23,9 @@
15
23
  // 模型连写工具都看不到,比任何权限档都硬
16
24
  // permission 权限模式 ask/auto/readonly(省略=当前配置)——**覆盖**语义:会改本回合的档位,
17
25
  // 因此**内置预设不得声明它**(v0.6.14:它只会造成"沉默覆盖用户选择",见 presetPermissionOverride)
18
- // recommendedPermission 建议权限模式(**只是建议**:只做展示/透出,绝不参与判定、绝不改档;
19
- // 内置预设要表达"建议只读"就用它,第三方/老预设的 permission 仍按反提权规则生效)
26
+ // recommendedPermission 建议权限模式(**只是建议**:只做展示/透出,绝不参与判定、绝不改档)。
27
+ // 字段支持**保留**(第三方预设可能想用它),但**内置预设不使用它**——
28
+ // 避免任何形式的权限覆盖/权限偏好暗示(v0.6.16)。第三方/老预设的 permission 仍受反提权约束。
20
29
  // model 建议模型(省略=当前模型)
21
30
  // temperature / maxOutputTokens / maxRounds / contextBudget 参数覆盖
22
31
  import fs from 'node:fs';
@@ -31,16 +40,22 @@ const PRESET_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
31
40
  //
32
41
  // 原来的内置 local-audit 一个名字扛了三件事:本地模型的保守参数 + 审计人格 + 只读工具白名单。
33
42
  // 负责人的原话:"本地模型只是用来代替云模型 API 而已,功能是一样的;预设的目的是预设上下文窗口/
34
- // 最大输出 tokens 等,让本地模型正常工作——它并不是专为代码审计而设。" 于是拆成两个:
43
+ // 最大输出 tokens 等,让本地模型正常工作——它并不是专为代码审计而设。" 于是当时拆成两个:
35
44
  // · local-model —— 只放"让本地模型跑得动"的参数(contextBudget/maxOutputTokens/maxRounds)
36
45
  // · readonly-audit —— 审计人格 + 只读工具白名单 + recommendedPermission(建议,不覆盖)
37
46
  //
47
+ // v0.6.16(负责人产品决策):**删除 readonly-audit**,内置预设只留 local-model。负责人的原话:
48
+ // "代码审计只是他作为一项较大型较长的任务对 MDH 进行的**测试**;发行版面向**普通大众**,
49
+ // 不是某种任务的定制——用户可能执行不同任务、不一定用本地模型、也不一定用来审计代码。"
50
+ // 所以内置预设的定位收窄为:**只提供参数类默认值,不携带人格、不限制工具、不涉及权限**。
51
+ // 需要"只读"的效果请由用户用**权限档**(readonly)+ **工具白名单**(tools)自行组合。
52
+ //
38
53
  // 老名字 `local-audit` 的兼容:**保留为别名**(不是删掉、也不是不再提)。
39
54
  // 为什么选"别名"而不是"只在加载时提示已更名":
40
55
  // · 下游与测试按名引用过它(config.preset / 计划任务 / 会话粘滞 / 脚本),改成"报个提示然后不给"
41
56
  // 等于把这些调用点从"能用"变成"用不了"——一个改名不该造成运行时中断;
42
57
  // · 别名仍然**必须说出来**(loadPreset 里一次性 warn + WebUI banner),所以不会变成静默改名;
43
- // · 别名指向 local-model:老名字的语义本来就是"本地模型预设",审计那部分另有新家。
58
+ // · 别名指向 local-model:老名字的语义本来就是"本地模型预设",审计那部分已随 readonly-audit 删除。
44
59
  // 若用户/项目自己写了同名 `local-audit.json`,**以磁盘上的为准**(别名只在"没找到同名预设"时才生效),
45
60
  // 遮蔽语义与既有一致。
46
61
  const PRESET_ALIASES = /** @type {Record<string, string>} */ ({ 'local-audit': 'local-model' });
@@ -63,6 +78,12 @@ export function presetAliasOf(/** @type {any} */ name) {
63
78
  return PRESET_ALIASES[n] ? { from: n, to: PRESET_ALIASES[n] } : null;
64
79
  }
65
80
  // 合法字段白名单:未知字段报错(防拼写错误静默失效——契约化核心)
81
+ //
82
+ // `recommendedPermission` **保留字段支持**:第三方/用户自写的预设可能想用它表达"建议档"(只是建议,
83
+ // 不参与判定、不改档,见 validatePreset 与 presetConfigOverrides)。**内置预设不使用它**——
84
+ // 发行版面向普通大众、不做任务定制,也不做任何形式的权限覆盖/权限偏好暗示(v0.6.16)。
85
+ // 之所以不删这个字段:删掉会让第三方预设从"能校验通过"变成"未知字段报错"(一次白名单收紧会造成
86
+ // 下游中断),而它本身没有覆盖语义、不会造成"沉默覆盖用户选择"。
66
87
  const KNOWN_FIELDS = new Set([
67
88
  'name', 'label', 'description', 'systemPrompt', 'tools',
68
89
  'permission', 'recommendedPermission', 'model', 'temperature', 'maxOutputTokens', 'maxRounds', 'contextBudget',
@@ -107,6 +128,7 @@ export function validatePreset(/** @type {any} */ obj) {
107
128
  }
108
129
  // recommendedPermission 与 permission 同一取值域,但**语义完全不同**:前者是建议(不参与判定),
109
130
  // 后者是覆盖。校验一并做,避免拼错的值(如 'read-only')静默躺在预设里当装饰。
131
+ // 字段支持保留给第三方预设;**内置预设不使用它**(v0.6.16)。
110
132
  if (obj.recommendedPermission !== undefined && !PERMISSION_MODES.includes(String(obj.recommendedPermission))) {
111
133
  errors.push(`recommendedPermission 必须是 ${PERMISSION_MODES.join('/')}`);
112
134
  }
@@ -182,7 +204,8 @@ export function listPresets(/** @type {any} */ workingDir) {
182
204
  ...(Array.isArray(obj.tools) ? { tools: obj.tools } : {}),
183
205
  ...(obj.permission ? { permission: String(obj.permission) } : {}),
184
206
  // v0.6.14:**建议**权限档也透出(WebUI 列表/诊断可读),但它与 permission 不是一回事——
185
- // 透出字段名分开,谁都不会把它当覆盖用(覆盖只认 permission,且内置预设不得声明 permission)。
207
+ // 透出字段名分开,谁都不会把它当覆盖用(覆盖只认 permission;且**内置预设不使用任何权限字段**,
208
+ // 这里透出的只可能是第三方/用户自写预设声明的值——v0.6.16)。
186
209
  ...(obj.recommendedPermission ? { recommendedPermission: String(obj.recommendedPermission) } : {}),
187
210
  ...(obj.model ? { model: String(obj.model) } : {}),
188
211
  }));
@@ -260,10 +283,12 @@ const PERM_RANK = /** @type {Record<string, number>} */ ({ readonly: 0, ask: 1,
260
283
  * (如当前 ask → 预设 auto 属提权,忽略并返回当前值)。clone 恶意仓库含 .mingdao/presets/*.json
261
284
  * 声明 auto 时,不能静默跳过用户全部确认。返回 { permission, escalated }:escalated=true 表示已拦截提权。
262
285
  *
263
- * 纪律(v0.6.14):**内置预设不得声明 `permission`**——它是"覆盖"语义,只会造成"沉默覆盖用户选择"
264
- * (负责人实测:界面上选了「自动」,内置 local-audit 仍把会话按 readonly 跑,每调用一次非只读工具
265
- * 都弹「只读模式将拦截 …」)。内置预设要表达"建议只读"用 `recommendedPermission`(只透出、不参与判定),
266
- * 只读的硬约束交给 `tools` 白名单(没有 write/edit)。
286
+ * 纪律(v0.6.14,v0.6.16 收窄到"内置预设不涉及权限"):**内置预设不使用任何权限字段**——
287
+ * `permission` 是"覆盖"语义,只会造成"沉默覆盖用户选择"(负责人实测:界面上选了「自动」,
288
+ * 当时的内置 local-audit 仍把会话按 readonly 跑,每调用一次非只读工具都弹「只读模式将拦截 …」);
289
+ * `recommendedPermission` 虽只是建议,但同样是"替用户表达权限偏好"的暗示,内置预设也不用。
290
+ * 内置预设只提供参数类默认值(见文件头"内置预设的定位");只读的硬约束交给**工具白名单**
291
+ * (tools 里没有 write/edit),或由用户显式选权限档。
267
292
  * 第三方/老预设仍可声明 `permission`,本节的反提权语义**不放松**(只拦"变宽松",不放行 readonly→auto)。
268
293
  * @param {any} preset @param {string} currentPermission
269
294
  */