cron-up 26.10.32 → 26.10.34
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/CONTRIBUTING.md +61 -0
- package/README.md +228 -112
- package/docs/internals.md +190 -0
- package/docs/migration.md +47 -0
- package/docs/release.md +75 -0
- package/package.json +5 -3
- package/src/args.mjs +1 -2
- package/src/cli.mjs +18 -20
- package/src/config.mjs +22 -19
- package/src/constants.mjs +7 -17
- package/src/cron.mjs +18 -9
- package/src/display.mjs +2 -2
- package/src/list.mjs +1 -1
- package/src/logs.mjs +2 -1
- package/src/overview.mjs +7 -7
- package/src/patrol.mjs +199 -162
- package/src/registry.mjs +10 -13
- package/src/service.mjs +189 -49
- package/src/sessions.mjs +61 -20
- package/src/tasks.mjs +45 -84
package/src/service.mjs
CHANGED
|
@@ -22,34 +22,52 @@ import {
|
|
|
22
22
|
validateConfig,
|
|
23
23
|
normalizeRoots,
|
|
24
24
|
} from './config.mjs';
|
|
25
|
-
import { scriptProcLogPath } from './sessions.mjs';
|
|
25
|
+
import { scriptProcLogPath, shellQuote } from './sessions.mjs';
|
|
26
26
|
|
|
27
27
|
function guiTarget() {
|
|
28
28
|
return `gui/${process.getuid()}`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
// launchctl 调用统一走不抛的 execFile,判成败看 status。与 ps 同款防御:
|
|
32
|
-
// launchctl
|
|
32
|
+
// launchctl 卡住时命令挂死不如超时报「未加载」——总览页至少还能出来;卸载
|
|
33
|
+
// 等破坏性操作另用下方三态 probe,状态未知时拒绝继续。
|
|
33
34
|
export function launchctl(...args) {
|
|
34
35
|
return deps.execFile('/bin/launchctl', args, { timeout: 10000 });
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
// 走 deps 而非同文件裸名:与 bootout/bootstrap 的打桩面保持一致,测试可
|
|
39
|
-
// 在 launchctl 一层整体模拟 launchd。
|
|
40
|
-
const r = deps.launchctl('print', `${guiTarget()}/${LABEL}`);
|
|
41
|
-
if (r.status !== 0) return null;
|
|
38
|
+
function parseLaunchctlInfo(stdout = '') {
|
|
42
39
|
const info = {};
|
|
43
40
|
for (const key of ['state', 'last exit code']) {
|
|
44
|
-
const m =
|
|
41
|
+
const m = stdout.match(
|
|
45
42
|
new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=\\s*(.+)$`, 'm'));
|
|
46
43
|
if (m) info[key] = m[1].trim();
|
|
47
44
|
}
|
|
48
|
-
const iv =
|
|
45
|
+
const iv = stdout.match(/^\s*run interval\s*=\s*(\d+)\s*seconds/m);
|
|
49
46
|
if (iv) info.interval = parseInt(iv[1], 10);
|
|
50
47
|
return info;
|
|
51
48
|
}
|
|
52
49
|
|
|
50
|
+
// 卸载需要比 info/null 更细的结论:launchctl 对未加载与查询失败都返回非零,
|
|
51
|
+
// 只有明确的「找不到服务」才能安全继续删除安装产物。
|
|
52
|
+
function launchctlProbe() {
|
|
53
|
+
const r = deps.launchctl('print', `${guiTarget()}/${LABEL}`);
|
|
54
|
+
if (r.status === 0) {
|
|
55
|
+
return { kind: 'loaded', info: parseLaunchctlInfo(r.stdout ?? '') };
|
|
56
|
+
}
|
|
57
|
+
const stderr = (r.stderr ?? '').trim();
|
|
58
|
+
if (/could not find service/i.test(stderr)) return { kind: 'unloaded' };
|
|
59
|
+
return {
|
|
60
|
+
kind: 'unknown',
|
|
61
|
+
message: stderr || `launchctl print 退出码 ${r.status ?? '未知'}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function launchctlInfo() {
|
|
66
|
+
// 总览页继续只看 info/null;详细三分状态仅供卸载等破坏性操作使用。
|
|
67
|
+
const probe = launchctlProbe();
|
|
68
|
+
return probe.kind === 'loaded' ? probe.info : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
53
71
|
const LAUNCHD_STATES = new Map([
|
|
54
72
|
['running', '运行中'],
|
|
55
73
|
['not running', '空闲中(按间隔触发)'],
|
|
@@ -90,14 +108,17 @@ function isExecutable(p) {
|
|
|
90
108
|
}
|
|
91
109
|
|
|
92
110
|
// 返回 {pathDirs}:启动脚本要写进 PATH 的 bin 目录。注入 home/execPath/
|
|
93
|
-
// systemPaths 仅为测试在临时 HOME 里伪造各安装形态。fnm 的
|
|
111
|
+
// cronUpPath/systemPaths 仅为测试在临时 HOME 里伪造各安装形态。fnm 的
|
|
94
112
|
// aliases/default/bin 是跟随 `fnm default` 切换的稳定 symlink(切勿
|
|
95
113
|
// realpath 到版本化目录,fnm 升级后那里会失效);volta 的 ~/.volta/bin、
|
|
96
114
|
// Homebrew 与官网 pkg 的系统 bin 同理是版本无关的稳定入口。都不认识时退
|
|
97
|
-
// 回当前安装进程自己的 bin 目录:nvm 等按版本目录安装的形态由此覆盖。
|
|
115
|
+
// 回当前安装进程自己的 node bin 目录:nvm 等按版本目录安装的形态由此覆盖。
|
|
116
|
+
// node 目录与当前实际 cron-up 的 bin 目录都必须保留:fnm 非默认版本、nvm
|
|
117
|
+
// 或独立 npm prefix 下两者经常不同,找到稳定 node 后提前返回会丢掉 cron-up。
|
|
98
118
|
export function resolveLauncher(opts = {}) {
|
|
99
119
|
const home = opts.home ?? paths.home;
|
|
100
120
|
const execPath = opts.execPath ?? process.execPath;
|
|
121
|
+
const cronUpPath = opts.cronUpPath ?? process.argv[1];
|
|
101
122
|
const systemPaths = opts.systemPaths
|
|
102
123
|
?? ['/opt/homebrew/bin/node', '/usr/local/bin/node'];
|
|
103
124
|
|
|
@@ -111,14 +132,19 @@ export function resolveLauncher(opts = {}) {
|
|
|
111
132
|
path.join(home, '.volta', 'bin'),
|
|
112
133
|
...systemPaths.map((p) => path.dirname(p)),
|
|
113
134
|
];
|
|
114
|
-
|
|
115
|
-
|
|
135
|
+
const stableNodeDir = candidates.find((dir) => isExecutable(path.join(dir, 'node')));
|
|
136
|
+
const nodeDir = stableNodeDir ?? path.dirname(execPath);
|
|
137
|
+
// argv[1] 保留 npm 全局 bin symlink 的调用路径;不要 realpath 到包内
|
|
138
|
+
// bin/cron-up.mjs,否则独立 npm prefix 的 shim 目录会丢。无 argv[1] 时
|
|
139
|
+
// 退回 node 目录,至少覆盖 node 与全局 bin 同目录的常见安装。
|
|
140
|
+
const cronUpDir = cronUpPath
|
|
141
|
+
? path.dirname(path.resolve(cronUpPath)) : path.dirname(execPath);
|
|
142
|
+
const result = { pathDirs: [...new Set([nodeDir, cronUpDir])] };
|
|
143
|
+
if (stableNodeDir === undefined) {
|
|
144
|
+
result.note = '未能定位版本无关的 node bin 目录,已使用当前 node 所在目录;'
|
|
145
|
+
+ '该目录失效(如版本目录被删)时请重新运行 cron-up install';
|
|
116
146
|
}
|
|
117
|
-
return
|
|
118
|
-
pathDirs: [path.dirname(execPath)],
|
|
119
|
-
note: '未能定位版本无关的 node bin 目录,已使用当前 node 所在目录;'
|
|
120
|
-
+ '该目录失效(如版本目录被删)时请重新运行 cron-up install',
|
|
121
|
-
};
|
|
147
|
+
return result;
|
|
122
148
|
}
|
|
123
149
|
|
|
124
150
|
// ---- plist ----
|
|
@@ -145,7 +171,9 @@ function xmlUnescape(s) {
|
|
|
145
171
|
export function writeServiceScript(launcher) {
|
|
146
172
|
const script = deps.paths.serviceScriptPath;
|
|
147
173
|
fs.mkdirSync(path.dirname(script), { recursive: true });
|
|
148
|
-
|
|
174
|
+
// 整个冒号前缀作为一个 shell word 引用:健康检查可无损解回同一串目录,
|
|
175
|
+
// 空格、单双引号、$ 等字符不会变成 shell 语法。
|
|
176
|
+
const dirs = shellQuote(launcher.pathDirs.join(':'));
|
|
149
177
|
fs.writeFileSync(script,
|
|
150
178
|
'#!/bin/bash\n'
|
|
151
179
|
+ '# 由 cron-up install 生成;重跑 cron-up install 覆盖重生成\n'
|
|
@@ -197,6 +225,51 @@ export function readPlistProgramArgs() {
|
|
|
197
225
|
return parts.length > 0 ? parts : null;
|
|
198
226
|
}
|
|
199
227
|
|
|
228
|
+
// 解一个 POSIX shell word(不执行它)。覆盖 shellQuote 生成的单引号与
|
|
229
|
+
// '"'"' 拼接,也兼容旧版脚本简单的双引号目录;含未引用空白、命令替换或
|
|
230
|
+
// 变量展开的手改内容拒绝猜测。
|
|
231
|
+
function parseShellWord(source) {
|
|
232
|
+
let value = '';
|
|
233
|
+
for (let i = 0; i < source.length;) {
|
|
234
|
+
const ch = source[i];
|
|
235
|
+
if (ch === "'") {
|
|
236
|
+
const end = source.indexOf("'", i + 1);
|
|
237
|
+
if (end < 0) return null;
|
|
238
|
+
value += source.slice(i + 1, end);
|
|
239
|
+
i = end + 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (ch === '"') {
|
|
243
|
+
i += 1;
|
|
244
|
+
let closed = false;
|
|
245
|
+
while (i < source.length) {
|
|
246
|
+
if (source[i] === '"') {
|
|
247
|
+
closed = true;
|
|
248
|
+
i += 1;
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
if (source[i] === '$' || source[i] === '`') return null;
|
|
252
|
+
if (source[i] === '\\') {
|
|
253
|
+
if (i + 1 >= source.length) return null;
|
|
254
|
+
i += 1;
|
|
255
|
+
}
|
|
256
|
+
value += source[i];
|
|
257
|
+
i += 1;
|
|
258
|
+
}
|
|
259
|
+
if (!closed) return null;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (/\s/.test(ch) || /[$`;&|<>()]/.test(ch)) return null;
|
|
263
|
+
if (ch === '\\') {
|
|
264
|
+
if (i + 1 >= source.length) return null;
|
|
265
|
+
i += 1;
|
|
266
|
+
}
|
|
267
|
+
value += source[i];
|
|
268
|
+
i += 1;
|
|
269
|
+
}
|
|
270
|
+
return value;
|
|
271
|
+
}
|
|
272
|
+
|
|
200
273
|
// 从启动脚本内容里解出 PATH 目录;脚本是我们生成的固定格式,解不出(手改
|
|
201
274
|
// 坏)返回 null。
|
|
202
275
|
function scriptPathDirs(scriptPath) {
|
|
@@ -207,7 +280,11 @@ function scriptPathDirs(scriptPath) {
|
|
|
207
280
|
return null;
|
|
208
281
|
}
|
|
209
282
|
const m = text.match(/^export PATH=(.+):"\$PATH"$/m);
|
|
210
|
-
|
|
283
|
+
if (!m) return null;
|
|
284
|
+
const prefix = parseShellWord(m[1]);
|
|
285
|
+
if (!prefix) return null;
|
|
286
|
+
const dirs = prefix.split(':');
|
|
287
|
+
return dirs.every(Boolean) ? dirs : null;
|
|
211
288
|
}
|
|
212
289
|
|
|
213
290
|
// 总览页健康检查:launchd 每轮按启动脚本拉起巡检,脚本丢失、被手改或
|
|
@@ -283,27 +360,45 @@ export async function cmdInstall(args) {
|
|
|
283
360
|
validateConfig(cfg, (m) => deps.print(m));
|
|
284
361
|
if (JSON.stringify(cfg.roots)
|
|
285
362
|
=== JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
|
|
286
|
-
// 与 README
|
|
363
|
+
// 与 README「安全与自动修改」同一口径,在配置落定时再提示一次。
|
|
287
364
|
deps.print(`提示:未指定 --roots,默认扫描 ${DEFAULT_CONFIG.roots.join('、')}`
|
|
288
365
|
+ '(不存在的目录跳过);今后 clone 进这些容器的仓库也会进入巡检,'
|
|
289
|
-
+ '
|
|
290
|
-
+ '--roots
|
|
366
|
+
+ '其中的周期任务会被自动续期永久化,任务、CLAUDE.md 与项目配置都会'
|
|
367
|
+
+ '影响无人值守会话。目录布局不同或需收窄时用 --roots 指定'
|
|
368
|
+
+ '(见 README「安全与自动修改」)');
|
|
291
369
|
}
|
|
292
370
|
|
|
293
|
-
const launcher = resolveLauncher();
|
|
371
|
+
const launcher = deps.resolveLauncher();
|
|
294
372
|
if (launcher.note) deps.print(`提示:${launcher.note}`);
|
|
295
373
|
|
|
296
374
|
const script = writeServiceScript(launcher);
|
|
375
|
+
fs.mkdirSync(path.dirname(deps.paths.plistPath), { recursive: true });
|
|
297
376
|
fs.writeFileSync(deps.paths.plistPath, renderPlist(script));
|
|
298
377
|
deps.print(`已写入启动脚本与 LaunchAgent:${script}、${deps.paths.plistPath}`);
|
|
299
378
|
|
|
379
|
+
// bootstrap 成功只代表 launchd 收下 plist,不代表脚本真能解析 node 与
|
|
380
|
+
// cron-up。先自检启动链,避免随后的手动首轮巡检掩盖坏脚本。
|
|
381
|
+
const health = launcherHealth();
|
|
382
|
+
if (health) {
|
|
383
|
+
deps.printErr(`启动链自检失败:${health}`);
|
|
384
|
+
throw new ExitError(1);
|
|
385
|
+
}
|
|
386
|
+
|
|
300
387
|
deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
|
|
301
388
|
const r = deps.launchctl('bootstrap', guiTarget(), deps.paths.plistPath);
|
|
302
389
|
if (r.status !== 0) {
|
|
303
390
|
deps.printErr(`加载到 launchd 失败:\n${r.stderr.trim()}`);
|
|
304
391
|
throw new ExitError(1);
|
|
305
392
|
}
|
|
306
|
-
deps.launchctl('enable', `${guiTarget()}/${LABEL}`);
|
|
393
|
+
const enabled = deps.launchctl('enable', `${guiTarget()}/${LABEL}`);
|
|
394
|
+
if (enabled.status !== 0) {
|
|
395
|
+
deps.printErr(`启用 launchd 服务失败:\n${enabled.stderr.trim()}`);
|
|
396
|
+
throw new ExitError(1);
|
|
397
|
+
}
|
|
398
|
+
if (deps.launchctlInfo() === null) {
|
|
399
|
+
deps.printErr('加载后未能向 launchd 核实服务状态,请检查 cron-up 与 launchctl');
|
|
400
|
+
throw new ExitError(1);
|
|
401
|
+
}
|
|
307
402
|
deps.print('已加载到 launchd');
|
|
308
403
|
|
|
309
404
|
// install 与巡检撞车时首轮会被跳过,不能照常说「完成」。
|
|
@@ -353,34 +448,79 @@ export async function cmdUninstall(args) {
|
|
|
353
448
|
throw new ExitError(2, 'uninstall 不支持 --config:卸载操作的是本机固定'
|
|
354
449
|
+ `的安装产物(${deps.paths.plistPath} 等)`);
|
|
355
450
|
}
|
|
356
|
-
const
|
|
357
|
-
if (
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
// 号再并行等齐。
|
|
362
|
-
const stopping = [];
|
|
363
|
-
for (const { label, ent } of allTrackedWarmups()) {
|
|
364
|
-
deps.print(`正在结束预热会话 ${label}`);
|
|
365
|
-
stopping.push(deps.stopSession(ent));
|
|
451
|
+
const lock = deps.acquireRunLock();
|
|
452
|
+
if (lock === null) {
|
|
453
|
+
deps.printErr('已有一轮巡检在进行,为避免误删正在使用的产物,本次不卸载;'
|
|
454
|
+
+ '请稍后重试');
|
|
455
|
+
throw new ExitError(1);
|
|
366
456
|
}
|
|
367
|
-
|
|
457
|
+
try {
|
|
458
|
+
const target = `${guiTarget()}/${LABEL}`;
|
|
459
|
+
const before = deps.launchctlProbe();
|
|
460
|
+
if (before.kind === 'unknown') {
|
|
461
|
+
deps.printErr(`无法确认 launchd 服务状态:${before.message}\n`
|
|
462
|
+
+ '状态未知时不能安全卸载,本次不删除 LaunchAgent、配置或日志');
|
|
463
|
+
throw new ExitError(1);
|
|
464
|
+
}
|
|
465
|
+
if (before.kind === 'unloaded') {
|
|
466
|
+
deps.print('launchd 服务未加载,无需重复卸载');
|
|
467
|
+
} else {
|
|
468
|
+
const r = deps.launchctl('bootout', target);
|
|
469
|
+
if (r.status !== 0) {
|
|
470
|
+
deps.printErr(`从 launchd 卸载失败:\n${r.stderr.trim()}\n`
|
|
471
|
+
+ '为避免留下无人回收的预热会话,本次不删除 LaunchAgent、配置或日志');
|
|
472
|
+
throw new ExitError(1);
|
|
473
|
+
}
|
|
474
|
+
const after = deps.launchctlProbe();
|
|
475
|
+
if (after.kind === 'unknown') {
|
|
476
|
+
deps.printErr(`bootout 后无法确认 launchd 服务状态:${after.message}\n`
|
|
477
|
+
+ '本次不继续删除 LaunchAgent、配置或日志');
|
|
478
|
+
throw new ExitError(1);
|
|
479
|
+
}
|
|
480
|
+
if (after.kind === 'loaded') {
|
|
481
|
+
deps.printErr('launchctl bootout 已返回,但服务仍能查到;本次不继续清理');
|
|
482
|
+
throw new ExitError(1);
|
|
483
|
+
}
|
|
484
|
+
deps.print('已从 launchd 卸载');
|
|
485
|
+
}
|
|
368
486
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
487
|
+
// 服务没了就没有回收者:默认结束全部预热会话,不留以 bypass 权限空转
|
|
488
|
+
// 的孤儿。各会话的停轮询(SIGTERM→等→SIGKILL)互不依赖,先逐个发信
|
|
489
|
+
// 号再并行等齐。
|
|
490
|
+
const stopping = [];
|
|
491
|
+
for (const { label, ent } of allTrackedWarmups()) {
|
|
492
|
+
deps.print(`正在结束预热会话 ${label}`);
|
|
493
|
+
stopping.push(deps.stopSession(ent).then(
|
|
494
|
+
(stopped) => stopped ? null : label,
|
|
495
|
+
));
|
|
496
|
+
}
|
|
497
|
+
const unsafe = (await Promise.all(stopping))
|
|
498
|
+
.filter((label) => label !== null);
|
|
499
|
+
if (unsafe.length > 0) {
|
|
500
|
+
deps.printErr('警告:以下预热会话仍活着,本次无法安全停止:'
|
|
501
|
+
+ `${unsafe.join('、')}。已中止后续清理,`
|
|
502
|
+
+ '不会继续删除 LaunchAgent、配置或日志;请人工核对后重试');
|
|
503
|
+
throw new ExitError(1);
|
|
504
|
+
}
|
|
373
505
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
deps.print(`已删除 ${p}`);
|
|
506
|
+
if (fs.existsSync(deps.paths.plistPath)) {
|
|
507
|
+
fs.rmSync(deps.paths.plistPath, { force: true });
|
|
508
|
+
deps.print(`已删除 ${deps.paths.plistPath}`);
|
|
378
509
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
510
|
+
|
|
511
|
+
if (args.purge) {
|
|
512
|
+
for (const p of [deps.paths.dataDir, deps.paths.logDir]) {
|
|
513
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
514
|
+
deps.print(`已删除 ${p}`);
|
|
515
|
+
}
|
|
516
|
+
// npm 管的文件不能手删:命令行本身交给 npm 卸载。
|
|
517
|
+
deps.print('命令行本身请运行 npm uninstall -g cron-up 卸载');
|
|
518
|
+
} else {
|
|
519
|
+
deps.print('配置与日志已保留(--purge 可删除 cron-up 自有配置与日志)');
|
|
520
|
+
}
|
|
521
|
+
} finally {
|
|
522
|
+
deps.releaseRunLock(lock);
|
|
383
523
|
}
|
|
384
524
|
}
|
|
385
525
|
|
|
386
|
-
Object.assign(deps, { launchctl, launchctlInfo });
|
|
526
|
+
Object.assign(deps, { launchctl, launchctlInfo, launchctlProbe, resolveLauncher });
|
package/src/sessions.mjs
CHANGED
|
@@ -81,7 +81,8 @@ export function sessionArgv(ws, logPath) {
|
|
|
81
81
|
// 里靠继承现成 PATH 一切正常,交给 launchd 就 fnm: command not found。
|
|
82
82
|
return [
|
|
83
83
|
'/usr/bin/script', '-q', logPath,
|
|
84
|
-
'/bin/zsh', '-lic',
|
|
84
|
+
'/bin/zsh', '-lic',
|
|
85
|
+
`cd ${shellQuote(ws)} && claude --permission-mode bypassPermissions`,
|
|
85
86
|
];
|
|
86
87
|
}
|
|
87
88
|
|
|
@@ -109,14 +110,20 @@ export function rotateOnce(file) {
|
|
|
109
110
|
fs.renameSync(file, old);
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// 进程组信号只接受明确大于 1 的整数 pid:0/-1 等有广播含义,浮点、
|
|
114
|
+
// 负数或非安全整数也都不能交给 process.kill。
|
|
115
|
+
function canSignalProcessGroup(pid) {
|
|
116
|
+
return Number.isSafeInteger(pid) && pid > 1;
|
|
117
|
+
}
|
|
118
|
+
|
|
112
119
|
// 为一个任务拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
|
|
113
120
|
export function spawnSession(ws, taskId) {
|
|
114
121
|
const logPath = sessionLogPath(ws, taskId);
|
|
115
|
-
// 会话日志目录 700(README
|
|
122
|
+
// 会话日志目录 700(README 权限约定):每任务独立文件,目录只在此处创建。
|
|
116
123
|
ensurePrivateDir(path.dirname(logPath));
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
124
|
+
// script(1) 启动即截断:每次新拉起前无条件保留上一代(.1),避免上次
|
|
125
|
+
// 启动以来的界面记录被抹掉。总量上界两代,活着的一代增长再快也只影响
|
|
126
|
+
// 自身,不会经轮转放大。
|
|
120
127
|
try {
|
|
121
128
|
if (fs.existsSync(logPath)) rotateOnce(logPath);
|
|
122
129
|
} catch {
|
|
@@ -149,34 +156,68 @@ export function spawnSession(ws, taskId) {
|
|
|
149
156
|
// 拉不起来(spawn 当场失败,pid 是 undefined)时返回 null:没有进程可跟
|
|
150
157
|
// 踪,调用方按启动失败计数,不把 pid=undefined 写进 state。
|
|
151
158
|
if (child.pid === undefined) return null;
|
|
152
|
-
|
|
159
|
+
let procStart = null;
|
|
160
|
+
let fingerprintError = null;
|
|
161
|
+
try {
|
|
162
|
+
procStart = deps.procStartedAt(child.pid);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
fingerprintError = e;
|
|
165
|
+
}
|
|
166
|
+
if (procStart) return { pid: child.pid, procStart };
|
|
167
|
+
|
|
168
|
+
// 没有指纹的条目今后无法安全发信号,不能登记成正常会话。这里只清理
|
|
169
|
+
// 刚刚由本次 spawn 创建的进程组;pid 形状异常时宁可留下日志也不冒险。
|
|
170
|
+
const reason = fingerprintError
|
|
171
|
+
? `读取进程身份指纹失败:${fingerprintError?.message ?? fingerprintError}`
|
|
172
|
+
: '未读到进程身份指纹';
|
|
173
|
+
let cleanup;
|
|
174
|
+
if (canSignalProcessGroup(child.pid)) {
|
|
175
|
+
try {
|
|
176
|
+
process.kill(-child.pid, 'SIGTERM');
|
|
177
|
+
cleanup = '已向刚拉起的进程组发送 SIGTERM';
|
|
178
|
+
} catch (e) {
|
|
179
|
+
cleanup = `清理刚拉起的进程组失败:${e?.code ? `${e.code}:` : ''}`
|
|
180
|
+
+ `${e?.message ?? e}`;
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
cleanup = 'pid 不是大于 1 的整数,未发信号';
|
|
184
|
+
}
|
|
185
|
+
deps.log(`会话启动失败 ${ws} 任务 ${taskId} pid=${child.pid}:${reason};`
|
|
186
|
+
+ cleanup);
|
|
187
|
+
return null;
|
|
153
188
|
}
|
|
154
189
|
|
|
155
190
|
// 按进程组结束我们拉起的会话。发信号前先核身份:state 条目可能比它命名
|
|
156
191
|
// 的进程活得久,给回收 pid 发组信号会杀掉无关进程组。detached(用户
|
|
157
|
-
// resume 接管)时 trackedAlive 为 false
|
|
158
|
-
// claude。
|
|
192
|
+
// resume 接管)时 trackedAlive 为 false:本函数返回 true,绝不碰用户的裸
|
|
193
|
+
// claude。false 表示记录的进程仍活着,但因缺少指纹或信号失败而未能安全
|
|
194
|
+
// 停止;调用方必须保留跟踪并告警,不能假装已经回收。
|
|
159
195
|
export async function stopSession(ent) {
|
|
160
|
-
|
|
161
|
-
|
|
196
|
+
const pid = ent?.pid;
|
|
197
|
+
if (!canSignalProcessGroup(pid)) return true;
|
|
198
|
+
if (!deps.trackedAlive(ent)) return true;
|
|
199
|
+
// trackedAlive 为兼容旧条目会在没有 procStart 时退回 kill -0;读取可以
|
|
200
|
+
// 沿用这个判断,停止却不行。死进程无需处理,活进程只能如实报告未停止。
|
|
201
|
+
if (!ent.procStart) return deps.alive(pid) ? false : true;
|
|
162
202
|
try {
|
|
163
203
|
process.kill(-pid, 'SIGTERM');
|
|
164
204
|
} catch {
|
|
165
|
-
return;
|
|
205
|
+
return deps.alive(pid) ? false : true;
|
|
166
206
|
}
|
|
167
207
|
for (let i = 0; i < 20; i++) {
|
|
168
208
|
await deps.sleep(100);
|
|
169
|
-
if (!deps.alive(pid)) return;
|
|
209
|
+
if (!deps.alive(pid)) return true;
|
|
170
210
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
await deps.sleep(100);
|
|
211
|
+
if (!deps.trackedAlive(ent)) return true;
|
|
212
|
+
// 升级 SIGKILL 前已再核一次指纹;信号失败或结束后仍是同一进程,都不能
|
|
213
|
+
// 向调用方报告停止成功。
|
|
214
|
+
try {
|
|
215
|
+
process.kill(-pid, 'SIGKILL');
|
|
216
|
+
} catch {
|
|
217
|
+
return deps.alive(pid) ? false : true;
|
|
179
218
|
}
|
|
219
|
+
await deps.sleep(100);
|
|
220
|
+
return deps.trackedAlive(ent) ? false : true;
|
|
180
221
|
}
|
|
181
222
|
|
|
182
223
|
// typescript 多少秒没被写。文件缺失读作「没有任何活动证据」:Infinity 大
|
package/src/tasks.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Claude Code 任务文件层:发现工作区、读任务、续期(permanent 标记)、
|
|
2
|
-
//
|
|
1
|
+
// Claude Code 任务文件层:发现工作区、读任务、续期(permanent 标记)、
|
|
2
|
+
// 归属盖印与原子写。任务文件是另一个程序写的外部输入,所有入口都
|
|
3
3
|
// 校验形状,坏数据读作「没有任务」而不是抛进巡检循环。
|
|
4
4
|
|
|
5
5
|
import fs from 'node:fs';
|
|
@@ -10,8 +10,6 @@ import { deps, isPlainObject } from './internals.mjs';
|
|
|
10
10
|
import { TASK_REL } from './paths.mjs';
|
|
11
11
|
import {
|
|
12
12
|
PRUNE_DIRS,
|
|
13
|
-
CC_JITTER,
|
|
14
|
-
AUTO_MIN_ID_TARGET_DELAY_MS,
|
|
15
13
|
MAX_SCAN_DEPTH,
|
|
16
14
|
} from './constants.mjs';
|
|
17
15
|
|
|
@@ -140,9 +138,9 @@ export function taskKey(t) {
|
|
|
140
138
|
return `anon:${createHash('sha1').update(taskSig(t)).digest('hex').slice(0, 16)}`;
|
|
141
139
|
}
|
|
142
140
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
141
|
+
// 任务的稳定内容签名:只含不会被我们与调度器改动的字段。盖印改
|
|
142
|
+
// createdBy*、执行改 lastFiredAt、续期加 permanent 都不改变签名;无 id
|
|
143
|
+
// 任务的 taskKey 用它生成内容摘要键。
|
|
146
144
|
export function taskSig(t) {
|
|
147
145
|
return JSON.stringify([
|
|
148
146
|
t?.cron ?? null,
|
|
@@ -152,9 +150,25 @@ export function taskSig(t) {
|
|
|
152
150
|
]);
|
|
153
151
|
}
|
|
154
152
|
|
|
153
|
+
// 按任务身份键分组的共享视图:重复键天然落在同一组,调用方不必各自
|
|
154
|
+
// 维护重复计数与键集合。非对象条目不参与盖印或巡检,和 readTasks/
|
|
155
|
+
// maintain 的既有口径一致。
|
|
156
|
+
export function groupTasksByKey(tasks) {
|
|
157
|
+
const groups = new Map();
|
|
158
|
+
for (const t of tasks) {
|
|
159
|
+
if (!isPlainObject(t)) continue;
|
|
160
|
+
const key = taskKey(t);
|
|
161
|
+
const group = groups.get(key) ?? [];
|
|
162
|
+
group.push(t);
|
|
163
|
+
groups.set(key, group);
|
|
164
|
+
}
|
|
165
|
+
return groups;
|
|
166
|
+
}
|
|
167
|
+
|
|
155
168
|
// 任务文件在「读到」与「写回」之间被别的写者改了(Claude Code 更新
|
|
156
|
-
// lastFiredAt
|
|
157
|
-
//
|
|
169
|
+
// lastFiredAt 或删除已触发任务):最终检查时能发现就抛 TaskFileChanged,
|
|
170
|
+
// 让下轮巡检基于更新的文档重试。检查与 rename 之间仍有极小竞态,不能把
|
|
171
|
+
// 这层检测当成与上游共用的互斥锁。
|
|
158
172
|
export class TaskFileChanged extends Error {
|
|
159
173
|
constructor(file) {
|
|
160
174
|
super(`任务文件已被其他写者改动:${file}`);
|
|
@@ -184,8 +198,8 @@ function tmpSibling(file) {
|
|
|
184
198
|
|
|
185
199
|
// 旁边写再 rename 的原子替换,保留原文件权限位与中文字面量。
|
|
186
200
|
// tmp 以 0600 创建(绝不更宽),写完再放宽到原 mode。expectedMtimeNs 是
|
|
187
|
-
//
|
|
188
|
-
//
|
|
201
|
+
// 乐观检测:最终检查时路径 mtime 已变说明别的写者抢先,抛 TaskFileChanged
|
|
202
|
+
// 而不是覆盖这次检查之前发生的更新。
|
|
189
203
|
export function atomicWriteJson(file, doc, expectedMtimeNs) {
|
|
190
204
|
let mode = 0o600;
|
|
191
205
|
try {
|
|
@@ -221,72 +235,17 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
|
|
|
221
235
|
fs.renameSync(tmp, file);
|
|
222
236
|
}
|
|
223
237
|
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return parseInt(id, 16);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
// autoMinId 的 id 数值上界:每日任务延迟 ≤ AUTO_MIN_ID_TARGET_DELAY_MS。
|
|
233
|
-
export function minTaskIdMaxN() {
|
|
234
|
-
return Math.floor(
|
|
235
|
-
AUTO_MIN_ID_TARGET_DELAY_MS
|
|
236
|
-
/ (CC_JITTER.recurringFrac * 86400_000) * 2 ** 32);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// 把显式周期任务里 id 数值超过上界(或形状不合法)的,改写成文件内未占用
|
|
240
|
-
// 的小号(00000001 起的 8 位十六进制),使其投递抖动降到目标延迟内。原地
|
|
241
|
-
// 修改 tasks 元素,返回 {changed, idChanges};idChanges 用 taskKey 口径
|
|
242
|
-
// 记录改前/改后键,巡检同轮把 state 条目跟着改键(state 以任务 id 为
|
|
243
|
-
// 键,不改键会把刚换 id 的任务误判为「旧任务删除 + 新任务」而错杀重
|
|
244
|
-
// 拉)。关键纪律:
|
|
245
|
-
// - 只碰 t.recurring truthy 的显式周期任务,一次性与无标志手写条目不碰;
|
|
246
|
-
// - 已达标的小号(含 00000000)原样保留,永不重排——多次巡检结果稳定,
|
|
247
|
-
// 第二轮起 0 改动、文件字节不动;
|
|
248
|
-
// - 空号在「文件内全部任务」(含一次性)已占用的 id 之外分配,保证同文
|
|
249
|
-
// 件唯一;跨文件重复无妨(各工作区独立加载)。
|
|
250
|
-
export function minifyIds(tasks) {
|
|
251
|
-
const maxN = minTaskIdMaxN();
|
|
252
|
-
const used = new Set();
|
|
253
|
-
for (const t of tasks) {
|
|
254
|
-
if (!isPlainObject(t)) continue;
|
|
255
|
-
const n = parseTaskIdN(t.id);
|
|
256
|
-
if (n !== null) used.add(n);
|
|
257
|
-
}
|
|
258
|
-
let next = 1;
|
|
259
|
-
const takeFree = () => {
|
|
260
|
-
while (used.has(next)) next += 1;
|
|
261
|
-
used.add(next);
|
|
262
|
-
return next;
|
|
263
|
-
};
|
|
264
|
-
let changed = 0;
|
|
265
|
-
const idChanges = [];
|
|
266
|
-
for (const t of tasks) {
|
|
267
|
-
if (!isPlainObject(t) || !t.recurring) continue;
|
|
268
|
-
const n = parseTaskIdN(t.id);
|
|
269
|
-
if (n !== null && n <= maxN) continue;
|
|
270
|
-
const from = taskKey(t);
|
|
271
|
-
const to = takeFree().toString(16).padStart(8, '0');
|
|
272
|
-
t.id = to;
|
|
273
|
-
changed += 1;
|
|
274
|
-
idChanges.push({ from, to });
|
|
275
|
-
}
|
|
276
|
-
return { changed, idChanges };
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// 一轮巡检对一个任务文件做的维护:renew(补 permanent)、minify(id 改
|
|
280
|
-
// 小)、reown(把任务归属盖给各自的预热会话)合并为同一次受 mtime 乐观
|
|
281
|
-
// 锁保护的原子写。renew/minify 无条件执行,没有开关。先取 mtimeNs、清残
|
|
282
|
-
// 留 .cron-up-tmp、loadTaskDoc round-trip 整文档;三项计数都为 0 时字节
|
|
283
|
-
// 级不动。返回 {renewed,minified,reowned,idChanges};null 表示文件缺失/
|
|
284
|
-
// 损坏;读到写间被改抛 TaskFileChanged。
|
|
238
|
+
// 一轮巡检对一个任务文件做的维护:renew(补 permanent)与 reown(把任务
|
|
239
|
+
// 归属盖给各自的预热会话)合并为同一次带 mtime 乐观检测的原子写。先取
|
|
240
|
+
// mtimeNs、清残留 .cron-up-tmp、loadTaskDoc round-trip 整文档;两项计数
|
|
241
|
+
// 都为 0 时字节级不动。返回 {renewed,reowned};null 表示文件缺失/损坏;
|
|
242
|
+
// 读到写间被改抛 TaskFileChanged。
|
|
285
243
|
//
|
|
286
|
-
// reown 形状:Map<
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
244
|
+
// reown 形状:Map<taskKey, {sessionId, pid, procStart}>。一轮内同一文件的
|
|
245
|
+
// 不同任务可盖向不同预热(一任务一会话);内容相同但 id 不同的任务互不
|
|
246
|
+
// 影响。同一个 taskKey 对应多条任务是病态输入,无法把 state/会话唯一对应
|
|
247
|
+
// 到条目,这些键全部跳过盖印。只盖 createdBySessionId 不等于目标的任务,
|
|
248
|
+
// 第二轮起字节级不动;pid/procStart 是登记里 claude 进程的 pid 与 lstart。
|
|
290
249
|
export function maintainWorkspace(taskfile, { reown = null } = {}) {
|
|
291
250
|
let mtimeNs;
|
|
292
251
|
try {
|
|
@@ -306,23 +265,25 @@ export function maintainWorkspace(taskfile, { reown = null } = {}) {
|
|
|
306
265
|
if (doc === null) return null;
|
|
307
266
|
let renewed = 0;
|
|
308
267
|
for (const t of doc.tasks) {
|
|
268
|
+
if (!isPlainObject(t)) continue;
|
|
309
269
|
// truthy 而非 === true:与 Claude Code 自己的加载器一致
|
|
310
270
|
// (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
|
|
311
271
|
// 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
|
|
312
272
|
// 一次性任务不碰。
|
|
313
|
-
if (
|
|
273
|
+
if (t.recurring && !t.permanent) {
|
|
314
274
|
t.permanent = true;
|
|
315
275
|
renewed += 1;
|
|
316
276
|
}
|
|
317
277
|
}
|
|
318
|
-
const { changed: minified, idChanges } = minifyIds(doc.tasks);
|
|
319
278
|
let reowned = 0;
|
|
320
279
|
if (reown instanceof Map && reown.size > 0) {
|
|
321
|
-
|
|
280
|
+
// 只有确实需要盖印时才计算任务键;纯续期与常见空操作不付摘要成本。
|
|
281
|
+
for (const [key, group] of groupTasksByKey(doc.tasks)) {
|
|
282
|
+
if (group.length !== 1) continue;
|
|
283
|
+
const t = group[0];
|
|
284
|
+
const target = reown.get(key);
|
|
322
285
|
// 没有 owner 字段的外部条目同样盖——窗口内任务统一归各自预热,语义
|
|
323
286
|
// 不因条目来源留例外。
|
|
324
|
-
if (!isPlainObject(t)) continue;
|
|
325
|
-
const target = reown.get(taskSig(t));
|
|
326
287
|
if (target && t.createdBySessionId !== target.sessionId) {
|
|
327
288
|
t.createdBySessionId = target.sessionId;
|
|
328
289
|
t.createdByPid = target.pid;
|
|
@@ -332,11 +293,11 @@ export function maintainWorkspace(taskfile, { reown = null } = {}) {
|
|
|
332
293
|
}
|
|
333
294
|
}
|
|
334
295
|
}
|
|
335
|
-
if (renewed === 0 &&
|
|
336
|
-
return { renewed: 0,
|
|
296
|
+
if (renewed === 0 && reowned === 0) {
|
|
297
|
+
return { renewed: 0, reowned: 0 };
|
|
337
298
|
}
|
|
338
299
|
atomicWriteJson(taskfile, doc, mtimeNs);
|
|
339
|
-
return { renewed,
|
|
300
|
+
return { renewed, reowned };
|
|
340
301
|
}
|
|
341
302
|
|
|
342
303
|
Object.assign(deps, { discover, readTasks, loadTaskDoc });
|