cron-up 26.10.23 → 26.10.25

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
@@ -83,6 +83,7 @@ cron-up install --force --no-auto-renew
83
83
  | 启动脚本 | `~/.local/share/cron-up/cron-up-service`(launchd 拉起的入口,PATH 里写有 node 与 cron-up 所在的 bin 目录;后台列表显示名即来自它) |
84
84
  | 配置文件 | `~/.local/share/cron-up/config.json` |
85
85
  | 运行状态 | 同目录下的 `state.json` |
86
+ | 巡检心跳 | 同目录下的 `heartbeat`(每轮成功跑完写一次,总览页据此判断巡检是否在按时跑) |
86
87
  | 日志 | `~/Library/Logs/cron-up/`(巡检日志;各后台会话的界面记录在 `sessions/` 子目录下,该目录权限 700、日志文件 600) |
87
88
 
88
89
  ### 安装前的三个前提
@@ -163,8 +164,9 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
163
164
  - **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。「需要会话」按计入投递抖动(见上节)的**预计实际触发时刻**计算:某任务的 cron 落点已过、但还没到它的延迟投递点的这段尾窗(落点后最多 30 分钟)里会话照样保留,尾窗内若会话意外死亡也会立即重拉——否则同目录两个落点相近的任务,较早的执行完后会话被回收,较晚的还没到投递点就会漏跑(周期任务没有补执行)。效果:**错开约 30 分钟以上(已含投递抖动、静默判定与余量)的任务才必然各自拿到全新会话**,更近的共享同一会话(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
164
165
  - **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
165
166
  - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;同目录当时还有你本人打开的会话时除外,见「已知限制」)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
166
- - **日志大小**:后台会话的界面输出是持续的全屏渲染流。`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`,只保留上一份。
167
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 会合并非错过的间隔,醒来只补一轮,属正常现象)。
168
170
 
169
171
  ## 安全说明
170
172
 
@@ -182,6 +184,7 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
182
184
  - 仅支持 macOS。电脑休眠期间错过的周期任务不会补执行;唤醒后 launchd 会立即跑一轮巡检,错过的**一次性**任务会在后台会话启动时由 Claude Code 补执行。
183
185
  - Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
184
186
  - autoMinId 改小 id 依赖逆向出来的投递哈希算法(见「投递抖动」),不是官方承诺的接口:新版 Claude Code 若更换算法,小 id 不再能保证准点——届时关掉 autoMinId、改用「cron 往前提 30 分钟」即可,`list` 的预计时间与 findings 的验算脚本可用来发现这种漂移。此外在交互会话里用 `CronCreate` 重建任务会生成新的随机大 id(需等下轮巡检再改回),改时间请直接编辑 cron 字段而不是重建。
187
+ - autoMinId 改写 id 的当轮起,巡检按新 id(投递延迟不足 1 分钟)计算投递尾窗,而**已在运行的会话仍按旧 id 投递**(顶格约 30 分钟后):若保活会话恰在「新投递时刻 ~ 旧投递时刻」这最长约 29 分钟的空隙里死亡,该次投递漏跑(周期任务无补执行),下个周期恢复。window 模式下会话执行完即回收、重拉的新会话读到新 id,风险窗口至多覆盖一次执行;always 模式则持续到 7 天换代。在意的话,等一轮执行完(会话已重拉)再跑 `cron-up renew` 可完全避开。
185
188
  - 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」,同目录多会话还有重复执行的风险。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。真正的并发任务级隔离需要 Claude Code 官方支持任务路由。
186
189
  - 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
187
190
  - 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
@@ -199,6 +202,8 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
199
202
  | 总览提示「启动脚本已失效 / 内容无法解读」 | 启动脚本(`~/.local/share/cron-up/cron-up-service`)被删或手改,重跑 `cron-up install` 覆盖重生成 |
200
203
  | 总览提示「PATH 目录里找不到 cron-up」 | 切换了默认 Node 版本,新版本里还没装包:在新版本里 `npm i -g cron-up` 即可 |
201
204
  | 总览提示「PATH 目录里找不到 node」 | 启动脚本 PATH 指向的版本目录被删或移动(多见于手动删除 nvm/fnm 版本),重跑 `cron-up install` 重新解析 |
