@coclaw/openclaw-coclaw 0.26.2 → 0.26.3

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.
@@ -2,17 +2,15 @@ import fs from 'node:fs/promises';
2
2
  import nodeFs from 'node:fs';
3
3
  import nodePath from 'node:path';
4
4
 
5
- import { checkForUpdate } from './updater-check.js';
5
+ import { checkForUpdate, getPackageInfo, inspectPluginInstall } from './updater-check.js';
6
6
  import { spawnUpgradeWorker } from './updater-spawn.js';
7
- import { readState, resolveStateDir, writeState } from './state.js';
8
- import { getClawConfig } from '../claw-config.js';
7
+ import { readInflight, readState, recordUpgradeTerminal, resolveStateDir, writeState } from './state.js';
8
+ import { removeBackup } from './worker-backup.js';
9
+ import { isVersionReached } from './worker-verify.js';
10
+ import { getRuntime } from '../runtime.js';
9
11
  import { remoteLog } from '../remote-log.js';
10
12
  import { atomicWriteFile } from '../utils/atomic-write.js';
11
13
 
12
- // OpenClaw ≥ 2026.4.25 起把插件安装记录从 openclaw.json 的 plugins.installs
13
- // 迁移到独立账本文件,并在 loadConfig() 返回前剥掉 plugins.installs。
14
- const INSTALLS_LEDGER_RELATIVE_PATH = nodePath.join('plugins', 'installs.json');
15
-
16
14
  // 首次检查延迟较长:失败时由 worker 触发 gateway restart,scheduler 重启后会重新计时;
17
15
  // 60 分钟基线(实际随机 60-120 分钟)能把"失败→重启→再次检查"的循环周期拉长,
18
16
  // 避免连续升级失败时 gateway 在短时间内反复被打扰。
@@ -30,6 +28,46 @@ const LOCK_FILENAME = 'upgrade.lock';
30
28
  // 且底层升级命令失败会走回滚,不会破坏插件。
31
29
  const LOCK_TTL_MS = 110 * 60 * 1000; // 110 分钟
32
30
 
31
+ // 精确 semver 形态(含可选 prerelease / build 段),spec 钉死检测用
32
+ const EXACT_SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.-]+)?(\+[\w.-]+)?$/;
33
+
34
+ // 运行态版本:模块加载时刻同步捕获,coclaw.upgradeHealth handler 与 inflight
35
+ // 对账共用本快照(getLoadedPluginVersion)。成功判据必须反映"当前 gateway
36
+ // 真正加载的代码",禁止判定时再读磁盘 package.json:中断的升级可能已把磁盘
37
+ // 写成新版本而新代码从未被加载,误记成功会删掉唯一的回滚备份。
38
+ // (src/plugin-version.js 的懒读缓存原语是 info 展示用,语义不同,勿互相替代。)
39
+ let LOADED_PLUGIN_VERSION = null;
40
+ try {
41
+ const rawPkg = nodeFs.readFileSync(nodePath.resolve(import.meta.dirname, '../../package.json'), 'utf8');
42
+ const ver = JSON.parse(rawPkg)?.version;
43
+ LOADED_PLUGIN_VERSION = typeof ver === 'string' ? ver : null;
44
+ }
45
+ /* c8 ignore next 3 -- 自身 package.json 读取失败的兜底:测试环境必可读 */
46
+ catch {
47
+ // 读不到自身 package.json:对账退化为"无法判定",保留 inflight 下轮重试
48
+ }
49
+
50
+ /**
51
+ * 加载时刻钉住的自身版本快照(即当前进程真正加载的代码)。
52
+ * 快照不可得时返回 null——调用方把 null 当"未达标"是保守正确方向;
53
+ * 禁止加"为 null 就回退读磁盘"的 fallback(见上方快照注释的 Why)。
54
+ * @returns {string|null}
55
+ */
56
+ export function getLoadedPluginVersion() {
57
+ return LOADED_PLUGIN_VERSION;
58
+ }
59
+
60
+ /**
61
+ * 测试用:覆盖加载时快照(判别"返回快照 vs 调用时读磁盘"),返回旧值便于恢复。
62
+ * @param {string|null} version
63
+ * @returns {string|null}
64
+ */
65
+ export function __setLoadedPluginVersionForTest(version) {
66
+ const prev = LOADED_PLUGIN_VERSION;
67
+ LOADED_PLUGIN_VERSION = version;
68
+ return prev;
69
+ }
70
+
33
71
  // ── upgrade.lock:保证同时最多一个 worker 进程 ──
