cron-up 26.9.22 → 26.10.24

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  为命令行 AI agent 的 cron 定时任务**提前启动并保持一个可用的交互会话**。任务本身仍由 agent 自己的定时机制执行,cron-up 只负责保证:任务到点时,对应目录里已经有一个开着的会话。
4
4
 
5
5
  > [!WARNING]
6
- > cron-up 建立在 Claude Code **未公开文档的内部机制**之上(`.claude/scheduled_tasks.json` 文件格式、`~/.claude/sessions/*.json` 会话登记、定时任务只在交互界面里执行、周期任务创建满 7 天后失效、任务的 `permanent` 字段可豁免该失效、登记的会话进程其进程命令行可识别为 claude——原生安装是可执行名含 claude 的二进制,npm/bun 安装是解释器执行 claude 入口脚本)。这些行为可能随版本变化。会话保活已在 **Claude Code 2.1.278** 实测;7 天判定与 `permanent` 豁免的代码逻辑核对自 **2.1.280**(均为 macOS)。升级 Claude Code 后建议重新核对。
6
+ > cron-up 建立在 Claude Code **未公开文档的内部机制**之上(`.claude/scheduled_tasks.json` 文件格式、`~/.claude/sessions/*.json` 会话登记、定时任务只在交互界面里执行、周期任务创建满 7 天后失效、任务的 `permanent` 字段可豁免该失效、登记的会话进程其进程命令行可识别为 claude——原生安装是可执行名含 claude 的二进制,npm/bun 安装是解释器执行 claude 入口脚本)。这些行为可能随版本变化。会话保活已在 **Claude Code 2.1.278** 实测;7 天判定与 `permanent` 豁免的代码逻辑核对自 **2.1.280**;定时任务的投递抖动与任务 id 哈希算法(autoMinId 依赖它)逆向核对自 **2.1.285**(均为 macOS)。升级 Claude Code 后建议重新核对。
7
7
 
8
8
  ## 平台要求
9
9
 
@@ -29,7 +29,7 @@ cron-up 是一个由 launchd 每 5 分钟运行一次的巡检进程,逻辑只
29
29
  | 环节 | 由谁负责 |
30
30
  |---|---|
31
31
  | 任务登记 | Claude Code 原生命令 `CronCreate(durable: true)`,写入工作目录下的 `.claude/scheduled_tasks.json` |
32
- | 定时执行 | Claude Code 交互会话自身(包括 cron 时间计算、随机延迟、一次性任务执行后自动删除) |
32
+ | 定时执行 | Claude Code 交互会话自身(包括 cron 时间计算、按任务 id 确定的投递抖动——每日周期任务通常顶格延后约 30 分钟、一次性任务执行后自动删除;详见下「投递抖动」) |
33
33
  | 保证有会话在场 | launchd 每 5 分钟调用一次 `cron-up run`(经自有启动脚本拉起,PATH 指向安装时解析的 node bin 目录),运行完立即退出,不常驻 |
34
34
  | 周期任务续期 | 巡检时给周期任务补 `permanent: true`(默认开启、可关),使其不受 7 天过期限制;见下 |
35
35
  | 后台会话 | 通过 macOS 自带的 `script(1)` 启动无窗口交互界面,输出写入日志,可随时恢复 |
@@ -60,7 +60,19 @@ Claude Code 的周期任务在**创建满 7 天**后会末次执行、随即被
60
60
  cron-up install --force --no-auto-renew
61
61
  ```
62
62
 
63
- > 续期会**改写各工作目录的 `.claude/scheduled_tasks.json`**:原子写入、保留原权限与中文,只新增 `permanent` 一个字段,绝不改动任务的 prompt、cron 或 id。被续期的任务不再被自动删除,为它保活的后台会话也会因此**长期常驻**——这等于关闭了 Claude Code 对自主会话的寿命护栏。相关风险见「安全说明」。
63
+ ### 自动把任务 id 改小以消除投递延迟(默认开启)
64
+
65
+ 投递延迟完全由任务 id 的哈希决定(见下节),而 id 就是 8 位十六进制字符串——cron-up 默认在每轮巡检时把周期任务里哈希过大的 id 改写成小号(`00000001` 起),把每日任务的投递延迟压到 **1 分钟以内**(id 够小时观感就是准点)。规则与自动续期同样保守:
66
+
67
+ - 只改显式**周期**任务,一次性任务不碰;只在同一任务文件内分配未被占用的序号,已够小的 id(含 `00000000`)原样保留、**绝不重排**,所以幂等——首轮改完,之后巡检字节级不动文件;
68
+ - 与续期合并为同一次原子写,任务文件在「巡检读到」与「写回」之间被 Claude Code 改动时整轮跳过、下轮再来,不会覆盖它写入的 `lastFiredAt`;
69
+ - 生效时机:window 模式(默认)下任务执行完即回收会话、下个窗口提前 10 分钟重拉,**下次触发的新会话就读到小 id**;always 模式常驻会话需换代(或手动重启)后才生效。
70
+
71
+ 关闭:配置 `autoMinId: false`(下轮巡检生效),或 `cron-up install --force --no-auto-min-id`。`cron-up renew` 会无视开关、立即把所有周期任务补 permanent 并改小 id。
72
+
73
+ > 这是在利用逆向出来的内部行为:id 哈希算法不是官方承诺的接口,Claude Code 若更换算法(改用完整 id、混入创建时间、加 salt 等),小 id 会失效。届时把 `autoMinId` 关掉、改回「cron 往前提 30 分钟」即可;排查见 findings 主题 `claude-code-scheduled-task-jitter`。另注意:改小后**不要再在交互会话里用 CronCreate 重建这些任务**,重建会生成新的随机大 id,需要等下轮巡检再改回来。
74
+
75
+ > 续期(autoRenew)会**改写各工作目录的 `.claude/scheduled_tasks.json`**:原子写入、保留原权限与中文,只新增 `permanent` 一个字段,不改动 prompt、cron 或 id(id 改小是上一节 autoMinId 的独立动作)。被续期的任务不再被自动删除,为它保活的后台会话也会因此**长期常驻**——这等于关闭了 Claude Code 对自主会话的寿命护栏。相关风险见「安全说明」。
64
76
 
65
77
  安装产物:
66
78
 
@@ -71,6 +83,7 @@ cron-up install --force --no-auto-renew
71
83
  | 启动脚本 | `~/.local/share/cron-up/cron-up-service`(launchd 拉起的入口,PATH 里写有 node 与 cron-up 所在的 bin 目录;后台列表显示名即来自它) |
72
84
  | 配置文件 | `~/.local/share/cron-up/config.json` |
73
85
  | 运行状态 | 同目录下的 `state.json` |
86
+ | 巡检心跳 | 同目录下的 `heartbeat`(每轮成功跑完写一次,总览页据此判断巡检是否在按时跑) |
74
87
  | 日志 | `~/Library/Logs/cron-up/`(巡检日志;各后台会话的界面记录在 `sessions/` 子目录下,该目录权限 700、日志文件 600) |
75
88
 
76
89
  ### 安装前的三个前提
@@ -83,9 +96,9 @@ cron-up install --force --no-auto-renew
83
96
 
84
97
  ```bash
85
98
  cron-up # 总览:服务、配置、最近任务、保活会话明细,以及需要留意的告警
86
- cron-up list # 逐任务列出:下次执行时间、周期/一次性、cron 表达式、内容摘要、会话状态
99
+ cron-up list # 逐任务列出:设定/预计实际触发时间、周期/一次性、cron 表达式、内容摘要、会话状态
87
100
  cron-up run # 立即手动巡检一次(与定时巡检互斥,同时运行时后到者自动退出)
88
- cron-up renew # 立即给所有周期任务补 permanent(手动执行,不受 autoRenew 开关影响)
101
+ cron-up renew # 立即给所有周期任务补 permanent 并改小 id(手动执行,不受两个开关影响)
89
102
  cron-up logs # 查看巡检日志(stderr 有内容时在开头提示一行)
90
103
  cron-up logs <目录名片段> -f # 实时查看某个后台会话的界面输出
91
104
  cron-up uninstall # 停止并卸载服务,结束后台保活会话,保留配置和日志
@@ -101,7 +114,23 @@ npm i -g cron-up@latest
101
114
 
102
115
  launchd 每轮经启动脚本从 PATH 重新解析 cron-up 与 node,npm 替换包文件后下一轮巡检自然跑新版,无需重载服务;配置与任务文件都不动。
103
116
 
104
- `cron-up list` 按工作区分组,每个任务一行:状态列可能是下次执行时间、`已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「启动脚本的 PATH 里解析不到 cron-up 或 node」「会话登记格式疑似变更」等需要处理的情况集中列在「需要留意」里。
117
+ `cron-up list` 按工作区分组,每个任务一行:状态列对有效任务显示「设定落点 → 预计实际触发」(机制见下节「投递抖动」,无抖动时只显示一个时间,非整分抖动带秒),异常时显示 `已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,「最近」一行按预计实际触发时间排序;总览还把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「启动脚本的 PATH 里解析不到 cron-up 或 node」「会话登记格式疑似变更」等需要处理的情况集中列在「需要留意」里。
118
+
119
+ ## 投递抖动:设定时间与实际触发时间
120
+
121
+ Claude Code 的任务**并不在 cron 落点投递**,会话内部的调度器会加一个按任务 id 确定的偏移(不是随机数,同一任务每次触发的偏移相同;会话秒级轮询,实际到点有秒级尾差):
122
+
123
+ - **周期任务**:实际触发 = 落点 + min( `hash(id)` × 0.5 × 周期 , 30 分钟 ),其中 `hash(id)` 是任务 id 前 8 位十六进制除以 2³²。每日任务里约 96% 的 id 会撞 30 分钟上限——所以日报类任务几乎必然固定晚 30 分钟;周/月等长周期同样顶格 30 分钟。
124
+ - **一次性任务**:落点分钟是 :00/:30 时提前 0~90 秒(同样按 id 哈希缩放),其余分钟不抖动。
125
+
126
+ 两种办法让任务准点:
127
+
128
+ 1. **用 cron-up 的自动改小 id(默认开启,推荐)**:id 改小后周期延迟降到 1 分钟内,cron 设几点就几点到,不用再心算补偿——见上文「自动把任务 id 改小以消除投递延迟」。
129
+ 2. **把 cron 往前提 30 分钟**(不用 cron-up 或关掉 autoMinId 时):如要 0 点到就设 `30 23 * * *`。
130
+
131
+ `cron-up list` 的「设定 → 预计」列就是按上式算的;id 已被改小后延迟不足 1 分钟,该列只显示一个时间。
132
+
133
+ 这套参数逆向自 Claude Code v2.1.285,**没有稳定性承诺**:版本升级可能调整,运行时还可能被远程配置键 `tengu_kairos_cron_config` 覆盖。若发现实际到点与预计时间系统性偏离,说明上游算法已变,排查步骤与验算脚本见 findings 主题 `claude-code-scheduled-task-jitter`。
105
134
 
