@coclaw/openclaw-coclaw 0.26.1 → 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.
- package/index.js +9 -7
- package/package.json +1 -1
- package/src/auto-upgrade/state.js +136 -21
- package/src/auto-upgrade/updater-check.js +66 -1
- package/src/auto-upgrade/updater-spawn.js +92 -7
- package/src/auto-upgrade/updater.js +347 -119
- package/src/auto-upgrade/worker-backup.js +67 -30
- package/src/auto-upgrade/worker-verify.js +22 -21
- package/src/auto-upgrade/worker.js +221 -68
- package/src/plugin-version.js +3 -1
- package/src/provider-auth/portal-model-catalog.js +11 -7
- package/src/provider-auth/reconcile.js +16 -6
package/index.js
CHANGED
|
@@ -11,8 +11,7 @@ import { createSessionManager } from './src/session-manager/manager.js';
|
|
|
11
11
|
import { TopicManager } from './src/topic-manager/manager.js';
|
|
12
12
|
import { ChatHistoryManager, classifyChatHistorySessionKey } from './src/chat-history-manager/manager.js';
|
|
13
13
|
import { generateTitle } from './src/topic-manager/title-gen.js';
|
|
14
|
-
import { AutoUpgradeScheduler } from './src/auto-upgrade/updater.js';
|
|
15
|
-
import { getPackageInfo } from './src/auto-upgrade/updater-check.js';
|
|
14
|
+
import { AutoUpgradeScheduler, getLoadedPluginVersion } from './src/auto-upgrade/updater.js';
|
|
16
15
|
import { createFileHandler } from './src/file-manager/handler.js';
|
|
17
16
|
import { abortAgentRun } from './src/agent-abort.js';
|
|
18
17
|
import { decideCancelResponse } from './src/agent-cancel-heuristic.js';
|
|
@@ -208,10 +207,10 @@ const plugin = {
|
|
|
208
207
|
// 防"写配置触发重启"时的反复重启)。升级补了新模型靠这条让老用户重启后自动同步。
|
|
209
208
|
// config-mutation 字面量 specifier 必须出现在本入口源码里(loader 只扫入口识别 jiti alias)。
|
|
210
209
|
const portalSyncP = import('openclaw/plugin-sdk/config-mutation')
|
|
211
|
-
.then(({ mutateConfigFile }) => reconcilePortalModels({ getConfig: getClawConfig, mutateConfigFile }))
|
|
212
|
-
.then((r) => { if (r.changed) logger.info?.('[coclaw] minimax-portal model list synced from plugin catalog'); })
|
|
210
|
+
.then(({ mutateConfigFile }) => reconcilePortalModels({ getConfig: getClawConfig, mutateConfigFile, logger, remoteLog }))
|
|
213
211
|
.catch((err) => {
|
|
214
212
|
logger.warn?.(`[coclaw] minimax-portal model reconcile failed: ${String(err?.message ?? err)}`);
|
|
213
|
+
remoteLog(`providerAuth.portalReconcile.failed reason=${String(err?.message ?? err)}`);
|
|
215
214
|
});
|
|
216
215
|
__pluginInitDone = Promise.all([topicLoadP, chatHistoryLoadP, portalSyncP]);
|
|
217
216
|
|
|
@@ -742,10 +741,13 @@ const plugin = {
|
|
|
742
741
|
}
|
|
743
742
|
});
|
|
744
743
|
|
|
745
|
-
|
|
744
|
+
// 升级健康检查:返回模块加载时刻钉住的版本快照(即当前进程真正加载的代码),
|
|
745
|
+
// 禁止改回调用时读磁盘 package.json——重启命令失败被吞、旧 gateway 仍活着时
|
|
746
|
+
// 磁盘可能已是未加载的新版本,按磁盘回答会让 worker verify 假阳性通过并删掉
|
|
747
|
+
// 回滚备份。快照不可得时返回 { version: null },verify 侧按"未达标"保守处理。
|
|
748
|
+
api.registerGatewayMethod('coclaw.upgradeHealth', ({ respond }) => {
|
|
746
749
|
try {
|
|
747
|
-
|
|
748
|
-
respond(true, { version });
|
|
750
|
+
respond(true, { version: getLoadedPluginVersion() });
|
|
749
751
|
}
|
|
750
752
|
catch (err) {
|
|
751
753
|
respondError(respond, err);
|
package/package.json
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* 1. runtime.state.resolveStateDir()(gateway 进程内)
|
|
7
7
|
* 2. OPENCLAW_STATE_DIR 环境变量(worker 子进程,由 spawner 传入)
|
|
8
8
|
* 3. ~/.openclaw(兜底默认值)
|
|
9
|
+
*
|
|
10
|
+
* 锁策略:state 文件的 read-modify-write 统一走 stateMutex;纯读不加锁。
|
|
11
|
+
* 锁只能护住同进程内并发(gateway 与 worker 跨进程仍各写各的),但 worker /
|
|
12
|
+
* scheduler 各自内部是串行流,进程内互斥已足够。
|
|
9
13
|
*/
|
|
10
14
|
import fs from 'node:fs/promises';
|
|
11
15
|
import nodePath from 'node:path';
|
|
@@ -13,12 +17,18 @@ import os from 'node:os';
|
|
|
13
17
|
|
|
14
18
|
import { getRuntime } from '../runtime.js';
|
|
15
19
|
import { atomicWriteFile } from '../utils/atomic-write.js';
|
|
20
|
+
import { createMutex } from '../utils/mutex.js';
|
|
16
21
|
|
|
17
22
|
const CHANNEL_ID = 'coclaw';
|
|
18
23
|
const STATE_FILENAME = 'upgrade-state.json';
|
|
19
24
|
const LOG_FILENAME = 'upgrade-log.jsonl';
|
|
20
25
|
const LOG_MAX_LINES = 200;
|
|
21
26
|
const LOG_KEEP_LINES = 100;
|
|
27
|
+
// lastUpgrade.error 截断上限:远端上报行不宜过长;jsonl 保留完整 error
|
|
28
|
+
const ERROR_MAX_CHARS = 500;
|
|
29
|
+
|
|
30
|
+
const stateMutex = createMutex();
|
|
31
|
+
const logMutex = createMutex();
|
|
22
32
|
|
|
23
33
|
export function resolveStateDir() {
|
|
24
34
|
const rt = getRuntime();
|
|
@@ -38,11 +48,8 @@ export function getLogPath() {
|
|
|
38
48
|
return nodePath.join(resolveStateDir(), CHANNEL_ID, LOG_FILENAME);
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
/**
|
|
42
|
-
|
|
43
|
-
* @returns {Promise<{ skippedVersions?: string[], lastCheck?: string, lastUpgrade?: object }>}
|
|
44
|
-
*/
|
|
45
|
-
export async function readState() {
|
|
51
|
+
/** 不加锁的裸读,仅供本模块在 withLock 内复用(避免嵌套同把锁死锁) */
|
|
52
|
+
async function readStateRaw() {
|
|
46
53
|
const filePath = getStatePath();
|
|
47
54
|
try {
|
|
48
55
|
const raw = await fs.readFile(filePath, 'utf8');
|
|
@@ -56,13 +63,26 @@ export async function readState() {
|
|
|
56
63
|
}
|
|
57
64
|
}
|
|
58
65
|
|
|
66
|
+
/** 不加锁的裸写,仅供本模块在 withLock 内复用 */
|
|
67
|
+
async function writeStateRaw(state) {
|
|
68
|
+
const filePath = getStatePath();
|
|
69
|
+
await atomicWriteFile(filePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 读取 upgrade-state.json(纯读不加锁,最多读到略旧快照)
|
|
74
|
+
* @returns {Promise<{ skippedVersions?: string[], lastCheck?: string, lastUpgrade?: object, inflight?: object }>}
|
|
75
|
+
*/
|
|
76
|
+
export async function readState() {
|
|
77
|
+
return readStateRaw();
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
/**
|
|
60
81
|
* 写入 upgrade-state.json(完整覆盖)
|
|
61
82
|
* @param {object} state
|
|
62
83
|
*/
|
|
63
84
|
export async function writeState(state) {
|
|
64
|
-
|
|
65
|
-
await atomicWriteFile(filePath, `${JSON.stringify(state, null, 2)}\n`);
|
|
85
|
+
await stateMutex.withLock(() => writeStateRaw(state));
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
/**
|
|
@@ -70,22 +90,31 @@ export async function writeState(state) {
|
|
|
70
90
|
* @param {string} version
|
|
71
91
|
*/
|
|
72
92
|
export async function addSkippedVersion(version) {
|
|
73
|
-
|
|
93
|
+
await stateMutex.withLock(async () => {
|
|
94
|
+
const state = await readStateRaw();
|
|
95
|
+
appendSkippedTo(state, version);
|
|
96
|
+
await writeStateRaw(state);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 在 state 对象上原地追加 skippedVersions(去重) */
|
|
101
|
+
function appendSkippedTo(state, version) {
|
|
74
102
|
const skipped = Array.isArray(state.skippedVersions) ? state.skippedVersions : [];
|
|
75
103
|
if (!skipped.includes(version)) {
|
|
76
104
|
skipped.push(version);
|
|
77
105
|
}
|
|
78
106
|
state.skippedVersions = skipped;
|
|
79
|
-
await writeState(state);
|
|
80
107
|
}
|
|
81
108
|
|
|
82
109
|
/**
|
|
83
110
|
* 更新 lastCheck 时间戳
|
|
84
111
|
*/
|
|
85
112
|
export async function updateLastCheck() {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
await stateMutex.withLock(async () => {
|
|
114
|
+
const state = await readStateRaw();
|
|
115
|
+
state.lastCheck = new Date().toISOString();
|
|
116
|
+
await writeStateRaw(state);
|
|
117
|
+
});
|
|
89
118
|
}
|
|
90
119
|
|
|
91
120
|
/**
|
|
@@ -93,21 +122,107 @@ export async function updateLastCheck() {
|
|
|
93
122
|
* @param {{ from: string, to: string, result: string }} info
|
|
94
123
|
*/
|
|
95
124
|
export async function updateLastUpgrade(info) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
await stateMutex.withLock(async () => {
|
|
126
|
+
const state = await readStateRaw();
|
|
127
|
+
state.lastUpgrade = { ...info, ts: new Date().toISOString() };
|
|
128
|
+
await writeStateRaw(state);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 读取 inflight 标记(纯读不加锁)
|
|
134
|
+
* @returns {Promise<object|null>}
|
|
135
|
+
*/
|
|
136
|
+
export async function readInflight() {
|
|
137
|
+
const state = await readStateRaw();
|
|
138
|
+
/* c8 ignore next -- ?? fallback */
|
|
139
|
+
return state.inflight ?? null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 写入 inflight 标记(worker 进 update 前调用;整体覆盖并附加 ts)。
|
|
144
|
+
* worker 若没活到终态记账(典型:被自己触发的网关重启杀死),scheduler
|
|
145
|
+
* 下轮据此对账补记终态。
|
|
146
|
+
* @param {{ from: string, to: string, verifyTarget: string, pluginDir: string, phase: string }} info
|
|
147
|
+
*/
|
|
148
|
+
export async function writeInflight(info) {
|
|
149
|
+
await stateMutex.withLock(async () => {
|
|
150
|
+
const state = await readStateRaw();
|
|
151
|
+
state.inflight = { ...info, ts: new Date().toISOString() };
|
|
152
|
+
await writeStateRaw(state);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 合并更新 inflight 字段(phase 推进 / verifyTarget 修正)。
|
|
158
|
+
* inflight 不存在时 no-op——终态已清账后,迟到的更新不应复活账目。
|
|
159
|
+
* @param {object} patch
|
|
160
|
+
*/
|
|
161
|
+
export async function updateInflight(patch) {
|
|
162
|
+
await stateMutex.withLock(async () => {
|
|
163
|
+
const state = await readStateRaw();
|
|
164
|
+
if (!state.inflight) return;
|
|
165
|
+
state.inflight = { ...state.inflight, ...patch };
|
|
166
|
+
await writeStateRaw(state);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** lastUpgrade.error 截断:保尾部(子命令真因通常在输出尾部) */
|
|
171
|
+
function truncateErrorTail(text) {
|
|
172
|
+
const s = String(text);
|
|
173
|
+
return s.length > ERROR_MAX_CHARS ? s.slice(-ERROR_MAX_CHARS) : s;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 原子记录升级终态:同一把锁内一次读改写完成
|
|
178
|
+
* "写 lastUpgrade + 清 inflight + 可选 addSkippedVersion"。
|
|
179
|
+
* 终态写失败时 inflight 保留 → scheduler 下轮对账可见,不丢账。
|
|
180
|
+
* upgrade-log.jsonl 追加放锁后 best-effort——终态已落盘,日志失败不回滚账目。
|
|
181
|
+
*
|
|
182
|
+
* @param {object} params
|
|
183
|
+
* @param {string} params.from
|
|
184
|
+
* @param {string} params.to
|
|
185
|
+
* @param {string} params.result - ok / noop-skip / rollback / rollback-failed / interrupted
|
|
186
|
+
* @param {string} [params.error] - lastUpgrade 内截断保存,jsonl 保留完整
|
|
187
|
+
* @param {string} [params.phase] - 中断时刻所处阶段(interrupted 账目用)
|
|
188
|
+
* @param {string} [params.skipVersion] - 需加入 skippedVersions 的版本(可缺)
|
|
189
|
+
*/
|
|
190
|
+
export async function recordUpgradeTerminal({ from, to, result, error, phase, skipVersion }) {
|
|
191
|
+
await stateMutex.withLock(async () => {
|
|
192
|
+
const state = await readStateRaw();
|
|
193
|
+
if (skipVersion) {
|
|
194
|
+
appendSkippedTo(state, skipVersion);
|
|
195
|
+
}
|
|
196
|
+
const last = { from, to, result };
|
|
197
|
+
if (error) last.error = truncateErrorTail(error);
|
|
198
|
+
if (phase) last.phase = phase;
|
|
199
|
+
state.lastUpgrade = { ...last, ts: new Date().toISOString() };
|
|
200
|
+
delete state.inflight;
|
|
201
|
+
await writeStateRaw(state);
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
const entry = { from, to, result };
|
|
205
|
+
if (error) entry.error = error;
|
|
206
|
+
if (phase) entry.phase = phase;
|
|
207
|
+
await appendLog(entry);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// best-effort:jsonl 只是诊断日志,追加失败不影响终态
|
|
211
|
+
}
|
|
99
212
|
}
|
|
100
213
|
|
|
101
214
|
/**
|
|
102
215
|
* 追加升级日志
|
|
103
|
-
* @param {{ from: string, to: string, result
|
|
216
|
+
* @param {{ from: string, to: string, result?: string, error?: string, phase?: string, event?: string }} entry
|
|
104
217
|
*/
|
|
105
218
|
export async function appendLog(entry) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
219
|
+
await logMutex.withLock(async () => {
|
|
220
|
+
const filePath = getLogPath();
|
|
221
|
+
await fs.mkdir(nodePath.dirname(filePath), { recursive: true });
|
|
222
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), ...entry });
|
|
223
|
+
await fs.appendFile(filePath, `${line}\n`, 'utf8');
|
|
224
|
+
await trimLog(filePath);
|
|
225
|
+
});
|
|
111
226
|
}
|
|
112
227
|
|
|
113
228
|
/**
|
|
@@ -54,6 +54,51 @@ export async function getLatestVersion(pkgName, opts) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// inspect 走独立 CLI 子进程,对齐 npm view / worker runCmd 先例的 execFile 选项
|
|
58
|
+
const INSPECT_TIMEOUT_MS = 30_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 经官方 CLI 读取本插件的权威安装记录(`openclaw plugins inspect <id> --json`)。
|
|
62
|
+
*
|
|
63
|
+
* 账本文件直读已废弃(上游 ≥2026.6.1 迁 SQLite,旧 JSON 停更);inspect 是
|
|
64
|
+
* 三代稳定的官方契约,输出 JSON 顶层 `install` 字段即原始 install record。
|
|
65
|
+
* gateway 的 L1 来源门禁与 worker 的 L2 结局核对共用本函数。
|
|
66
|
+
*
|
|
67
|
+
* 归一化返回、永不抛:
|
|
68
|
+
* - `{ ok: true, install }`:inspect 成功;无 install 记录(如 load.paths 装置)时 install 为 null
|
|
69
|
+
* - `{ ok: false, reason }`:CLI 退出非 0 / 输出非 JSON
|
|
70
|
+
*
|
|
71
|
+
* @param {string} pluginId
|
|
72
|
+
* @param {object} [opts]
|
|
73
|
+
* @param {Function} [opts.execFileFn] - 可注入的 execFile(测试用)
|
|
74
|
+
* @returns {Promise<{ ok: true, install: object|null } | { ok: false, reason: string }>}
|
|
75
|
+
*/
|
|
76
|
+
export async function inspectPluginInstall(pluginId, opts) {
|
|
77
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
78
|
+
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
doExecFile('openclaw', ['plugins', 'inspect', pluginId, '--json'], {
|
|
81
|
+
timeout: INSPECT_TIMEOUT_MS,
|
|
82
|
+
shell: process.platform === 'win32',
|
|
83
|
+
}, (err, stdout) => {
|
|
84
|
+
if (err) {
|
|
85
|
+
resolve({ ok: false, reason: `inspect failed: ${err.message}` });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let payload;
|
|
89
|
+
try {
|
|
90
|
+
payload = JSON.parse(String(stdout));
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
resolve({ ok: false, reason: 'inspect output is not valid JSON' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const install = payload?.install && typeof payload.install === 'object' ? payload.install : null;
|
|
97
|
+
resolve({ ok: true, install });
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
57
102
|
/**
|
|
58
103
|
* 简易 semver 比较:a > b 返回 true
|
|
59
104
|
* @param {string} a
|
|
@@ -77,12 +122,25 @@ export function isNewerVersion(a, b) {
|
|
|
77
122
|
return false;
|
|
78
123
|
}
|
|
79
124
|
|
|
125
|
+
/**
|
|
126
|
+
* 判断版本是否含 prerelease 段。
|
|
127
|
+
* semver 语义:build metadata(`+` 之后)不算 prerelease——必须先剥掉再看 `-`,
|
|
128
|
+
* 否则 `1.0.0+exp-sha.5` 这类纯 build 后缀会被误判(裸 indexOf('-') 的坑)。
|
|
129
|
+
* 核心段 X.Y.Z 不含 `-`,剥掉 build 后出现 `-` 即为 prerelease。
|
|
130
|
+
* @param {string} version
|
|
131
|
+
* @returns {boolean}
|
|
132
|
+
*/
|
|
133
|
+
export function isPrereleaseVersion(version) {
|
|
134
|
+
const core = String(version).split('+')[0];
|
|
135
|
+
return core.includes('-');
|
|
136
|
+
}
|
|
137
|
+
|
|
80
138
|
/**
|
|
81
139
|
* 检查是否有可用更新
|
|
82
140
|
* @param {object} [opts]
|
|
83
141
|
* @param {Function} [opts.execFileFn] - 可注入的 execFile(测试用)
|
|
84
142
|
* @param {string} [opts.pluginDir] - 插件目录
|
|
85
|
-
* @returns {Promise<{ available: boolean, currentVersion: string, latestVersion?: string, pkgName: string }>}
|
|
143
|
+
* @returns {Promise<{ available: boolean, currentVersion: string, latestVersion?: string, pkgName: string, skipped?: boolean, prerelease?: boolean }>}
|
|
86
144
|
*/
|
|
87
145
|
export async function checkForUpdate(opts) {
|
|
88
146
|
const { name: pkgName, version: currentVersion } = await getPackageInfo(opts?.pluginDir);
|
|
@@ -94,6 +152,13 @@ export async function checkForUpdate(opts) {
|
|
|
94
152
|
return { available: false, currentVersion, pkgName };
|
|
95
153
|
}
|
|
96
154
|
|
|
155
|
+
// prerelease 闸:上游 plugins update 对裸 spec 一律拒装 prerelease,放行会进
|
|
156
|
+
// "spawn → 拒装 → 回滚 → 重启" 的每周期循环。此处一票否决:不 spawn、
|
|
157
|
+
// 不写 skip / lastUpgrade(latest 回到正式版本后自然恢复,无持久状态)
|
|
158
|
+
if (isPrereleaseVersion(latestVersion)) {
|
|
159
|
+
return { available: false, currentVersion, latestVersion, pkgName, prerelease: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
97
162
|
// 检查是否在 skippedVersions 中(曾升级失败并回滚的版本)
|
|
98
163
|
const state = await readState();
|
|
99
164
|
const skipped = Array.isArray(state.skippedVersions) ? state.skippedVersions : [];
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { spawn as nodeSpawn } from 'node:child_process';
|
|
1
|
+
import { spawn as nodeSpawn, execFile as nodeExecFile } from 'node:child_process';
|
|
2
2
|
import nodePath from 'node:path';
|
|
3
3
|
import { resolveStateDir } from './state.js';
|
|
4
4
|
|
|
5
5
|
const WORKER_FILENAME = 'worker.js';
|
|
6
|
+
// 探针毫秒级完成;短超时防 systemd-run 异常挂起拖住 __check
|
|
7
|
+
const PROBE_TIMEOUT_MS = 3_000;
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* 获取 worker.js 的路径
|
|
@@ -12,6 +14,49 @@ export function getWorkerPath() {
|
|
|
12
14
|
return nodePath.join(import.meta.dirname, WORKER_FILENAME);
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 是否值得尝试 systemd scope 脱逃。
|
|
19
|
+
* gateway 跑在 systemd service 形态(KillMode=control-group)时,worker 虽
|
|
20
|
+
* detached 仍在同一 cgroup,自己触发的 `gateway restart` 会把整组杀掉——
|
|
21
|
+
* 重启后的验证/回滚/记账全部不可达。仅 linux 且疑似 systemd 环境(unit 注入
|
|
22
|
+
* 的环境变量在场)才探针,其它形态零改动。
|
|
23
|
+
* @param {string} platform
|
|
24
|
+
* @param {NodeJS.ProcessEnv} env
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
export function shouldAttemptScopeEscape(platform, env) {
|
|
28
|
+
return platform === 'linux' && Boolean(env.OPENCLAW_SYSTEMD_UNIT || env.INVOCATION_ID);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 探针:试跑 `systemd-run [--user] --scope --quiet --collect -- /bin/true`,
|
|
33
|
+
* 先 --user 变体(user service 推荐形态),失败再试无 --user(system service)。
|
|
34
|
+
* @param {object} [opts]
|
|
35
|
+
* @param {Function} [opts.execFileFn] - 可注入的 execFile(测试用)
|
|
36
|
+
* @returns {Promise<string[]|null>} 可用变体的附加参数(['--user'] 或 []);都不可用返回 null
|
|
37
|
+
*/
|
|
38
|
+
export async function probeSystemdScopeArgs(opts) {
|
|
39
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
40
|
+
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
41
|
+
const tryVariant = (variant) => new Promise((resolve) => {
|
|
42
|
+
try {
|
|
43
|
+
doExecFile(
|
|
44
|
+
'systemd-run',
|
|
45
|
+
[...variant, '--scope', '--quiet', '--collect', '--', '/bin/true'],
|
|
46
|
+
{ timeout: PROBE_TIMEOUT_MS },
|
|
47
|
+
(err) => resolve(!err),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// 注入实现同步抛错(如 systemd-run 不存在的极端实现)按失败处理
|
|
52
|
+
resolve(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
if (await tryVariant(['--user'])) return ['--user'];
|
|
56
|
+
if (await tryVariant([])) return [];
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
15
60
|
/**
|
|
16
61
|
* 以 detached 进程方式启动 upgrade worker
|
|
17
62
|
*
|
|
@@ -19,37 +64,78 @@ export function getWorkerPath() {
|
|
|
19
64
|
* detached + unref 确保 gateway 进程不会等待 worker。
|
|
20
65
|
* 通过 -- 命名参数传递业务数据,worker 使用 util.parseArgs 解析。
|
|
21
66
|
*
|
|
67
|
+
* systemd 环境下探针通过则包成 `systemd-run [--user] --scope --quiet --collect
|
|
68
|
+
* -- <node> worker.js ...`:scope 是 service 的兄弟 cgroup,KillMode=control-group
|
|
69
|
+
* 清场打不到;--scope 自挪 cgroup 后 exec、不产生 wrapper 进程,child.pid 即
|
|
70
|
+
* worker 真 pid → 锁机制零改动。探针失败 → 现行裸 spawn(降级=现状),由调用方
|
|
71
|
+
* 据 escapeFailed 发去重信号。真 worker 永远只 spawn 一次,没有失败重拉。
|
|
72
|
+
*
|
|
22
73
|
* @param {object} params
|
|
23
74
|
* @param {string} params.pluginDir - 插件安装目录
|
|
24
75
|
* @param {string} params.fromVersion - 当前版本
|
|
25
76
|
* @param {string} params.toVersion - 目标版本
|
|
77
|
+
* @param {string} [params.baselineVersion] - 升级前权威安装记录的版本(L2 结局核对基线;可缺)
|
|
26
78
|
* @param {string} params.pluginId - 插件 ID
|
|
27
79
|
* @param {string} params.pkgName - npm 包名
|
|
28
80
|
* @param {object} [params.opts]
|
|
29
81
|
* @param {Function} [params.opts.spawnFn] - 可注入的 spawn(测试用)
|
|
82
|
+
* @param {Function} [params.opts.execFileFn] - 可注入的 execFile(探针,测试用)
|
|
83
|
+
* @param {string} [params.opts.platform] - 平台覆盖(测试用)
|
|
84
|
+
* @param {NodeJS.ProcessEnv} [params.opts.scopeEnv] - 探针启用判定的 env 覆盖(测试用)
|
|
30
85
|
* @param {object} [params.logger] - 需提供 .info() 方法(如 pino/gateway logger)
|
|
31
|
-
* @returns {{ child: object }}
|
|
86
|
+
* @returns {Promise<{ child: object, escapeFailed: boolean }>} escapeFailed:疑似 systemd
|
|
87
|
+
* 环境但两个探针变体都失败(降级裸 spawn),调用方据此发信号
|
|
32
88
|
*/
|
|
33
|
-
export function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, pluginId, pkgName, opts, logger }) {
|
|
89
|
+
export async function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, baselineVersion, pluginId, pkgName, opts, logger }) {
|
|
34
90
|
/* c8 ignore next -- ?./?? fallback */
|
|
35
91
|
const doSpawn = opts?.spawnFn ?? nodeSpawn;
|
|
92
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
93
|
+
const platform = opts?.platform ?? process.platform;
|
|
94
|
+
/* c8 ignore next -- ?./?? fallback */
|
|
95
|
+
const scopeEnv = opts?.scopeEnv ?? process.env;
|
|
36
96
|
const workerPath = getWorkerPath();
|
|
37
97
|
|
|
38
98
|
logger?.info?.(`[spawner] Spawning upgrade worker: ${fromVersion} → ${toVersion}`);
|
|
39
99
|
|
|
100
|
+
let scopeArgs = null;
|
|
101
|
+
let escapeFailed = false;
|
|
102
|
+
if (shouldAttemptScopeEscape(platform, scopeEnv)) {
|
|
103
|
+
scopeArgs = await probeSystemdScopeArgs(opts);
|
|
104
|
+
if (scopeArgs) {
|
|
105
|
+
logger?.info?.(`[spawner] systemd scope escape enabled (${scopeArgs.includes('--user') ? 'user' : 'system'} variant)`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
escapeFailed = true;
|
|
109
|
+
logger?.warn?.('[spawner] systemd scope probe failed, falling back to bare spawn (worker may die on gateway restart)');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
40
113
|
// 将 state dir 传递给 worker,确保 worker 写入正确的路径
|
|
41
114
|
const stateDir = resolveStateDir();
|
|
42
115
|
const env = { ...process.env };
|
|
43
116
|
if (stateDir) env.OPENCLAW_STATE_DIR = stateDir;
|
|
44
117
|
|
|
45
|
-
const
|
|
118
|
+
const args = [
|
|
46
119
|
workerPath,
|
|
47
120
|
'--pluginDir', pluginDir,
|
|
48
121
|
'--fromVersion', fromVersion,
|
|
49
122
|
'--toVersion', toVersion,
|
|
50
123
|
'--pluginId', pluginId,
|
|
51
124
|
'--pkgName', pkgName,
|
|
52
|
-
]
|
|
125
|
+
];
|
|
126
|
+
// 基线缺失时不传 flag,worker 按"基线不可得"退化处理
|
|
127
|
+
if (baselineVersion) {
|
|
128
|
+
args.push('--baselineVersion', baselineVersion);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let spawnCmd = process.execPath;
|
|
132
|
+
let spawnArgs = args;
|
|
133
|
+
if (scopeArgs) {
|
|
134
|
+
spawnCmd = 'systemd-run';
|
|
135
|
+
spawnArgs = [...scopeArgs, '--scope', '--quiet', '--collect', '--', process.execPath, ...args];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const child = doSpawn(spawnCmd, spawnArgs, {
|
|
53
139
|
detached: true,
|
|
54
140
|
stdio: 'ignore',
|
|
55
141
|
env,
|
|
@@ -63,6 +149,5 @@ export function spawnUpgradeWorker({ pluginDir, fromVersion, toVersion, pluginId
|
|
|
63
149
|
child.unref();
|
|
64
150
|
|
|
65
151
|
logger?.info?.(`[spawner] Worker spawned (pid: ${child.pid})`);
|
|
66
|
-
return { child };
|
|
152
|
+
return { child, escapeFailed };
|
|
67
153
|
}
|
|
68
|
-
|