34
72
 
35
73
  export function getLockPath() {
@@ -115,107 +153,77 @@ export async function writeUpgradeLock(pid) {
115
153
  }
116
154
 
117
155
  /**
118
- * 读取本插件的安装记录(兼容新旧 OpenClaw 契约)
119
- *
120
- * - 新版(OpenClaw ≥ 2026.4.25):账本文件 `<state-dir>/plugins/installs.json`
121
- * 下的 `installRecords[pluginId]` 是来源真相;`loadConfig()` 返回的对象里
122
- * `plugins.installs` 已被剥离。
123
- * - 旧版(OpenClaw ≤ 2026.4.24):账本文件不存在,
124
- * `loadConfig().plugins.installs[pluginId]` 是来源真相。
125
- *
126
- * 兼容策略:先尝试账本文件;ENOENT(文件不存在)→ 回落到旧字段;
127
- * 其它失败(权限/JSON 损坏/缺记录)→ 视为账本不可用,按"无来源信息"处理,不回落。
128
- * 这两条互斥(新 gateway 必有账本、旧 gateway 必无)能让两个分支天然分流。
129
- *
130
- * 失败路径会通过 `remoteLog` 外推诊断信号(`upgrade.state-dir-failed` /
131
- * `upgrade.ledger-read-failed` / `upgrade.ledger-parse-failed`),避免运维只
132
- * 看到 start() 那条 "Skipping: not an npm-installed plugin" 时误判方向。
156
+ * 包含谓词:判断 child 是否位于 parent 内(两者均须已 realpath 归一)。
133
157
  *
134
- * 注:内部 `readFileSync` 为同步 IO,**有意保留**——只在升级周期决策时读一次
135
- * 账本(整个进程生命周期通常一锤子)。改 async 必须沿 `shouldSkipAutoUpgrade`
136
- * 等调用链向上传播,收益不抵成本。
158
+ * 禁用裸 `startsWith('..')`——`..foo` 这类目录名会被误判为"在外"。
159
+ * Windows 大小写差异由 path.win32.relative 天然处理;跨盘符(relative 返回
160
+ * 绝对路径)落 isAbsolute 兜住。
137
161
  *
138
- * 另:OpenClaw plugin SDK 当前未暴露查询 installRecords 的 API,只能直接读
139
- * `<state-dir>/plugins/installs.json`(与上游 `manifest-metadata-scan`
140
- * 内部模块同源做法)。如果上游后续开放官方接口,可切换并删除直读分支。
141
- *
142
- * @param {string} pluginId
143
- * @returns {object|null}
162
+ * @param {string} parent
163
+ * @param {string} child
164
+ * @param {object} [pathImpl] - 测试注入(win32 盘符/大小写边角);默认 nodePath
165
+ * @returns {boolean}
144
166
  */
145
- function loadInstallRecord(pluginId) {
146
- let ledgerPath;
147
- try {
148
- ledgerPath = nodePath.join(resolveStateDir(), INSTALLS_LEDGER_RELATIVE_PATH);
149
- }
150
- catch (err) {
151
- // 极少触发:host runtime 的 state resolver 自身异常
152
- /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
153
- remoteLog(`upgrade.state-dir-failed msg=${err?.message ?? String(err)}`);
154
- return null;
155
- }
156
- let raw;
157
- try {
158
- raw = nodeFs.readFileSync(ledgerPath, 'utf8');
159
- }
160
- catch (err) {
161
- if (err?.code === 'ENOENT') {
162
- return loadInstallRecordFromLegacyConfig(pluginId);
163
- }
164
- // 账本应该可读但读不到(权限/EISDIR/IO 错误):不回落到旧字段,避免误判老路径
165
- // 静默返回 null 会让 start() 打 "Skipping: not an npm-installed plugin",对运维毫无指向;
166
- // 把诊断信号外推到 server,便于定位
167
- /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
168
- remoteLog(`upgrade.ledger-read-failed code=${err?.code ?? 'unknown'} msg=${err?.message ?? String(err)}`);
169
- return null;
170
- }
171
- let parsed;
172
- try {
173
- parsed = JSON.parse(raw);
174
- }
175
- catch (err) {
176
- // 账本损坏:同样不回落,并外推诊断信号
177
- /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
178
- remoteLog(`upgrade.ledger-parse-failed msg=${err?.message ?? String(err)}`);
179
- return null;
180
- }
181
- return parsed?.installRecords?.[pluginId] ?? null;
167
+ export function isPathInside(parent, child, pathImpl) {
168
+ /* c8 ignore next -- ?? fallback */
169
+ const p = pathImpl ?? nodePath;
170
+ const rel = p.relative(parent, child);
171
+ return rel === '' || (!rel.startsWith(`..${p.sep}`) && rel !== '..' && !p.isAbsolute(rel));
182
172
  }
183
173
 
184
174
  /**
185
- * 旧版 OpenClaw(≤ 2026.4.24)账本路径:openclaw.json 的 plugins.installs。
186
- * @param {string} pluginId
187
- * @returns {object|null}
175
+ * 判断是否应跳过自动升级(L0:Nix 短路 + 位置自检)
176
+ *
177
+ * 账本直读已移除;来源判定(npm/path/archive/...)后移到 L1(`__check` 内
178
+ * 逐周期 inspect,瞬时失败下周期自愈)。这里只做启动时刻的同步短路:
179
+ * - Nix mode:config 不可变,自动升级无意义;
180
+ * - 位置自检:正式安装三代均落在 state-dir 内,自身包根在外 ⇒ dev/link 装置,
181
+ * 跳过整个 scheduler——避免 dev 长命网关在"已发版未 pull"常态窗口每小时
182
+ * spawn 一次 inspect,且不依赖 CLI 可用性。
183
+ *
184
+ * 只信 runtime 注入的 resolveStateDir:state.js 的 env/home 兜底可能与上游
185
+ * 真实 state-dir 分叉,不得用于"在外"判定。runtime 不可用或 realpath/谓词
186
+ * 任一步抛错 → 不下"在外"结论,放行到 L1 兜底 + remoteLog 信号。
187
+ *
188
+ * @param {string} pluginId - 兼容旧签名保留;位置判定不依赖它
189
+ * @param {object} [opts] - 测试注入
190
+ * @param {string} [opts.pluginRoot] - 覆盖自身包根
191
+ * @param {string} [opts.stateDir] - 覆盖 state-dir(绕过 runtime)
192
+ * @returns {boolean} true 表示应跳过自动升级
188
193
  */
189
- function loadInstallRecordFromLegacyConfig(pluginId) {
194
+ export function shouldSkipAutoUpgrade(pluginId, opts) {
195
+ if (isNixMode()) return true;
190
196
  try {
191
- const config = getClawConfig();
192
- return config?.plugins?.installs?.[pluginId] ?? null;
197
+ let stateDir = opts?.stateDir;
198
+ if (stateDir == null) {
199
+ const rt = getRuntime();
200
+ if (typeof rt?.state?.resolveStateDir !== 'function') {
201
+ // runtime 不可用:不能用 env/home 兜底下"在外"结论,放行到 L1
202
+ remoteLog('upgrade.state-dir-failed msg=runtime resolveStateDir unavailable');
203
+ return false;
204
+ }
205
+ stateDir = rt.state.resolveStateDir();
206
+ }
207
+ // 先 resolve 得根、再 realpath:link 模式下结果确定为 stage 根(与 updater-check.js 同锚点)
208
+ const pkgRoot = nodeFs.realpathSync(
209
+ opts?.pluginRoot ?? nodePath.resolve(import.meta.dirname, '../..'),
210
+ );
211
+ const realStateDir = nodeFs.realpathSync(stateDir);
212
+ if (!isPathInside(realStateDir, pkgRoot)) {
213
+ // start() 每次 gateway 启动只走一次,至多一条,与 nix-skip 同级
214
+ remoteLog(`upgrade.position-skip pkgRoot=${pkgRoot} stateDir=${realStateDir}`);
215
+ return true;
216
+ }
217
+ return false;
193
218
  }
194
219
  catch (err) {
195
- // 与同函数其他 catch 风格对齐:旧版账本读取异常也外推诊断信号,
196
- // 否则下游只能看到笼统的 "Skipping: not an npm-installed plugin",无定位线索
220
+ // realpath/谓词任一步异常:不下"在外"结论,放行 + 信号
197
221
  /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
198
- remoteLog(`upgrade.legacy-config-read-failed msg=${err?.message ?? String(err)}`);
199
- return null;
222
+ remoteLog(`upgrade.state-dir-failed msg=${err?.message ?? String(err)}`);
223
+ return false;
200
224
  }
201
225
  }
202
226
 
203
- /**
204
- * 判断是否应跳过自动升级
205
- *
206
- * `openclaw plugins update` 仅对 source === "npm" 的安装生效。
207
- * source 的可能值:
208
- * - "npm":从 npm registry 安装(生产环境,允许自动升级)
209
- * - "path":link 模式(本地开发,跳过)
210
- * - "archive":从 tarball 安装(跳过)
211
- *
212
- * @param {string} pluginId
213
- * @returns {boolean} true 表示应跳过自动升级
214
- */
215
- export function shouldSkipAutoUpgrade(pluginId) {
216
- return loadInstallRecord(pluginId)?.source !== 'npm';
217
- }
218
-
219
227
  /**
220
228
  * 判断 host 是否运行在 Nix mode。
221
229
  *
@@ -231,15 +239,6 @@ export function isNixMode() {
231
239
  return process.env.OPENCLAW_NIX_MODE === '1';
232
240
  }
233
241
 
234
- /**
235
- * 获取插件安装路径
236
- * @param {string} pluginId
237
- * @returns {string|null}
238
- */
239
- export function getPluginInstallPath(pluginId) {
240
- return loadInstallRecord(pluginId)?.installPath ?? null;
241
- }
242
-
243
242
  /**
244
243
  * 自动升级调度器
245
244
  */
@@ -255,6 +254,8 @@ export class AutoUpgradeScheduler {
255
254
  __opts = {};
256
255
  /** 已报告过的 lastUpgrade.ts,用于去重 */
257
256
  __lastReportedUpgradeTs = null;
257
+ /** L1 门禁信号去重:key=`<原因>|<toVersion>`,重启重置(稳定态装置至多重发一条) */
258
+ __reportedGateSignals = new Set();
258
259
 
259
260
  /**
260
261
  * @param {object} [params]
@@ -267,7 +268,15 @@ export class AutoUpgradeScheduler {
267
268
  * @param {Function} [params.opts.spawnFn]
268
269
  * @param {Function} [params.opts.shouldSkipFn]
269
270
  * @param {Function} [params.opts.isNixModeFn]
270
- * @param {Function} [params.opts.getPluginInstallPathFn]
271
+ * @param {Function} [params.opts.inspectInstallFn] - L1 来源门禁的 inspect 注入
272
+ * @param {string} [params.opts.pluginRoot] - L0 位置自检/L1 回退的包根注入
273
+ * @param {string} [params.opts.stateDir] - L0 位置自检的 state-dir 注入
274
+ * @param {Function} [params.opts.readInflightFn] - inflight 对账读注入
275
+ * @param {Function} [params.opts.recordUpgradeTerminalFn] - inflight 对账终态写注入
276
+ * @param {Function} [params.opts.removeBackupFn] - inflight 对账备份清理注入
277
+ * @param {string} [params.opts.runtimeVersion] - 运行态版本覆盖(对账判据注入)
278
+ * @param {string} [params.opts.platform] - spawn 探针平台覆盖
279
+ * @param {NodeJS.ProcessEnv} [params.opts.scopeEnv] - spawn 探针 env 覆盖
271
280
  */
272
281
  constructor(params) {
273
282
  if (params?.pluginId) this.__pluginId = params.pluginId;
@@ -300,13 +309,13 @@ export class AutoUpgradeScheduler {
300
309
  }
301
310
 
302
311
  const shouldSkip = this.__opts.shouldSkipFn ?? shouldSkipAutoUpgrade;
303
- if (shouldSkip(this.__pluginId)) {
304
- this.__logger.info?.('[auto-upgrade] Skipping: not an npm-installed plugin');
312
+ if (shouldSkip(this.__pluginId, this.__opts)) {
313
+ this.__logger.info?.('[auto-upgrade] Skipping: plugin package root is outside state-dir (dev/link install)');
305
314
  this.__running = false;
306
315
  return;
307
316
  }
308
317
 
309
- // 默认 5~10 分钟随机延迟,避免多实例同时发起检查
318
+ // 默认 60~120 分钟随机延迟,避免多实例同时发起检查
310
319
  /* c8 ignore next 2 -- ?? fallback:测试始终注入 initialDelayMs */
311
320
  const initialDelay = this.__opts.initialDelayMs
312
321
  ?? (INITIAL_DELAY_MS + Math.floor(Math.random() * INITIAL_DELAY_MS));
@@ -339,6 +348,20 @@ export class AutoUpgradeScheduler {
339
348
  this.__logger.info?.('[auto-upgrade] Scheduler stopped');
340
349
  }
341
350
 
351
+ /**
352
+ * L1 门禁信号去重上报:同一 (原因, toVersion) 每个 gateway 进程周期只发一条。
353
+ * source-skip / 无记录是稳定态,逐周期重验若不去重会每小时刷 server;
354
+ * gate-inspect-failed 持续存在时也只需一条(重启重置,至多重发一条)。
355
+ * installPath 回退两条信号(fallback / pkg-name-mismatch)同模式去重。
356
+ * @param {string} key - 去重键,`<原因>|<toVersion>`
357
+ * @param {string} text - remoteLog 文本
358
+ */
359
+ __gateSignalOnce(key, text) {
360
+ if (this.__reportedGateSignals.has(key)) return;
361
+ this.__reportedGateSignals.add(key);
362
+ remoteLog(text);
363
+ }
364
+
342
365
  /**
343
366
  * 检查 lastUpgrade 是否有未报告的结果,若有则 remoteLog 并标记已报告
344
367
  */
@@ -350,7 +373,9 @@ export class AutoUpgradeScheduler {
350
373
  if (!last?.ts || last.ts === state.lastReport) return;
351
374
  if (last.ts === this.__lastReportedUpgradeTs) return;
352
375
  this.__lastReportedUpgradeTs = last.ts;
353
- remoteLog(`upgrade.result result=${last.result} from=${last.from} to=${last.to}`);
376
+ // error 附上报行(lastUpgrade.error 已在写入时截断),真因远程可见
377
+ const errSuffix = last.error ? ` error=${last.error}` : '';
378
+ remoteLog(`upgrade.result result=${last.result} from=${last.from} to=${last.to}${errSuffix}`);
354
379
  this.__logger.info?.(`[auto-upgrade] Last upgrade: ${last.from} → ${last.to} result=${last.result}`);
355
380
  state.lastReport = last.ts;
356
381
  const writeStateFn = this.__opts.writeStateFn ?? writeState;
@@ -362,6 +387,98 @@ export class AutoUpgradeScheduler {
362
387
  }
363
388
  }
364
389
 
390
+ /**
391
+ * inflight 对账:worker 写过 inflight 但没活到终态记账(典型:被自己触发的
392
+ * 网关重启杀死)时,由 scheduler 在锁空闲周期补记终态。
393
+ *
394
+ * 成功判据用模块加载时刻捕获的运行态版本(LOADED_PLUGIN_VERSION,与
395
+ * upgradeHealth 同源):达 verifyTarget → 补记 ok + 删备份(verifyTarget ≠
396
+ * toVersion 时复刻 worker advancedShortfall 语义补 skip);未达 → 补记
397
+ * interrupted(带 phase)+ 告警,不自动 skip——回滚记账已前移 + 终态原子化后,
398
+ * 中断窗口只剩"死在回滚中途",下周期重验自然收敛,自动 skip 反而误伤瞬态。
399
+ * interrupted 不清备份(保留人工恢复,下次备份前覆盖)。
400
+ *
401
+ * 畸形 inflight(verifyTarget 与 to 皆缺)永远无法判定达标,defer 会让升级
402
+ * 管线永久停摆——按 interrupted(phase=malformed)消化;只有自身快照不可得
403
+ * (runtimeVersion 空)才 defer("无法判定就不动盘"的正确保守)。
404
+ *
405
+ * @returns {Promise<boolean>} false 表示 inflight 存在但未消化(判据不可得 /
406
+ * 终态写失败),本周期应跳过 checkForUpdate,避免新 spawn 覆盖中断账目
407
+ */
408
+ async __reconcileInflight() {
409
+ let inflight;
410
+ try {
411
+ /* c8 ignore next -- ?? fallback */
412
+ const readInflightFn = this.__opts.readInflightFn ?? readInflight;
413
+ inflight = await readInflightFn();
414
+ }
415
+ catch (err) {
416
+ this.__logger.warn?.(`[auto-upgrade] Inflight read failed: ${err?.message}`);
417
+ remoteLog(`upgrade.reconcile-failed msg=${err?.message}`);
418
+ return false;
419
+ }
420
+ if (!inflight) return true;
421
+
422
+ const { from, to, verifyTarget, phase } = inflight;
423
+ /* c8 ignore next -- ?? fallback */
424
+ const recordTerminal = this.__opts.recordUpgradeTerminalFn ?? recordUpgradeTerminal;
425
+ const target = verifyTarget || to;
426
+ if (!target) {
427
+ // 畸形 inflight:无判定目标,defer 等不来转机,按 interrupted 消化让管线继续
428
+ try {
429
+ await recordTerminal({ from: from || 'unknown', to: to || 'unknown', result: 'interrupted', phase: 'malformed' });
430
+ }
431
+ catch (err) {
432
+ // 终态写失败:inflight 保留,下轮重试;本周期不再 spawn
433
+ this.__logger.warn?.(`[auto-upgrade] Inflight reconcile failed: ${err?.message}`);
434
+ remoteLog(`upgrade.reconcile-failed msg=${err?.message}`);
435
+ return false;
436
+ }
437
+ this.__gateSignalOnce('reconcile-malformed', 'upgrade.reconcile-malformed');
438
+ this.__logger.warn?.('[auto-upgrade] Malformed inflight (no verifyTarget/to), recorded as interrupted');
439
+ return true;
440
+ }
441
+ /* c8 ignore next -- ?? fallback */
442
+ const runtimeVersion = this.__opts.runtimeVersion ?? LOADED_PLUGIN_VERSION;
443
+ if (!runtimeVersion) {
444
+ // 判据不可得:不下结论,保留 inflight 下轮重试
445
+ this.__gateSignalOnce(
446
+ `reconcile-no-version|${to}`,
447
+ `upgrade.reconcile-no-version to=${to}`,
448
+ );
449
+ this.__logger.warn?.('[auto-upgrade] Inflight found but reconcile criteria unavailable, deferring');
450
+ return false;
451
+ }
452
+ try {
453
+ if (isVersionReached(runtimeVersion, target)) {
454
+ // 运行态已达 verifyTarget:升级实际生效(worker 只是没活到记账),补记成功
455
+ await recordTerminal({
456
+ from, to: runtimeVersion, result: 'ok',
457
+ skipVersion: target !== to ? to : undefined,
458
+ });
459
+ /* c8 ignore next -- ?? fallback */
460
+ const doRemoveBackup = this.__opts.removeBackupFn ?? removeBackup;
461
+ try { await doRemoveBackup(this.__pluginId); }
462
+ catch (e) { this.__logger.warn?.(`[auto-upgrade] Reconcile backup cleanup failed (non-fatal): ${e?.message}`); }
463
+ this.__logger.info?.(`[auto-upgrade] Reconciled interrupted upgrade as ok: ${from} → ${runtimeVersion}`);
464
+ }
465
+ else {
466
+ /* c8 ignore next -- ?? fallback */
467
+ const phaseToken = phase ?? 'unknown';
468
+ await recordTerminal({ from, to, result: 'interrupted', phase: phaseToken });
469
+ remoteLog(`upgrade.interrupted from=${from} to=${to} phase=${phaseToken} runtime=${runtimeVersion}`);
470
+ this.__logger.warn?.(`[auto-upgrade] Interrupted upgrade detected: ${from} → ${to} (phase=${phaseToken})`);
471
+ }
472
+ return true;
473
+ }
474
+ catch (err) {
475
+ // 终态写失败:inflight 保留,下轮重试;本周期不再 spawn
476
+ this.__logger.warn?.(`[auto-upgrade] Inflight reconcile failed: ${err?.message}`);
477
+ remoteLog(`upgrade.reconcile-failed msg=${err?.message}`);
478
+ return false;
479
+ }
480
+ }
481
+
365
482
  /**
366
483
  * 执行一次检查
367
484
  */
@@ -377,6 +494,10 @@ export class AutoUpgradeScheduler {
377
494
  return;
378
495
  }
379
496
 
497
+ // 锁空闲才对账:先消化 inflight(补记中断 worker 的终态),再跑新
498
+ // checkForUpdate——未消化就 spawn 会让新 worker 覆盖中断账目
499
+ if (!(await this.__reconcileInflight())) return;
500
+
380
501
  // 报告上一次升级结果(若有未报告的)
381
502
  await this.__reportLastUpgradeResult();
382
503
 
@@ -386,8 +507,19 @@ export class AutoUpgradeScheduler {
386
507
  });
387
508
 
388
509
  if (!result.available) {
389
- if (result.skipped) {
390
- remoteLog(`upgrade.skipped version=${result.latestVersion}`);
510
+ if (result.prerelease) {
511
+ // prerelease 挂 latest(人为失误)是稳定态,逐周期重验须去重防刷屏
512
+ this.__gateSignalOnce(
513
+ `prerelease-skip|${result.latestVersion}`,
514
+ `upgrade.prerelease-skip version=${result.latestVersion}`,
515
+ );
516
+ this.__logger.info?.(`[auto-upgrade] Latest ${result.latestVersion} is a prerelease, skipping`);
517
+ } else if (result.skipped) {
518
+ // skipped 同为稳定态(直到下个正式版发布),同模式去重
519
+ this.__gateSignalOnce(
520
+ `skipped|${result.latestVersion}`,
521
+ `upgrade.skipped version=${result.latestVersion}`,
522
+ );
391
523
  this.__logger.info?.(`[auto-upgrade] Version ${result.latestVersion} skipped (previously failed)`);
392
524
  } else {
393
525
  this.__logger.info?.(`[auto-upgrade] No update available (current: ${result.currentVersion})`);
@@ -395,26 +527,120 @@ export class AutoUpgradeScheduler {
395
527
  return;
396
528
  }
397
529
 
530
+ // L1 来源门禁:有新版才核对权威安装记录(独立 CLI 子进程,不冻结网关,
531
+ // 逐周期重验——结构上消灭"一次误判 → 永久静默停摆")。
532
+ // 必须局部 try/catch:外层 catch-all 会把这里的异常吞成泛化 upgrade.check-failed,混淆信号。
533
+ let install;
534
+ try {
535
+ /* c8 ignore next -- ?? fallback */
536
+ const inspectInstall = this.__opts.inspectInstallFn ?? inspectPluginInstall;
537
+ const inspected = await inspectInstall(this.__pluginId, { execFileFn: this.__opts.execFileFn });
538
+ if (!inspected.ok) {
539
+ // inspect 真失败(exit≠0 / 解析失败):本周期跳过,下周期自动重试(瞬时自愈、持续可见)
540
+ this.__gateSignalOnce(
541
+ `gate-inspect-failed|${result.latestVersion}`,
542
+ `upgrade.gate-inspect-failed to=${result.latestVersion} msg=${inspected.reason}`,
543
+ );
544
+ this.__logger.warn?.(`[auto-upgrade] Install record inspect failed, skipping this cycle: ${inspected.reason}`);
545
+ return;
546
+ }
547
+ install = inspected.install;
548
+ }
549
+ catch (err) {
550
+ // 注入实现异常也按真失败处理:跳过本周期,下周期重试
551
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
552
+ const msg = err?.message ?? String(err);
553
+ this.__gateSignalOnce(
554
+ `gate-inspect-failed|${result.latestVersion}`,
555
+ `upgrade.gate-inspect-failed to=${result.latestVersion} msg=${msg}`,
556
+ );
557
+ this.__logger.warn?.(`[auto-upgrade] Install record inspect threw, skipping this cycle: ${msg}`);
558
+ return;
559
+ }
560
+
561
+ const source = install?.source ?? 'none';
562
+ if (source !== 'npm') {
563
+ // 非 npm 装置(path/archive/git/...)或无安装记录(source=none):
564
+ // 与现状语义等价(这些装置今天也不升级),但多了远程可见性
565
+ this.__gateSignalOnce(
566
+ `source-skip:${source}|${result.latestVersion}`,
567
+ `upgrade.source-skip source=${source} to=${result.latestVersion}`,
568
+ );
569
+ this.__logger.info?.(`[auto-upgrade] Skipping: install source is ${source} (not npm)`);
570
+ return;
571
+ }
572
+
573
+ // spec 钉死可见性:install.spec 是精确版本时 update 永远 resolve 同一版本
574
+ //(fallback install / 人工 `pkg@x.y.z` 留下的)。worker 侧裸包名 update 会
575
+ // 顺带解钉,这里只发去重信号,不改行为
576
+ const spec = typeof install.spec === 'string' ? install.spec : '';
577
+ const specAt = spec.lastIndexOf('@');
578
+ if (specAt > 0 && EXACT_SEMVER_RE.test(spec.slice(specAt + 1))) {
579
+ this.__gateSignalOnce(
580
+ `spec-pinned|${result.latestVersion}`,
581
+ `upgrade.spec-pinned spec=${spec}`,
582
+ );
583
+ }
584
+
585
+ // 来源验明 npm 后才上报 available——否则永不升级的装置每小时刷一条
398
586
  remoteLog(`upgrade.available from=${result.currentVersion} to=${result.latestVersion}`);
399
587
  this.__logger.info?.(`[auto-upgrade] Update available: ${result.currentVersion} → ${result.latestVersion}`);
400
588
 
401
- const getInstallPath = this.__opts.getPluginInstallPathFn ?? getPluginInstallPath;
402
- const pluginDir = getInstallPath(this.__pluginId);
589
+ // installPath 取自权威记录(新鲜);缺失时回退自推包根,
590
+ // 并核验该目录 package.json 的包名,防错传目录给备份/回滚
591
+ let pluginDir = install.installPath;
403
592
  if (!pluginDir) {
404
- remoteLog('upgrade.no-install-path');
405
- this.__logger.warn?.('[auto-upgrade] Cannot determine plugin install path');
406
- return;
593
+ pluginDir = this.__opts.pluginRoot ?? nodePath.resolve(import.meta.dirname, '../..');
594
+ // 记录缺 installPath 是稳定异常态,与门禁信号同模式按 (原因, toVersion) 去重
595
+ this.__gateSignalOnce(
596
+ `install-path-fallback|${result.latestVersion}`,
597
+ `upgrade.install-path-fallback to=${result.latestVersion} dir=${pluginDir}`,
598
+ );
599
+ this.__logger.warn?.(`[auto-upgrade] Install record has no installPath, falling back to plugin root: ${pluginDir}`);
600
+ let pkgName = null;
601
+ try {
602
+ ({ name: pkgName } = await getPackageInfo(pluginDir));
603
+ }
604
+ catch {
605
+ // package.json 读不到/损坏:按核验失败处理
606
+ }
607
+ if (pkgName !== result.pkgName) {
608
+ this.__gateSignalOnce(
609
+ `no-install-path:pkg-name-mismatch|${result.latestVersion}`,
610
+ `upgrade.no-install-path reason=pkg-name-mismatch got=${pkgName}`,
611
+ );
612
+ this.__logger.warn?.(`[auto-upgrade] Fallback plugin dir failed package name check (got ${pkgName}), skipping`);
613
+ return;
614
+ }
407
615
  }
408
616
 
409
- const { child } = spawnUpgradeWorker({
617
+ // 基线版本来自权威记录:worker 据此区分"record 推进/未推进"(L2 结局核对)
618
+ const baselineVersion = typeof install.version === 'string' ? install.version : '';
619
+ this.__logger.info?.(`[auto-upgrade] Pre-upgrade baseline: version=${baselineVersion || '(unknown)'} path=${pluginDir}`);
620
+
621
+ const { child, escapeFailed } = await spawnUpgradeWorker({
410
622
  pluginDir,
411
623
  fromVersion: result.currentVersion,
412
624
  toVersion: result.latestVersion,
625
+ baselineVersion,
413
626
  pluginId: this.__pluginId,
414
627
  pkgName: result.pkgName,
415
- opts: { spawnFn: this.__opts.spawnFn },
628
+ opts: {
629
+ spawnFn: this.__opts.spawnFn,
630
+ execFileFn: this.__opts.execFileFn,
631
+ platform: this.__opts.platform,
632
+ scopeEnv: this.__opts.scopeEnv,
633
+ },
416
634
  logger: this.__logger,
417
635
  });
636
+ if (escapeFailed) {
637
+ // systemd 形态但 scope 脱逃不可用:worker 大概率活不过自己触发的重启,
638
+ // 终态靠下轮 inflight 对账兜底;稳定态信号去重防每小时刷屏
639
+ this.__gateSignalOnce(
640
+ `cgroup-escape-failed|${result.latestVersion}`,
641
+ `upgrade.cgroup-escape-failed to=${result.latestVersion}`,
642
+ );
643
+ }
418
644
 
419
645
  // 记录 worker PID,下次 check 时据此判断 worker 是否仍在运行
420
646
  /* c8 ignore next -- ?? fallback */
@@ -422,8 +648,10 @@ export class AutoUpgradeScheduler {
422
648
  await writeLock(child.pid);
423
649
  }
424
650
  catch (err) {
425
- remoteLog(`upgrade.check-failed msg=${err.message}`);
426
- this.__logger.warn?.(`[auto-upgrade] Check failed: ${err.message}`);
651
+ /* c8 ignore next -- ?? fallback:err 字段缺省的兜底分支不强制覆盖 */
652
+ const msg = err?.message ?? String(err);
653
+ remoteLog(`upgrade.check-failed msg=${msg}`);
654
+ this.__logger.warn?.(`[auto-upgrade] Check failed: ${msg}`);
427
655
  }
428
656
  finally {
429
657
  this.__checking = false;