106
135
  ## 使用定时任务时的三个约定
107
136
 
@@ -126,24 +155,26 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
126
155
 
127
156
  - **修改扫描范围**:直接编辑配置文件的 `roots`、`maxDepth`,最多 5 分钟后的下一轮巡检自动生效,无需重新加载服务。
128
157
  - **开关自动续期**:编辑配置文件的 `autoRenew`(`true`/`false`)同样在下一轮巡检生效;或运行 `cron-up install --force --auto-renew`(`--no-auto-renew` 关闭)。想立即处理一次而不改长期开关,用 `cron-up renew`。
158
+ - **开关自动改小 id**:配置项 `autoMinId`,命令行 `cron-up install --force --auto-min-id` / `--no-auto-min-id`,语义与自动续期完全对称。
129
159
  - **修改巡检间隔或提前量**:重新运行 `cron-up install --force --interval 600`,会重新生成 launchd 配置。`--force` 只更新命令行里显式给出的字段,其余配置(如 `roots`)保留;不带 `--force` 时配置原样保留,命令行显式给的参数会被忽略并在安装输出里点名提示;路径参数建议都加引号书写,例如 `--roots "$HOME/code,$HOME/work"`,避免 shell 对逗号后的波浪号不展开。
130
160
  - **升级**:`npm i -g cron-up@latest`,下一轮巡检自然跑新版。fnm/volta/Homebrew 下切换默认 Node 版本同样自动跟随(在新版本里装过一次 `npm i -g cron-up` 即可,忘了装总览会提示),全程无需重跑 install;nvm 等按版本目录安装的形态绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切到新版本。版本号与发布流程见下文「发布」一节。
131
161
  - **配置校验**:手改配置时字段类型写错(`roots` 不是数组、数值字段写成字符串或布尔、`autoRenew` 不是布尔)不会静默回退默认值——巡检会以退出码 2 响亮失败并在总览页列出具体字段,修好或重跑 `cron-up install --force`(坏字段回默认,其余保留)。软问题只警告不中止:提前量小于间隔(可能来不及在任务前启动会话)、扫描目录不存在。无参数的 `cron-up` 总览随时也会显示这些问题;其中 interval 显示的是 launchd **实际生效**的间隔,手动改过 config 但与实际不一致时会提示需要重新 install。
132
162
  - **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
133
163
  - **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,在写入配置之前就被响亮拒绝,非法值不会落盘。
134
- - **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。效果:**错开够远(约十分钟、且含上一次任务的执行时长)的任务必然各自拿到全新会话**,稍近的可能共享(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
164
+ - **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。「需要会话」按计入投递抖动(见上节)的**预计实际触发时刻**计算:某任务的 cron 落点已过、但还没到它的延迟投递点的这段尾窗(落点后最多 30 分钟)里会话照样保留,尾窗内若会话意外死亡也会立即重拉——否则同目录两个落点相近的任务,较早的执行完后会话被回收,较晚的还没到投递点就会漏跑(周期任务没有补执行)。效果:**错开约 30 分钟以上(已含投递抖动、静默判定与余量)的任务才必然各自拿到全新会话**,更近的共享同一会话(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
135
165
  - **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
136
166
  - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;同目录当时还有你本人打开的会话时除外,见「已知限制」)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
137
- - **日志大小**:后台会话的界面输出是持续的全屏渲染流。`script(1)` 每次启动会清空旧日志,因此每次拉起该目录的会话时,上一代的界面记录先轮转为 `.log.1`(只保留一代,总量上界两代)——否则 window 模式下上一次任务执行的记录会随下一代拉起被抹掉。活着的一代日志总量由会话生命周期约束(window 模式一代即一次执行;`always` 模式由 7 天换代约束),唯一无法覆盖的是 `always` 模式下连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)只在有事件时才有输出(安静的一轮巡检一行都不写),超过 1MB 时同样轮转为 `.1`,只保留上一份。
167
+ - **日志大小**:后台会话的界面输出是持续的全屏渲染流。`script(1)` 每次启动会清空旧日志,因此每次拉起该目录的会话时,上一代的界面记录先轮转为 `.log.1`(只保留一代,总量上界两代)——否则 window 模式下上一次任务执行的记录会随下一代拉起被抹掉。活着的一代日志总量由会话生命周期约束(window 模式一代即一次执行;`always` 模式由 7 天换代约束),唯一无法覆盖的是 `always` 模式下连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)每轮固定两行心跳(`巡检开始…`/`巡检结束…`,后者带「发现 N 个工作区、活动交互会话 N 个、动作汇总与耗时」),事件行只在有事发生时才打:启动、主动回收、换代、卡死、冷却、接管孤儿、启动失败(含 spawn 异步错误原因)、会话非巡检主动结束(带存活时长)、登记格式漂移告警。每轮都有心跳,所以 launchd 配置没坏但就是没在跑时,从日志断档和总览页都能发现;距上轮超过两个间隔还会提示可能漏轮(常见于电脑休眠后 launchd 合并错过的间隔)。超过 1MB 时同样轮转为 `.1`,只保留上一份。
138
168
  - **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有三重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、或 interactive 登记的 pid/工作目录字段集体无法解读时,「需要留意」会明确提示登记格式疑似变更;看到这条后,在重新核对目录结构之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
169
+ - **巡检心跳与沉默告警**:每轮巡检成功跑完都会刷新 `~/.local/share/cron-up/heartbeat`。总览页的巡检区显示「上次巡检:N 分钟前」;launchd 显示已加载、但心跳超过两个轮次间隔没刷新时,「需要留意」会报「巡检已沉默」——启动脚本静态检查全绿但运行期拉不起来(或轮次持续崩在半道)只有靠它发现。轮首发现距上轮过久也会在巡检日志里记一条漏轮提示(电脑休眠后 launchd 会合并非错过的间隔,醒来只补一轮,属正常现象)。
139
170
 
140
171
  ## 安全说明
141
172
 