205
+ | 总览提示「巡检已沉默」 | launchd 显示已加载但最近没有成功跑完的轮次:先 `cron-up logs` 看轮次心跳是否断档、`launchd.err.log` 有没有崩溃记录,再对照「启动脚本已失效/PATH 找不到 node」两条;都正常则手动 `cron-up run` 验证,必要时重跑 install |
206
+ | 日志里出现「会话退出 … 非巡检主动结束」 | 会话自行崩溃或被外部终止(不是 cron-up 的回收/换代/卡死流程)。偶发且下轮自动重拉可忽略;同一目录反复出现时,按提示 `cron-up logs <目录名>` 看该会话的启动与退出过程 |
202
207
  | 日志里出现「卡死」 | 会话进程起来了但没能变成可用会话,绝大多数是停在信任确认或授权提问。先手动 `cd <目录> && claude` 走完确认,再等下一轮巡检 |
203
208
  | 日志里出现「任务文件读不出 \<目录\>」 | 该目录的 `.claude/scheduled_tasks.json` 存在却解析不了(格式损坏),巡检跳过这一个目录,**其中的定时任务一个都不会执行**;其余目录照常。检查该文件 |
204
209
  | 日志里出现「错误 \<目录\>」 | 巡检该目录时抛出未预期的异常,已按目录隔离、不影响其余目录。这通常意味着 cron-up 自身的 bug,请带上该行反馈 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.10.23",
3
+ "version": "26.10.25",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -187,6 +187,24 @@ export function saveState(state) {
187
187
  atomicSave(deps.paths.statePath, state);
188
188
  }
189
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
+
190
208
  export function saveConfig(cfg) {
191
209
  atomicSave(deps.paths.configPath, cfg);
192
210
  }
package/src/constants.mjs CHANGED
@@ -55,9 +55,12 @@ export const SESSION_RECYCLE_IDLE_SECONDS = 180;
55
55
  // 回收后同轮重拉的新会话约 10 秒完成登记,余量保的是登记抖动;更近的触
56
56
  // 发直接复用现有会话(时间紧邻的任务共享)。
57
57
  export const SESSION_FIRE_MARGIN_SECONDS = 120;
58
- // 巡检日志每轮几行,但跑几年的机器也该有上限。launchd 每次按路径重开,
59
- // 轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
58
+ // 巡检日志每轮两行心跳加零星事件,跑几年的机器也该有上限。launchd 每次按
59
+ // 路径重开,轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
60
60
  export const PATROL_LOG_ROTATE_BYTES = 1024 * 1024;
61
+ // 心跳沉默多少倍轮次间隔算异常:轮首据此提示漏轮(电脑休眠时 launchd 会
62
+ // 合并错过的间隔,醒来只补一轮),总览页据此报「服务在但巡检没在跑」。
63
+ export const HEARTBEAT_STALE_FACTOR = 2;
61
64
 
62
65
  // 不进入这些目录:VCS/依赖内部与 macOS 家目录系统文件夹——agent 工作区不
63
66
  // 会在里面,光一个 ~/Library 就足以让家目录扫描爆炸。
