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/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
- export function launchctlInfo() {
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 = r.stdout.match(
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 = r.stdout.match(/^\s*run interval\s*=\s*(\d+)\s*seconds/m);
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
- for (const dir of candidates) {
115
- if (isExecutable(path.join(dir, 'node'))) return { pathDirs: [dir] };
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
- const dirs = launcher.pathDirs.map((d) => `"${d}"`).join(':');
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
- return m ? m[1].split('":"').map((s) => s.replace(/"/g, '')) : null;
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 指定(见 README 安全说明)');
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 r = deps.launchctl('bootout', `${guiTarget()}/${LABEL}`);
357
- if (r.status === 0) deps.print('已从 launchd 卸载');
358
-
359
- // 服务没了就没有回收者:默认结束全部预热会话,不留以 bypass 权限空转
360
- // 的孤儿。各会话的停轮询(SIGTERM→等→SIGKILL)互不依赖,先逐个发信
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
- await Promise.all(stopping);
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
- if (fs.existsSync(deps.paths.plistPath)) {
370
- fs.rmSync(deps.paths.plistPath, { force: true });
371
- deps.print(`已删除 ${deps.paths.plistPath}`);
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
- if (args.purge) {
375
- for (const p of [deps.paths.dataDir, deps.paths.logDir]) {
376
- fs.rmSync(p, { recursive: true, force: true });
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
- // npm 管的文件不能手删:命令行本身交给 npm 卸载。
380
- deps.print('命令行本身请运行 npm uninstall -g cron-up 卸载');
381
- } else {
382
- deps.print('配置与日志已保留(--purge 可删除全部产物)');
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', `cd ${shellQuote(ws)} && claude`,
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
- // 「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的话上一代(上
118
- // 一次执行)的界面记录会随本次拉起被抹掉。无条件保留一代(.1)——总
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
- return { pid: child.pid, procStart: deps.procStartedAt(child.pid) };
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
- if (!deps.trackedAlive(ent)) return;
161
- const pid = ent.pid;
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
- // 升级 SIGKILL 前再核一次指纹。
172
- if (deps.trackedAlive(ent)) {
173
- try {
174
- process.kill(-pid, 'SIGKILL');
175
- } catch {
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 标记)、id
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
- // 任务的稳定内容签名:只含不会被我们与调度器改动的字段。minify 改 id、盖
144
- // 印改 createdBy*、执行改 lastFiredAt、续期加 permanent 都不改变签名——
145
- // 它是「id 改写前后 state 条目改键」与「一批多 target 盖印」的串联键。
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 或删除已触发任务):绝不覆盖,抛 TaskFileChanged 让下轮
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
- // 乐观锁:路径 mtime 自调用方读后变了说明别的写者抢先,抛
188
- // TaskFileChanged 而不是覆盖它的更新。
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
- // 严格 8 位十六进制的任务 id(Claude Code 自己生成的形状)转数值;其他
225
- // 形状(短串、UUID、缺失、非字符串)返回 null——它们算「未达标」,由
226
- // minifyIds 改写成合规小号。
227
- export function parseTaskIdN(id) {
228
- if (typeof id !== 'string' || !/^[0-9a-fA-F]{8}$/.test(id)) return null;
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<taskSig, {sessionId, pid, procStart}>。一轮内同一文件的
287
- // 不同任务可盖向不同预热(一任务一会话);Map 以签名为键,minify 先改 id
288
- // 不影响随后的盖印查找。只盖 createdBySessionId 不等于目标的任务,第二轮
289
- // 起字节级不动;pid/procStart 是会话登记里 claude 进程的 pid 与 lstart。
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 (isPlainObject(t) && t.recurring && !t.permanent) {
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
- for (const t of doc.tasks) {
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 && minified === 0 && reowned === 0) {
336
- return { renewed: 0, minified: 0, reowned: 0, idChanges: [] };
296
+ if (renewed === 0 && reowned === 0) {
297
+ return { renewed: 0, reowned: 0 };
337
298
  }
338
299
  atomicWriteJson(taskfile, doc, mtimeNs);
339
- return { renewed, minified, reowned, idChanges };
300
+ return { renewed, reowned };
340
301
  }
341
302
 
342
303
  Object.assign(deps, { discover, readTasks, loadTaskDoc });