142
- - 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions/`(判断目录里是否已有会话)与 roots 下的任务文件,并写入自己的配置、状态与日志目录。**开启 autoRenew(默认)时,它还会改写 roots 下各工作目录的 `.claude/scheduled_tasks.json`**:在同目录写临时文件(先按 0600 创建)再原子替换、保留原文件权限(写一半被杀留下的临时文件,下一轮巡检会顺手清掉),且只给周期任务新增 `permanent: true`,绝不改动 prompt、cron、id;每个任务只在首次打标时写一次,之后幂等不再重写。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再续(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
173
+ - 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions/`(判断目录里是否已有会话)与 roots 下的任务文件,并写入自己的配置、状态与日志目录。**开启 autoRenew 或 autoMinId(两者默认都开)时,它还会改写 roots 下各工作目录的 `.claude/scheduled_tasks.json`**:在同目录写临时文件(先按 0600 创建)再原子替换、保留原文件权限(写一半被杀留下的临时文件,下一轮巡检会顺手清掉)。改写只针对显式周期任务、只动两个字段——补 `permanent: true`(autoRenew)、把哈希过大的 `id` 换成文件内未占用的小号(autoMinId),**绝不改动 prompt 与 cron,也不碰一次性任务**;两项都幂等,每个任务只在首次需要时写一次,之后字节级不再重写。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再来(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
143
174
  - **续期会改变三条生命周期/边界**:
144
175
  1. 被打 `permanent` 的周期任务不再被 Claude Code 在创建满 7 天后删除,cron-up 为它保活的后台会话随之**长期常驻**,不再随任务过期被回收——这等于主动关闭了 Claude Code 对自主会话的寿命护栏。
145
176
  2. 7 天上限本也是一道**自动淘汰**防线:一个你没留意、随 `git pull` 或他人提交进入工作目录的周期任务,原本最多存活 7 天;autoRenew 会无差别把它也永久化。因此**不要把他人可写、或你不完全掌控的仓库纳入 roots**(与下一条任务文件即执行输入的警告同理);拉取外部代码后先检查 `.claude/scheduled_tasks.json` 里有没有意料之外的周期任务,必要时先关闭 autoRenew。
146
- 3. 若某个仓库把 `.claude/scheduled_tasks.json` 纳入了版本控制,首次续期会让该文件因新增 `"permanent": true`(及按统一的两空格缩进排版)出现 diff,提交前请知悉;本机自用仓通常已在 `.git/info/exclude` 忽略它,不受影响。
177
+ 3. 若某个仓库把 `.claude/scheduled_tasks.json` 纳入了版本控制,首轮自动维护会让该文件出现 diff——新增 `"permanent": true`、把任务 `id` 改写成小号(autoMinId),外加统一的两空格缩进排版,提交前请知悉;本机自用仓通常已在 `.git/info/exclude` 忽略它,不受影响。
147
178
  - **任务文件本身就是执行输入,而不只是数据**:任何能在 roots 下某个已信任目录落盘 `.claude/scheduled_tasks.json` 的主体,等于登记了一条定时指令——最快 5 分钟后,cron-up 就会为该目录拉起一个以你的身份运行、自动执行其中 prompt 的会话。这包括一次普通的 `git pull`(任务文件随提交进入工作树)和共享仓库的协作者。因此不要把他人可写仓库的父目录放进 roots;拉取外部代码后若发现 `.claude/scheduled_tasks.json` 被新增或改动,先看内容再让它留在那里。
148
179
  - 会话日志可能包含任务执行过程和输出,默认仅本人可读(600),对外分享或截图前请留意内容。
149
180
  - 巡检、保活与续期都不发起网络请求,也不依赖任何第三方 npm 包;唯一的网络活动发生在**安装与升级**,即 npm 访问 registry.npmjs.org 拉取包本身(由你本机的 npm 配置与凭证负责)。除此之外的外部通信全部来自被启动的 agent 会话。
@@ -152,6 +183,7 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
152
183
 
153
184
  - 仅支持 macOS。电脑休眠期间错过的周期任务不会补执行;唤醒后 launchd 会立即跑一轮巡检,错过的**一次性**任务会在后台会话启动时由 Claude Code 补执行。
154
185
  - Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
186
+ - autoMinId 改小 id 依赖逆向出来的投递哈希算法(见「投递抖动」),不是官方承诺的接口:新版 Claude Code 若更换算法,小 id 不再能保证准点——届时关掉 autoMinId、改用「cron 往前提 30 分钟」即可,`list` 的预计时间与 findings 的验算脚本可用来发现这种漂移。此外在交互会话里用 `CronCreate` 重建任务会生成新的随机大 id(需等下轮巡检再改回),改时间请直接编辑 cron 字段而不是重建。
155
187
  - 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」,同目录多会话还有重复执行的风险。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。真正的并发任务级隔离需要 Claude Code 官方支持任务路由。
156
188
  - 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
157
189
  - 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
@@ -169,6 +201,8 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
169
201
  | 总览提示「启动脚本已失效 / 内容无法解读」 | 启动脚本(`~/.local/share/cron-up/cron-up-service`)被删或手改,重跑 `cron-up install` 覆盖重生成 |
170
202
  | 总览提示「PATH 目录里找不到 cron-up」 | 切换了默认 Node 版本,新版本里还没装包:在新版本里 `npm i -g cron-up` 即可 |
171
203
  | 总览提示「PATH 目录里找不到 node」 | 启动脚本 PATH 指向的版本目录被删或移动(多见于手动删除 nvm/fnm 版本),重跑 `cron-up install` 重新解析 |
204
+ | 总览提示「巡检已沉默」 | launchd 显示已加载但最近没有成功跑完的轮次:先 `cron-up logs` 看轮次心跳是否断档、`launchd.err.log` 有没有崩溃记录,再对照「启动脚本已失效/PATH 找不到 node」两条;都正常则手动 `cron-up run` 验证,必要时重跑 install |
205
+ | 日志里出现「会话退出 … 非巡检主动结束」 | 会话自行崩溃或被外部终止(不是 cron-up 的回收/换代/卡死流程)。偶发且下轮自动重拉可忽略;同一目录反复出现时,按提示 `cron-up logs <目录名>` 看该会话的启动与退出过程 |
172
206
  | 日志里出现「卡死」 | 会话进程起来了但没能变成可用会话,绝大多数是停在信任确认或授权提问。先手动 `cd <目录> && claude` 走完确认,再等下一轮巡检 |
173
207
  | 日志里出现「任务文件读不出 \<目录\>」 | 该目录的 `.claude/scheduled_tasks.json` 存在却解析不了(格式损坏),巡检跳过这一个目录,**其中的定时任务一个都不会执行**;其余目录照常。检查该文件 |
174
208
  | 日志里出现「错误 \<目录\>」 | 巡检该目录时抛出未预期的异常,已按目录隔离、不影响其余目录。这通常意味着 cron-up 自身的 bug,请带上该行反馈 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.9.22",
3
+ "version": "26.10.24",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -12,7 +12,7 @@ import { cmdOverview } from './overview.mjs';
12
12
  import { cmdLogs } from './logs.mjs';
13
13
 
14
14
  // 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无值
15
- // 开关;tri 支持 --name / --no-name(install 的 auto-renew)。
15
+ // 开关;tri 支持 --name / --no-name(install 的 auto-renew、auto-min-id)。
16
16
  const SPECS = {
17
17
  install: {
18
18
  options: {
@@ -21,6 +21,7 @@ const SPECS = {
21
21
  lead: 'int',
22
22
  force: 'bool',
23
23
  'auto-renew': 'tri',
24
+ 'auto-min-id': 'tri',
24
25
  'session-retain': 'string',
25
26
  },
26
27
  },
@@ -160,11 +161,11 @@ export const USAGE = `cron-up —— 为定时任务提前备好交互会话
160
161
 
161
162
  用法:
162
163
  cron-up 查看总览(服务、配置、任务、会话)
163
- cron-up install 安装配置与 LaunchAgent(--roots --interval --lead --session-retain --force --no-auto-renew)
164
+ cron-up install 安装配置与 LaunchAgent(--roots --interval --lead --session-retain --force --no-auto-renew --no-auto-min-id)
164
165
  cron-up uninstall 卸载(--purge 连配置日志一起删,--keep-sessions 保留后台会话)
165
- cron-up list 逐个列出定时任务及下次执行时间
166
+ cron-up list 逐个列出定时任务的设定/预计实际触发时间
166
167
  cron-up run 执行一轮巡检(launchd 入口)
167
- cron-up renew 立即给所有周期任务补 permanent
168
+ cron-up renew 立即给所有周期任务补 permanent 并改小 id
168
169
  cron-up logs [目录片段] 查看巡检日志或后台会话记录(-f 跟踪)
169
170
 
170
171
  升级:npm i -g cron-up@latest`;
package/src/config.mjs CHANGED
@@ -70,6 +70,10 @@ export function configFieldErrors(cfg) {
70
70
  errs.push(['autoRenew',
71
71
  `autoRenew 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.autoRenew)}`]);
72
72
  }
73
+ if (typeof cfg.autoMinId !== 'boolean') {
74
+ errs.push(['autoMinId',
75
+ `autoMinId 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.autoMinId)}`]);
76
+ }
73
77
  if (cfg.sessionRetain !== 'window' && cfg.sessionRetain !== 'always') {
74
78
  errs.push(['sessionRetain',
75
79
  `sessionRetain 应是 "window" 或 "always",当前为 ${JSON.stringify(cfg.sessionRetain)}`]);
@@ -183,6 +187,24 @@ export function saveState(state) {
183
187
  atomicSave(deps.paths.statePath, state);
184
188
  }
185
189
 
190
+ // 巡检心跳:每轮成功跑完覆盖写一次。轮首读它发现漏轮(距上次超过两个间
191
+ // 隔),总览页读它报「服务在但巡检没在跑」。缺失/损坏/形状不对一律读作
192
+ // 没有心跳——旧版本升级后的首轮、与总览读到半截文件都按此降级。
193
+ export function loadHeartbeat() {
194
+ let doc;
195
+ try {
196
+ doc = JSON.parse(fs.readFileSync(deps.paths.heartbeatPath, 'utf-8'));
197
+ } catch {
198
+ return null;
199
+ }
200
+ if (!isPlainObject(doc) || !Number.isFinite(doc.ranAt)) return null;
201
+ return doc;
202
+ }
203
+
204
+ export function saveHeartbeat(doc) {
205
+ atomicSave(deps.paths.heartbeatPath, doc);
206
+ }
207
+
186
208
  export function saveConfig(cfg) {
187
209
  atomicSave(deps.paths.configPath, cfg);
188
210
  }
package/src/constants.mjs CHANGED
@@ -14,6 +14,12 @@ export const DEFAULT_CONFIG = {
14
14
  // 全部补标。配置里 autoRenew:false 可关(下轮生效),或安装时
15
15
  // --no-auto-renew。
16
16
  autoRenew: true,
17
+ // 每轮巡检把周期任务的 id 改写成小哈希值,把投递抖动压到
18
+ // AUTO_MIN_ID_TARGET_DELAY_MS 以内(每日任务约 1 分钟),观感近乎准点。
19
+ // 只改显式周期任务、只分配文件内空号、幂等。配置 autoMinId:false 可关,
20
+ // 或安装时 --no-auto-min-id。机制见 CC_JITTER 与 findings 主题
21
+ // claude-code-scheduled-task-jitter。
22
+ autoMinId: true,
17
23
  // 会话保留策略:'window'(默认)——会话生命周期对齐「一次执行」:任务
18
24
  // 触发过、执行完毕且距下次需要还有余量即回收,下次窗口重拉,任务之间
19
25
  // 上下文不互相累积;'always'——旧常驻行为,执行间隔里也保留会话,仅受
@@ -49,9 +55,12 @@ export const SESSION_RECYCLE_IDLE_SECONDS = 180;
49
55
  // 回收后同轮重拉的新会话约 10 秒完成登记,余量保的是登记抖动;更近的触
50
56
  // 发直接复用现有会话(时间紧邻的任务共享)。
51
57
  export const SESSION_FIRE_MARGIN_SECONDS = 120;
52
- // 巡检日志每轮几行,但跑几年的机器也该有上限。launchd 每次按路径重开,
53
- // 轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
58
+ // 巡检日志每轮两行心跳加零星事件,跑几年的机器也该有上限。launchd 每次按
59
+ // 路径重开,轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
54
60
  export const PATROL_LOG_ROTATE_BYTES = 1024 * 1024;
61
+ // 心跳沉默多少倍轮次间隔算异常:轮首据此提示漏轮(电脑休眠时 launchd 会
62
+ // 合并错过的间隔,醒来只补一轮),总览页据此报「服务在但巡检没在跑」。
63
+ export const HEARTBEAT_STALE_FACTOR = 2;
55
64
 
56
65
  // 不进入这些目录:VCS/依赖内部与 macOS 家目录系统文件夹——agent 工作区不
57
66
  // 会在里面,光一个 ~/Library 就足以让家目录扫描爆炸。
@@ -62,6 +71,33 @@ export const PRUNE_DIRS = new Set([
62
71
  '.cache', '.npm', '.local', '.config',
63
72
  ]);
64
73
 
74
+ // Claude Code 会话投递定时任务时的抖动参数,两处使用:list/总览预测
75
+ // 「预计实际触发时间」;巡检把「落点之后、延迟投递点之前」这段投递尾窗
76
+ // 也算作需要会话在场,避免 window 模式在任务真正投递前回收会话。逆向自
77
+ // Claude Code v2.1.285
78
+ // (darwin-arm64 原生二进制,2026-09-30 安装)内部 createCronScheduler 的
79
+ // gV 默认值:周期任务 fire = 落点 + min(hash(id)·recurringFrac·周期,
80
+ // recurringCapMs);一次性任务在 :00/:30 网格点提前 0~oneShotMaxMs。
81
+ // 这些值没有稳定性承诺——版本升级可能改,运行时还可能被远程配置键
82
+ // tengu_kairos_cron_config(60 秒刷新,校验失败回退默认)覆盖。预测与
83
+ // 实际系统性对不上时,按 findings 主题
84
+ // claude-code-scheduled-task-jitter 的排查步骤复核新版本。
85
+ export const CC_JITTER = {
86
+ recurringFrac: 0.5,
87
+ recurringCapMs: 30 * 60 * 1000,
88
+ oneShotMaxMs: 90 * 1000,
89
+ oneShotFloorMs: 0,
90
+ oneShotMinuteMod: 30,
91
+ };
92
+
93
+ // autoMinId 改小 id 的目标投递延迟上界。周期延迟 = hash(id)·frac·周期,
94
+ // 按每日周期(86400000ms,周期最短、要求最严)反解 id 数值上界:
95
+ // maxN = floor(目标延迟 / (frac·每日ms) · 2^32) ≈ 5_965_232 (0x005b05b0)
96
+ // id 数值不超过它的每日任务延迟 ≤ 60 秒;周/月任务周期更长,同阈值下延迟
97
+ // 更小。改小逻辑(src/tasks.mjs 的 minifyIds)从这里与 CC_JITTER 现算
98
+ // maxN,常量只保留「目标延迟」这一个可调项。
99
+ export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
100
+
65
101
  // 定时任务 7 天过期(周期任务被调度器删除,一次性任务早就触发过),巡检
66
102
  // 不需要看得更远。展示页另算。
67
103
  export const SEARCH_DAYS = 7;
package/src/cron.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // 分钟级 cron 解析与下一次触发计算。字段全是别的程序写的,畸形表达式在
2
2
  // 边界处返回 null/不可满足,绝不抛进巡检循环。
3
3
 
4
- import { SEARCH_DAYS } from './constants.mjs';
4
+ import { SEARCH_DAYS, DISPLAY_SEARCH_DAYS, CC_JITTER } from './constants.mjs';
5
5
 
6
6
  const MS_DAY = 86400_000;
7
7
  const MS_MINUTE = 60_000;
@@ -113,6 +113,42 @@ export class Cron {
113
113
  }
114
114
  return null;
115
115
  }
116
+
117
+ // 不晚于 t 所在分钟的最近一次匹配(含当前分钟);withinDays 天前还没有
118
+ // 则 null。与 nextAfter 对称做整月/整小时快进,366 天窗口也便宜。用于
119
+ // 判断「现在是否落在某次触发的投递延迟尾窗里」。
120
+ prevAtOrBefore(t, withinDays = SEARCH_DAYS) {
121
+ if (!this.satisfiable) return null;
122
+ const cur = new Date(t.getTime());
123
+ cur.setSeconds(0, 0);
124
+ const deadline = cur.getTime() - withinDays * MS_DAY;
125
+ while (cur.getTime() >= deadline) {
126
+ if (!this.months.has(cur.getMonth() + 1)) {
127
+ // 退到上个月最后一分钟:置本月 1 号 00:00 再退 1 分钟。
128
+ cur.setDate(1);
129
+ cur.setHours(0, 0, 0, 0);
130
+ cur.setMinutes(cur.getMinutes() - 1);
131
+ continue;
132
+ }
133
+ if (!this.hours.has(cur.getHours())) {
134
+ // 退到严格更早的最近匹配小时的 59 分;当天已无更早匹配则落到前一
135
+ // 天的最大匹配小时(小时集合每天相同,satisfiable 保证其非空)。
136
+ let h = cur.getHours() - 1;
137
+ while (h >= 0 && !this.hours.has(h)) h -= 1;
138
+ if (h < 0) {
139
+ const maxH = Math.max(...this.hours);
140
+ cur.setDate(cur.getDate() - 1);
141
+ cur.setHours(maxH, 59, 0, 0);
142
+ } else {
143
+ cur.setHours(h, 59, 0, 0);
144
+ }
145
+ continue;
146
+ }
147
+ if (this.matches(cur)) return cur;
148
+ cur.setMinutes(cur.getMinutes() - 1);
149
+ }
150
+ return null;
151
+ }
116
152
  }
117
153
 
118
154
  // CronCreate 产生的一次性任务形状像 'M H DoM Mon *',作为没有 recurring
@@ -168,6 +204,18 @@ function wanted(cron, task, now, leadMs) {
168
204
  span,
169
205
  );
170
206
  if (first && first.getTime() <= now.getTime()) return true;
207
+ return false;
208
+ }
209
+ // 周期任务:投递不是在落点,而是落点 + 按 id 确定的抖动(每日任务通常
210
+ // 30 分)。落点已过、抖动投递时刻未到的这段「投递尾窗」里会话仍必须在
211
+ // 场——否则 window 模式会把执行完近邻任务后空闲下来的会话回收且不重
212
+ // 拉,让本次触发漏跑(周期任务没有补执行)。找上一落点用 366 天窗口,
213
+ // 覆盖月/年任务;prevAtOrBefore 有日历快进,不贵。
214
+ const prev = cron.prevAtOrBefore(now, DISPLAY_SEARCH_DAYS);
215
+ if (prev !== null) {
216
+ const fireAt = prev.getTime()
217
+ + recurringDelayMs(cron, prev, task.id, DISPLAY_SEARCH_DAYS + 1);
218
+ if (now.getTime() < fireAt) return true;
171
219
  }
172
220
  return false;
173
221
  }
@@ -181,3 +229,55 @@ export function taskWanted(task, now, leadMs) {
181
229
 
182
230
  // wanted 供展示视图复用(同一个已解析 cron 顺带回答巡检问题)。
183
231
  export { wanted };
232
+
233
+ // ---- Claude Code 投递抖动(预计实际触发时间)----
234
+ // 复刻 v2.1.285 内部的 M():任务 id 前 8 位十六进制除以 2^32,得 [0,1) 的
235
+ // 固定哈希——同一任务每次触发的抖动完全相同,不是随机数。id 缺失或前 8
236
+ // 位都不是十六进制时 parseInt 得 NaN,上游回退 0;任务文件由别的程序书
237
+ // 写,这里同样永不抛。
238
+ export function taskIdHash(taskId) {
239
+ if (typeof taskId !== 'string') return 0;
240
+ const n = parseInt(taskId.slice(0, 8), 16) / 2 ** 32;
241
+ return Number.isFinite(n) ? n : 0;
242
+ }
243
+
244
+ // 任务按 Claude Code 的抖动规则预计的实际触发时刻;cron 无效、不可满足或
245
+ // 窗口内没有落点时返回 null。基准为 now(上游调度器以上次触发时刻为基
246
+ // 准,展示只关心从现在起的下一次,两者算出的落点一致)。
247
+ export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
248
+ const cron = parseCronOrNone(task?.cron);
249
+ if (cron === null || !cron.satisfiable) return null;
250
+ const i = cron.nextAfter(now, withinDays);
251
+ if (i === null) return null;
252
+ const h = taskIdHash(task?.id);
253
+
254
+ if (taskIsOneshot(task)) {
255
+ // 复刻 Wsn():只有落点分钟落在 :00/:30 网格上才提前一个按哈希缩放的
256
+ // 小量(0~90 秒),其余分钟整点不抖;提前量不越过 now。
257
+ if (i.getMinutes() % CC_JITTER.oneShotMinuteMod !== 0) return i;
258
+ const ahead = CC_JITTER.oneShotFloorMs
259
+ + h * (CC_JITTER.oneShotMaxMs - CC_JITTER.oneShotFloorMs);
260
+ return new Date(Math.max(i.getTime() - ahead, now.getTime()));
261
+ }
262
+
263
+ // 复刻 bOt():抖动按「本落点到下一落点」的周期比例缩放并封顶。nextAfter
264
+ // 语义是严格晚于入参(内部进位一分钟),传 slot 本身即取下一落点;窗口
265
+ // 边缘取不到时与上游一致:视为无后续,不抖动。
266
+ return new Date(i.getTime() + recurringDelayMs(cron, i, task?.id, withinDays));
267
+ // 注:上游 bOt 另有一个 cacheLeadMs 分支——仅当相邻落点间隔落在
268
+ // [300000, 315000)ms(约 5 分钟周期的高频 cron,如 */5)且表达式匹配
269
+ // 其内部步进正则时,调度时刻提前 15 秒。日/周/月任务周期远大于此,不
270
+ // 经过该分支,故不予复刻。
271
+ }
272
+
273
+ // 周期任务从某个落点起的投递延迟(ms):hash(id)·frac·周期,封顶 30 分。
274
+ // slot 后取不到下一落点(窗口边缘、不可满足)时返回 0。
275
+ export function recurringDelayMs(cron, slot, taskId, withinDays = SEARCH_DAYS) {
276
+ const next = cron.nextAfter(new Date(slot.getTime()), withinDays);
277
+ if (next === null) return 0;
278
+ const period = next.getTime() - slot.getTime();
279
+ return Math.min(
280
+ taskIdHash(taskId) * CC_JITTER.recurringFrac * period,
281
+ CC_JITTER.recurringCapMs,
282
+ );
283
+ }
package/src/display.mjs CHANGED
@@ -1,6 +1,11 @@
1
1
  // 等宽终端展示原语与任务的展示视图。
2
2
 
3
- import { parseCronOrNone, taskIsOneshot, wanted } from './cron.mjs';
3
+ import {
4
+ parseCronOrNone,
5
+ taskIsOneshot,
6
+ wanted,
7
+ predictedFire,
8
+ } from './cron.mjs';
4
9
  import { DISPLAY_SEARCH_DAYS, ZERO_LEAD_MS } from './constants.mjs';
5
10
 
6
11
  // 等宽终端里的宽字符区间(CJK 文字、全角标点、谚文、常用 emoji):启发式
@@ -81,6 +86,7 @@ export function taskView(task, now, leadMs = undefined) {
81
86
  summary: taskSummary(task),
82
87
  permanent: false,
83
88
  wanted: null,
89
+ fire: null,
84
90
  };
85
91
  }
