claw-subagent-service 0.0.42 → 0.0.45
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/package.json +2 -2
- package/service/daemon.js +20 -11
- package/service/modules/script-executor.js +4 -2
- package/service/updater.js +151 -15
- package/service/worker.js +8 -23
package/package.json
CHANGED
package/service/daemon.js
CHANGED
|
@@ -145,11 +145,11 @@ function freePortIfNeeded(port) {
|
|
|
145
145
|
log.warn(`[DAEMON] 终止进程 ${pid} 失败: ${e.message}`);
|
|
146
146
|
}
|
|
147
147
|
} else {
|
|
148
|
-
//
|
|
148
|
+
// 所有端口查询命令都不可用(常见于精简 Docker 镜像)
|
|
149
|
+
// 兜底:杀掉残留 Worker 进程(Daemon 自身的命令行不含 worker.js,不会自杀)
|
|
149
150
|
try {
|
|
150
|
-
execSync('pkill -9 -f "daemon.js" 2>/dev/null || true', { timeout: 5000 });
|
|
151
151
|
execSync('pkill -9 -f "worker.js" 2>/dev/null || true', { timeout: 5000 });
|
|
152
|
-
log.warn(`[DAEMON]
|
|
152
|
+
log.warn(`[DAEMON] 已尝试杀掉残留 Worker 进程`);
|
|
153
153
|
} catch { /* 忽略 */ }
|
|
154
154
|
}
|
|
155
155
|
}
|
|
@@ -211,10 +211,17 @@ function startWorker(isAfterUpdate = false, backupDirForRollback = null) {
|
|
|
211
211
|
healthTimer = null;
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
log.error(`[DAEMON] Worker 退出,code=${code}, signal=${signal}`);
|
|
215
214
|
worker = null;
|
|
216
215
|
currentWorkerPid = null;
|
|
217
216
|
|
|
217
|
+
// 正常退出(code=0, signal=null)视为优雅关闭,不增加崩溃计数
|
|
218
|
+
const isNormalExit = code === 0 && signal === null;
|
|
219
|
+
if (isNormalExit) {
|
|
220
|
+
log.info(`[DAEMON] Worker 正常退出,code=${code}, signal=${signal}`);
|
|
221
|
+
} else {
|
|
222
|
+
log.error(`[DAEMON] Worker 异常退出,code=${code}, signal=${signal}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
218
225
|
if (isAfterUpdate && code !== 0 && currentBackupDir && !isRollingBack) {
|
|
219
226
|
isRollingBack = true;
|
|
220
227
|
log.error('[DAEMON] 新版 Worker 启动后异常退出,触发自动回滚!');
|
|
@@ -234,9 +241,11 @@ function startWorker(isAfterUpdate = false, backupDirForRollback = null) {
|
|
|
234
241
|
}
|
|
235
242
|
|
|
236
243
|
if (!stopping && !isRollingBack) {
|
|
237
|
-
//
|
|
238
|
-
|
|
239
|
-
|
|
244
|
+
// 正常退出使用固定 1 秒延迟;异常退出使用指数退避
|
|
245
|
+
const delay = isNormalExit ? 1000 : getRestartDelay();
|
|
246
|
+
if (!isNormalExit) {
|
|
247
|
+
log.info(`[DAEMON] Worker 连续崩溃 ${crashCount} 次,等待 ${delay / 1000}s 后重启`);
|
|
248
|
+
}
|
|
240
249
|
setTimeout(() => startWorker(false, null), delay);
|
|
241
250
|
}
|
|
242
251
|
});
|
|
@@ -270,10 +279,10 @@ function restartWorkerWithUpdate(backupDir) {
|
|
|
270
279
|
execSync(`taskkill /pid ${worker.pid} /T /F 2>nul`, { windowsHide: true });
|
|
271
280
|
} catch (e) {
|
|
272
281
|
// 忽略错误,使用 kill 作为后备
|
|
273
|
-
worker.kill('SIGTERM');
|
|
282
|
+
try { worker.kill('SIGTERM'); } catch { /* 进程可能已退出 */ }
|
|
274
283
|
}
|
|
275
284
|
} else {
|
|
276
|
-
worker.kill('SIGTERM');
|
|
285
|
+
try { worker.kill('SIGTERM'); } catch { /* 进程可能已退出 */ }
|
|
277
286
|
}
|
|
278
287
|
|
|
279
288
|
const waitKill = setInterval(() => {
|
|
@@ -288,10 +297,10 @@ function restartWorkerWithUpdate(backupDir) {
|
|
|
288
297
|
const { execSync } = require('child_process');
|
|
289
298
|
execSync(`taskkill /pid ${worker.pid} /T /F 2>nul`, { windowsHide: true });
|
|
290
299
|
} catch (e) {
|
|
291
|
-
worker.kill('SIGKILL');
|
|
300
|
+
try { worker.kill('SIGKILL'); } catch { /* 进程可能已退出 */ }
|
|
292
301
|
}
|
|
293
302
|
} else {
|
|
294
|
-
worker.kill('SIGKILL');
|
|
303
|
+
try { worker.kill('SIGKILL'); } catch { /* 进程可能已退出 */ }
|
|
295
304
|
}
|
|
296
305
|
}
|
|
297
306
|
}, 500);
|
|
@@ -113,7 +113,8 @@ class ScriptExecutor {
|
|
|
113
113
|
cmd = 'cmd';
|
|
114
114
|
args = ['/c', command];
|
|
115
115
|
} else {
|
|
116
|
-
|
|
116
|
+
// Alpine 等精简镜像可能缺少 bash,优先使用 sh
|
|
117
|
+
cmd = 'sh';
|
|
117
118
|
args = ['-c', command];
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -186,7 +187,8 @@ class ScriptExecutor {
|
|
|
186
187
|
cmd = 'cmd';
|
|
187
188
|
args = ['/c', scriptPath];
|
|
188
189
|
} else {
|
|
189
|
-
|
|
190
|
+
// Alpine 等精简镜像可能缺少 bash,优先使用 sh
|
|
191
|
+
cmd = 'sh';
|
|
190
192
|
args = [scriptPath];
|
|
191
193
|
}
|
|
192
194
|
|
package/service/updater.js
CHANGED
|
@@ -15,14 +15,25 @@ class Updater {
|
|
|
15
15
|
this.currentVersion = this.loadCurrentVersion();
|
|
16
16
|
this.isUpdating = false;
|
|
17
17
|
this.updateTimer = null;
|
|
18
|
+
this.pkgJsonPath = path.join(__dirname, '..', 'package.json');
|
|
19
|
+
this.isGlobalInstall = this.detectGlobalInstall();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
detectGlobalInstall() {
|
|
23
|
+
const cwd = path.normalize(__dirname).toLowerCase();
|
|
24
|
+
const inNodeModules = cwd.includes('node_modules');
|
|
25
|
+
const globalPaths = [
|
|
26
|
+
'npm', 'node_modules', '.nvm', 'usr/local/lib', 'usr/lib',
|
|
27
|
+
];
|
|
28
|
+
const inGlobalPath = globalPaths.some(p => cwd.includes(p.toLowerCase()));
|
|
29
|
+
return inNodeModules || inGlobalPath;
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
loadCurrentVersion() {
|
|
21
33
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const pkg = JSON.parse(fs.readFileSync(globalPkg, 'utf8'));
|
|
34
|
+
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
35
|
+
if (fs.existsSync(pkgPath)) {
|
|
36
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
26
37
|
return pkg.version || '0.0.0';
|
|
27
38
|
}
|
|
28
39
|
} catch {
|
|
@@ -77,20 +88,19 @@ class Updater {
|
|
|
77
88
|
try {
|
|
78
89
|
log.info(`[UPDATER] 开始更新: ${this.currentVersion} → ${info.version}`);
|
|
79
90
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (stderr) {
|
|
87
|
-
log.warn(`[UPDATER] npm 安装警告: ${stderr.substring(0, 500)}`);
|
|
91
|
+
let updateOk = false;
|
|
92
|
+
if (this.isGlobalInstall) {
|
|
93
|
+
updateOk = await this.updateGlobalPackage(info.version);
|
|
94
|
+
} else {
|
|
95
|
+
updateOk = await this.updateLocalPackage(info.version);
|
|
88
96
|
}
|
|
89
97
|
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
if (!updateOk) {
|
|
99
|
+
return { success: false, error: '版本未变化' };
|
|
100
|
+
}
|
|
92
101
|
|
|
93
102
|
this.currentVersion = info.version;
|
|
103
|
+
log.info(`[UPDATER] 更新完成,当前版本: ${this.currentVersion},准备重启 Worker`);
|
|
94
104
|
return { success: true };
|
|
95
105
|
} catch (err) {
|
|
96
106
|
log.error(`[UPDATER] 更新失败: ${err.message}`);
|
|
@@ -100,16 +110,142 @@ class Updater {
|
|
|
100
110
|
}
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
async updateGlobalPackage(version) {
|
|
114
|
+
const { stdout, stderr } = await execAsync(
|
|
115
|
+
`npm install -g ${PACKAGE_NAME}@${version}`,
|
|
116
|
+
{ timeout: 300000, windowsHide: true }
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (stderr) {
|
|
120
|
+
log.warn(`[UPDATER] npm 安装警告: ${stderr.substring(0, 500)}`);
|
|
121
|
+
}
|
|
122
|
+
log.info(`[UPDATER] npm 安装输出: ${stdout.substring(0, 500)}`);
|
|
123
|
+
|
|
124
|
+
// 全局安装后重新读取版本确认
|
|
125
|
+
const newVersion = this.loadCurrentVersion();
|
|
126
|
+
if (this.compareVersion(newVersion, this.currentVersion) <= 0) {
|
|
127
|
+
log.warn(`[UPDATER] 更新后版本未变化: ${this.currentVersion} → ${newVersion},可能安装路径不一致`);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async updateLocalPackage(version) {
|
|
134
|
+
const pkgDir = path.dirname(this.pkgJsonPath);
|
|
135
|
+
const tmpDir = path.join(pkgDir, '.update-tmp');
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// 1. 清理临时目录
|
|
139
|
+
if (fs.existsSync(tmpDir)) {
|
|
140
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
143
|
+
|
|
144
|
+
// 2. 下载 tarball
|
|
145
|
+
log.info(`[UPDATER] 下载 ${PACKAGE_NAME}@${version} ...`);
|
|
146
|
+
const { stdout } = await execAsync(
|
|
147
|
+
`npm pack ${PACKAGE_NAME}@${version}`,
|
|
148
|
+
{ cwd: tmpDir, timeout: 120000, windowsHide: true }
|
|
149
|
+
);
|
|
150
|
+
const tarballName = stdout.trim().split('\n').pop();
|
|
151
|
+
const tarballPath = path.join(tmpDir, tarballName);
|
|
152
|
+
if (!fs.existsSync(tarballPath)) {
|
|
153
|
+
throw new Error('tarball 下载失败');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 3. 解压 tarball
|
|
157
|
+
log.info(`[UPDATER] 解压 ${tarballName} ...`);
|
|
158
|
+
await execAsync(
|
|
159
|
+
`tar -xzf ${tarballPath} -C ${tmpDir}`,
|
|
160
|
+
{ timeout: 30000 }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// 4. 复制 package/ 下的文件覆盖当前目录
|
|
164
|
+
const extractedDir = path.join(tmpDir, 'package');
|
|
165
|
+
if (!fs.existsSync(extractedDir)) {
|
|
166
|
+
throw new Error('解压后未找到 package 目录');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
log.info(`[UPDATER] 覆盖本地文件...`);
|
|
170
|
+
this.copyDirSync(extractedDir, pkgDir);
|
|
171
|
+
|
|
172
|
+
// 5. 重新安装依赖(因为 node_modules 被覆盖)
|
|
173
|
+
log.info(`[UPDATER] 重新安装依赖...`);
|
|
174
|
+
const { stdout: installOut, stderr: installErr } = await execAsync(
|
|
175
|
+
'npm install --production',
|
|
176
|
+
{ cwd: pkgDir, timeout: 300000, windowsHide: true }
|
|
177
|
+
);
|
|
178
|
+
if (installErr) {
|
|
179
|
+
log.warn(`[UPDATER] npm install 警告: ${installErr.substring(0, 500)}`);
|
|
180
|
+
}
|
|
181
|
+
log.info(`[UPDATER] npm install 输出: ${installOut.substring(0, 500)}`);
|
|
182
|
+
|
|
183
|
+
// 6. 验证版本
|
|
184
|
+
const newVersion = this.loadCurrentVersion();
|
|
185
|
+
if (this.compareVersion(newVersion, this.currentVersion) <= 0) {
|
|
186
|
+
log.warn(`[UPDATER] 更新后版本未变化: ${this.currentVersion} → ${newVersion}`);
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
log.info(`[UPDATER] 本地更新成功: ${this.currentVersion} → ${newVersion}`);
|
|
191
|
+
return true;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
log.error(`[UPDATER] 本地更新异常: ${err.message}`);
|
|
194
|
+
return false;
|
|
195
|
+
} finally {
|
|
196
|
+
// 清理临时目录
|
|
197
|
+
try {
|
|
198
|
+
if (fs.existsSync(tmpDir)) {
|
|
199
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
} catch { /* 忽略 */ }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
copyDirSync(src, dest) {
|
|
206
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
207
|
+
for (const entry of entries) {
|
|
208
|
+
const srcPath = path.join(src, entry.name);
|
|
209
|
+
const destPath = path.join(dest, entry.name);
|
|
210
|
+
|
|
211
|
+
// 跳过隐藏文件、日志和临时目录
|
|
212
|
+
if (entry.name.startsWith('.') || entry.name === 'logs') {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (entry.isDirectory()) {
|
|
217
|
+
if (!fs.existsSync(destPath)) {
|
|
218
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
219
|
+
}
|
|
220
|
+
this.copyDirSync(srcPath, destPath);
|
|
221
|
+
} else {
|
|
222
|
+
fs.copyFileSync(srcPath, destPath);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
103
227
|
startSchedule(restartWorkerCallback) {
|
|
104
228
|
this.restartWorker = restartWorkerCallback;
|
|
105
229
|
|
|
230
|
+
// Docker / 本地开发环境可通过环境变量禁用自动更新
|
|
231
|
+
if (process.env.DISABLE_AUTO_UPDATE === 'true' || process.env.DISABLE_AUTO_UPDATE === '1') {
|
|
232
|
+
log.info('[UPDATER] 已禁用自动更新(DISABLE_AUTO_UPDATE 已设置)');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Linux 平台默认禁用自动更新 npm 包
|
|
237
|
+
if (process.platform === 'linux') {
|
|
238
|
+
log.info('[UPDATER] Linux 平台已禁用自动 npm 更新');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
106
242
|
const run = async () => {
|
|
107
243
|
try {
|
|
108
244
|
const info = await this.check();
|
|
109
245
|
if (info) {
|
|
110
246
|
const result = await this.execute(info);
|
|
111
247
|
if (result.success) {
|
|
112
|
-
// npm
|
|
248
|
+
// npm 安装完成后,直接重启 Worker 即可加载新代码
|
|
113
249
|
this.restartWorker(null);
|
|
114
250
|
}
|
|
115
251
|
}
|
package/service/worker.js
CHANGED
|
@@ -18,16 +18,6 @@ const PORT = process.env.SILENT_SERVICE_PORT ? parseInt(process.env.SILENT_SERVI
|
|
|
18
18
|
const HOST = process.env.SILENT_SERVICE_HOST || '127.0.0.1';
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
// 捕获所有异常,强制打印到控制台(绕过 logger)
|
|
22
|
-
process.on('uncaughtException', (err) => {
|
|
23
|
-
console.error('[WORKER_FATAL] 未捕获异常:', err);
|
|
24
|
-
process.exit(1);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
process.on('unhandledRejection', (reason, promise) => {
|
|
28
|
-
console.error('[WORKER_FATAL] 未捕获Promise:', reason);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
21
|
// 如果 logger 初始化后,也同步写到 logger
|
|
32
22
|
const originalStderr = process.stderr.write.bind(process.stderr);
|
|
33
23
|
process.stderr.write = (chunk, encoding, callback) => {
|
|
@@ -96,13 +86,11 @@ function ensurePortFree(port) {
|
|
|
96
86
|
for (let i = 0; i < 3; i++) {
|
|
97
87
|
const pid = findPidOnPort(port);
|
|
98
88
|
if (!pid) {
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
105
|
-
} catch { /* 忽略 */ }
|
|
89
|
+
// 端口查询工具都不可用(常见于精简 Docker 镜像)
|
|
90
|
+
// 不执行 pkill 兜底,避免自杀;依赖 Daemon 的 freePortIfNeeded 清理残留 Worker
|
|
91
|
+
if (i === 0) {
|
|
92
|
+
log.warn(`[WORKER] 端口查询工具不可用,等待 Daemon 清理残留进程...`);
|
|
93
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
|
|
106
94
|
continue;
|
|
107
95
|
}
|
|
108
96
|
return true;
|
|
@@ -494,12 +482,9 @@ server.on('error', (err) => {
|
|
|
494
482
|
log.warn(`[WORKER] 发现占用进程 ${pid},强制终止...`);
|
|
495
483
|
forceKill(pid);
|
|
496
484
|
} else if (!pid) {
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
execSync('pkill -9 -f "daemon.js" 2>/dev/null || true', { timeout: 5000 });
|
|
501
|
-
execSync('pkill -9 -f "worker.js" 2>/dev/null || true', { timeout: 5000 });
|
|
502
|
-
} catch { /* 忽略 */ }
|
|
485
|
+
// 端口查询工具都不可用(常见于精简 Docker 镜像)
|
|
486
|
+
// 不执行 pkill 兜底避免自杀;依赖 Daemon 的 freePortIfNeeded 清理残留 Worker
|
|
487
|
+
log.warn('[WORKER] 无法查询端口占用进程,等待 Daemon 清理残留...');
|
|
503
488
|
}
|
|
504
489
|
// 延迟 3 秒后重试,给进程退出和端口释放留出足够时间
|
|
505
490
|
setTimeout(() => {
|