cron-up 26.10.34 → 26.10.35

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 CHANGED
@@ -26,11 +26,12 @@ CI 在 macOS 上分别使用 Node 22、24 运行全部测试。需要本机联
26
26
 
27
27
  ## 文档同步要求
28
28
 
29
- 用户可观察行为变化时,至少检查以下位置,避免命令、README 和实现各自描述一套口径:
29
+ 用户可观察行为或维护者操作边界变化时,至少检查以下位置,避免命令、文档和实现各自描述一套口径:
30
30
 
31
31
  | 变化 | 需要同步的位置 |
32
32
  | --- | --- |
33
33
  | 产品范围、安装、日常使用、限制、卸载或数据保留 | `README.md` |
34
+ | 维护者可操作的机器范围或自动化执行边界 | 根目录 `CLAUDE.md`;相关流程只引用该节,不复制机器名单 |
34
35
  | 命令、选项、升级步骤或帮助文案 | `src/cli.mjs`,必要时包括 `src/overview.mjs` |
35
36
  | npm 包定位 | `package.json` 的 `description` |
36
37
  | 内部机制、生命周期常量、兼容性证据或投递算法 | `docs/internals.md` |
package/docs/migration.md CHANGED
@@ -26,7 +26,7 @@
26
26
  为防止 pid 复用导致误杀,缺少进程启动指纹的旧条目不会被贸然发信号:
27
27
 
28
28
  - 对应进程已经退出时,不再需要停止它
29
- - 对应进程仍在运行且无法安全核实时,巡检会明确告警,不会把“未停止”记成“已回收”
29
+ - 对应进程仍在运行且无法安全核实时,巡检会明确告警并保留旧状态,在后续轮次重试,不会把“未停止”记成“已回收”
30
30
  - 出现这类告警时,先用 `cron-up logs` 找到“旧版迁移”条目,记录其中的 pid、工作区或日志路径;旧版扁平状态不一定会出现在总览中
31
31
  - 再把下列命令中的 12345 换成实际 pid,核对进程启动时间、父进程和完整启动命令:
32
32
 
package/docs/release.md CHANGED
@@ -60,7 +60,7 @@ npm 包设置中需要一次性登记:
60
60
 
61
61
  按 26.10.32 的实际发布记录,publish 成功后 registry 通常约两分钟可查,偶发需要七八分钟;带 provenance 时还要等待透明日志与后端入库,本机元数据缓存可能进一步滞后
62
62
 
63
- 对每一台已有安装的机器分别执行:
63
+ 先按仓库根目录 `CLAUDE.md` 的「使用边界」确定本次允许操作的机器,再对范围内每一台已有安装的机器分别执行:
64
64
 