86
92
  const nxt = cron.nextAfter(now, DISPLAY_SEARCH_DAYS);
@@ -98,9 +104,46 @@ export function taskView(task, now, leadMs = undefined) {
98
104
  // wanted 恒为 null 或布尔:没传 leadMs(不问)与 cron 无效(问不了)
99
105
  // 都是 null,键的形状不随参数变化。
100
106
  wanted: leadMs !== undefined ? wanted(cron, task, now, leadMs) : null,
107
+ // 计入 Claude Code 投递抖动后的预计实际触发时刻;已错过的一次性任务
108
+ // 没有未来触发,为 null。与 nxt 同为 Date 或 null。
109
+ fire: missed ? null : predictedFire(task, now),
101
110
  };
102
111
  }
103
112
 
113
+ // MM-DD HH:MM 口径下是否同一天/同一分钟。
114
+ function sameDay(a, b) {
115
+ return a.getFullYear() === b.getFullYear()
116
+ && a.getMonth() === b.getMonth()
117
+ && a.getDate() === b.getDate();
118
+ }
119
+ function sameMinute(a, b) {
120
+ return sameDay(a, b) && a.getHours() === b.getHours()
121
+ && a.getMinutes() === b.getMinutes();
122
+ }
123
+
124
+ // 「设定落点 → 预计实际触发」文案。抖动四舍五入到秒(调度器秒级轮询):
125
+ // 同日省略第二个日期;预计值带非零秒时显示 HH:MM:SS(如 05:15:37);落
126
+ // 在同一分钟(无抖动)则只显示落点,不画无意义的箭头。
127
+ export function fmtFireRange(nxt, fire) {
128
+ if (!(fire instanceof Date) || !Number.isFinite(fire.getTime())) {
129
+ return fmtMDHM(nxt);
130
+ }
131
+ const r = new Date(Math.round(fire.getTime() / 1000) * 1000);
132
+ if (sameMinute(nxt, r)) return fmtMDHM(nxt);
133
+ const p = (n) => String(n).padStart(2, '0');
134
+ const tail = sameDay(nxt, r)
135
+ ? `${p(r.getHours())}:${p(r.getMinutes())}`
136
+ + (r.getSeconds() ? `:${p(r.getSeconds())}` : '')
137
+ : `${fmtMDHM(r)}${r.getSeconds() ? `:${p(r.getSeconds())}` : ''}`;
138
+ return `${fmtMDHM(nxt)} → ${tail}`;
139
+ }
140
+
141
+ // 仅 HH:MM(总览「(设定 HH:MM)」附注用)。
142
+ export function fmtHM(d) {
143
+ const p = (n) => String(n).padStart(2, '0');
144
+ return `${p(d.getHours())}:${p(d.getMinutes())}`;
145
+ }
146
+
104
147
  // MM-DD HH:MM(任务触发点的统一展示格式)。