@@ -95,6 +98,13 @@ export const CC_JITTER = {
95
98
  // maxN,常量只保留「目标延迟」这一个可调项。
96
99
  export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
97
100
 
101
+ // 投递尾窗判定的回看/前探窗口(wanted 与 predictedFire 共用,见
102
+ // src/cron.mjs 的 recurringTailWindow):找上一落点用 prevAtOrBefore、算
103
+ // 周期用 nextAfter,两个方向都要罩住月/年任务之外的 2 月 29 日——相邻落
104
+ // 点隔 4 年,世纪非闰年(2100)隔 8 年;窗口小了尾窗会塌缩成零:落点已
105
+ // 过、投递未到,死亡却不重拉。日历快进按月跳,十年窗口也只有百余次迭代。
106
+ export const TAIL_WINDOW_DAYS = 366 * 10 + 1;
107
+
98
108
  // 定时任务 7 天过期(周期任务被调度器删除,一次性任务早就触发过),巡检
99
109
  // 不需要看得更远。展示页另算。
100
110
  export const SEARCH_DAYS = 7;
package/src/cron.mjs CHANGED
@@ -1,7 +1,12 @@
1
1
  // 分钟级 cron 解析与下一次触发计算。字段全是别的程序写的,畸形表达式在
2
2
  // 边界处返回 null/不可满足,绝不抛进巡检循环。
3
3
 
4
- import { SEARCH_DAYS, DISPLAY_SEARCH_DAYS, CC_JITTER } from './constants.mjs';
4
+ import {
5
+ SEARCH_DAYS,
6
+ DISPLAY_SEARCH_DAYS,
7
+ CC_JITTER,
8
+ TAIL_WINDOW_DAYS,
9
+ } from './constants.mjs';
5
10
 
6
11
  const MS_DAY = 86400_000;
7
12
  const MS_MINUTE = 60_000;
@@ -24,8 +29,12 @@ export function parseCronField(expr, lo, hi) {
24
29
  const slash = token.indexOf('/');
25
30
  if (slash >= 0) {
26
31
  rng = token.slice(0, slash);
27
- step = strictInt(token.slice(slash + 1)); // "/0" 必须抛:步进 0 会死循环
28
- if (step === 0) throw new Error('cron 步进不能为 0');
32
+ step = strictInt(token.slice(slash + 1));
33
+ // 步进必须显式卡正数:strictInt 接受负号,0 让步进循环原地打转、
34
+ // 负数让 v+=step 递减、v<=end 恒真——两者都是永不退出的死循环,
35
+ // 而一个含 "/-5" 的任务文件就能挂死整轮巡检。拒绝后由
36
+ // parseCronOrNone 读作「cron 无效」。
37
+ if (!(step > 0)) throw new Error('cron 步进必须是正整数');
29
38
  } else {
30
39
  rng = token;
31
40
  step = 1;
@@ -209,15 +218,9 @@ function wanted(cron, task, now, leadMs) {
209
218
  // 周期任务:投递不是在落点,而是落点 + 按 id 确定的抖动(每日任务通常
210
219
  // 30 分)。落点已过、抖动投递时刻未到的这段「投递尾窗」里会话仍必须在
211
220
  // 场——否则 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;
219
- }
220
- return false;
221
+ // 拉,让本次触发漏跑(周期任务没有补执行)。机制与窗口口径见
222
+ // recurringTailWindow。
223
+ return recurringTailWindow(cron, task.id, now) !== null;
221
224
  }
222
225
 
223
226
  // 任务是否需要已预热的会话。任务字段全是别的程序写的,畸形条目读作「不
@@ -241,17 +244,19 @@ export function taskIdHash(taskId) {
241
244
  return Number.isFinite(n) ? n : 0;
242
245
  }
243
246
 
244
- // 任务按 Claude Code 的抖动规则预计的实际触发时刻;cron 无效、不可满足或
245
- // 窗口内没有落点时返回 null。基准为 now(上游调度器以上次触发时刻为基
246
- // 准,展示只关心从现在起的下一次,两者算出的落点一致)。
247
+ // 任务按 Claude Code 的抖动规则预计的实际触发时刻;cron 无效、不可满足且
248
+ // 不在尾窗内、窗口内没有落点时返回 null。落点已过、投递未到的尾窗里答
249
+ // 「正在等待的这次投递」(上一落点 + 抖动),而不是下个落点。基准为 now
250
+ // (上游调度器以上次触发时刻为基准,展示只关心从现在起的下一次,两者算
251
+ // 出的落点一致)。
247
252
  export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
248
253
  const cron = parseCronOrNone(task?.cron);
249
254
  if (cron === null || !cron.satisfiable) return null;
250
- const i = cron.nextAfter(now, withinDays);
251
- if (i === null) return null;
252
255
  const h = taskIdHash(task?.id);
253
256
 
254
257
  if (taskIsOneshot(task)) {
258
+ const i = cron.nextAfter(now, withinDays);
259
+ if (i === null) return null;
255
260
  // 复刻 Wsn():只有落点分钟落在 :00/:30 网格上才提前一个按哈希缩放的
256
261
  // 小量(0~90 秒),其余分钟整点不抖;提前量不越过 now。
257
262
  if (i.getMinutes() % CC_JITTER.oneShotMinuteMod !== 0) return i;
@@ -260,6 +265,16 @@ export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
260
265
  return new Date(Math.max(i.getTime() - ahead, now.getTime()));
261
266
  }
262
267
 
268
+ // 尾窗内的周期任务:下一次真实投递是「上一落点 + 抖动」,数分钟内就
269
+ // 到,不是 nextAfter 给出的下个落点——后者严格在未来,会把展示拐到明
270
+ // 天(list 的落点列由 taskView 用 recurringTailWindow 对齐同一落点)。
271
+ // 先于窗口检查:2 月 29 日这类长周期任务的下一落点可能在展示窗口之
272
+ // 外,正在等待的投递却依然成立。
273
+ const tail = recurringTailWindow(cron, task?.id, now);
274
+ if (tail !== null) return new Date(tail[1]);
275
+
276
+ const i = cron.nextAfter(now, withinDays);
277
+ if (i === null) return null;
263
278
  // 复刻 bOt():抖动按「本落点到下一落点」的周期比例缩放并封顶。nextAfter
264
279
  // 语义是严格晚于入参(内部进位一分钟),传 slot 本身即取下一落点;窗口
265
280
  // 边缘取不到时与上游一致:视为无后续,不抖动。
@@ -281,3 +296,16 @@ export function recurringDelayMs(cron, slot, taskId, withinDays = SEARCH_DAYS) {
281
296
  CC_JITTER.recurringCapMs,
282
297
  );
283
298
  }
299
+
300
+ // 周期任务的「投递尾窗」判定:上一落点已过、按 id 算的延迟投递时刻未
301
+ // 到。返回 [落点, 投递时刻 ms];不在尾窗、找不到上一或下一落点返回
302
+ // null。wanted(保活判定)与 predictedFire/taskView(展示)共用这一口径,
303
+ // 两边不会各说各话;窗口取 TAIL_WINDOW_DAYS,理由见 constants。
304
+ export function recurringTailWindow(cron, taskId, now) {
305
+ const prev = cron.prevAtOrBefore(now, TAIL_WINDOW_DAYS);
306
+ if (prev === null) return null;
307
+ const fireMs = prev.getTime()
308
+ + recurringDelayMs(cron, prev, taskId, TAIL_WINDOW_DAYS);
309
+ if (now.getTime() >= fireMs) return null;
310
+ return [prev, fireMs];
311
+ }
package/src/display.mjs CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  taskIsOneshot,
6
6
  wanted,
7
7
  predictedFire,
8
+ recurringTailWindow,
8
9
  } from './cron.mjs';