65
65
  ```bash
66
66
  npm i -g cron-up@<新版本> --prefer-online
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.10.34",
3
+ "version": "26.10.35",
4
4
  "description": "为 Claude Code 定时任务在触发前按需预热交互会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  isPlainObject,
11
11
  isDecInt,
12
12
  ensurePrivateDir,
13
+ setOwn,
13
14
  } from './internals.mjs';
14
15
  import { PID_T_MAX, HEARTBEAT_STALE_FACTOR } from './constants.mjs';
15
16
  import { expandHome, isDir } from './tasks.mjs';
@@ -175,9 +176,9 @@ export function loadState() {
175
176
  const cleanBucket = {};
176
177
  for (const [taskId, raw] of Object.entries(bucket)) {
177
178
  const ent = cleanEntry(raw, ws, taskId);
178
- if (ent) cleanBucket[taskId] = ent;
179
+ if (ent) setOwn(cleanBucket, taskId, ent);
179
180
  }
180
- if (Object.keys(cleanBucket).length > 0) out[ws] = cleanBucket;
181
+ if (Object.keys(cleanBucket).length > 0) setOwn(out, ws, cleanBucket);
181
182
  }
182
183
  return out;
183
184
  }
package/src/cron.mjs CHANGED
@@ -48,7 +48,9 @@ export function parseCronField(expr, lo, hi) {
48
48
  start = lo;
49
49
  end = hi;
50
50
  } else if (rng.includes('-')) {
51
- const [a, b] = rng.split('-');
51
+ const bounds = rng.split('-');
52
+ if (bounds.length !== 2) throw new Error(`cron 区间格式无效:${rng}`);
53
+ const [a, b] = bounds;
52
54
  start = strictInt(a);
53
55
  end = strictInt(b);
54
56
  } else {
@@ -173,7 +175,7 @@ export class Cron {
173
175
  // CronCreate 产生的一次性任务形状像 'M H DoM Mon *',作为没有 recurring
174
176
  // 标志时的兜底识别法。
175
177
  function cronLooksOneshot(expr) {
176
- const parts = expr.split(/\s+/);
178
+ const parts = expr.trim().split(/\s+/);
177
179
  return parts.length === 5 && parts[4] === '*'
178
180
  && parts.slice(0, 4).every((p) => /^\d+$/.test(p));
179
181
  }
package/src/internals.mjs CHANGED
@@ -26,6 +26,23 @@ export function isPlainObject(v) {
26
26
  return v !== null && typeof v === 'object' && !Array.isArray(v);
27
27
  }
28
28
 
29
+ // state 的桶键来自外部任务 id。普通对象的 __proto__ 赋值会改原型而不是
30
+ // 建条目,读取还会把继承来的 Object.prototype 当成 entry;所有外部键都
31
+ // 通过自有属性访问,保持 JSON 形状不变,也无需特殊的任务 id 黑名单。
32
+ export function getOwn(obj, key) {
33
+ return obj != null && Object.hasOwn(obj, key) ? obj[key] : undefined;
34
+ }
35
+
36
+ export function setOwn(obj, key, value) {
37
+ Object.defineProperty(obj, key, {
38
+ value,
39
+ writable: true,
40
+ enumerable: true,
41
+ configurable: true,
42
+ });
43
+ return value;
44
+ }
45
+
29
46
  // 严格十进制整数(可选负号 + 纯数字):parseInt("5x")、Number("0x10") 都会
30
47
  // 静默给出错误结果,来自外部字符串的整数一律先过这个谓词再 parseInt。
31
48
  export function isDecInt(s) {
package/src/list.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // `cron-up list`:逐工作区列出任务的下次触发、节奏、类型、摘要与该任务
2
2
  // 的预热状态。
3
3
 
4
- import { deps } from './internals.mjs';
4
+ import { deps, getOwn } from './internals.mjs';
5
5
  import { LEAD_SECONDS } from './constants.mjs';
6
6
  import { loadConfig, loadState, isCooling } from './config.mjs';
7
7
  import { taskKey } from './tasks.mjs';
@@ -70,7 +70,7 @@ export async function cmdList(args) {
70
70
  return {
71
71
  v,
72
72
  t,
73
- tag: warmTag(t, v, state[ws]?.[taskKey(t)], liveSids, ws, cur),
73
+ tag: warmTag(t, v, getOwn(state[ws], taskKey(t)), liveSids, ws, cur),
74
74
  };
75
75
  });
76
76
  // 错过待补执行排最前,然后按下次时间升序,一年内无安排的垫底,无效
@@ -96,6 +96,9 @@ export async function cmdList(args) {
96
96
 
97
97
  blocks.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
98
98
  deps.print(`共 ${blocks.length} 个工作区、${total} 个定时任务`);
99
+ if (unreadable > 0) {
100
+ deps.print(`另有 ${unreadable} 个任务文件读不出,其中的定时任务不会执行`);
101
+ }
99
102
 
100
103
  // 列宽从全部实际文案动态算;cadence 是截断列,取固定上限。
101
104
  const statusW = Math.max(11,
package/src/logs.mjs CHANGED
@@ -77,5 +77,8 @@ export function cmdLogs(args) {
77
77
  tailArgs.push(file);
78
78
  const r = deps.spawnSync('/usr/bin/tail', tailArgs, { stdio: 'inherit' });
79
79
  if (r.error) throw r.error;
80
- if (r.status && r.status !== 0) throw new ExitError(r.status);
80
+ if (r.signal) {
81
+ throw new ExitError(1, `日志查看进程被信号 ${r.signal} 终止`);
82
+ }
83
+ if (r.status !== 0) throw new ExitError(r.status ?? 1);
81
84
  }
package/src/overview.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { deps, packageVersion } from './internals.mjs';
8
+ import { deps, packageVersion, getOwn } from './internals.mjs';
9
9
  import {
10
10
  INTERVAL_SECONDS,
11
11
  LEAD_SECONDS,
@@ -87,7 +87,7 @@ function overviewTasks(cfg, cols, details, state) {
87
87
  // 承接,都不是异常;否则按 entry 的冷却/失败状态给一条可执行的告
88
88
  // 警。
89
89
  const key = taskKey(t);
90
- const ent = state[ws]?.[key];
90
+ const ent = getOwn(state[ws], key);
91
91
  if (deps.creatorSessionAlive(t, liveSids) || entryHolds(ent, details, ws)) {
92
92
  continue;
93
93
  }
package/src/patrol.mjs CHANGED
@@ -10,7 +10,13 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { randomUUID } from 'node:crypto';
12
12
 
13
- import { deps, packageVersion, isPlainObject } from './internals.mjs';
13
+ import {
14
+ deps,
15
+ packageVersion,
16
+ isPlainObject,
17
+ getOwn,
18
+ setOwn,
19
+ } from './internals.mjs';
14
20
  import {
15
21
  FAIL_LIMIT,
16
22
  COOLDOWN_SECONDS,
@@ -41,6 +47,7 @@ import {
41
47
  TaskFileChanged,
42
48
  taskFileExists,
43
49
  groupTasksByKey,
50
+ taskOwnedBy,
44
51
  } from './tasks.mjs';
45
52
  import { taskWanted, parseCronOrNone, wantedNow } from './cron.mjs';
46
53
  import {
@@ -381,7 +388,7 @@ export async function patrolWorkspace(
381
388
  for (const [key, group] of taskGroups) {
382
389
  if (group.length !== 1) continue;
383
390
  const t = group[0];
384
- let ent = bucket[key] ?? null;
391
+ let ent = getOwn(bucket, key) ?? null;
385
392
  const logPath = sessionLogPath(ws, key);
386
393
  const ownerIsOurs = entryClaimed(ent)
387
394
  && typeof t.createdBySessionId === 'string'
@@ -476,9 +483,9 @@ export async function patrolWorkspace(
476
483
  // 盖印(跨轮老条目):已认亲且任务归属还没盖到本代预热(活创建者
477
484
  // 已在 (a) 排除)。当轮新 spawn 的会话在第三步之后的轮内等待里认
478
485
  // 亲,由 cmdRun 的 stampClaimed 补盖,不走这里。
479
- if (entryClaimed(ent)
480
- && (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
481
- stamps.set(key, reownTarget(ent));
486
+ if (entryClaimed(ent)) {
487
+ const target = reownTarget(ent);
488
+ if (!taskOwnedBy(t, target)) stamps.set(key, target);
482
489
  }
483
490
 
484
491
  // warm 未到回收 / spawning 宽限内:本轮无事。
@@ -496,15 +503,15 @@ export async function patrolWorkspace(
496
503
  }
497
504
 
498
505
  if (fails >= FAIL_LIMIT) {
499
- bucket[key] = {
506
+ const next = setOwn(bucket, key, {
500
507
  ...ent,
501
508
  ws,
502
509
  taskId: key,
503
510
  pid: null,
504
511
  fails,
505
512
  log: ent?.log ?? logPath,
506
- };
507
- enterCooldown(bucket[key], ws, key, fails, cur, stats);
513
+ });
514
+ enterCooldown(next, ws, key, fails, cur, stats);
508
515
  continue;
509
516
  }
510
517
 
@@ -520,7 +527,7 @@ export async function patrolWorkspace(
520
527
  scriptProcLogPath(procArgs) === logPath && deps.alive(procPid));
521
528
  if (adopted) {
522
529
  const [procPid] = adopted;
523
- bucket[key] = {
530
+ setOwn(bucket, key, {
524
531
  ws,
525
532
  taskId: key,
526
533
  pid: procPid,
@@ -528,7 +535,7 @@ export async function patrolWorkspace(
528
535
  procStart: deps.procStartedAt(procPid),
529
536
  fails: 0,
530
537
  log: logPath,
531
- };
538
+ });
532
539
  note('adopt', `接管 ${ws} 任务 ${key} pid=${procPid}:state 记录缺失,`
533
540
  + '但进程表里仍有本该任务的预热会话,重新纳入跟踪');
534
541
  pending.push({ ws, key, pid: procPid });
@@ -542,25 +549,25 @@ export async function patrolWorkspace(
542
549
  if (spawned?.pid == null) {
543
550
  const newFails = fails + 1;
544
551
  if (newFails >= FAIL_LIMIT) {
545
- bucket[key] = {
552
+ const next = setOwn(bucket, key, {
546
553
  ws, taskId: key, pid: null, fails: newFails, log: logPath,
547
- };
548
- enterCooldown(bucket[key], ws, key, newFails, cur, stats);
554
+ });
555
+ enterCooldown(next, ws, key, newFails, cur, stats);
549
556
  continue;
550
557
  }
551
- bucket[key] = {
558
+ setOwn(bucket, key, {
552
559
  ws,
553
560
  taskId: key,
554
561
  pid: null,
555
562
  startedAt: cur,
556
563
  fails: newFails,
557
564
  log: logPath,
558
- };
565
+ });
559
566
  note('fail', `启动失败 ${ws} 任务 ${key}:会话进程没有拉起`
560
567
  + `(连续失败计数 ${newFails})`);
561
568
  continue;
562
569
  }
563
- bucket[key] = {
570
+ setOwn(bucket, key, {
564
571
  ws,
565
572
  taskId: key,
566
573
  pid: spawned.pid,
@@ -568,7 +575,7 @@ export async function patrolWorkspace(
568
575
  procStart: spawned.procStart,
569
576
  fails,
570
577
  log: logPath,
571
- };
578
+ });
572
579
  note('spawn', `已启动 ${ws} 任务 ${key} pid=${spawned.pid}`
573
580
  + `(连续失败计数 ${fails})`);
574
581
  pending.push({ ws, key, pid: spawned.pid });
@@ -588,8 +595,9 @@ export async function patrolWorkspace(
588
595
  }
589
596
  throw e;
590
597
  }
591
- // 与 readTasks 不一致的极端竞态:保守不动,新 spawn 也全留跨轮路径。
592
- if (result === null) return [];
598
+ // 与 readTasks 不一致的极端竞态:维护保守不动,但本轮新 spawn 仍要进入
599
+ // 等待认亲与补盖流程;丢掉 pending 会把同轮收尾无故推迟到下一轮。
600
+ if (result === null) return pending;
593
601
  if (result.renewed) {
594
602
  deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
595
603
  + '不再受 7 天过期限制');
@@ -628,7 +636,7 @@ async function awaitWarmups(pending, state, cur, stats) {
628
636
  const recognize = () => {
629
637
  const rest = [];
630
638
  for (const p of pending) {
631
- const ent = state[p.ws]?.[p.key];
639
+ const ent = getOwn(state[p.ws], p.key);
632
640
  if (!ent || ent.pid !== p.pid) continue;
633
641
  if (!deps.trackedAlive(ent)) {
634
642
  // 启动即崩的典型窗口:真拉起过却死掉,计一次失败并置空 pid,条
@@ -696,10 +704,12 @@ function stampClaimed(claimedByWs, taskfiles, stats) {
696
704
  // 拉);state.json 已丢的旧孤儿 script(日志是旧命名,不含任务键段)从
697
705
  // 进程表兜底清扫。只在 loadLegacyEntries 非空(确在跨版本)时执行。
698
706
  async function retireLegacy(legacy, stats) {
707
+ let retired = true;
699
708
  for (const ent of legacy) {
700
709
  if (deps.trackedAlive(ent)) {
701
710
  const stopped = await deps.stopSession(ent);
702
711
  if (!stopped) {
712
+ retired = false;
703
713
  bump(stats, 'warn');
704
714
  deps.log(`旧版迁移暂缓 ${ent.ws} pid=${ent.pid}:记录的进程仍存活,`
705
715
  + '本次无法安全停止目录级预热会话;本次不记为已回收');
@@ -721,6 +731,7 @@ async function retireLegacy(legacy, stats) {
721
731
  if (!deps.trackedAlive(ent)) continue;
722
732
  const stopped = await deps.stopSession(ent);
723
733
  if (!stopped) {
734
+ retired = false;
724
735
  bump(stats, 'warn');
725
736
  deps.log(`旧版迁移暂缓 pid=${pid}:记录的进程仍存活,本次无法安全停止`
726
737
  + `目录级预热孤儿会话(日志 ${file});本次不记为已回收`);
@@ -730,6 +741,7 @@ async function retireLegacy(legacy, stats) {
730
741
  + '升级后按任务重新预热');
731
742
  }
732
743
  }
744
+ return retired;
733
745
  }
734
746
 
735
747
  // 跑一轮巡检;抢不到锁返回 false。
@@ -793,8 +805,9 @@ export async function cmdRun(args) {
793
805
  deps.log(`告警:${registryAlert}`);
794
806
  }
795
807
 
796
- // 旧版收尾必须先于一切 spawn 决策(state 轮末被新形状整体覆盖)。
797
- if (legacy.length > 0) await retireLegacy(legacy, stats);
808
+ // 旧版收尾必须先于一切 spawn 决策。停止失败时轮末保留原 state,下轮
809
+ // 才能继续重试;本轮新会话可由任务专属日志认领,不会因未落盘而重拉。
810
+ const legacyRetired = legacy.length === 0 || await retireLegacy(legacy, stats);
798
811
 
799
812
  // 轮初把上轮末仍相信活着的条目分成三组:活、resume 脱离(script 已
800
813
  // 死但 sid 还在,不报退出)、真死(立即报)。带 deadSince 的旧报条
@@ -865,7 +878,7 @@ export async function cmdRun(args) {
865
878
  for (const d of died) {
866
879
  bump(stats, 'exit');
867
880
  deps.log(exitLine(d.ws, d.key,
868
- { ...state[d.ws]?.[d.key], pid: d.pid }));
881
+ { ...getOwn(state[d.ws], d.key), pid: d.pid }));
869
882
  }
870
883
  stampClaimed(claimedByWs, taskfiles, stats);
871
884
  } catch (e) {
@@ -894,27 +907,23 @@ export async function cmdRun(args) {
894
907
  for (const [ws, bucket] of Object.entries(state)) {
895
908
  const out = {};
896
909
  for (const [taskId, ent] of Object.entries(bucket)) {
897
- if (deps.trackedAlive(ent)) {
898
- out[taskId] = ent;
899
- continue;
900
- }
901
- if (typeof ent.sessionId === 'string'
902
- && sidInDetails(ws, ent.sessionId)) {
903
- out[taskId] = ent;
904
- continue;
905
- }
906
- if (isCooling(ent, cur)) {
907
- out[taskId] = ent;
910
+ if (deps.trackedAlive(ent)
911
+ || (typeof ent.sessionId === 'string'
912
+ && sidInDetails(ws, ent.sessionId))
913
+ || isCooling(ent, cur)) {
914
+ setOwn(out, taskId, ent);
908
915
  continue;
909
916
  }
910
917
  if ((ent.fails ?? 0) > 0) {
911
918
  ent.deadSince ??= cur;
912
- if (cur - ent.deadSince < FAIL_TTL_SECONDS) out[taskId] = ent;
919
+ if (cur - ent.deadSince < FAIL_TTL_SECONDS) {
920
+ setOwn(out, taskId, ent);
921
+ }
913
922
  }
914
923
  }
915
- if (Object.keys(out).length > 0) kept[ws] = out;
924
+ if (Object.keys(out).length > 0) setOwn(kept, ws, out);
916
925
  }
917
- saveState(kept);
926
+ if (legacyRetired) saveState(kept);
918
927
 
919
928
  // 轮末汇总:一行交代这轮看见了什么、动了什么、花了多久,随后更新心跳。
920
929
  const actions = EVENT_LABELS
package/src/service.mjs CHANGED
@@ -292,7 +292,10 @@ function scriptPathDirs(scriptPath) {
292
292
  export function launcherHealth() {
293
293
  if (!fs.existsSync(deps.paths.plistPath)) return null;
294
294
  const args = readPlistProgramArgs();
295
- if (!args || args.length !== 1) return null;
295
+ if (!args || args.length !== 1) {
296
+ return `LaunchAgent ${deps.paths.plistPath} 的 ProgramArguments 无法解读,`
297
+ + '重跑 cron-up install 覆盖重生成';
298
+ }
296
299
  const [scriptPath] = args;
297
300
  if (!fs.existsSync(scriptPath)) {
298
301
  return `launchd 配置中的启动脚本已失效(${path.basename(scriptPath)} `
package/src/tasks.mjs CHANGED
@@ -235,6 +235,14 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
235
235
  fs.renameSync(tmp, file);
236
236
  }
237
237
 
238
+ // 任务归属是否已完全等于盖印目标。巡检预判与实际写入共用同一判据,
239
+ // 避免一层只看 sessionId、另一层比较三字段而逐渐漂移。
240
+ export function taskOwnedBy(t, target) {
241
+ return t.createdBySessionId === target.sessionId
242
+ && t.createdByPid === target.pid
243
+ && t.createdByProcStart === target.procStart;
244
+ }
245
+
238
246
  // 一轮巡检对一个任务文件做的维护:renew(补 permanent)与 reown(把任务
239
247
  // 归属盖给各自的预热会话)合并为同一次带 mtime 乐观检测的原子写。先取
240
248
  // mtimeNs、清残留 .cron-up-tmp、loadTaskDoc round-trip 整文档;两项计数
@@ -244,8 +252,8 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
244
252
  // reown 形状:Map<taskKey, {sessionId, pid, procStart}>。一轮内同一文件的
245
253
  // 不同任务可盖向不同预热(一任务一会话);内容相同但 id 不同的任务互不
246
254
  // 影响。同一个 taskKey 对应多条任务是病态输入,无法把 state/会话唯一对应
247
- // 到条目,这些键全部跳过盖印。只盖 createdBySessionId 不等于目标的任务,
248
- // 第二轮起字节级不动;pid/procStart 是登记里 claude 进程的 pid 与 lstart。
255
+ // 到条目,这些键全部跳过盖印。归属三字段未完全等于目标时才写,第二轮起
256
+ // 字节级不动;pid/procStart 是登记里 claude 进程的 pid 与 lstart。
249
257
  export function maintainWorkspace(taskfile, { reown = null } = {}) {
250
258
  let mtimeNs;
251
259
  try {
@@ -284,7 +292,7 @@ export function maintainWorkspace(taskfile, { reown = null } = {}) {
284
292
  const target = reown.get(key);
285
293
  // 没有 owner 字段的外部条目同样盖——窗口内任务统一归各自预热,语义
286
294
  // 不因条目来源留例外。
287
- if (target && t.createdBySessionId !== target.sessionId) {
295
+ if (target && !taskOwnedBy(t, target)) {
288
296
  t.createdBySessionId = target.sessionId;
289
297
  t.createdByPid = target.pid;
290
298
  // ps lstart 原文(含「Oct 8」双空格),原样取自会话登记。