105
148
  export function fmtMDHM(d) {
106
149
  const p = (n) => String(n).padStart(2, '0');
@@ -114,3 +157,13 @@ export function sessionAgeZh(startedAt, cur) {
114
157
  if (secs >= 86400) return `${Math.floor(secs / 86400)} 天`;
115
158
  return `${Math.floor(secs / 60)} 分钟`;
116
159
  }
160
+
161
+ // 已过去时长的粗略中文说法,秒级分辨率:巡检漏轮间隔、被动退出会话的存
162
+ // 活时长、总览页的「上次巡检」用它。
163
+ export function elapsedZh(secs) {
164
+ secs = Math.max(0, Math.floor(secs));
165
+ if (secs < 60) return `${secs} 秒`;
166
+ if (secs < 3600) return `${Math.floor(secs / 60)} 分钟`;
167
+ if (secs < 86400) return `${Math.floor(secs / 3600)} 小时`;
168
+ return `${Math.floor(secs / 86400)} 天`;
169
+ }
package/src/list.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  pad,
9
9
  dispWidth,
10
10
  taskView,
11
- fmtMDHM,
11
+ fmtFireRange,
12
12
  } from './display.mjs';
13
13
 
14
14
  function rank(v) {
@@ -17,6 +17,14 @@ function rank(v) {
17
17
  return v.nxt ? 1 : 2;
18
18
  }
19
19
 
20
+ // 状态列文案:有效任务是「设定落点 → 预计实际触发」(无抖动时只有落点)。
21
+ function statusText(v) {
22
+ if (!v.valid) return 'cron 无效';
23
+ if (v.missed) return '已错过';
24
+ if (!v.nxt) return '一年内无';
25
+ return fmtFireRange(v.nxt, v.fire);
26
+ }
27
+
20
28
  export async function cmdList(args) {
21
29
  const cfg = loadConfig(args?.config ?? deps.paths.configPath);
22
30
  const now = new Date();
@@ -60,41 +68,41 @@ export async function cmdList(args) {
60
68
  blocks.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
61
69
  deps.print(`共 ${blocks.length} 个工作区、${total} 个定时任务`);
62
70
 
63
- // 列宽从全部候选文案动态算(时间戳 "09-25 08:30" 恒为 11 列),改文案
64
- // 不会静默错位;cadence 是截断列,取固定上限。
71
+ // 列宽从全部实际文案动态算(跨天箭头最长 "09-25 08:30 → 09-26 09:00"),
72
+ // 改文案不会静默错位;cadence 是截断列,取固定上限。
65
73
  const statusW = Math.max(11,
66
- ...['cron 无效', '已错过', '一年内无'].map(dispWidth));
74
+ ...blocks.flatMap(([, , views]) => views.map((v) => dispWidth(statusText(v)))));
67
75
  const kindW = Math.max(...['一次性', '周期', '周期·永久'].map(dispWidth)) + 1;
68
76
  const cadW = 16;
69
- // " " + status + " " + kind + " " + cadence + " " + summary
70
- const summaryW = Math.max(12,
71
- cols - (2 + statusW + 2 + kindW + 2 + cadW + 2));
77
+ // 固定列(不含摘要段):" " status " " kind " " cadence。摘要段再占
78
+ // " " + summaryW。空间紧张时摘要先让位;为 0 则整段省略——状态箭头在
79
+ // 60 列下限下本身就可能占 25 列(跨天),不能再用 12 列摘要下限把行撑爆。
80
+ const fixedW = 2 + statusW + 2 + kindW + 2 + cadW;
81
+ const summaryW = Math.max(0, cols - fixedW - 2);
72
82
  for (const [ws, consumer, views] of blocks) {
73
83
  deps.print('');
74
84
  const label = consumer ? '交互会话:有' : '交互会话:无';
75
85
  const pathW = cols - dispWidth(label) - 1;
76
86
  deps.print(`${pad(clip(ws, pathW), pathW)} ${label}`);
77
87
  for (const v of views) {
78
- let status;
79
88
  let kind;
80
89
  let cadence;
81
90
  let summary;
82
91
  if (!v.valid) {
83
- status = 'cron 无效';
84
92
  kind = '—';
85
93
  cadence = '—';
86
94
  summary = `原字段值:${JSON.stringify(v.expr)};${v.summary}`;
87
95
  } else {
88
- // 已错过:一次性任务触发点已过,下次会话启动时补执行。missed 时
89
- // permanent 恒为 false(taskView 对一次性任务剔除了该标记)。
90
- status = v.missed ? '已错过'
91
- : (v.nxt ? fmtMDHM(v.nxt) : '一年内无');
92
96
  kind = v.permanent ? '周期·永久' : v.kind;
93
97
  cadence = v.cadence;
94
98
  summary = v.summary;
95
99
  }
96
- deps.print(' ' + pad(status, statusW) + ' ' + pad(kind, kindW) + ' '
97
- + pad(clip(cadence, cadW), cadW) + ' ' + clip(summary, summaryW));
100
+ let line = ' ' + pad(statusText(v), statusW) + ' '
101
+ + pad(kind, kindW) + ' '
102
+ + pad(clip(cadence, cadW), cadW);
103
+ // 极窄屏摘要列算到 0:整段省略(含前导间隔),保证行宽不超终端。
104
+ if (summaryW > 0) line += ' ' + clip(summary, summaryW);
105
+ deps.print(line);
98
106
  }
99
107
  }
100
108
  }
package/src/overview.mjs CHANGED
@@ -6,9 +6,15 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  import { deps } from './internals.mjs';
9
- import { loadConfig, loadState, validateConfig } from './config.mjs';
9
+ import {
10
+ loadConfig,
11
+ loadState,
12
+ validateConfig,
13
+ loadHeartbeat,
14
+ } from './config.mjs';
10
15
  import { packageVersion, TASK_REL } from './paths.mjs';
11
16
  import { serviceLine, launcherHealth } from './service.mjs';
17
+ import { HEARTBEAT_STALE_FACTOR } from './constants.mjs';
12
18
  import {
13
19
  humanDelta,
14
20
  clip,
@@ -16,7 +22,9 @@ import {
16
22
  dispWidth,
17
23
  taskView,
18
24
  sessionAgeZh,
25
+ elapsedZh,
19
26
  fmtMDHM,
27
+ fmtHM,
20
28
  } from './display.mjs';
21
29
 
22
30
  // 总览页任务盘点。返回 [告警, 已在告警里说明冷却的工作区集合, 本轮发现
@@ -59,8 +67,10 @@ function overviewTasks(cfg, cols, consumers, state) {
59
67
  bad += 1;
60
68
  continue;
61
69
  }
62
- if (v.nxt && (!soonest || v.nxt.getTime() < soonest[0].getTime())) {
63
- soonest = [v.nxt, ws, v.summary];
70
+ // 「最近」按计入抖动后的预计实际触发排,而非 cron 落点。
71
+ const fire = v.fire ?? v.nxt;
72
+ if (fire && (!soonest || fire.getTime() < soonest[0].getTime())) {
73
+ soonest = [fire, ws, v.summary, v.nxt];
64
74
  }
65
75
  if (v.wanted) wanted = true;
66
76
  }
@@ -96,9 +106,13 @@ function overviewTasks(cfg, cols, consumers, state) {
96
106
  ? `任务:${nTasks} 个,分布在 ${nWs} 个工作区`
97
107
  : '任务:暂无');
98
108
  if (soonest) {
99
- const [nxt, ws, summary] = soonest;
100
- const line = `最近:${fmtMDHM(nxt)}(${humanDelta(nxt.getTime() - now.getTime())}) `
101
- + `${path.basename(ws)} · ${summary}`;
109
+ const [fire, ws, summary, nxt] = soonest;
110
+ // 预计与设定不在同一分钟时,附注 cron 落点,解释两个时间的差。
111
+ const setTag = (nxt && (fmtHM(fire) !== fmtHM(nxt)
112
+ || fire.getDate() !== nxt.getDate()))
113
+ ? `,设定 ${fmtHM(nxt)}` : '';
114
+ const line = `最近:${fmtMDHM(fire)}(${humanDelta(fire.getTime() - now.getTime())}`
115
+ + `${setTag}) ${path.basename(ws)} · ${summary}`;
102
116
  deps.print(clip(line, cols));
103
117
  }
104
118
  if (!cfg.autoRenew && unrenewed > 0) {
@@ -155,10 +169,10 @@ function overviewSessions(cols, state, suppressCooling = new Set(),
155
169
  }
156
170
 
157
171
  const COMMANDS = [
158
- ['list', '逐个列出任务的内容摘要、执行时间与会话状态'],
172
+ ['list', '逐个列出任务的内容摘要、设定/预计触发时间与会话状态'],
159
173
  ['logs [目录片段] [-f]', '查看巡检日志或后台会话记录'],
160
174
  ['run', '立即手动巡检一轮'],
161
- ['renew', '立即给所有周期任务补 permanent(跳过 7 天过期)'],
175
+ ['renew', '立即给所有周期任务补 permanent 并改小 id'],
162
176
  ['install --force', '更新配置并重新安装(全部参数见 --help)'],
163
177
  ];
164
178
 
@@ -189,6 +203,8 @@ export async function cmdOverview(args) {
189
203
  const [consumers, registryAlert] = await deps.scanSessions();
190
204
  if (registryAlert) alerts.push(registryAlert);
191
205
  const state = loadState();
206
+ const curOv = Math.floor(Date.now() / 1000);
207
+ const heartbeat = loadHeartbeat();
192
208
  const coolingWarned = new Set();
193
209
  let discovered = null;
194
210
 
@@ -202,14 +218,15 @@ export async function cmdOverview(args) {
202
218
  } else {
203
219
  const actualIv = info ? info.interval : null;
204
220
  const renewTag = cfg.autoRenew ? ',自动续期开' : ',自动续期关';
221
+ const minIdTag = cfg.autoMinId ? ',id 自动改小' : ',id 保持原样';
205
222
  const retainTag = cfg.sessionRetain === 'always'
206
223
  ? ',会话常驻' : ',会话按执行回收';
207
224
  if (actualIv === null || actualIv === undefined) {
208
225
  deps.print(`巡检:配置为每 ${cfg.intervalSeconds} 秒一轮(服务未加载),`
209
- + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${retainTag}`);
226
+ + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${minIdTag}${retainTag}`);
210
227
  } else {
211
228
  deps.print(`巡检:每 ${actualIv} 秒一轮,提前 ${cfg.leadSeconds} 秒启动`
212
- + `会话${renewTag}${retainTag}`);
229
+ + `会话${renewTag}${minIdTag}${retainTag}`);
213
230
  if (actualIv !== cfg.intervalSeconds) {
214
231
  alerts.push(`launchd 实际间隔 ${actualIv} 秒与配置 ${cfg.intervalSeconds} `
215
232
  + '秒不一致,重新运行 cron-up install 后生效');
@@ -221,6 +238,20 @@ export async function cmdOverview(args) {
221
238
  deps.print(prefix
222
239
  + clip(rootsText, cols - dispWidth(prefix) - dispWidth(suffix))
223
240
  + suffix);
241
+ // 心跳回答「巡检最近真的在跑吗」:launchd 已加载但启动脚本运行期失
242
+ // 效时,静态健康检查(launcherHealth)全绿而轮次早已停摆,只有沉默的
243
+ // 心跳能戳穿。服务未加载(info 为 null)时不告警,服务行已说明未安装。
244
+ if (heartbeat) {
245
+ deps.print(`上次巡检:${elapsedZh(curOv - heartbeat.ranAt)}前`);
246
+ if (info
247
+ && curOv - heartbeat.ranAt
248
+ > cfg.intervalSeconds * HEARTBEAT_STALE_FACTOR) {
249
+ alerts.push(`巡检已沉默 ${elapsedZh(curOv - heartbeat.ranAt)}:launchd `
250
+ + `显示服务已加载(配置每 ${cfg.intervalSeconds} 秒一轮),但最近没有`
251
+ + '成功跑完的轮次,启动链可能已失效;请运行 cron-up logs 查看巡检'
252
+ + '日志,必要时重跑 cron-up install');
253
+ }
254
+ }
224
255
  alerts.push(...validateConfig(cfg, () => {}));
225
256
  deps.print('');
226
257
  const [taskAlerts, cooled, found] = overviewTasks(cfg, cols, consumers, state);
package/src/paths.mjs CHANGED
@@ -32,6 +32,10 @@ export const paths = {
32
32
  get statePath() {
33
33
  return path.join(this.dataDir, 'state.json');
34
34
  },
35
+ // 巡检心跳:每轮成功跑完写一次,总览页据此判断上次巡检多久前。
36
+ get heartbeatPath() {
37
+ return path.join(this.dataDir, 'heartbeat');
38
+ },
35
39
  // launchd 拉起的启动脚本。ProgramArguments[0] 直接指向它而非 node:
36
40
  // macOS 后台项目列表按可执行文件名显示条目,直连 node 会归到「Node.js
37
41
  // Foundation」名下(node 的签名者),自有脚本则显示脚本自己的名字。
package/src/patrol.mjs CHANGED
@@ -16,16 +16,43 @@ import {
16
16
  SESSION_RECYCLE_IDLE_SECONDS,
17
17
  SESSION_FIRE_MARGIN_SECONDS,
18
18
  PATROL_LOG_ROTATE_BYTES,
19
+ HEARTBEAT_STALE_FACTOR,
19
20
  } from './constants.mjs';
20
- import { loadConfig, loadState, saveState } from './config.mjs';
21
- import { renewWorkspace, TaskFileChanged } from './tasks.mjs';
21
+ import {
22
+ loadConfig,
23
+ loadState,
24
+ saveState,
25
+ loadHeartbeat,
26
+ saveHeartbeat,
27
+ } from './config.mjs';
28
+ import { maintainWorkspace, TaskFileChanged } from './tasks.mjs';
22
29
  import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
23
30
  import {
24
31
  sessionLogPath,
25
32
  sessionLogIdleSeconds,
26
33
  scriptProcLogPath,
27
34
  } from './sessions.mjs';
28
- import { TASK_REL } from './paths.mjs';
35
+ import { TASK_REL, packageVersion } from './paths.mjs';
36
+ import { elapsedZh } from './display.mjs';
37
+
38
+ // 事件类型 → 轮末汇总里的中文标签。patrolWorkspace 与 cmdRun 往同一个
39
+ // stats 计数器里记账,平静轮也会打出「本轮无动作」。
40
+ const EVENT_LABELS = [
41
+ ['spawn', '启动'],
42
+ ['recycle', '回收'],
43
+ ['ageout', '换代'],
44
+ ['exit', '自行退出'],
45
+ ['stuck', '卡死退休'],
46
+ ['adopt', '接管'],
47
+ ['fail', '启动失败'],
48
+ ['cool', '进入冷却'],
49
+ ['warn', '告警'],
50
+ ['error', '错误'],
51
+ ];
52
+
53
+ function bump(stats, kind) {
54
+ if (stats) stats[kind] = (stats[kind] ?? 0) + 1;
55
+ }
29
56
 
30
57
  export function log(msg) {
31
58
  const d = new Date();
@@ -144,17 +171,23 @@ function needHorizonMs(tasks, now) {
144
171
  // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。
145
172
  export async function patrolWorkspace(
146
173
  ws, state, now, leadMs, cur, consumers, scriptProcs = [],
147
- retain = 'always',
174
+ retain = 'always', stats = null,
148
175
  ) {
149
176
  const tasks = deps.readTasks(ws);
150
177
  let ent = state[ws] ?? null;
178
+ // 边记日志边给轮末汇总记账:日志给人翻,stats 给轮首/轮末心跳行一个
179
+ // 紧凑全貌。stats 为 null(直接单测)时退化为纯日志。
180
+ const note = (kind, msg) => {
181
+ bump(stats, kind);
182
+ deps.log(msg);
183
+ };
151
184
 
152
185
  if (tasks === null) {
153
186
  // 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
154
187
  // 不出意味着这个目录的定时任务一个都不会执行。文件已消失则是正常竞
155
188
  // 态(一次性任务执行完被删),不报。
156
189
  if (fs.existsSync(path.join(ws, TASK_REL))) {
157
- deps.log(`任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
190
+ note('warn', `任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
158
191
  + `其中 ${TASK_REL} 里的定时任务都不会执行`);
159
192
  }
160
193
  return;
@@ -166,7 +199,7 @@ export async function patrolWorkspace(
166
199
  if (ent !== null) {
167
200
  if (deps.trackedAlive(ent)) {
168
201
  await deps.stopSession(ent);
169
- deps.log(`回收 ${ws} pid=${ent.pid}:任务已全部清空,结束保活会话`);
202
+ note('recycle', `回收 ${ws} pid=${ent.pid}:任务已全部清空,结束保活会话`);
170
203
  }
171
204
  delete state[ws];
172
205
  }
@@ -182,7 +215,7 @@ export async function patrolWorkspace(
182
215
  && sessionLogIdleSeconds(
183
216
  ent.log || sessionLogPath(ws), cur) > SESSION_IDLE_SECONDS) {
184
217
  await deps.stopSession(ent);
185
- deps.log(`换代 ${ws} pid=${ent.pid}:会话已连续运行 `
218
+ note('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
186
219
  + `${Math.floor(age / 86400)} 天且近期无输出,结束后按需重拉`);
187
220
  delete state[ws];
188
221
  ent = null;
@@ -212,7 +245,7 @@ export async function patrolWorkspace(
212
245
  > SESSION_FIRE_MARGIN_SECONDS * 1000) {
213
246
  const pid = ent.pid;
214
247
  await deps.stopSession(ent);
215
- deps.log(`回收 ${ws} pid=${pid}:本代任务已执行完毕且界面静默,`
248
+ note('recycle', `回收 ${ws} pid=${pid}:本代任务已执行完毕且界面静默,`
216
249
  + '结束会话,下次需要时重拉');
217
250
  delete state[ws];
218
251
  ent = null;
@@ -248,7 +281,7 @@ export async function patrolWorkspace(
248
281
  const age = cur - (ent.startedAt ?? cur);
249
282
  if (age <= WARMUP_GRACE_SECONDS) return;
250
283
  await deps.stopSession(ent);
251
- deps.log(`卡死 ${ws} pid=${ent.pid}:进程已存活 ${age} 秒仍未登记会话,`
284
+ note('stuck', `卡死 ${ws} pid=${ent.pid}:进程已存活 ${age} 秒仍未登记会话,`
252
285
  + '已终止(排查:cron-up logs '
253
286
  + `${path.basename(ws)})`);
254
287
  }
@@ -272,7 +305,7 @@ export async function patrolWorkspace(
272
305
  fails,
273
306
  cooldownUntil: cur + COOLDOWN_SECONDS,
274
307
  };
275
- deps.log(`冷却 ${ws}:仍有任务需要会话,但保活会话连续失败 ${fails} 次,`
308
+ note('cool', `冷却 ${ws}:仍有任务需要会话,但保活会话连续失败 ${fails} 次,`
276
309
  + `暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
277
310
  return;
278
311
  }
@@ -294,7 +327,7 @@ export async function patrolWorkspace(
294
327
  fails: 0,
295
328
  log: logPath,
296
329
  };
297
- deps.log(`接管 ${ws} pid=${procPid}:state 记录缺失,但进程表里仍有`
330
+ note('adopt', `接管 ${ws} pid=${procPid}:state 记录缺失,但进程表里仍有`
298
331
  + '本工作区的保活会话,重新纳入跟踪');
299
332
  return;
300
333
  }
@@ -313,7 +346,7 @@ export async function patrolWorkspace(
313
346
  fails: newFails,
314
347
  log: sessionLogPath(ws),
315
348
  };
316
- deps.log(`启动失败 ${ws}:会话进程没有拉起(连续失败计数 ${newFails})`);
349
+ note('fail', `启动失败 ${ws}:会话进程没有拉起(连续失败计数 ${newFails})`);
317
350
  return;
318
351
  }
319
352
  state[ws] = {
@@ -323,7 +356,7 @@ export async function patrolWorkspace(
323
356
  fails,
324
357
  log: sessionLogPath(ws),
325
358
  };
326
- deps.log(`已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
359
+ note('spawn', `已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
327
360
  }
328
361
 
329
362
  // 跑一轮巡检;抢不到锁返回 false。
@@ -333,53 +366,127 @@ export async function cmdRun(args) {
333
366
  deps.log('已有一轮巡检在进行,本轮跳过');
334
367
  return false;
335
368
  }
369
+ // 包一层 stopSession 记下本轮主动结束的 pid:轮末凭它区分「我们回收的」
370
+ // 与「会话自行退出/被外部杀掉的」,后者以前是无声丢失。finally 还原。
371
+ const prevStopSession = deps.stopSession;
372
+ const stoppedPids = new Set();
373
+ deps.stopSession = async (ent) => {
374
+ if (ent?.pid) stoppedPids.add(ent.pid);
375
+ return prevStopSession(ent);
376
+ };
336
377
  try {
378
+ const startedAtMs = Date.now();
379
+ const stats = {};
337
380
  deps.rotatePatrolLogs();
338
381
  const cfg = loadConfig(args?.config ?? deps.paths.configPath);
339
382
  const now = new Date();
340
383
  const leadMs = cfg.leadSeconds * 1000;
341
384
  const state = loadState();
385
+ const cur = Math.floor(Date.now() / 1000);
386
+
387
+ // 轮首心跳:没有这两行,launchd 拉不起巡检(启动脚本失效、node 缺失)
388
+ // 时日志会一片寂静,无法和「平安无事」区分。上轮心跳过老说明中间漏
389
+ // 轮——休眠期间错过的 StartInterval,launchd 醒来只补跑一轮。
390
+ const hb = loadHeartbeat();
391
+ if (hb !== null
392
+ && cur - hb.ranAt > cfg.intervalSeconds * HEARTBEAT_STALE_FACTOR) {
393
+ bump(stats, 'warn');
394
+ deps.log(`距上次巡检已 ${elapsedZh(cur - hb.ranAt)},超过 `
395
+ + `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${cfg.intervalSeconds} 秒),`
396
+ + '期间 launchd 可能漏轮(常见于电脑休眠)');
397
+ }
398
+ deps.log(`巡检开始:${cfg.sessionRetain} 模式,扫描根目录 `
399
+ + `${cfg.roots.length} 个`);
342
400
  if (cfg.roots.length === 0) {
401
+ bump(stats, 'warn');
343
402
  deps.log('警告:扫描目录 roots 为空,巡检不会发现任何工作区;'
344
403
  + '请检查配置文件或用 install --roots 指定');
345
404
  }
346
405
 
347
- const cur = Math.floor(Date.now() / 1000);
406
+ // 轮初拍一份「上轮末仍相信活着」的会话快照(entry 复制,本轮原地改写
407
+ // 不影响),分成「轮初已没气」与「轮初还活着」两组。不能只收此刻
408
+ // kill -0 成功的——两轮之间崩溃正是最典型的被动死亡,本轮开始时它早
409
+ // 已没气;带 deadSince 的是上轮已报过死亡的旧条目,跳过以免每轮重复报。
410
+ const prevTracked = Object.entries(state)
411
+ .filter(([, ent]) => ent.pid && !ent.deadSince)
412
+ .map(([ws, ent]) => [ws, { ...ent }]);
413
+ const prevDead = prevTracked.filter(([, ent]) => !deps.trackedAlive(ent));
414
+ const prevAlive = prevTracked.filter(([, ent]) => deps.trackedAlive(ent));
415
+ const exitLine = (ws, old) => `会话退出 ${ws} pid=${old.pid}:非巡检主动结束`
416
+ + `(已存活 ${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或`
417
+ + '被外部终止;若反复出现请用 cron-up logs '
418
+ + `${path.basename(ws)} 排查启动过程`;
419
+
348
420
  // 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
349
421
  // 属这里。
350
422
  const [consumers, registryAlert] = await deps.scanSessions();
351
- if (registryAlert) deps.log(`告警:${registryAlert}`);
423
+ if (registryAlert) {
424
+ bump(stats, 'warn');
425
+ deps.log(`告警:${registryAlert}`);
426
+ }
352
427
  const scriptProcs = deps.listScriptProcesses();
353
428
 
429
+ // 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
430
+ // 日志时间线就是「退出 → 重拉」,而不是反过来。
431
+ for (const [ws, old] of prevDead) {
432
+ bump(stats, 'exit');
433
+ deps.log(exitLine(ws, old));
434
+ }
435
+
436
+ let found = 0;
354
437
  for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
438
+ found += 1;
355
439
  // 一个工作区读不动不能中断整轮:discover 是生成器,这里抛了会跳过
356
- // 后面所有工作区。续期与保活各自独立守卫——续期失败绝不压制该工作
357
- // 区的会话保活。
358
- if (cfg.autoRenew) {
440
+ // 后面所有工作区。续期/改 id 与保活各自独立守卫——维护失败绝不压制
441
+ // 该工作区的会话保活。两者合并为同一次原子写(见 maintainWorkspace)。
442
+ if (cfg.autoRenew || cfg.autoMinId) {
359
443
  try {
360
- const n = renewWorkspace(taskfile);
361
- if (n) {
362
- deps.log(`已续期 ${ws}:${n} 个周期任务标记为 permanent,`
444
+ const { renewed, minified } = maintainWorkspace(taskfile, {
445
+ renew: cfg.autoRenew,
446
+ minify: cfg.autoMinId,
447
+ }) ?? { renewed: null, minified: null };
448
+ if (renewed) {
449
+ deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
363
450
  + '不再受 7 天过期限制');
364
451
  }
452
+ if (minified) {
453
+ deps.log(`已改小 id ${ws}:${minified} 个周期任务的 id 改为小哈希`
454
+ + '值,投递延迟降到约 1 分钟内;下次预热拉起的新会话起生效,'
455
+ + '已在运行的会话要等其结束重拉(window 模式执行完即回收、'
456
+ + 'always 模式换代)后才读到新 id');
457
+ }
365
458
  } catch (e) {
459
+ bump(stats, e instanceof TaskFileChanged ? 'warn' : 'error');
366
460
  if (e instanceof TaskFileChanged) {
367
- deps.log(`续期暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
368
- + '本轮不覆盖,下轮再续');
461
+ deps.log(`维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
462
+ + '本轮不覆盖,下轮再来');
369
463
  } else {
370
- deps.log(`续期错误 ${ws}:${e.constructor.name}:${e.message}`);
464
+ deps.log(`维护错误 ${ws}:${e.constructor.name}:${e.message}`);
371
465
  }
372
466
  }
373
467
  }
374
468
  try {
375
469
  await patrolWorkspace(
376
470
  ws, state, now, leadMs, cur, consumers, scriptProcs,
377
- cfg.sessionRetain);
471
+ cfg.sessionRetain, stats);
378
472
  } catch (e) {
473
+ bump(stats, 'error');
379
474
  deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
380
475
  }
381
476
  }
382
477
 
478
+ // 轮中死亡:轮初还活着、轮末复核没气、又不是本轮主动 stop 的。指纹复
479
+ // 核由 trackedAlive 完成(带 procStart 指纹,pid 回收不会误报)。主动
480
+ // 回收/换代/卡死有各自的日志,这里只补轮中自行消亡那一类;轮间死亡刚
481
+ // 才已在循环前报过。报过的死条目随后由 kept 过滤打上 deadSince,后续
482
+ // 轮次不会重复报。
483
+ for (const [ws, old] of prevAlive) {
484
+ if (stoppedPids.has(old.pid)) continue;
485
+ if (deps.trackedAlive(old)) continue;
486
+ bump(stats, 'exit');
487
+ deps.log(exitLine(ws, old));
488
+ }
489
+
383
490
  // 丢掉已死且不处冷却的条目;保留冷却、活会话,以及带失败计数的死条
384
491
  // 目——计数跨任务窗口累积才能数满 FAIL_LIMIT(提前窗口常常只覆盖一两
385
492
  // 轮巡检),首次发现死亡时打 deadSince,超过 FAIL_TTL 后连同计数一起
@@ -396,14 +503,35 @@ export async function cmdRun(args) {
396
503
  }),
397
504
  );
398
505
  saveState(kept);
506
+
507
+ // 轮末汇总:一行交代这轮看见了什么、动了什么、花了多久,随后更新心跳。
508
+ const actions = EVENT_LABELS
509
+ .filter(([kind]) => stats[kind])
510
+ .map(([kind, label]) => `${label} ${stats[kind]}`)
511
+ .join('、');
512
+ const durationMs = Date.now() - startedAtMs;
513
+ const durationZh = durationMs < 1000
514
+ ? `${durationMs} 毫秒` : `${(durationMs / 1000).toFixed(1)} 秒`;
515
+ deps.log(`巡检结束:发现 ${found} 个工作区,活动交互会话 ${consumers.size} `
516
+ + `个;本轮${actions || '无动作'},耗时 ${durationZh}`);
517
+ saveHeartbeat({
518
+ ranAt: cur,
519
+ version: packageVersion(),
520
+ intervalSeconds: cfg.intervalSeconds,
521
+ sessionRetain: cfg.sessionRetain,
522
+ durationMs,
523
+ events: stats,
524
+ });
399
525
  return true;
400
526
  } finally {
527
+ deps.stopSession = prevStopSession;
401
528
  releaseLock(lock);
402
529
  }
403
530
  }
404
531
 
405
- // 立即给所有周期任务补 permanent。忽略 cfg.autoRenew——手动跑命令本身就
406
- // 是显式指令。与巡检共用同一把锁。
532
+ // 立即给所有周期任务补 permanent 并把 id 改小。忽略 cfg.autoRenew /
533
+ // cfg.autoMinId——手动跑命令本身就是显式指令(想只续期不改 id 的场景极
534
+ // 少,真需要可临时关掉配置后跑巡检)。与巡检共用同一把锁。
407
535
  export async function cmdRenew(args) {
408
536
  const lock = deps.acquireRunLock();
409
537
  if (lock === null) {
@@ -414,13 +542,14 @@ export async function cmdRenew(args) {
414
542
  const cfg = loadConfig(args?.config ?? deps.paths.configPath);
415
543
  let files = 0;
416
544
  let tagged = 0;
545
+ let minified = 0;
417
546
  let unchanged = 0;
418
547
  const skipped = [];
419
548
  for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
420
549
  files += 1;
421
- let n;
550
+ let r;
422
551
  try {
423
- n = renewWorkspace(taskfile);
552
+ r = maintainWorkspace(taskfile, { renew: true, minify: true });
424
553
  } catch (e) {
425
554
  if (e instanceof TaskFileChanged) {
426
555
  deps.print(`暂缓 ${ws}:任务文件刚被 Claude Code 改动,本次未写入,`
@@ -432,20 +561,30 @@ export async function cmdRenew(args) {
432
561
  skipped.push(ws);
433
562
  continue;
434
563
  }
435
- if (n === null) {
564
+ if (r === null) {
436
565
  deps.print(`跳过 ${ws}:任务文件读不出或格式损坏`);
437
566
  skipped.push(ws);
438
- } else if (n) {
439
- tagged += n;
440
- deps.print(`已续期 ${ws}:${n} 个周期任务标记为 permanent`);
567
+ continue;
568
+ }
569
+ if (r.renewed || r.minified) {
570
+ if (r.renewed) {
571
+ tagged += r.renewed;
572
+ deps.print(`已续期 ${ws}:${r.renewed} 个周期任务标记为 permanent`);
573
+ }
574
+ if (r.minified) {
575
+ minified += r.minified;
576
+ deps.print(`已改小 id ${ws}:${r.minified} 个周期任务投递延迟降到约`
577
+ + ' 1 分钟内(下次预热的新会话起生效)');
578
+ }
441
579
  } else {
442
580
  unchanged += 1;
443
- deps.print(`已是最新 ${ws}:周期任务均已 permanent,无需改动`);
581
+ deps.print(`已是最新 ${ws}:周期任务均已 permanent 且 id 已足够小,`
582
+ + '无需改动');
444
583
  }
445
584
  }
446
585
  deps.print('');
447
- let line = `扫描 ${files} 个任务文件:本次续期 ${tagged} 个任务,`
448
- + `${unchanged} 个文件无需改动`;
586
+ let line = `扫描 ${files} 个任务文件:本次续期 ${tagged} 个、改小 id `
587
+ + `${minified} 个任务,${unchanged} 个文件无需改动`;
449
588
  if (skipped.length) line += `,${skipped.length} 个跳过`;
450
589
  deps.print(line);
451
590
  if (tagged) {
package/src/service.mjs CHANGED
@@ -280,6 +280,7 @@ export async function cmdInstall(args) {
280
280
  if (args.interval !== undefined) ignored.push('--interval');
281
281
  if (args.lead !== undefined) ignored.push('--lead');
282
282
  if (args.autoRenew !== undefined) ignored.push('--auto-renew');
283
+ if (args.autoMinId !== undefined) ignored.push('--auto-min-id');
283
284
  if (args.sessionRetain !== undefined) ignored.push('--session-retain');
284
285
  if (ignored.length > 0) {
285
286
  deps.print(`警告:参数 ${ignored.join('、')} 未生效(现有配置保留);`
@@ -338,6 +339,7 @@ export async function cmdInstall(args) {
338
339
  overrides.leadSeconds = args.lead;
339
340
  }
340
341
  if (args.autoRenew !== undefined) overrides.autoRenew = args.autoRenew;
342
+ if (args.autoMinId !== undefined) overrides.autoMinId = args.autoMinId;
341
343
  if (args.sessionRetain !== undefined) {
342
344
  if (args.sessionRetain !== 'window' && args.sessionRetain !== 'always') {
343
345
  deps.printErr(`--session-retain 只接受 window 或 always,当前为 ${JSON.stringify(args.sessionRetain)}`);
package/src/sessions.mjs CHANGED
@@ -126,8 +126,13 @@ export function spawnSession(ws) {
126
126
  cwd: ws,
127
127
  env: buildChildEnv(process.env),
128
128
  });
129
- // unref 后即使异步启动失败也不要变成 uncaught(监听器不影响 loop 退出)。
130
- child.on('error', () => {});
129
+ // unref 后即使异步启动失败也不要变成 uncaught(监听器不影响 loop 退出);
130
+ // 但错误原因要留进巡检日志——spawn 同步返回成功、子进程 exec 阶段才失败
131
+ // (ENOENT/EAGAIN 等)时,上层只能看到会话随后死亡,没有任何原因可查。
132
+ child.on('error', (e) => {
133
+ deps.log(`会话进程拉起异常 ${ws}:${e?.code ? `${e.code}:` : ''}`
134
+ + `${e?.message ?? e}`);
135
+ });
131
136
  child.unref();
132
137
  // 拉不起来(spawn 当场失败,pid 是 undefined)时返回 null:没有进程可跟
133
138
  // 踪,调用方按启动失败计数,不把 pid=undefined 写进 state。
package/src/tasks.mjs CHANGED
@@ -7,7 +7,11 @@ import path from 'node:path';
7
7
 
8
8
  import { deps } from './internals.mjs';
9
9
  import { TASK_REL } from './paths.mjs';
10
- import { PRUNE_DIRS } from './constants.mjs';
10
+ import {
11
+ PRUNE_DIRS,
12
+ CC_JITTER,
13
+ AUTO_MIN_ID_TARGET_DELAY_MS,
14
+ } from './constants.mjs';
11
15
 
12
16
  export function expandHome(p) {
13
17
  const home = deps.paths.home;
@@ -192,12 +196,60 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
192
196
  fs.renameSync(tmp, file);
193
197
  }
194
198
 
195
- // 给所有缺 truthy permanent 标记的周期任务打上标记。
196
- // 返回:打标的数量(文件只重写一次);0 表示文件已全部标好(字节级不
197
- // 动,这是 5 分钟巡检不会永远重写别人文件的幂等性);null 表示文件缺失/
198
- // 损坏;读到的与写间被改抛 TaskFileChanged。一次性任务与无标志的手写文
199
- // 件永不修改。
200
- export function renewWorkspace(taskfile) {
199
+ // 严格 8 位十六进制的任务 id(Claude Code 自己生成的形状)转数值;其他
200
+ // 形状(短串、UUID、缺失、非字符串)返回 null——它们算「未达标」,由
201
+ // minifyIds 改写成合规小号。
202
+ export function parseTaskIdN(id) {
203
+ if (typeof id !== 'string' || !/^[0-9a-fA-F]{8}$/.test(id)) return null;
204
+ return parseInt(id, 16);
205
+ }
206
+
207
+ // autoMinId 的 id 数值上界:每日任务延迟 ≤ AUTO_MIN_ID_TARGET_DELAY_MS。
208
+ export function minTaskIdMaxN() {
209
+ return Math.floor(
210
+ AUTO_MIN_ID_TARGET_DELAY_MS
211
+ / (CC_JITTER.recurringFrac * 86400_000) * 2 ** 32);
212
+ }
213
+
214
+ // 把显式周期任务里 id 数值超过上界(或形状不合法)的,改写成文件内未占用
215
+ // 的小号(00000001 起的 8 位十六进制),使其投递抖动降到目标延迟内。原地
216
+ // 修改 tasks 元素,返回改写条数。关键纪律:
217
+ // - 只碰 t.recurring truthy 的显式周期任务,一次性与无标志手写条目不碰;
218
+ // - 已达标的小号(含 00000000)原样保留,永不重排——多次巡检结果稳定,
219
+ // 第二轮起 0 改动、文件字节不动;
220
+ // - 空号在「文件内全部任务」(含一次性)已占用的 id 之外分配,保证同文
221
+ // 件唯一;跨文件重复无妨(各工作区独立加载)。
222
+ export function minifyIds(tasks) {
223
+ const maxN = minTaskIdMaxN();
224
+ const used = new Set();
225
+ for (const t of tasks) {
226
+ if (!isPlainObject(t)) continue;
227
+ const n = parseTaskIdN(t.id);
228
+ if (n !== null) used.add(n);
229
+ }
230
+ let next = 1;
231
+ const takeFree = () => {
232
+ while (used.has(next)) next += 1;
233
+ used.add(next);
234
+ return next;
235
+ };
236
+ let changed = 0;
237
+ for (const t of tasks) {
238
+ if (!isPlainObject(t) || !t.recurring) continue;
239
+ const n = parseTaskIdN(t.id);
240
+ if (n !== null && n <= maxN) continue;
241
+ t.id = takeFree().toString(16).padStart(8, '0');
242
+ changed += 1;
243
+ }
244
+ return changed;
245
+ }
246
+
247
+ // 一轮巡检对一个任务文件做的维护:renew(补 permanent)与 minify(id 改
248
+ // 小)合并为同一次受 mtime 乐观锁保护的原子写。外壳与旧 renewWorkspace
249
+ // 完全一致:先取 mtimeNs、清残留 .cron-up-tmp、loadTaskDoc round-trip 整
250
+ // 文档;两项计数都为 0 时字节级不动。返回 {renewed,minified};null 表示
251
+ // 文件缺失/损坏;读到写间被改抛 TaskFileChanged。
252
+ export function maintainWorkspace(taskfile, { renew = false, minify = false } = {}) {
201
253
  let mtimeNs;
202
254
  try {
203
255
  mtimeNs = fs.statSync(taskfile, { bigint: true }).mtimeNs;
@@ -205,7 +257,7 @@ export function renewWorkspace(taskfile) {
205
257
  return null;
206
258
  }
207
259
  // 上次写一半被杀(SIGKILL/断电)留下的临时文件会永远躺在用户的仓库里
208
- // ——已全部标好的文件不再触发写入,没人替它收尾。只清专属后缀的,顺手
260
+ // ——已无需改动的文件不再触发写入,没人替它收尾。只清专属后缀的,顺手
209
261
  // 且不碰别人。
210
262
  try {
211
263
  fs.unlinkSync(tmpSibling(taskfile));
@@ -214,20 +266,29 @@ export function renewWorkspace(taskfile) {
214
266
  }
215
267
  const doc = deps.loadTaskDoc(taskfile);
216
268
  if (doc === null) return null;
217
- let tagged = 0;
218
- for (const t of doc.tasks) {
219
- // truthy 而非 === true:与 Claude Code 自己的加载器一致
220
- // (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
221
- // 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
222
- // 一次性任务不碰。
223
- if (isPlainObject(t) && t.recurring && !t.permanent) {
224
- t.permanent = true;
225
- tagged += 1;
269
+ let renewed = 0;
270
+ if (renew) {
271
+ for (const t of doc.tasks) {
272
+ // truthy 而非 === true:与 Claude Code 自己的加载器一致
273
+ // (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
274
+ // 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
275
+ // 一次性任务不碰。
276
+ if (isPlainObject(t) && t.recurring && !t.permanent) {
277
+ t.permanent = true;
278
+ renewed += 1;
279
+ }
226
280
  }
227
281
  }
228
- if (tagged === 0) return 0;
282
+ const minified = minify ? minifyIds(doc.tasks) : 0;
283
+ if (renewed === 0 && minified === 0) return { renewed: 0, minified: 0 };
229
284
  atomicWriteJson(taskfile, doc, mtimeNs);
230
- return tagged;
285
+ return { renewed, minified };
286
+ }
287
+
288
+ // 手动 renew 的薄封装:只补 permanent(不改 id),保持 null/0/N 三态契约。
289
+ export function renewWorkspace(taskfile) {
290
+ const r = maintainWorkspace(taskfile, { renew: true });
291
+ return r === null ? null : r.renewed;
231
292
  }
232
293
 
233
294
  Object.assign(deps, { discover, readTasks, loadTaskDoc });