9
10
  import { DISPLAY_SEARCH_DAYS, ZERO_LEAD_MS } from './constants.mjs';
10
11
 
@@ -89,13 +90,21 @@ export function taskView(task, now, leadMs = undefined) {
89
90
  fire: null,
90
91
  };
91
92
  }
92
- const nxt = cron.nextAfter(now, DISPLAY_SEARCH_DAYS);
93
93
  const oneshot = taskIsOneshot(task);
94
- // nxt 按定义严格晚于 now,「已错过」完全由补执行判定回答。
94
+ // nxt 按定义不晚于展示语境里的下一次投递,「已错过」完全由补执行判定
95
+ // 回答。
95
96
  const missed = oneshot && wanted(cron, task, now, ZERO_LEAD_MS);
97
+ // 周期任务的投递尾窗内,即将到来的投递属于上一落点:展示落点对齐到
98
+ // 那个已过的 slot,箭头才画得出「09:00 → 09:30」,而不是从明天的落点
99
+ // 拐回来(2 月 29 日任务尾窗内 nextAfter 甚至找不到下一落点)。fire 与
100
+ // 落点同源,两边不会各说各话。
101
+ const tail = oneshot ? null : recurringTailWindow(cron, task?.id, now);
102
+ const nxt = tail ? tail[0] : cron.nextAfter(now, DISPLAY_SEARCH_DAYS);
96
103
  return {
97
104
  valid: true,
98
- nxt,
105
+ // 已错过的一次性任务没有未来触发:nxt 置 null,否则「一年内无」的判
106
+ // 断和总览「最近」(fire ?? nxt)会把它当成明年同刻的安排。
107
+ nxt: missed ? null : nxt,
99
108
  missed,
100
109
  kind: oneshot ? '一次性' : '周期',
101
110
  permanent: Boolean(task.permanent) && !oneshot,
@@ -105,8 +114,9 @@ export function taskView(task, now, leadMs = undefined) {
105
114
  // 都是 null,键的形状不随参数变化。
106
115
  wanted: leadMs !== undefined ? wanted(cron, task, now, leadMs) : null,
107
116
  // 计入 Claude Code 投递抖动后的预计实际触发时刻;已错过的一次性任务
108
- // 没有未来触发,为 null。与 nxt 同为 Date 或 null。
109
- fire: missed ? null : predictedFire(task, now),
117
+ // 没有未来触发,为 null;尾窗内的周期任务是即将到来的这次延迟投递。
118
+ // 与 nxt 同为 Date 或 null。
119
+ fire: missed ? null : (tail ? new Date(tail[1]) : predictedFire(task, now)),
110
120
  };
111
121
  }
