cron-up 26.10.30 → 26.10.32
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 +111 -176
- package/package.json +1 -1
- package/src/args.mjs +5 -14
- package/src/cli.mjs +12 -29
- package/src/config.mjs +90 -66
- package/src/constants.mjs +36 -44
- package/src/cron.mjs +25 -7
- package/src/display.mjs +6 -5
- package/src/list.mjs +49 -22
- package/src/logs.mjs +36 -24
- package/src/overview.mjs +108 -109
- package/src/patrol.mjs +604 -432
- package/src/registry.mjs +78 -80
- package/src/service.mjs +89 -166
- package/src/sessions.mjs +34 -17
- package/src/tasks.mjs +73 -51
package/README.md
CHANGED
|
@@ -1,257 +1,192 @@
|
|
|
1
1
|
# cron-up
|
|
2
2
|
|
|
3
|
-
为命令行 AI agent 的 cron
|
|
3
|
+
为命令行 AI agent 的 cron 定时任务**按任务提前启动并保持一个可用的交互会话**:任务到点时,对应目录里早就有一个开着的 claude 会话,任务一个不少地投进各自的会话执行。平台要求 macOS(依赖 launchd、`script(1)`、zsh)。
|
|
4
4
|
|
|
5
5
|
> [!WARNING]
|
|
6
|
-
> cron-up 建立在 Claude Code
|
|
7
|
-
|
|
8
|
-
## 平台要求
|
|
9
|
-
|
|
10
|
-
- **macOS**(依赖 launchd、`script(1)`、zsh 登录配置;暂不支持其他系统)
|
|
11
|
-
- **Node.js 22 以上**(零运行时依赖,巡检本身只调系统自带工具)。fnm、volta、Homebrew、官网安装包与其他版本管理器都支持——安装时把 node 与 cron-up 所在的 bin 目录写进启动脚本的 PATH,不依赖 shell 的 PATH
|
|
12
|
-
- Claude Code 命令行(在交互登录 shell 里能直接运行 `claude`)
|
|
6
|
+
> cron-up 建立在 **Claude Code 未公开文档的内部机制**之上(`.claude/scheduled_tasks.json` 格式、会话登记 `~/.claude/sessions/`、逐任务调度谓词与目录级调度锁、任务投递抖动、三代进程结构(已核对本机 **2.1.302**))。这些行为可能随版本变化;看到「会话登记格式疑似变更」类告警,或升级 Claude Code 后任务到点没有会话在场,先核对新版本再继续使用。开头的 WARNING 与各章节的逆向注解,就是给这种时刻准备的。
|
|
13
7
|
|
|
14
8
|
## 它解决什么问题
|
|
15
9
|
|
|
16
|
-
在 Claude Code
|
|
17
|
-
|
|
18
|
-
- 会话一直开着 → 一切正常;
|
|
19
|
-
- 会话被关掉 → 一次性任务会在下次打开该目录的会话时补执行一次,**周期任务则直接跳过**,没有任何提示。
|
|
10
|
+
在 Claude Code 里用 CronCreate(durable: true)创建的定时任务,记录在工作目录的 `.claude/scheduled_tasks.json` 里。但这些任务**只有在该目录有一个正在运行的交互 claude 会话时才会到点执行**:会话一旦被关掉,周期任务在该会话直接失效;任务到点时没有会话,一次性任务会在下次打开会话时补执行(会话被休眠期间错过的周期任务不会补)。cron-up 是一个由 launchd 每 5 分钟运行一次的巡检进程,逻辑只有一条:**窗口内每个创建者会话已不在的任务,都有一个只属于它的后台会话在该目录等着**。
|
|
20
11
|
|
|
21
|
-
|
|
12
|
+
## 任务级预热:一任务一会话
|
|
22
13
|
|
|
23
|
-
|
|
14
|
+
Claude Code 的任务归属是**任务级**的:任务文件里每条任务各自带 `createdBySessionId`,会话调度器逐任务判定投不投递(2.1.302 二进制核对到的谓词,按优先级):
|
|
24
15
|
|
|
25
|
-
|
|
16
|
+
1. 任务没有归属会话 → 只有持目录调度锁的会话投;
|
|
17
|
+
2. 任务归属的就是本会话 → **无条件投,不需要锁**;
|
|
18
|
+
3. 本进程是任务记录的创建者进程(resume 形态)→ 投;
|
|
19
|
+
4. 其余情况 → 必须持锁、任务属于本项目、且创建者进程已死,才接管。
|
|
26
20
|
|
|
27
|
-
|
|
21
|
+
cron-up 利用这套规则:为每个「归属会话已死、6 分钟内需要执行」(一次性任务按最早可能投递时刻计,最多再提前 90 秒)的任务单独拉起一个后台会话,并把该任务的归属字段盖成这个新会话。于是同目录多个任务即使同一时刻触发,也各自投进各自的会话、互不插队;任一个预热中途死了,它的任务还会由另一个持锁活会话按规则 4 兜底接管。你本人的交互会话优先级最高:**只要任务的创建者会话还开着,cron-up 完全不碰它**。
|
|
28
22
|
|
|
29
23
|
| 环节 | 由谁负责 |
|
|
30
24
|
|---|---|
|
|
31
|
-
| 任务登记 | Claude Code 原生命令
|
|
32
|
-
| 定时执行 | Claude Code
|
|
33
|
-
|
|
|
34
|
-
|
|
|
35
|
-
| 后台会话 | 通过 macOS 自带的 `script(1)` 启动无窗口交互界面,输出写入日志,可随时恢复 |
|
|
36
|
-
| 安装与升级 | 一律走 npm:`npm i -g cron-up` / `npm i -g cron-up@latest` |
|
|
25
|
+
| 任务登记 | Claude Code 原生命令 CronCreate,写入工作目录下 `.claude` |
|
|
26
|
+
| 定时执行 | Claude Code 交互会话自身(包括补执行和抖动) |
|
|
27
|
+
| 保证每个窗口内死主任务有专属会话在场 | cron-up(按任务拉起、盖印、执行完回收) |
|
|
28
|
+
| 安装与升级 | npm:`~/Library/LaunchAgents/local.cron-up.plist` 与 `~/.local/share/cron-up/cron-up-service` |
|
|
37
29
|
|
|
38
|
-
各部分互不影响:卸载 cron-up
|
|
30
|
+
各部分互不影响:卸载 cron-up 后任务依然有效,只是没人保证会话在场。
|
|
39
31
|
|
|
40
32
|
## 安装
|
|
41
33
|
|
|
42
34
|
```bash
|
|
43
35
|
npm i -g cron-up
|
|
44
|
-
cron-up install --roots ~/code,~/work
|
|
36
|
+
cron-up install --roots ~/code,~/work
|
|
45
37
|
```
|
|
46
38
|
|
|
47
|
-
第二步会写入配置与 LaunchAgent
|
|
39
|
+
第二步会写入配置与 LaunchAgent 并立即跑首轮巡检;参数都可省略,不带 `--roots` 时沿用现有配置,首次安装默认扫描 `~/space`、`~/workspace`、`~/tasks`。
|
|
48
40
|
|
|
49
|
-
`--roots` 填写存放 agent
|
|
41
|
+
`--roots` 填写存放 agent 工作目录的父目录(逗号分隔)。不填则默认扫描这三个容器(不存在的跳过),两层以内的目录都能发现任务文件。不扫整个家目录的原因见安全说明。
|
|
50
42
|
|
|
51
|
-
launchd 的环境变量极少(PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin
|
|
43
|
+
`launchd` 的环境变量极少(PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`),因此安装时会解析 node 与 cron-up 所在的 bin 目录(fnm/volta/Homebrew/官网安装都支持),把它写进一个自有启动脚本(`cron-up-service`),launchd 每轮从该脚本解析。后台列表里显示 **cron-up-service** 而不是 Node.js 名字。
|
|
52
44
|
|
|
53
|
-
###
|
|
45
|
+
### 平台要求
|
|
54
46
|
|
|
55
|
-
|
|
47
|
+
- **macOS**(`script(1)`、launchd、zsh 登录 shell)。暂不支持其他系统。
|
|
48
|
+
- **Claude Code 2.1 以上**(在 2.1.302 实测;7 天判定与归属字段从 2.1.300 起核对,逐任务谓词与调度锁核对自 2.1.302)。fnm、bun、volta、Homebrew、官网 pkg 与其他版本管理器均支持——安装时把 node bin 目录与当前安装形态解析进启动脚本。
|
|
49
|
+
- Node.js 22 以上(零运行时依赖,巡检本身只调系统自带工具)。
|
|
50
|
+
- 无人值守时的工具权限:后台会话没有人在场点授权确认。后台会话以 bypass permissions 模式启动,命令在无人授权下照常执行;目录没走过信任确认、或工具需要二次授权,会话会一直停在提问界面,预热失败后连续失败进入冷却。只把 roots 指向你本人信任的目录。
|
|
51
|
+
- 建议在任务描述末尾固定加一句任务结束会话的指令(见下)。
|
|
56
52
|
|
|
57
|
-
|
|
53
|
+
## 日常使用
|
|
58
54
|
|
|
59
55
|
```bash
|
|
60
|
-
cron-up
|
|
56
|
+
cron-up # 总览:服务、配置、最近任务、每个预热会话与告警
|
|
57
|
+
cron-up list # 逐任务列出设定/预计实际触发时间与预热状态
|
|
58
|
+
cron-up run # 立即手动巡检一次(与定时巡检互斥,同时运行时后到者自动退出)
|
|
59
|
+
cron-up logs # 查看巡检日志(最近几百行巡检心跳与事件)
|
|
60
|
+
cron-up logs team-space # 查看匹配的后台会话界面记录(多个任务命中时列出候选)
|
|
61
|
+
cron-up logs team-space--00000002 -f
|
|
62
|
+
cron-up uninstall # 停止全部预热会话并卸载(配置日志保留;--purge 连产物一起删)
|
|
61
63
|
```
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
投递延迟完全由任务 id 的哈希决定(见下节),而 id 就是 8 位十六进制字符串——cron-up 默认在每轮巡检时把周期任务里哈希过大的 id 改写成小号(`00000001` 起),把每日任务的投递延迟压到 **1 分钟以内**(id 够小时观感就是准点)。规则与自动续期同样保守:
|
|
65
|
+
巡检固定每 5 分钟一轮、提前 6 分钟为任务备好会话;这些不是可配项。任何任务最迟在执行前一轮巡检被发现;拉起会话后轮内每 3 秒重扫一次会话登记(先扫后睡、至多等 24 秒),健康会话约 10 秒即可完成登记,归属在**同一轮**盖印完成,不再跨一轮。24 秒只是盖印加速的等待上限,不是判死时限:等不到的会话原样留到下轮,仍按跨轮 3 分钟未登记判死,失败计数不因等待而增加。一次性任务在 :00/:30 落点最多提前 90 秒投递,其窗口前沿按最早投递时刻再前推 90 秒,保证任意巡检相位都有一轮在投递前把它发现。
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
- 与续期合并为同一次原子写,任务文件在「巡检读到」与「写回」之间被 Claude Code 改动时整轮跳过、下轮再来,不会覆盖它写入的 `lastFiredAt`;
|
|
69
|
-
- 生效时机:window 模式(默认)下任务执行完即回收会话、下个窗口提前 10 分钟重拉,**下次触发的新会话就读到小 id**;always 模式常驻会话需换代(或手动重启)后才生效。
|
|
67
|
+
### 自动维护(每轮无条件进行,没有开关)
|
|
70
68
|
|
|
71
|
-
|
|
69
|
+
巡检每轮会顺手维护扫描到的任务文件,全部合并在一次带乐观锁的原子写里:
|
|
72
70
|
|
|
73
|
-
|
|
71
|
+
- **自动续期**:给显式周期任务补 `permanent: true`,绕开 Claude Code 的 7 天周期任务回收。
|
|
72
|
+
- **自动改小任务 id**:把周期任务的随机大 id 改成分文件不冲突的小序号,投递抖动压到 1 分钟以内,观感近乎准点。只改显式周期任务、幂等;一次性任务不碰。
|
|
73
|
+
- **自动任务级盖印**:把窗口内死主任务的三个归属字段盖成各自预热会话的身份。只改 `createdBySessionId/createdByPid/createdByProcStart`,不碰 `prompt`、`cron`、`id`(除上面的改小)与 `createdInProject`。
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
三轮维护与 Claude Code 自己对文件的写入(更新 `lastFiredAt`、执行后删一次性任务)用 mtime 乐观锁互斥:撞上就整轮跳过写入,下轮再来,绝不覆盖它的更新。
|
|
76
76
|
|
|
77
|
-
###
|
|
77
|
+
### 让一次性任务结束后自动退出会话
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
窗口预热会话在任务执行完、界面静默一段时间后自动回收;但推荐在任务描述里要求执行完自己退出,整个过程更干脆:
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
> 按本仓工作流存好结果后,执行 `/exit` 退出会话。
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
- **创建者会话已关闭或崩溃**——同目录即使还有别的会话在开(它不是创建者),巡检也**另拉一个独立的预热会话**承接,并把窗口内任务的 `createdBySessionId`、`createdByPid`、`createdByProcStart` 三个归属字段盖成它:Claude Code 见到自己是创建者就铁定投到预热,别的会话见到 owner 是另一个活会话则主动回避,不抢锁、不双发;
|
|
85
|
-
- 只盖「窗口内且创建者已不活」的任务、幂等(第二轮起字节级不动)、与续期/改小 id 合并为同一次带乐观锁的原子写;预热会话若中途死亡,任务自动退回 Claude Code 原生的「创建者已死、持锁会话接管」规则,不会漏跑,新一代预热拉起后重新盖印;
|
|
86
|
-
- 一个边界:同一目录的窗口任务里只要**还有一个**创建者会话活着,巡检就不会在该目录另拉预热(避免任何双会话);其中创建者已死的任务会按 Claude Code 原生规则由持锁的活会话接管这一次。
|
|
83
|
+
这样任务结束当刻会话就退出;cron-up 巡检发现任务已离开窗口且会话不在,什么都不用做。即使忘加这句,无人会话也会在执行完并静默后被自动回收,下次窗口重新拉起。
|
|
87
84
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
1. window 模式的回收余量从约 2 分钟收紧为「一个巡检间隔 + 2 分钟」(默认约 7 分钟)——回收后要留足「下轮重拉、再下轮盖印」的时间,任务才不可能在盖印前到点;
|
|
91
|
-
2. 卡在提问界面、超过 3 分钟没登记的预热会话会被终止(默认行为下同目录有别的会话时会跳过这次回收):活创建者在场时只终止不重拉(创建者自己承接任务),创建者已死时终止并重拉。
|
|
92
|
-
|
|
93
|
-
> 这同样依赖逆向出来的内部机制:任务文件的三个归属字段、以及「进程是 `script → zsh → claude` 三层、靠祖先 pid 认亲」都不是官方承诺的接口。识别退化(如 `ps` 输出异常)时巡检会发告警并**保守退回默认行为**(不另拉会话、暂停盖印),绝不会在同目录堆会话。残余的回退窗口(电脑休眠漏轮、会话恰在盖印前后换代)见「已知限制」。
|
|
94
|
-
|
|
95
|
-
安装产物:
|
|
96
|
-
|
|
97
|
-
| 产物 | 位置 |
|
|
98
|
-
|---|---|
|
|
99
|
-
| 命令行与代码 | npm 全局目录(位置因安装方式而异,如 fnm 是 `~/.local/share/fnm/aliases/default/lib/node_modules/cron-up/`,命令在同级 `bin/cron-up`) |
|
|
100
|
-
| launchd 配置 | `~/Library/LaunchAgents/local.cron-up.plist` |
|
|
101
|
-
| 启动脚本 | `~/.local/share/cron-up/cron-up-service`(launchd 拉起的入口,PATH 里写有 node 与 cron-up 所在的 bin 目录;后台列表显示名即来自它) |
|
|
102
|
-
| 配置文件 | `~/.local/share/cron-up/config.json` |
|
|
103
|
-
| 运行状态 | 同目录下的 `state.json` |
|
|
104
|
-
| 巡检心跳 | 同目录下的 `heartbeat`(每轮成功跑完写一次,总览页据此判断巡检是否在按时跑) |
|
|
105
|
-
| 日志 | `~/Library/Logs/cron-up/`(巡检日志;各后台会话的界面记录在 `sessions/` 子目录下,该目录权限 700、日志文件 600) |
|
|
85
|
+
## 窗口轮转与生命周期
|
|
106
86
|
|
|
107
|
-
|
|
87
|
+
预热会话是缓存不是宠物:每个会话只活在「落点前 6 分钟(一次性网格任务至多 7.5 分钟)→ 本任务执行完并静默 3 分钟 → 回收」这一段,任务之间上下文互不累积,内存也只在窗口内占用(每个会话数百 MB,窗口外为零)。回收还要求距该任务下次需要至少 7 分钟——回收后下轮巡检(≤5 分钟)即重新拉起并在当轮完成认亲盖印,余量罩住一次性任务的 90 秒提前投递与唤醒抖动,任务不可能在空档到点。更近的触发直接复用现有会话。
|
|
108
88
|
|
|
109
|
-
|
|
110
|
-
2. **工作目录已通过 Claude Code 的信任确认**:从未打开过的新目录会停在「是否信任此文件夹」的提问界面,会话无法进入空闲状态,定时任务也就不会执行。只扫描已经正常使用过的目录。
|
|
111
|
-
3. **无人值守时的工具权限**:后台会话没有人可以点授权确认。请使用 bypass permissions 模式,或在配置里准备好完整的允许列表,否则会话会一直停在授权提问处。
|
|
89
|
+
其它生命周期规则:
|
|
112
90
|
|
|
113
|
-
|
|
91
|
+
- **失败冷却**:预热连续 3 次启动失败(通常是缺目录信任、shell 环境损坏),暂停重试 30 分钟,而不是每 5 分钟无限重拉;失败计数按任务各自累积,互不连坐。总览页冷却中的任务合并成一条带排查指引的告警。
|
|
92
|
+
- **卡死退休**:会话进程活了 3 分钟仍没在会话登记里出现(停在信任确认或工具授权提问上),终止并按失败处理。
|
|
93
|
+
- **终端接管**:你随时可以 `claude --resume <预热会话id>` 把一个预热拉回终端。cron-up 发现原后台进程没了、但同一 sessionId 还活着,就停止管理它:不再另拉、绝不碰你的终端进程;该会话关闭后,任务恢复自动预热。
|
|
94
|
+
- **孤儿认领**:state 记录丢失时,巡检能按会话日志路径从进程表里把仍在运行的预热重新认回来。
|
|
95
|
+
- **升级迁移**:从目录级单预热的旧版本升级时,首轮巡检会结束旧版预热,之后按任务重新拉起。
|
|
114
96
|
|
|
115
|
-
|
|
116
|
-
cron-up # 总览:服务、配置、最近任务、保活会话明细,以及需要留意的告警
|
|
117
|
-
cron-up list # 逐任务列出:设定/预计实际触发时间、周期/一次性、cron 表达式、内容摘要、会话状态
|
|
118
|
-
cron-up run # 立即手动巡检一次(与定时巡检互斥,同时运行时后到者自动退出)
|
|
119
|
-
cron-up renew # 立即给所有周期任务补 permanent 并改小 id(手动执行,不受两个开关影响)
|
|
120
|
-
cron-up logs # 查看巡检日志(stderr 有内容时在开头提示一行)
|
|
121
|
-
cron-up logs <目录名片段> -f # 实时查看某个后台会话的界面输出
|
|
122
|
-
cron-up uninstall # 停止并卸载服务,结束后台保活会话,保留配置和日志
|
|
123
|
-
cron-up uninstall --keep-sessions # 卸载但保留后台会话(例如马上重装)
|
|
124
|
-
cron-up uninstall --purge # 结束后台会话并删除配置与日志(命令行本身用 npm 卸载)
|
|
125
|
-
```
|
|
97
|
+
## 恢复或接管后台会话
|
|
126
98
|
|
|
127
|
-
|
|
99
|
+
后台会话与普通会话一样保存对话记录,可以在任意终端恢复:
|
|
128
100
|
|
|
129
101
|
```bash
|
|
130
|
-
|
|
102
|
+
claude --resume <sessionId> # 或 claude -r 在列表中选择
|
|
131
103
|
```
|
|
132
104
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
`cron-up list` 按工作区分组,每个任务一行:状态列对有效任务显示「设定落点 → 预计实际触发」(机制见下节「投递抖动」,无抖动时只显示一个时间,非整分抖动带秒),异常时显示 `已错过`(一次性任务触发点已过,下次会话启动时会补执行)、`一年内无`(一年的展示查找窗口内没有触发点,如 2 月 30 日这类永不满足的合法表达式)或 `cron 无效`(表达式无法解析,或字段越界、反向区间等任何分钟都不可能匹配的写法);内容摘要取任务 prompt 的首个非空行;已续期的周期任务类型列显示为 `周期·永久`。自动续期关闭且仍有周期任务未打标时,无参数总览的「需要留意」会提示这些任务将于创建满 7 天后失效。不带任何参数的 `cron-up` 是一页总览,「最近」一行按预计实际触发时间排序;总览还把「任务已进入提前启动窗口却没有会话」「保活冷却中(若同时有任务待执行,会合并成一条带排查指引的告警,不会再承诺『下轮启动』)」「cron 无效」「配置与 launchd 实际间隔不一致」「启动脚本的 PATH 里解析不到 cron-up 或 node」「会话登记格式疑似变更」等需要处理的情况集中列在「需要留意」里。
|
|
105
|
+
对话记录在 `~/.claude/projects/<目录编码>/<会话ID>.jsonl`,即使后台进程被结束也能恢复。恢复后该会话如上面「终端接管」所述脱离巡检管理。
|
|
136
106
|
|
|
137
107
|
## 投递抖动:设定时间与实际触发时间
|
|
138
108
|
|
|
139
|
-
Claude Code
|
|
140
|
-
|
|
141
|
-
- **周期任务**:实际触发 = 落点 + min( `hash(id)` × 0.5 × 周期 , 30 分钟 ),其中 `hash(id)` 是任务 id 前 8 位十六进制除以 2³²。每日任务里约 96% 的 id 会撞 30 分钟上限——所以日报类任务几乎必然固定晚 30 分钟;周/月等长周期同样顶格 30 分钟。
|
|
142
|
-
- **一次性任务**:落点分钟是 :00/:30 时提前 0~90 秒(同样按 id 哈希缩放),其余分钟不抖动。
|
|
143
|
-
|
|
144
|
-
两种办法让任务准点:
|
|
109
|
+
Claude Code 任务的实际投递不在 cron 落点,而是落点之后加一个按任务 id 确定的偏移(会话内部每秒轮询,实际到点有秒级尾差)。周期任务的延迟 = `min(哈希(id) × 0.5 × 周期, 30 分钟)`,每日任务在不改 id 时几乎必然固定晚到约 30 分钟;一次性任务在 :00/:30 整点最多提前 90 秒。`cron-up list` 的状态列和总览的「最近」显示的是计入抖动后的预计实际时间;自动改小 id 把每日任务的这段延迟压到 1 分钟内。
|
|
145
110
|
|
|
146
|
-
|
|
147
|
-
2. **把 cron 往前提 30 分钟**(不用 cron-up 或关掉 autoMinId 时):如要 0 点到就设 `30 23 * * *`。
|
|
111
|
+
巡检判定「任务现在是否需要会话」时,把周期任务落点之后、抖动投递点之前的这段尾窗也算作需要在场,会话不会在任务真正投递前被回收。这些参数逆向自 Claude Code 内部实现,没有稳定性承诺(远程配置键 `tengu_kairos_cron_config` 还能覆盖);预测与实际系统性偏离时说明上游算法已变。
|
|
148
112
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
这套参数逆向自 Claude Code v2.1.285,**没有稳定性承诺**:版本升级可能调整,运行时还可能被远程配置键 `tengu_kairos_cron_config` 覆盖。若发现实际到点与预计时间系统性偏离,说明上游算法已变,排查步骤与验算脚本见 findings 主题 `claude-code-scheduled-task-jitter`。
|
|
152
|
-
|
|
153
|
-
## 使用定时任务时的三个约定
|
|
154
|
-
|
|
155
|
-
1. **创建方式不变**:照常在用着的会话里口头让 Claude 创建定时任务(`durable: true`),任务记录仍然只存在 Claude Code 自己的文件里。
|
|
156
|
-
2. **让一次性任务结束后自动退出**:在任务描述末尾固定加一句「按本仓工作流存好结果后,执行 `/exit` 退出会话」。这样一次性任务执行完会从任务文件自动删除,后台会话随即退出,整个过程有始有终。即使忘了加这句也不会留下常驻进程——cron-up 巡检发现任务列表清空(一次性任务执行后被删、周期任务 7 天过期、手动删除任务)时,会自动结束**自己启动的**会话;加 `/exit` 仍然更推荐,任务结束当刻就退出,不用等下一轮巡检,也避免会话空跑。用户本人在该目录打开的会话不受影响,cron-up 只停自己登记过的进程。
|
|
157
|
-
3. **超过 7 天的周期任务默认由 cron-up 自动续期**:Claude Code 的周期任务本会在创建满 7 天后末次执行并被删除;cron-up 默认在巡检时给它们补 `permanent: true` 来豁免这条规则(见上文「自动续期」),正常使用无需手动续签。只有在关闭了 autoRenew、又不使用 cron-up 时,才需要旧办法兜底——在任务描述里要求每次执行结束时用 `CronCreate` 重新登记下一个周期;这会生成新的任务 id,需同时让它删掉旧任务,否则同一节奏会有两条任务重复执行。
|
|
113
|
+
## 安全说明
|
|
158
114
|
|
|
159
|
-
|
|
115
|
+
- **任务文件是执行输入,root 等同写权限**:任何能在 roots 下写 `.claude/scheduled_tasks.json` 的主体,都登记了一条到点会以你的身份在后台会话里执行的指令。cron-up 读任务文件时严格校验形状,但信任的是文件内容本身——不要把他人可写或共享的目录放进 roots。自动续期还会把扫描到的周期任务补成永久任务,默认 roots 必须是你完全掌控的容器。
|
|
116
|
+
- **预热会话拥有你在 Claude Code 中的全部权限**:后台会话以 bypass permissions 模式无人值守运行。roots 的边界就是无人授权写权限的边界。
|
|
117
|
+
- **归属盖印会改写任务文件**:cron-up 会改窗口内死主任务的 `createdBySessionId/Pid/ProcStart` 字段。写入采用原子替换(临时文件 0600、保留原文件权限),并与 Claude Code 的写入做 mtime 乐观锁互斥。
|
|
118
|
+
- **会话日志可能包含任务输出**:后台会话的界面记录写到 `~/Library/Logs/cron-up/sessions/<工作区>--<任务id>.log`,权限 600、目录 700,每个任务独立文件、轮转保留一代;巡检日志在 `~/Library/Logs/cron-up/`。
|
|
119
|
+
- **扫描不深入符号链接的子目录**,默认还排除系统目录与依赖目录。
|
|
120
|
+
- **巡检、预热与你的 claude 会话之间不发起网络请求**,也不依赖 npm 注册表;网络活动全部来自被启动的会话本身。唯一的网络发生在安装/升级时访问 npm registry 拉包。
|
|
160
121
|
|
|
161
|
-
|
|
122
|
+
## 已知限制
|
|
162
123
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
124
|
+
- 仅支持 macOS。电脑休眠期间错过的周期任务不会补执行(launchd 醒来只补跑一轮巡检);错过的**一次性**任务会在后台会话启动时补执行。
|
|
125
|
+
- 每个窗口内的死主任务对应一个后台会话:同一分钟内有大量任务同时到期时,会同时存在相应数量的会话(每个数百 MB 内存)。时间错开的日常任务只在各自的 6 分钟窗口内占用(一次性任务至多 7.5 分钟)。
|
|
126
|
+
- 手工创建的任务若缺少 `createdAt`,错过补执行判定不生效。`cron-up list` 对缺字段的手写任务仍显示「已错过/设定时间」,但预热与补执行依赖 Claude Code 与 cron-up 的字段纪律。
|
|
127
|
+
- 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除或移出 roots)后,仍在运行的预热不会被回收,总览会列出这类会话,不需要时手动结束。
|
|
128
|
+
- 预热依据的逐任务谓词、调度锁与三代进程结构是未公开内部实现:新版若调整归属/锁语义,任务级隔离的前提需要重新核对。cron-up 保留了会话登记格式的烟雾告警,格式漂移时在总览页明示。
|
|
167
129
|
|
|
168
|
-
|
|
130
|
+
## 故障排查
|
|
169
131
|
|
|
170
|
-
|
|
132
|
+
| 现象 | 排查方法 |
|
|
133
|
+
|---|---|
|
|
134
|
+
| 总览提示「冷却中」 | 运行 `cron-up logs <目录>--<任务id>` 查看启动界面:停在信任确认就先在该目录手动走完信任;shell 初始化缺命令(command not found)按「启动脚本 PATH」段修 |
|
|
135
|
+
| 任务到点没有执行 | `cron-up list` 看任务与预热状态;预热在则看它的会话日志;总览的「需要留意」按任务给出原因 |
|
|
136
|
+
| 总览提示「启动脚本已失效 / PATH 找不到 node」 | 重跑 `cron-up install`(见下) |
|
|
137
|
+
| 总览提示「巡检已沉默」 | launchd 显示加载但心跳超时:先 `cron-up logs`,再重跑 install |
|
|
138
|
+
| `last exit code=2` | 配置损坏或字段类型不对:修好 `~/.local/share/cron-up/config.json`(只有 roots 一个字段)或重跑 install |
|
|
139
|
+
| 手动 `cron-up run` 正常、定时执行不正常 | 手动测试继承终端 PATH,定时由启动脚本驱动;换过 Node 安装方式后重跑 install |
|
|
140
|
+
| 任务表显示「已错过」 | 一次性任务落点已过:后台会话一启动就会补执行;周期任务没有补执行 |
|
|
141
|
+
| 日志里出现「卡死」 | 会话 3 分钟没完成登记:信任确认/工具授权提问没人应答,手动进目录走一遍授权 |
|
|
142
|
+
| 同目录多个任务互相干扰 | 升级后首轮会结束旧版目录级预热;如仍异常,确认所有预热会话都由新版本拉起(日志名含 `--任务id`) |
|
|
171
143
|
|
|
172
144
|
## 维护
|
|
173
145
|
|
|
174
|
-
-
|
|
175
|
-
-
|
|
176
|
-
-
|
|
177
|
-
- **修改巡检间隔或提前量**:重新运行 `cron-up install --force --interval 600`,会重新生成 launchd 配置。`--force` 只更新命令行里显式给出的字段,其余配置(如 `roots`)保留;不带 `--force` 时配置原样保留,命令行显式给的参数会被忽略并在安装输出里点名提示;路径参数建议都加引号书写,例如 `--roots "$HOME/code,$HOME/work"`,避免 shell 对逗号后的波浪号不展开。
|
|
178
|
-
- **升级**:`npm i -g cron-up@latest`,下一轮巡检自然跑新版。fnm/volta/Homebrew 下切换默认 Node 版本同样自动跟随(在新版本里装过一次 `npm i -g cron-up` 即可,忘了装总览会提示),全程无需重跑 install;nvm 等按版本目录安装的形态绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切到新版本。版本号与发布流程见下文「发布」一节。
|
|
179
|
-
- **配置校验**:手改配置时字段类型写错(`roots` 不是数组、数值字段写成字符串或布尔、`autoRenew` 不是布尔)不会静默回退默认值——巡检会以退出码 2 响亮失败并在总览页列出具体字段,修好或重跑 `cron-up install --force`(坏字段回默认,其余保留)。软问题只警告不中止:提前量小于间隔(可能来不及在任务前启动会话)、扫描目录不存在。无参数的 `cron-up` 总览随时也会显示这些问题;其中 interval 显示的是 launchd **实际生效**的间隔,手动改过 config 但与实际不一致时会提示需要重新 install。
|
|
180
|
-
- **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
|
|
181
|
-
- **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,在写入配置之前就被响亮拒绝,非法值不会落盘。
|
|
182
|
-
- **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。「需要会话」按计入投递抖动(见上节)的**预计实际触发时刻**计算:某任务的 cron 落点已过、但还没到它的延迟投递点的这段尾窗(落点后最多 30 分钟)里会话照样保留,尾窗内若会话意外死亡也会立即重拉——否则同目录两个落点相近的任务,较早的执行完后会话被回收,较晚的还没到投递点就会漏跑(周期任务没有补执行)。效果:**错开约 30 分钟以上(已含投递抖动、静默判定与余量)的任务才必然各自拿到全新会话**,更近的共享同一会话(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
|
|
183
|
-
- **开关任务归属预热**:配置项 `stampOwner`,命令行 `cron-up install --force --stamp-owner` / `--no-stamp-owner`。开启后以「创建者会话是否还活着」为最高优先级:建任务的会话还开着就完全尊重、任务照旧进它;创建者已关闭/崩溃时才另拉独立预热承接,不让任务漏给同目录无关的会话(完整语义见安装一节的「任务归属预热」)。两个附带变化:window 回收余量从约 2 分钟收紧为约 7 分钟(间隔 + 2 分钟);卡在提问界面的预热会话会被 3 分钟卡死退休——活创建者在场时只终止不重拉,创建者已死时终止并重拉(默认行为下同目录有别的会话时跳过这次回收)。
|
|
184
|
-
- **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
|
|
185
|
-
- **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;默认配置下同目录当时还有你本人打开的会话时除外,见「已知限制」;`stampOwner` 开启时不除外——活创建者在场则只终止不重拉,创建者已死则终止并重拉)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
|
|
186
|
-
- **日志大小**:后台会话的界面输出是持续的全屏渲染流。`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`,只保留上一份。
|
|
187
|
-
- **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有四重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、interactive 登记的 pid/工作目录字段集体无法解读、或存活会话的进程父链读不完整(仅影响 stampOwner 的认亲与盖印,不影响基础保活)时,「需要留意」会明确提示登记格式或进程信息疑似变更;看到这类告警后,在重新核对之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
|
|
188
|
-
- **巡检心跳与沉默告警**:每轮巡检成功跑完都会刷新 `~/.local/share/cron-up/heartbeat`。总览页的巡检区显示「上次巡检:N 分钟前」;launchd 显示已加载、但心跳超过两个轮次间隔没刷新时,「需要留意」会报「巡检已沉默」——启动脚本静态检查全绿但运行期拉不起来(或轮次持续崩在半道)只有靠它发现。轮首发现距上轮过久也会在巡检日志里记一条漏轮提示(电脑休眠后 launchd 会合并非错过的间隔,醒来只补一轮,属正常现象)。
|
|
146
|
+
- **修改扫描范围**:`cron-up install --roots "$HOME/code,$HOME/work"`。重跑 install 会重写配置(旧版本留下的废弃字段一并纯化)、启动脚本与 LaunchAgent。
|
|
147
|
+
- **升级**:`npm i -g cron-up@latest` 后重跑一次 `cron-up install`。下一轮巡检自然跑新版;Node 版本与包文件在 install 后自动跟随。fnm 切换默认版本同理。
|
|
148
|
+
- **版本号**:总览首行、心跳文件都带版本;发版收尾跑一次无参数 `cron-up` 确认首行版本、服务已加载且退出码 0、「需要留意」为空。
|
|
189
149
|
|
|
190
|
-
|
|
150
|
+
### 配置文件
|
|
191
151
|
|
|
192
|
-
|
|
193
|
-
- **续期会改变三条生命周期/边界**:
|
|
194
|
-
1. 被打 `permanent` 的周期任务不再被 Claude Code 在创建满 7 天后删除,cron-up 为它保活的后台会话随之**长期常驻**,不再随任务过期被回收——这等于主动关闭了 Claude Code 对自主会话的寿命护栏。
|
|
195
|
-
2. 7 天上限本也是一道**自动淘汰**防线:一个你没留意、随 `git pull` 或他人提交进入工作目录的周期任务,原本最多存活 7 天;autoRenew 会无差别把它也永久化。因此**不要把他人可写、或你不完全掌控的仓库纳入 roots**(与下一条任务文件即执行输入的警告同理);拉取外部代码后先检查 `.claude/scheduled_tasks.json` 里有没有意料之外的周期任务,必要时先关闭 autoRenew。
|
|
196
|
-
3. 若某个仓库把 `.claude/scheduled_tasks.json` 纳入了版本控制,首轮自动维护会让该文件出现 diff——新增 `"permanent": true`、把任务 `id` 改写成小号(autoMinId),外加统一的两空格缩进排版,提交前请知悉;本机自用仓通常已在 `.git/info/exclude` 忽略它,不受影响。
|
|
197
|
-
- **任务文件本身就是执行输入,而不只是数据**:任何能在 roots 下某个已信任目录落盘 `.claude/scheduled_tasks.json` 的主体,等于登记了一条定时指令——最快 5 分钟后,cron-up 就会为该目录拉起一个以你的身份运行、自动执行其中 prompt 的会话。这包括一次普通的 `git pull`(任务文件随提交进入工作树)和共享仓库的协作者。因此不要把他人可写仓库的父目录放进 roots;拉取外部代码后若发现 `.claude/scheduled_tasks.json` 被新增或改动,先看内容再让它留在那里。
|
|
198
|
-
- 会话日志可能包含任务执行过程和输出,默认仅本人可读(600),对外分享或截图前请留意内容。
|
|
199
|
-
- 巡检、保活与续期都不发起网络请求,也不依赖任何第三方 npm 包;唯一的网络活动发生在**安装与升级**,即 npm 访问 registry.npmjs.org 拉取包本身(由你本机的 npm 配置与凭证负责)。除此之外的外部通信全部来自被启动的 agent 会话。
|
|
152
|
+
配置只有扫描范围一个字段:`~/.local/share/cron-up/config.json`
|
|
200
153
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
- Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
|
|
205
|
-
- autoMinId 改小 id 依赖逆向出来的投递哈希算法(见「投递抖动」),不是官方承诺的接口:新版 Claude Code 若更换算法,小 id 不再能保证准点——届时关掉 autoMinId、改用「cron 往前提 30 分钟」即可,`list` 的预计时间与 findings 的验算脚本可用来发现这种漂移。此外在交互会话里用 `CronCreate` 重建任务会生成新的随机大 id(需等下轮巡检再改回),改时间请直接编辑 cron 字段而不是重建。
|
|
206
|
-
- autoMinId 改写 id 的当轮起,巡检按新 id(投递延迟不足 1 分钟)计算投递尾窗,而**已在运行的会话仍按旧 id 投递**(顶格约 30 分钟后):若保活会话恰在「新投递时刻 ~ 旧投递时刻」这最长约 29 分钟的空隙里死亡,该次投递漏跑(周期任务无补执行),下个周期恢复。window 模式下会话执行完即回收、重拉的新会话读到新 id,风险窗口至多覆盖一次执行;always 模式则持续到 7 天换代。在意的话,等一轮执行完(会话已重拉)再跑 `cron-up renew` 可完全避开。
|
|
207
|
-
- 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。`stampOwner` 开启后,创建者会话已关闭/崩溃的任务会走进程独立的预热会话、不漏给同目录无关会话;但创建者会话还开着的任务照旧进创建者(这是刻意的最高优先级),同目录重叠任务的归属仍由 Claude Code 裁决,真正的任务级并发隔离还是需要官方支持任务路由。
|
|
208
|
-
- 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
|
|
209
|
-
- 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
|
|
210
|
-
- 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up 默认的策略是「只在没有任何会话时才启动」,正常流程不会制造第二个会话;`stampOwner` 开启后也只在「窗口任务的创建者会话都已不活」时才制造「用户会话 + 预热会话」并存的状态(创建者会话还开着则永不双开),并存时靠把死主任务的归属盖给预热会话来保证只有它执行(你的会话按 Claude Code 自身规则主动回避,预热死亡才允许接管),不依赖「谁先动手」,因此不会各执行一次。
|
|
211
|
-
- 「已有会话在消费」按目录判断:默认配置下分不清消费者是我们的预热会话还是你本人打开的会话,卡在提问界面(始终不会登记)的预热会话,若同目录恰有你的交互会话在场,3 分钟的卡死回收会被跳过——最晚由 7 天自动换代收掉,或在你关掉自己的会话后、下次有任务需要会话的那轮巡检被回收。`stampOwner` 开启时改为两件事:按会话 id 判定窗口任务的创建者是否还活着(活着就完全尊重),按进程祖先链认出自己的预热(state 记的 `script` 是登记中 claude 进程的祖父);只有父链读不完整(`ps` 异常、进程正在退出)时才退回分不清的保守状态——该轮不另拉会话、不盖印并发出告警,下轮自动恢复。
|
|
212
|
-
- `stampOwner` 开启后仍有一个盖印跟不上的回退窗口:创建者会话在窗口内刚关闭、或预热会话换代/被回收后,要经历「下轮巡检重拉(≤ 一个间隔)→ 新会话登记 → 再下轮盖印」两步。若电脑休眠漏轮、或关闭/换代恰好发生在落点前不久,任务可能由同目录的其他活会话按 Claude Code 原生规则(持有调度锁且创建者已死)接管这一次——不会漏跑、不会重复,下轮自动盖回新预热,等同功能关闭时的行为。window 模式的回收余量已因此从约 2 分钟收紧为一个间隔 + 2 分钟(默认约 7 分钟),把这个窗口压缩到极端情况。
|
|
213
|
-
- `stampOwner` 依赖任务文件的三个 `createdBy*` 归属字段、活会话的 session id 与三代进程结构(核对自 Claude Code 2.1.300):新版若移除或改名这些字段、改变启动进程结构,盖印会失效或认不到自己的预热——退化时一律按保守方向处理:创建者存活性判断不到时视为「可能还活着」(不另拉),认亲失败按歧义处理(不双开),不会出现任务被抢走或同目录堆会话。核对方法同投递抖动:发现任务又弹进错误的会话时,检查任务文件字段与会话登记格式,必要时先关闭开关。
|
|
214
|
-
- 扫描不深入子目录里的符号链接(`roots` 本身的符号链接会先解析成真实路径):容器里用 symlink 指向别处的仓库不会被发现,其中的任务不会被保活。
|
|
215
|
-
- 系统设置「登录项与扩展 → 允许在后台」里,巡检条目显示为 **cron-up-service**:macOS 按 launchd 拉起的第一个可执行文件(启动脚本)给条目起名。条目小字可能标注「来自未识别的开发者」——启动脚本没有 Apple 签发证书的签名,这行小字没有本地手段改;主名称与开关行为不受影响。
|
|
216
|
-
- 若升级 Node 时删掉了启动脚本 PATH 指向的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「PATH 目录里找不到 node」,重跑一次 `cron-up install` 即可修复。
|
|
154
|
+
```json
|
|
155
|
+
{ "roots": ["/Users/you/code", "/Users/you/work"] }
|
|
156
|
+
```
|
|
217
157
|
|
|
218
|
-
|
|
158
|
+
间隔(300 秒)、提前量(360 秒,一次性任务另加至多 90 秒)、扫描深度(3)、轮内等待认亲上限(24 秒)都是固定常量;直接编辑文件改 roots 也可以,最多 5 分钟后的下轮巡检生效。字段损坏时巡检响亮失败(退出码 2),不静默回退。
|
|
219
159
|
|
|
220
|
-
|
|
|
160
|
+
| 产物 | 位置 |
|
|
221
161
|
|---|---|
|
|
222
|
-
|
|
|
223
|
-
|
|
|
224
|
-
|
|
|
225
|
-
|
|
|
226
|
-
|
|
|
227
|
-
|
|
|
228
|
-
|
|
|
229
|
-
|
|
|
230
|
-
| 日志里出现「错误 \<目录\>」 | 巡检该目录时抛出未预期的异常,已按目录隔离、不影响其余目录。这通常意味着 cron-up 自身的 bug,请带上该行反馈 |
|
|
231
|
-
| 任务到点没有执行 | `cron-up list` 查看下次执行时间和是否有会话;`launchctl print gui/$(id -u)/local.cron-up` 查看 last exit code |
|
|
232
|
-
| 手动 `cron-up run` 正常、定时执行不正常 | 基本都是 launchd 环境下 shell 初始化不一致(PATH、fnm、模型路由),会话日志里会有直接报错;换过 Node 安装方式的话重跑一次 `cron-up install` |
|
|
233
|
-
| `last exit code=2` | config.json 缺失、不是合法 JSON、顶层不是对象,或字段类型不对(如 `leadSeconds` 写成字符串、`autoRenew` 写成字符串);无参数 `cron-up` 总览的配置行或「需要留意」会给出具体原因 |
|
|
162
|
+
| 命令行与代码 | npm 全局目录(fnm 为 `~/.local/share/fnm/aliases/default/...`) |
|
|
163
|
+
| 配置文件 | `~/.local/share/cron-up/config.json` |
|
|
164
|
+
| 运行状态 | 同目录 `state.json`(按工作区与任务 id 两级记录预热) |
|
|
165
|
+
| 配置心跳 | 同目录 `heartbeat`(每轮写一次) |
|
|
166
|
+
| LaunchAgent | `~/Library/LaunchAgents/local.cron-up.plist` |
|
|
167
|
+
| 启动脚本 | `~/.local/share/cron-up/cron-up-service` |
|
|
168
|
+
| 巡检日志 | `~/Library/Logs/cron-up/launchd.{out,err}.log` |
|
|
169
|
+
| 各任务会话日志 | `~/Library/Logs/cron-up/sessions/<工作区>--<任务id>.log`(轮转保留一代 `.1`) |
|
|
234
170
|
|
|
235
171
|
## 开发与测试
|
|
236
172
|
|
|
237
173
|
```bash
|
|
238
174
|
git clone git@github.com:adaex/cron-up.git && cd cron-up
|
|
239
|
-
node --test # 全部测试(需要 Node 22
|
|
240
|
-
npm link # 本机把开发版链接成全局 cron-up
|
|
175
|
+
node --test # 全部测试(需要 Node 22+;零依赖)
|
|
176
|
+
npm link # 本机把开发版链接成全局 cron-up
|
|
241
177
|
```
|
|
242
178
|
|
|
243
|
-
`
|
|
244
|
-
|
|
245
|
-
用例名自说明覆盖面,不再这里另抄一份清单(抄来的清单只会腐化)。终端展示的东亚字符宽度用 `src/display.mjs` 里的一张 CJK 区间启发式判定,只覆盖任务摘要里真实出现的形态。
|
|
179
|
+
`bin/cron-up.mjs` 是 CLI 入口,`src/` 是各业务模块,通过 `src/internals.mjs` 的 deps 容器注入外部依赖(便于测试打桩)。测试与源码同量,改行为请同步改测试。
|
|
246
180
|
|
|
247
181
|
## 发布
|
|
248
182
|
|
|
249
|
-
-
|
|
250
|
-
- **发布方式**:先把 package.json 的 version 改成新版本并合入 main,再打同名 `v<version>` tag(如 `v26.9.21`);GitHub Actions 会跑通测试(Node 22/24)、校验 package.json 版本与 tag
|
|
251
|
-
-
|
|
252
|
-
-
|
|
253
|
-
-
|
|
183
|
+
- **版本号**:按「年.月.序号」——两位年份、两位月份、累计发布序号(0.x 时代的发布总数,含 0.x,序号永不重置、不随月份归零,始终等于含本次在内的历史发布总次数)。下一序号不用手数:发布前 `git fetch --prune --tags` 后数 v* tag 总数加一,`prune-tags` 让删错的 tag 不计数。
|
|
184
|
+
- **发布方式**:先把 package.json 的 version 改成新版本并合入 main,再打同名 `v<version>` tag(如 `v26.9.21`);GitHub Actions 会跑通测试(Node 22/24)、校验 package.json 版本与 tag 一致(不符直接失败)、并 publish。tag 失败的 launchd 入口旧 tag 重跑即可,序号不消耗。发布失败的 tag 重跑 release 工作流即可,序号不消耗。
|
|
185
|
+
- npm 经 GitHub OIDC 验证「确为 `adaex/cron-up` 仓库 `v` 触发的运行」后放行,仓库里没有 NPM_TOKEN,token 泄漏这条路不存在。
|
|
186
|
+
- **发版收尾**(每次发版必做):按上述方式升级后,跑一次无参数的 `cron-up`,确认首行是刚发的版本号、服务已加载且上次退出码 0、「需要留意」为空。
|
|
187
|
+
- **npm 传播延迟**:publish 成功约两分钟才完成处理,本机缓存各自滞后,装到旧版且毫无提示——等两分钟后用显式版本号安装,装完以总览首行版本号为准。
|
|
188
|
+
- **trusted publishing**:npm 包设置页里已登记 GitHub 仓库(repository `adaex/cron-up`,workflow `release.yml`),一次性配置,没了见 Trusted Publisher 文档。
|
|
254
189
|
|
|
255
190
|
## License
|
|
256
191
|
|
|
257
|
-
[
|
|
192
|
+
MIT([LICENSE](LICENSE))
|
package/package.json
CHANGED
package/src/args.mjs
CHANGED
|
@@ -1,37 +1,28 @@
|
|
|
1
|
-
// 子命令选项规格与选项名转换。独立于 cli
|
|
2
|
-
//
|
|
1
|
+
// 子命令选项规格与选项名转换。独立于 cli:选项表是唯一事实源,新增选项
|
|
2
|
+
// 时只改这里。
|
|
3
3
|
|
|
4
|
-
// 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool
|
|
5
|
-
//
|
|
4
|
+
// 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无
|
|
5
|
+
// 值开关。
|
|
6
6
|
export const SPECS = {
|
|
7
7
|
install: {
|
|
8
8
|
options: {
|
|
9
9
|
roots: 'string',
|
|
10
|
-
interval: 'int',
|
|
11
|
-
lead: 'int',
|
|
12
|
-
force: 'bool',
|
|
13
|
-
'auto-renew': 'tri',
|
|
14
|
-
'auto-min-id': 'tri',
|
|
15
|
-
'session-retain': 'string',
|
|
16
|
-
'stamp-owner': 'tri',
|
|
17
10
|
},
|
|
18
11
|
},
|
|
19
12
|
uninstall: {
|
|
20
13
|
options: {
|
|
21
14
|
purge: 'bool',
|
|
22
|
-
'keep-sessions': 'bool',
|
|
23
15
|
},
|
|
24
16
|
},
|
|
25
17
|
list: { options: {} },
|
|
26
18
|
run: { options: {} },
|
|
27
|
-
renew: { options: {} },
|
|
28
19
|
logs: {
|
|
29
20
|
options: { follow: 'bool', f: 'bool' },
|
|
30
21
|
positionalMax: 1,
|
|
31
22
|
},
|
|
32
23
|
};
|
|
33
24
|
|
|
34
|
-
// 选项名统一成代码里用的 camelCase(--
|
|
25
|
+
// 选项名统一成代码里用的 camelCase(--roots → roots)。
|
|
35
26
|
export function thisCamel(name) {
|
|
36
27
|
return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
37
28
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// 命令行解析与分发。node:util parseArgs
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// 命令行解析与分发。node:util parseArgs 不支持子命令与自动帮助,这里手
|
|
2
|
+
// 写一层:子命令前只允许隐藏的 --config(前后都可放),定位子命令后按
|
|
3
|
+
// 各命令的选项表解析。
|
|
4
4
|
|
|
5
5
|
// 先引全量入口,保证所有业务模块都已向 deps 自注册,再引命令函数。
|
|
6
6
|
import './index.mjs';
|
|
7
7
|
import { deps, ExitError, isDecInt } from './internals.mjs';
|
|
8
|
-
import { cmdRun
|
|
8
|
+
import { cmdRun } from './patrol.mjs';
|
|
9
9
|
import { cmdInstall, cmdUninstall } from './service.mjs';
|
|
10
10
|
import { cmdList } from './list.mjs';
|
|
11
11
|
import { cmdOverview } from './overview.mjs';
|
|
@@ -29,16 +29,12 @@ function usageExit(message) {
|
|
|
29
29
|
function consumeOption(tok, tokens, i, spec, opts) {
|
|
30
30
|
let name;
|
|
31
31
|
let inlineValue;
|
|
32
|
-
let negate = false;
|
|
33
32
|
if (tok.startsWith('--')) {
|
|
34
33
|
const body = tok.slice(2);
|
|
35
34
|
const eq = body.indexOf('=');
|
|
36
35
|
if (eq >= 0) {
|
|
37
36
|
name = body.slice(0, eq);
|
|
38
37
|
inlineValue = body.slice(eq + 1);
|
|
39
|
-
} else if (body.startsWith('no-') && spec.options[body.slice(3)] === 'tri') {
|
|
40
|
-
name = body.slice(3);
|
|
41
|
-
negate = true;
|
|
42
38
|
} else {
|
|
43
39
|
name = body;
|
|
44
40
|
}
|
|
@@ -52,18 +48,8 @@ function consumeOption(tok, tokens, i, spec, opts) {
|
|
|
52
48
|
opts[thisCamel(name)] = true;
|
|
53
49
|
return i + 1;
|
|
54
50
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (inlineValue !== 'true' && inlineValue !== 'false') {
|
|
58
|
-
usageExit(`--${name} 只接受 true 或 false`);
|
|
59
|
-
}
|
|
60
|
-
opts[thisCamel(name)] = inlineValue === 'true';
|
|
61
|
-
} else {
|
|
62
|
-
opts[thisCamel(name)] = !negate;
|
|
63
|
-
}
|
|
64
|
-
return i + 1;
|
|
65
|
-
}
|
|
66
|
-
// string / int 吃一个值(内联或下一 token)。
|
|
51
|
+
// string / int 吃一个值(内联或下一 token)。int 类型现已无选项使用,
|
|
52
|
+
// 保留解析能力给未来。
|
|
67
53
|
let value;
|
|
68
54
|
if (inlineValue !== undefined) value = inlineValue;
|
|
69
55
|
else {
|
|
@@ -124,15 +110,14 @@ export function parseCli(argv) {
|
|
|
124
110
|
return result;
|
|
125
111
|
}
|
|
126
112
|
|
|
127
|
-
const USAGE = `cron-up ——
|
|
113
|
+
const USAGE = `cron-up —— 为定时任务提前备好交互会话(一任务一会话)
|
|
128
114
|
|
|
129
115
|
用法:
|
|
130
|
-
cron-up
|
|
131
|
-
cron-up install 安装配置与 LaunchAgent
|
|
132
|
-
cron-up uninstall
|
|
133
|
-
cron-up list
|
|
134
|
-
cron-up run 执行一轮巡检(launchd
|
|
135
|
-
cron-up renew 立即给所有周期任务补 permanent 并改小 id
|
|
116
|
+
cron-up 查看总览(服务、配置、任务、预热会话)
|
|
117
|
+
cron-up install 安装配置与 LaunchAgent(可用 --roots 指定扫描目录)
|
|
118
|
+
cron-up uninstall 卸载并结束全部预热会话(--purge 连配置日志一起删)
|
|
119
|
+
cron-up list 逐个列出定时任务的设定/预计实际触发时间与预热状态
|
|
120
|
+
cron-up run 执行一轮巡检(launchd 入口,也可手动立即跑一轮)
|
|
136
121
|
cron-up logs [目录片段] 查看巡检日志或后台会话记录(-f 跟踪)
|
|
137
122
|
|
|
138
123
|
升级:npm i -g cron-up@latest`;
|
|
@@ -157,8 +142,6 @@ export async function main(argv = process.argv) {
|
|
|
157
142
|
return cmdList(args);
|
|
158
143
|
case 'run':
|
|
159
144
|
return cmdRun(args);
|
|
160
|
-
case 'renew':
|
|
161
|
-
return cmdRenew(args);
|
|
162
145
|
case 'logs':
|
|
163
146
|
return cmdLogs({ ...args, workspace: parsed.positional[0] });
|
|
164
147
|
}
|