112
122
 
@@ -157,3 +167,13 @@ export function sessionAgeZh(startedAt, cur) {
157
167
  if (secs >= 86400) return `${Math.floor(secs / 86400)} 天`;
158
168
  return `${Math.floor(secs / 60)} 分钟`;
159
169
  }
170
+
171
+ // 已过去时长的粗略中文说法,秒级分辨率:巡检漏轮间隔、被动退出会话的存
172
+ // 活时长、总览页的「上次巡检」用它。
173
+ export function elapsedZh(secs) {
174
+ secs = Math.max(0, Math.floor(secs));
175
+ if (secs < 60) return `${secs} 秒`;
176
+ if (secs < 3600) return `${Math.floor(secs / 60)} 分钟`;
177
+ if (secs < 86400) return `${Math.floor(secs / 3600)} 小时`;
178
+ return `${Math.floor(secs / 86400)} 天`;
179
+ }
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,6 +22,7 @@ import {
16
22
  dispWidth,
17
23
  taskView,
18
24
  sessionAgeZh,
25
+ elapsedZh,
19
26
  fmtMDHM,
20
27
  fmtHM,
21
28
  } from './display.mjs';
@@ -196,6 +203,8 @@ export async function cmdOverview(args) {
196
203
  const [consumers, registryAlert] = await deps.scanSessions();
197
204
  if (registryAlert) alerts.push(registryAlert);
198
205
  const state = loadState();
206
+ const curOv = Math.floor(Date.now() / 1000);
207
+ const heartbeat = loadHeartbeat();
199
208
  const coolingWarned = new Set();
200
209
  let discovered = null;
201
210
 
@@ -229,6 +238,20 @@ export async function cmdOverview(args) {
229
238
  deps.print(prefix
230
239
  + clip(rootsText, cols - dispWidth(prefix) - dispWidth(suffix))
231
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
+ }
232
255
  alerts.push(...validateConfig(cfg, () => {}));
233
256
  deps.print('');
234
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,8 +16,15 @@ 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 {
22
+ loadConfig,
23
+ loadState,
24
+ saveState,
25
+ loadHeartbeat,
26
+ saveHeartbeat,
27
+ } from './config.mjs';
21
28
  import { maintainWorkspace, TaskFileChanged } from './tasks.mjs';
22
29
  import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
23
30
  import {
@@ -25,7 +32,27 @@ import {
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,25 +366,76 @@ 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
440
  // 后面所有工作区。续期/改 id 与保活各自独立守卫——维护失败绝不压制
357
441
  // 该工作区的会话保活。两者合并为同一次原子写(见 maintainWorkspace)。
@@ -372,6 +456,7 @@ export async function cmdRun(args) {
372
456
  + 'always 模式换代)后才读到新 id');
373
457
  }
374
458
  } catch (e) {
459
+ bump(stats, e instanceof TaskFileChanged ? 'warn' : 'error');
375
460
  if (e instanceof TaskFileChanged) {
376
461
  deps.log(`维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
377
462
  + '本轮不覆盖,下轮再来');
@@ -383,12 +468,25 @@ export async function cmdRun(args) {
383
468
  try {
384
469
  await patrolWorkspace(
385
470
  ws, state, now, leadMs, cur, consumers, scriptProcs,
386
- cfg.sessionRetain);
471
+ cfg.sessionRetain, stats);
387
472
  } catch (e) {
473
+ bump(stats, 'error');
388
474
  deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
389
475
  }
390
476
  }
391
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
+
392
490
  // 丢掉已死且不处冷却的条目;保留冷却、活会话,以及带失败计数的死条
393
491
  // 目——计数跨任务窗口累积才能数满 FAIL_LIMIT(提前窗口常常只覆盖一两
394
492
  // 轮巡检),首次发现死亡时打 deadSince,超过 FAIL_TTL 后连同计数一起
@@ -405,8 +503,28 @@ export async function cmdRun(args) {
405
503
  }),
406
504
  );
407
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
+ });
408
525
  return true;
409
526
  } finally {
527
+ deps.stopSession = prevStopSession;
410
528
  releaseLock(lock);
411
529
  }
412
530
  }
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。