cron-up 26.10.32 → 26.10.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +61 -0
- package/README.md +228 -112
- package/docs/internals.md +190 -0
- package/docs/migration.md +47 -0
- package/docs/release.md +75 -0
- package/package.json +5 -3
- package/src/args.mjs +1 -2
- package/src/cli.mjs +18 -20
- package/src/config.mjs +22 -19
- package/src/constants.mjs +7 -17
- package/src/cron.mjs +18 -9
- package/src/display.mjs +2 -2
- package/src/list.mjs +1 -1
- package/src/logs.mjs +2 -1
- package/src/overview.mjs +7 -7
- package/src/patrol.mjs +199 -162
- package/src/registry.mjs +10 -13
- package/src/service.mjs +189 -49
- package/src/sessions.mjs +61 -20
- package/src/tasks.mjs +45 -84
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# 内部机制与兼容性
|
|
2
|
+
|
|
3
|
+
本文面向需要排查兼容问题或修改实现的维护者。这里记录的是当前实现依据和行为边界,不是 Claude Code 的公开契约;普通使用从 [README](../README.md) 开始
|
|
4
|
+
|
|
5
|
+
## 版本证据与验证口径
|
|
6
|
+
|
|
7
|
+
cron-up 依赖的上游机制没有稳定性承诺,不能把“2.1 以上”视为完整的兼容声明。当前仓库中已有的核对记录如下:
|
|
8
|
+
|
|
9
|
+
| 机制 | 已有核对依据 | 使用时的含义 |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| 7 天周期任务回收与任务归属字段 | Claude Code 2.1.300 起有核对记录 | 更早版本不能据此推断可用 |
|
|
12
|
+
| 逐任务调度谓词与目录级调度锁 | Claude Code 2.1.302 二进制 | 一任务一会话的核心依据 |
|
|
13
|
+
| `script → zsh → claude` 三代进程结构 | 本机 Claude Code 2.1.302 | 会话认亲和进程回收依赖这一结构及祖先链识别 |
|
|
14
|
+
| 任务文件与 `~/.claude/sessions/` 会话登记 | 当前实现以 2.1.302 为本机核对基线 | 登记格式漂移时只能提供烟雾告警,不能证明所有语义仍兼容 |
|
|
15
|
+
| 投递抖动默认值与算法 | Claude Code 2.1.285,darwin-arm64,2026-09-30 安装的二进制 | 不能写成已经在 2.1.302 重新验证;远程配置还可能覆盖默认值 |
|
|
16
|
+
|
|
17
|
+
这张表记录已有证据,不是“只有列出的版本才能运行”的断言。低于或高于核心基线的版本都按未验证处理;升级后应:
|
|
18
|
+
|
|
19
|
+
1. 运行 `cron-up`,查看会话登记、启动链和预热告警
|
|
20
|
+
2. 用低风险任务验证发现、预热、投递和回收的完整链路
|
|
21
|
+
3. 若预计投递与实际投递持续出现系统性偏差,重新核对上游算法,而不是只调整本地显示常量
|
|
22
|
+
|
|
23
|
+
`src/constants.mjs` 中的 `CC_JITTER` 注释还记录了上游远程配置键 `tengu_kairos_cron_config`。运行时的远程配置可能改变投递行为,因此本地逆向常量只能用于估算
|
|
24
|
+
|
|
25
|
+
## 工作区与任务身份
|
|
26
|
+
|
|
27
|
+
- 工作区是包含 `.claude/scheduled_tasks.json` 的精确目录,不自动等同于 Git 根目录
|
|
28
|
+
- 扫描从 roots 开始,root 本身及其下第 1 至第 3 层都检查候选文件;符号链接形式的子目录不递归,排除名单由 `PRUNE_DIRS` 定义
|
|
29
|
+
- 原生任务优先使用唯一 `id` 作为状态键;缺少 id 的手写任务使用稳定内容摘要形成 `anon:` 键
|
|
30
|
+
- 同一个状态键对应多条任务时,无法唯一映射会话,巡检跳过这些条目的预热与归属改写并给出告警
|
|
31
|
+
- 推荐入口是 Claude Code 的 CronCreate(`durable: true`);手写或第三方生成文件只是兼容性用法,字段必须遵循上游语义
|
|
32
|
+
|
|
33
|
+
## 逐任务归属与调度谓词
|
|
34
|
+
|
|
35
|
+
Claude Code 2.1.302 中核对到的投递判定按以下优先级理解:
|
|
36
|
+
|
|
37
|
+
1. 任务没有归属会话:只有持目录调度锁的会话投递
|
|
38
|
+
2. 任务归属当前会话:当前会话投递,不需要目录锁
|
|
39
|
+
3. 当前进程是任务记录的创建者进程(resume 形态):可以投递
|
|
40
|
+
4. 其他情况:会话必须持锁、任务属于本项目,并且记录中的创建者进程已死,才可接管
|
|
41
|
+
|
|
42
|
+
cron-up 为进入窗口且原归属不在场的任务分别启动会话,认亲后把任务归属写到对应 sessionId 和进程指纹。这样,同目录同时到期的不同任务不必共享一个交互会话
|
|
43
|
+
|
|
44
|
+
“原创建者在线时不碰任务”只适用于**不预热、不改写归属**;自动续期仍会修改显式周期任务的 `permanent` 字段
|
|
45
|
+
|
|
46
|
+
某个预热中途退出后,其他符合上游规则的活会话仍可能按第 4 条接管,但这不是 cron-up 对执行结果的额外保证
|
|
47
|
+
|
|
48
|
+
## 巡检与会话生命周期
|
|
49
|
+
|
|
50
|
+
主要常量集中在 [`src/constants.mjs`](../src/constants.mjs),数值以源码为准;本文解释其语义和派生关系。修改常量时应同时核对状态机,而不是只改文档
|
|
51
|
+
|
|
52
|
+
| 常量 | 含义 |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `INTERVAL_SECONDS` | launchd 相邻巡检轮次的目标间隔,休眠时可能漏轮 |
|
|
55
|
+
| `LEAD_SECONDS` | 周期任务的预热前瞻量 |
|
|
56
|
+
| `MAX_SCAN_DEPTH` | root 下最多再下降的层数,最大深度本身仍检查任务文件 |
|
|
57
|
+
| `WARMUP_POLL_MS` | 新会话启动后,当轮重扫会话登记的间隔 |
|
|
58
|
+
| `WARMUP_WAIT_MS` | 当轮等待认亲的上限,不是判死时限 |
|
|
59
|
+
| `WARMUP_GRACE_SECONDS` | 未登记进程超过该年龄后,才在跨轮状态机中判卡死 |
|
|
60
|
+
| `FAIL_LIMIT` | 没有成功登记的启动失败达到该次数后进入冷却 |
|
|
61
|
+
| `COOLDOWN_SECONDS` | 单任务暂停重试的时间 |
|
|
62
|
+
| `FAIL_TTL_SECONDS` | 已死亡条目的失败记录跨预热窗口保留后的老化时限;活条目和冷却条目按各自状态保留 |
|
|
63
|
+
| `SESSION_RECYCLE_IDLE_SECONDS` | 与 `WARMUP_GRACE_SECONDS` 同值;回收判断要求的界面静默时长 |
|
|
64
|
+
| `SESSION_FIRE_MARGIN_SECONDS` | 除一个巡检间隔外,为下次触发预留的余量 |
|
|
65
|
+
| `SESSION_RECYCLE_MARGIN_SECONDS` | 等于 `SESSION_FIRE_MARGIN_SECONDS + INTERVAL_SECONDS`;回收要求距下次需要超过该余量 |
|
|
66
|
+
| `HEARTBEAT_STALE_FACTOR` | 心跳超过该倍数的巡检间隔未更新时提示沉默 |
|
|
67
|
+
| `PATROL_LOG_ROTATE_BYTES` | 单个巡检日志超过该大小后,在轮初轮转 |
|
|
68
|
+
| `DISPLAY_SEARCH_DAYS` | `list` 和总览寻找未来落点的展示窗口 |
|
|
69
|
+
|
|
70
|
+
一次性任务的预热前瞻统一额外增加 `CC_JITTER.oneShotMaxMs`,窗口前沿为 `落点 - LEAD_SECONDS - CC_JITTER.oneShotMaxMs`。**所有一次性任务都使用该保守窗口**,不是只有 `:00`/`:30` 网格任务;额外前瞻用于覆盖网格任务可能提前投递的相位,非网格任务也采用同一口径。实际启动仍取决于巡检相位和运行状态
|
|
71
|
+
|
|
72
|
+
### 一轮巡检的主要阶段
|
|
73
|
+
|
|
74
|
+
1. 获取巡检互斥锁,读取配置、状态、心跳和 Claude Code 会话登记
|
|
75
|
+
2. 发现工作区,按任务逐组处理;单个工作区出错不应阻断后续工作区
|
|
76
|
+
3. 已有条目先尝试认亲,成功后回填 sessionId、Claude pid 和进程启动指纹,并清零失败计数
|
|
77
|
+
4. 依次处理原创建者在线、终端 resume 接管、执行后回收、预热窗口、卡死退休、冷却和重新启动
|
|
78
|
+
5. 合并自动续期与归属改写;新启动的会话在当轮等待认亲后再尝试补写归属
|
|
79
|
+
6. 回收任务已经删除的会话,保存仍有效的状态和本轮心跳
|
|
80
|
+
|
|
81
|
+
### 启动失败与卡死不是同一计时器
|
|
82
|
+
|
|
83
|
+
- `WARMUP_WAIT_MS` 的当轮等待结束只会停止等待,不增加失败计数,也不直接杀死仍存活的进程
|
|
84
|
+
- 未登记进程年龄超过 `WARMUP_GRACE_SECONDS` 后,由后续巡检执行卡死退休,而不是由到达阈值就精确触发的独立定时器处置
|
|
85
|
+
- 启动即失败、启动后死亡和卡死退休都会进入失败流程;成功认亲会清零失败计数
|
|
86
|
+
- 已死亡条目的失败记录按任务保留,可跨稀疏任务的多个预热窗口累计,并按 `FAIL_TTL_SECONDS` 老化;因此 `FAIL_LIMIT` 统计的不是“连续若干个巡检轮次”
|
|
87
|
+
|
|
88
|
+
### 回收是启发式判断
|
|
89
|
+
|
|
90
|
+
周期任务的会话回收需要同时满足:
|
|
91
|
+
|
|
92
|
+
- `lastFiredAt` 表明该任务在本代会话启动后已经触发
|
|
93
|
+
- 会话界面日志静默超过 `SESSION_RECYCLE_IDLE_SECONDS`
|
|
94
|
+
- 距该任务下次需要会话超过 `SESSION_RECYCLE_MARGIN_SECONDS`
|
|
95
|
+
|
|
96
|
+
这些条件只说明“可以按当前模型回收”,不证明 prompt 的所有副作用都已成功完成。任一条件不满足时会继续复用现有会话;高频周期任务可能因始终不满足回收余量而长期复用同一会话
|
|
97
|
+
|
|
98
|
+
任务被删除或一次性任务执行后从文件中消失时,还会走对应的条目清理路径
|
|
99
|
+
|
|
100
|
+
若工作区整个离开扫描视野,巡检无法继续按该任务状态做回收;总览会尽量报告仍能识别的运行中会话
|
|
101
|
+
|
|
102
|
+
## 任务文件维护与并发
|
|
103
|
+
|
|
104
|
+
每轮维护分两类:
|
|
105
|
+
|
|
106
|
+
- **续期**:只要 `recurring` 为真且 `permanent` 尚未为真,就写入 `permanent: true`,不依赖任务是否进入预热窗口
|
|
107
|
+
- **归属改写**:只针对进入窗口且需要预热的唯一任务键,写入 `createdBySessionId`、`createdByPid`、`createdByProcStart`
|
|
108
|
+
|
|
109
|
+
归属改写不以修改 `prompt`、`cron`、`id`、`createdInProject` 为目标。内容相同但 id 不同的任务分别定位;重复 id 或重复匿名键无法唯一映射时跳过
|
|
110
|
+
|
|
111
|
+
同一批续期和已有条目的归属改写会合并写入;本轮新启动的会话在等待期认亲后,可能通过补盖流程另做一次归属写入。两条路径都使用同目录临时文件和原子替换:
|
|
112
|
+
|
|
113
|
+
- 临时文件先以 0600 创建,再尽量保留原任务文件权限
|
|
114
|
+
- 读取与写入之间使用 `mtimeNs` 做乐观检测,检测到其他写者改动时放弃本轮写入
|
|
115
|
+
- 这不是跨进程 CAS,也不是与 Claude Code 共用的锁;最后检查之后、`rename` 之前仍有一个无法由 cron-up 单方面关闭的竞态窗口
|
|
116
|
+
|
|
117
|
+
因此,文档不能把原子替换表述为绝不覆盖并发更新,卸载也不会回滚已经完成的续期或归属改写
|
|
118
|
+
|
|
119
|
+
## 投递时间算法
|
|
120
|
+
|
|
121
|
+
以下算法用于 `cron-up list`、总览的“最近”和预热尾窗估算。它是上游内部实现的复刻,不是 cron-up 自己决定的调度策略
|
|
122
|
+
|
|
123
|
+
### 任务 id 的归一化值
|
|
124
|
+
|
|
125
|
+
当前实现对原生十六进制任务 id 取前 8 位,解析后除以 `2^32`,得到 `[0, 1)` 范围内的固定值,记作 `h`。同一 id 每次计算相同,不是每次触发重新随机
|
|
126
|
+
|
|
127
|
+
手写非标准 id 的解析不应被当作稳定兼容接口;推荐让 CronCreate 生成 id
|
|
128
|
+
|
|
129
|
+
### 周期任务
|
|
130
|
+
|
|
131
|
+
对某个 cron 落点,先计算它到下一落点的实际间隔 `period`,再估算:
|
|
132
|
+
|
|
133
|
+
```text
|
|
134
|
+
delay = min(h × 0.5 × period, 30 分钟)
|
|
135
|
+
预计投递 = 本落点 + delay
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
每日任务周期较长,很多原生随机 id 会触及 30 分钟上限。落点已经过去、预计投递尚未到来时,仍属于需要会话在场的尾窗,不能按“下一次未来落点”提前回收
|
|
139
|
+
|
|
140
|
+
代码已记录但**没有复刻**一个上游例外:相邻落点间隔约在 `[300000, 315000)` ms、且表达式匹配上游内部步进规则时,存在 `cacheLeadMs` 提前 15 秒的分支,例如部分约 5 分钟周期任务。对这类任务:
|
|
141
|
+
|
|
142
|
+
- “周期任务一律在落点后投递”的说法不成立
|
|
143
|
+
- 当前 `list` 的预计时间可能与上游实际调度存在约 15 秒或更多的系统性差异
|
|
144
|
+
- 是否补齐该分支需要单独实现和测试,不能只改文档宣称已经支持
|
|
145
|
+
|
|
146
|
+
### 一次性任务
|
|
147
|
+
|
|
148
|
+
- 落点分钟不属于 `:00`/`:30` 网格时,当前估算不增加投递提前量
|
|
149
|
+
- 落点属于网格时,按 `h × 90 秒` 提前,且不早于当前时刻
|
|
150
|
+
- 无论是否属于网格,巡检的预热前瞻都统一额外增加 90 秒,原因见生命周期章节
|
|
151
|
+
|
|
152
|
+
会话内部还存在秒级轮询尾差,因此“预计投递时间”不等于对实际开始执行时刻的保证
|
|
153
|
+
|
|
154
|
+
### 展示边界
|
|
155
|
+
|
|
156
|
+
未来落点的展示搜索范围由 `DISPLAY_SEARCH_DAYS` 定义。“一年内无”既包括下一落点超出展示窗口的表达式,例如部分闰日任务,也包括字段可以解析、但日历上永远无法匹配的表达式,例如 `0 0 30 2 *`
|
|
157
|
+
|
|
158
|
+
“cron 无效”只用于表达式解析失败,或字段值域、区间导致合法值集合为空;它与“一年内无”不是同一状态
|
|
159
|
+
|
|
160
|
+
## 会话登记、认亲与接管
|
|
161
|
+
|
|
162
|
+
预热进程的典型结构为:
|
|
163
|
+
|
|
164
|
+
```text
|
|
165
|
+
/usr/bin/script(cron-up state 记录这一层)
|
|
166
|
+
└─ /bin/zsh -lic
|
|
167
|
+
└─ claude(会话登记记录这一层)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
巡检读取 `~/.claude/sessions/*.json`,结合进程存活、cwd 和祖先链,把 script 进程与登记的 sessionId 对应起来。登记是上游输出,不是稳定 API;读取器包含三类烟雾告警:
|
|
171
|
+
|
|
172
|
+
- 有登记文件但识别不到 interactive 会话
|
|
173
|
+
- 存活登记进程都不是可识别的 `claude`
|
|
174
|
+
- interactive 会话缺少有效 pid 或工作目录
|
|
175
|
+
|
|
176
|
+
这些告警只能覆盖已知漂移形态,不能证明上游所有内部语义均未变化
|
|
177
|
+
|
|
178
|
+
其他恢复机制:
|
|
179
|
+
|
|
180
|
+
- **终端接管**:script 层已退出但同一 sessionId 仍在终端存活时,不再另拉或结束该会话
|
|
181
|
+
- **孤儿认领**:state 丢失时,按任务专属日志路径从进程表重新识别仍可确认的预热进程
|
|
182
|
+
- **停止保护**:发信号前核对进程启动指纹,降低 pid 复用时误伤其他进程组的概率
|
|
183
|
+
|
|
184
|
+
## 日志与隐私
|
|
185
|
+
|
|
186
|
+
- 巡检日志位于 `~/Library/Logs/cron-up/launchd.{out,err}.log`,默认 `cron-up logs` 展示当前 stdout 日志最近 100 行
|
|
187
|
+
- 会话界面日志按 `<工作区折名>--<任务id>.log` 隔离,启动新代时轮转保留一代 `.1`
|
|
188
|
+
- 工作区折名会转义下划线并把路径分隔符折叠为 `_`,避免不同路径轻易撞名
|
|
189
|
+
- 会话日志可能包含任务输出,文件按 0600、目录按 0700 管理
|
|
190
|
+
- Claude Code 自己仍在 `~/.claude/projects/` 保存对话记录,cron-up 的 `--purge` 不删除这些记录
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 旧版迁移
|
|
2
|
+
|
|
3
|
+
本文适用于从 **26.10.30 及更早版本**升级到 **26.10.31 或更高版本**。从 26.10.31 开始,预热模型由“每个目录一个会话”改为“每个进入窗口的任务一个会话”,配置也只保留 roots
|
|
4
|
+
|
|
5
|
+
## 主要变化
|
|
6
|
+
|
|
7
|
+
| 方面 | 26.10.30 及更早 | 26.10.31 起 |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| 预热粒度 | 每个工作区一个目录级会话 | 每个进入窗口的任务一个专属会话 |
|
|
10
|
+
| 会话日志 | 目录级命名 | `<工作区折名>--<任务id>.log` |
|
|
11
|
+
| 状态记录 | 扁平的目录级条目 | 按工作区、任务 id 两级记录 |
|
|
12
|
+
| 配置 | 可能留有旧行为开关和字段 | 只保留 `roots`,其他行为常量化 |
|
|
13
|
+
|
|
14
|
+
## 升级步骤
|
|
15
|
+
|
|
16
|
+
通用安装、启动链重建和总览验收按 [README 的升级步骤](../README.md#升级与启动链维护)执行。刚发布、需要避免 registry 或本机缓存带回旧版时,按[发布后的逐机升级与验收](release.md#每次发版后的逐机升级与验收)使用显式版本号
|
|
17
|
+
|
|
18
|
+
重跑 install 会移除旧版废弃配置字段;配置无法解读时的默认 roots 回退与首轮写入风险见 [README 的配置恢复说明](../README.md#配置无法解读时)
|
|
19
|
+
|
|
20
|
+
迁移后还要检查预热日志名称。新一代任务级日志应包含 `--<任务id>`;`cron-up logs <目录名>` 命中多个任务时,按候选提示补充任务 id
|
|
21
|
+
|
|
22
|
+
## 旧目录级会话如何收尾
|
|
23
|
+
|
|
24
|
+
升级后的首轮巡检会结束能够核实身份的旧目录级预热,再按任务重新启动。迁移不会把旧的目录级状态直接伪装成新任务级状态
|
|
25
|
+
|
|
26
|
+
为防止 pid 复用导致误杀,缺少进程启动指纹的旧条目不会被贸然发信号:
|
|
27
|
+
|
|
28
|
+
- 对应进程已经退出时,不再需要停止它
|
|
29
|
+
- 对应进程仍在运行且无法安全核实时,巡检会明确告警,不会把“未停止”记成“已回收”
|
|
30
|
+
- 出现这类告警时,先用 `cron-up logs` 找到“旧版迁移”条目,记录其中的 pid、工作区或日志路径;旧版扁平状态不一定会出现在总览中
|
|
31
|
+
- 再把下列命令中的 12345 换成实际 pid,核对进程启动时间、父进程和完整启动命令:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
/bin/ps -p 12345 -o pid=,ppid=,lstart=,args=
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
确认它确为旧版预热后再决定如何收尾;无法确认身份时,不要对该进程或其进程组直接发信号
|
|
38
|
+
|
|
39
|
+
## 不会自动回滚的内容
|
|
40
|
+
|
|
41
|
+
升级和卸载都不会把任务文件恢复到首次使用 cron-up 之前:
|
|
42
|
+
|
|
43
|
+
- 已写入的 `permanent` 会保留
|
|
44
|
+
- 已改写的 `createdBySessionId`、`createdByPid`、`createdByProcStart` 不会自动还原
|
|
45
|
+
- Claude Code 的任务和 `~/.claude/projects` 下的对话记录不由迁移流程删除
|
|
46
|
+
|
|
47
|
+
如果工作区不再纳入 roots,后续巡检不会再维护它,但既有字段和已经运行且失去扫描视野的会话仍需分别核查
|
package/docs/release.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# 发布流程
|
|
2
|
+
|
|
3
|
+
本文面向维护者,记录 `adaex/cron-up` 的版本号、tag、npm trusted publishing 和发版后逐机验收。普通升级见 [README](../README.md#升级与启动链维护)
|
|
4
|
+
|
|
5
|
+
## 版本号
|
|
6
|
+
|
|
7
|
+
格式为 `YY.MM.serial`,例如 `26.10.33`:
|
|
8
|
+
|
|
9
|
+
- `YY` 是两位年份,`MM` 是两位月份
|
|
10
|
+
- `serial` 是累计序号,包含 0.x 时代,跨月份不归零
|
|
11
|
+
- 工程上的最终校验口径是**远端 `v*` tag 数量**,不是 npm 成功发布次数;下一序号等于发布前该数量加一
|
|
12
|
+
|
|
13
|
+
发布前同步并计算:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git fetch --prune --prune-tags --tags
|
|
17
|
+
git tag -l 'v*' | wc -l
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
计数结果加一就是下一序号。release 工作流会重新直接统计远端 tag,防止本地状态与远端不一致;被删除的远端 tag 不再计数。已推送但尚未 publish、且 tag 内容无需改变的失败运行,优先重跑同一工作流,而不是递增序号;需要修复 tag 内容时按下文处理
|
|
21
|
+
|
|
22
|
+
## 发布步骤
|
|
23
|
+
|
|
24
|
+
1. 确认 `main` 已包含本次发布内容,测试通过
|
|
25
|
+
2. 按上述规则修改 `package.json` 的 `version`,并将发布提交合入 `main`
|
|
26
|
+
3. 在发布提交上创建同名签名注解 tag 并推送:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git tag -s v<version> <发布提交> -m "发布 <version>"
|
|
30
|
+
git push origin v<version>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
4. 等待 `.github/workflows/release.yml` 完成。工作流会:
|
|
34
|
+
- 在 macOS 上用 Node 22、24 运行测试
|
|
35
|
+
- 校验 `package.json` 版本与 tag 完全一致
|
|
36
|
+
- 校验 tag 序号与远端 `v*` tag 总数一致
|
|
37
|
+
- 执行 `npm pack --dry-run`
|
|
38
|
+
- 通过 `npm publish --provenance` 发布
|
|
39
|
+
|
|
40
|
+
发布失败时先确认失败发生在 publish 之前,并区分两种情况:
|
|
41
|
+
|
|
42
|
+
- **tag 内容无需改变**:例如 runner 或 registry 的临时故障,可以直接对同一个 tag 重跑 release 工作流,不消耗新序号
|
|
43
|
+
- **必须修改仓库内容**:例如测试、版本号或工作流本身有误,只修复 main 后重跑旧 tag 仍会检出旧内容。应先把修复提交合入 main,再将同名签名 tag 重新签在修复提交上,并按仓库 tag 保护规则替换远端同名 tag,使新运行检出修复后的内容;同名 tag 不增加远端计数,因此不消耗新序号
|
|
44
|
+
|
|
45
|
+
只有尚未成功 publish 的版本才能按后一种方式替换 tag。已经成功 publish 的版本不要再移动 tag,后续问题通过新的发布修复
|
|
46
|
+
|
|
47
|
+
## Trusted publishing 与凭据边界
|
|
48
|
+
|
|
49
|
+
npm 通过 GitHub OIDC 校验发布工作流身份,仓库中不保存长期 `NPM_TOKEN`。这降低了长期 npm 令牌泄漏的风险,但不等于所有凭据、工作流或账号安全风险都不存在
|
|
50
|
+
|
|
51
|
+
npm 包设置中需要一次性登记:
|
|
52
|
+
|
|
53
|
+
- repository:`adaex/cron-up`
|
|
54
|
+
- workflow:`release.yml`(仓库中的 `.github/workflows/release.yml`)
|
|
55
|
+
- 发布触发:同名 `v*` tag 对应的 GitHub Actions 运行
|
|
56
|
+
|
|
57
|
+
配置缺失或需要重建时,按 npm 官方 [Trusted Publishers 文档](https://docs.npmjs.com/trusted-publishers/)核对,同时确认 workflow 仍具有 `id-token: write`,且发布环境使用满足要求的 npm 版本
|
|
58
|
+
|
|
59
|
+
## 每次发版后的逐机升级与验收
|
|
60
|
+
|
|
61
|
+
按 26.10.32 的实际发布记录,publish 成功后 registry 通常约两分钟可查,偶发需要七八分钟;带 provenance 时还要等待透明日志与后端入库,本机元数据缓存可能进一步滞后
|
|
62
|
+
|
|
63
|
+
对每一台已有安装的机器分别执行:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npm i -g cron-up@<新版本> --prefer-online
|
|
67
|
+
cron-up install
|
|
68
|
+
cron-up
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- 第一条必须使用显式新版本号和 `--prefer-online`,不要用不带版本的模糊安装命令
|
|
72
|
+
- 第二条重建配置、启动脚本与 LaunchAgent,并尝试首轮巡检
|
|
73
|
+
- 最后以总览首行的实际版本号为准,同时确认服务已加载、上次退出码为 0、“需要留意”为空;不能用 npm 命令退出码或 registry 页面代替本机核验
|
|
74
|
+
|
|
75
|
+
未安装的机器不借本次升级新装;无法连接的机器明确记为未完成。npm publish 成功只代表包已发布,不代表各台机器已经升级或巡检健康
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cron-up",
|
|
3
|
-
"version": "26.10.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "26.10.34",
|
|
4
|
+
"description": "为 Claude Code 定时任务在触发前按需预热交互会话",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cron-up": "bin/cron-up.mjs"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
-
"src"
|
|
11
|
+
"src",
|
|
12
|
+
"docs",
|
|
13
|
+
"CONTRIBUTING.md"
|
|
12
14
|
],
|
|
13
15
|
"scripts": {
|
|
14
16
|
"test": "node --test"
|
package/src/args.mjs
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
// 先引全量入口,保证所有业务模块都已向 deps 自注册,再引命令函数。
|
|
6
6
|
import './index.mjs';
|
|
7
|
-
import { deps, ExitError
|
|
7
|
+
import { deps, ExitError } from './internals.mjs';
|
|
8
8
|
import { cmdRun } from './patrol.mjs';
|
|
9
9
|
import { cmdInstall, cmdUninstall } from './service.mjs';
|
|
10
10
|
import { cmdList } from './list.mjs';
|
|
@@ -12,15 +12,6 @@ import { cmdOverview } from './overview.mjs';
|
|
|
12
12
|
import { cmdLogs } from './logs.mjs';
|
|
13
13
|
import { SPECS, thisCamel } from './args.mjs';
|
|
14
14
|
|
|
15
|
-
// 严格十进制整数命令行值(谓词 isDecInt 见 internals):
|
|
16
|
-
// Number("0x10")、parseInt("5x") 都会静默给出错误结果,必须显式拒绝。
|
|
17
|
-
function cliInt(s) {
|
|
18
|
-
if (!isDecInt(s)) {
|
|
19
|
-
throw new ExitError(2, `需要整数却得到:${s}`);
|
|
20
|
-
}
|
|
21
|
-
return parseInt(s, 10);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
15
|
function usageExit(message) {
|
|
25
16
|
throw new ExitError(2, message);
|
|
26
17
|
}
|
|
@@ -41,22 +32,23 @@ function consumeOption(tok, tokens, i, spec, opts) {
|
|
|
41
32
|
} else {
|
|
42
33
|
name = tok.slice(1); // -f
|
|
43
34
|
}
|
|
44
|
-
if (name === 'f' && spec.options
|
|
35
|
+
if (name === 'f' && Object.hasOwn(spec.options, 'f')
|
|
36
|
+
&& spec.options.f === 'bool') name = 'follow';
|
|
37
|
+
if (!Object.hasOwn(spec.options, name)) usageExit(`未知选项:${tok}`);
|
|
45
38
|
const kind = spec.options[name];
|
|
46
|
-
if (!kind) usageExit(`未知选项:${tok}`);
|
|
47
39
|
if (kind === 'bool') {
|
|
40
|
+
if (inlineValue !== undefined) usageExit(`布尔选项 --${name} 不接受值`);
|
|
48
41
|
opts[thisCamel(name)] = true;
|
|
49
42
|
return i + 1;
|
|
50
43
|
}
|
|
51
|
-
// string
|
|
52
|
-
// 保留解析能力给未来。
|
|
44
|
+
// string 吃一个值(内联或下一 token)。
|
|
53
45
|
let value;
|
|
54
46
|
if (inlineValue !== undefined) value = inlineValue;
|
|
55
47
|
else {
|
|
56
48
|
value = tokens[i + 1];
|
|
57
49
|
if (value === undefined) usageExit(`选项 --${name} 缺少值`);
|
|
58
50
|
}
|
|
59
|
-
opts[thisCamel(name)] =
|
|
51
|
+
opts[thisCamel(name)] = value;
|
|
60
52
|
return inlineValue !== undefined ? i + 1 : i + 2;
|
|
61
53
|
}
|
|
62
54
|
|
|
@@ -86,7 +78,7 @@ export function parseCli(argv) {
|
|
|
86
78
|
continue;
|
|
87
79
|
}
|
|
88
80
|
if (!tok.startsWith('-') && command === null) {
|
|
89
|
-
if (!(tok
|
|
81
|
+
if (!Object.hasOwn(SPECS, tok)) usageExit(`未知子命令:${tok}`);
|
|
90
82
|
command = tok;
|
|
91
83
|
i += 1;
|
|
92
84
|
continue;
|
|
@@ -110,17 +102,21 @@ export function parseCli(argv) {
|
|
|
110
102
|
return result;
|
|
111
103
|
}
|
|
112
104
|
|
|
113
|
-
const USAGE = `cron-up ——
|
|
105
|
+
const USAGE = `cron-up —— 为 Claude Code 定时任务按需预热交互会话(一任务一会话)
|
|
114
106
|
|
|
115
107
|
用法:
|
|
116
108
|
cron-up 查看总览(服务、配置、任务、预热会话)
|
|
117
109
|
cron-up install 安装配置与 LaunchAgent(可用 --roots 指定扫描目录)
|
|
118
|
-
cron-up uninstall
|
|
119
|
-
cron-up list
|
|
110
|
+
cron-up uninstall 停止预热并移除 LaunchAgent(--purge 另删配置与日志)
|
|
111
|
+
cron-up list 逐个列出任务的设定/预计投递时间与预热状态
|
|
120
112
|
cron-up run 执行一轮巡检(launchd 入口,也可手动立即跑一轮)
|
|
121
113
|
cron-up logs [目录片段] 查看巡检日志或后台会话记录(-f 跟踪)
|
|
122
114
|
|
|
123
|
-
升级:npm i -g cron-up@latest
|
|
115
|
+
升级:npm i -g cron-up@latest --prefer-online
|
|
116
|
+
cron-up install
|
|
117
|
+
|
|
118
|
+
完全移除:cron-up uninstall --purge
|
|
119
|
+
npm uninstall -g cron-up`;
|
|
124
120
|
|
|
125
121
|
export async function main(argv = process.argv) {
|
|
126
122
|
const parsed = parseCli(argv.slice(2));
|
|
@@ -144,5 +140,7 @@ export async function main(argv = process.argv) {
|
|
|
144
140
|
return cmdRun(args);
|
|
145
141
|
case 'logs':
|
|
146
142
|
return cmdLogs({ ...args, workspace: parsed.positional[0] });
|
|
143
|
+
default:
|
|
144
|
+
return usageExit(`未知子命令:${parsed.command}`);
|
|
147
145
|
}
|
|
148
146
|
}
|
package/src/config.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
isDecInt,
|
|
12
12
|
ensurePrivateDir,
|
|
13
13
|
} from './internals.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { PID_T_MAX, HEARTBEAT_STALE_FACTOR } from './constants.mjs';
|
|
15
15
|
import { expandHome, isDir } from './tasks.mjs';
|
|
16
16
|
|
|
17
17
|
// Shell 的 tilde 展开只发生在词首:"--roots ~/a,~/b" 穿过 shell 后第二个
|
|
@@ -28,15 +28,9 @@ export function normalizeRoots(roots) {
|
|
|
28
28
|
return out;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
return typeof x === 'number' && Number.isInteger(x);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// 宽容整型化(手改的 state/登记文件里字段可能是字符串或浮点):纯十进制
|
|
38
|
-
// 字符串接受,浮点截断,布尔拒绝;非法输入抛错由调用方按「字段不存在」
|
|
39
|
-
// 处理。
|
|
31
|
+
// 手改的 state/登记文件里的整数字段可能是字符串或浮点:纯十进制字符串接受,
|
|
32
|
+
// 浮点截断,布尔拒绝;非法输入抛错由调用方按「字段不存在」处理。pid 另有
|
|
33
|
+
// 更严的入口,不允许浮点截断。
|
|
40
34
|
function lenientInt(v) {
|
|
41
35
|
if (typeof v === 'boolean') throw new Error('布尔不是整数字段');
|
|
42
36
|
if (typeof v === 'number') {
|
|
@@ -53,6 +47,9 @@ function lenientInt(v) {
|
|
|
53
47
|
// 登记两个外部来源共用同一条入口规则——超大整数能穿过类型转换直到
|
|
54
48
|
// process.kill 才抛,负数 pid 会被 kill 当成进程组号误伤无辜进程组。
|
|
55
49
|
export function parsePid(v) {
|
|
50
|
+
// number 必须本身就是整数:1.5 截成 1 可能把停止信号发给完全无关的
|
|
51
|
+
// pid。字符串仍走纯十进制解析,不接受小数、指数或十六进制。
|
|
52
|
+
if (typeof v === 'number' && !Number.isInteger(v)) return null;
|
|
56
53
|
let pid;
|
|
57
54
|
try {
|
|
58
55
|
pid = lenientInt(v);
|
|
@@ -66,7 +63,7 @@ export function parsePid(v) {
|
|
|
66
63
|
// 「形同空转」,回退成默认家目录扫描则会动到用户没打算纳入的仓库,两个
|
|
67
64
|
// 方向都比停机更危险。
|
|
68
65
|
export function configFieldErrors(cfg) {
|
|
69
|
-
if (!Array.isArray(cfg.roots)) {
|
|
66
|
+
if (!Object.hasOwn(cfg, 'roots') || !Array.isArray(cfg.roots)) {
|
|
70
67
|
return [['roots', `roots 应是字符串数组,当前为 ${JSON.stringify(cfg.roots)}`]];
|
|
71
68
|
}
|
|
72
69
|
return [];
|
|
@@ -99,13 +96,14 @@ export function loadConfig(file, opts = {}) {
|
|
|
99
96
|
fail(`配置文件顶层应是 JSON 对象而不是${Array.isArray(cfg) ? '数组' : typeof cfg}:`
|
|
100
97
|
+ `${file};运行 cron-up install 生成,或用 --config 指定路径`);
|
|
101
98
|
}
|
|
102
|
-
|
|
103
|
-
|
|
99
|
+
// 文件已经存在就必须自己声明 roots;这里绝不补默认值。默认扫描范围只
|
|
100
|
+
// 属于首次 install 或明确的安装修复,读取配置时补上会静默扩大巡检范围。
|
|
101
|
+
const errs = configFieldErrors(cfg);
|
|
104
102
|
if (errs.length > 0) {
|
|
105
103
|
fail(`配置字段类型不对:${errs.map(([, m]) => m).join(';')}`
|
|
106
104
|
+ `;修复 ${file} 或重跑 cron-up install`);
|
|
107
105
|
}
|
|
108
|
-
return { roots: normalizeRoots(
|
|
106
|
+
return { roots: normalizeRoots(cfg.roots) };
|
|
109
107
|
}
|
|
110
108
|
|
|
111
109
|
// ---- state.json:两级嵌套 {ws: {taskId: entry}} ----
|
|
@@ -115,9 +113,9 @@ export function loadConfig(file, opts = {}) {
|
|
|
115
113
|
// deadSince、log、notified。pid 为 null 的条目是「当场没拉起/冷却中」的
|
|
116
114
|
// 合法形态;未认亲时 claude* 三字段缺省。
|
|
117
115
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
116
|
+
// 旧版顶层标记字段只用于识别「值仍是标量的扁平 entry」,不能单独拿键名
|
|
117
|
+
// 判代:手工任务 id 也可以叫 pid/startedAt。新桶的自有值里会有任务 entry
|
|
118
|
+
// 对象,旧扁平 entry 的字段值则是标量或 null,先看这个值结构再分代。
|
|
121
119
|
const LEGACY_MARKERS = ['pid', 'startedAt', 'cooldownUntil', 'fails'];
|
|
122
120
|
|
|
123
121
|
function cleanEntry(raw, ws, taskId) {
|
|
@@ -153,8 +151,13 @@ function cleanEntry(raw, ws, taskId) {
|
|
|
153
151
|
return ent;
|
|
154
152
|
}
|
|
155
153
|
|
|
154
|
+
function hasNestedEntries(v) {
|
|
155
|
+
return isPlainObject(v) && Object.values(v).some(isPlainObject);
|
|
156
|
+
}
|
|
157
|
+
|
|
156
158
|
function looksLegacyEntry(v) {
|
|
157
|
-
return isPlainObject(v) &&
|
|
159
|
+
return isPlainObject(v) && !hasNestedEntries(v)
|
|
160
|
+
&& LEGACY_MARKERS.some((k) => Object.hasOwn(v, k));
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
export function loadState() {
|
|
@@ -181,7 +184,7 @@ export function loadState() {
|
|
|
181
184
|
|
|
182
185
|
// 旧版扁平 state({ws: 目录级单 entry}):升级轮用它停掉旧预热后整体丢
|
|
183
186
|
// 弃,不迁移任务归属(新流程按任务重拉)。与 loadState 读同一个文件,
|
|
184
|
-
//
|
|
187
|
+
// 两个读取器按值结构与标记字段互斥,互不污染。
|
|
185
188
|
export function loadLegacyEntries() {
|
|
186
189
|
let state;
|
|
187
190
|
try {
|
package/src/constants.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// 全部调参常量集中一处,改动时便于通盘核对。
|
|
2
2
|
|
|
3
|
-
// 配置文件只有 roots
|
|
4
|
-
//
|
|
3
|
+
// 配置文件只有 roots 一个字段:巡检间隔、提前量、扫描深度、续期与任务归属
|
|
4
|
+
// 盖印都是不可关闭的唯一行为(见 README),常量化在本文件。
|
|
5
5
|
export const DEFAULT_CONFIG = {
|
|
6
6
|
// 默认只扫本人的三个任务制容器,不扫整个家目录——他人可写仓库里的周期
|
|
7
7
|
// 任务会被自动续期永久化,默认范围必须是用户完全掌控的领地(见 README
|
|
8
|
-
//
|
|
8
|
+
// 「安全与自动修改」)。其他布局用 cron-up install --roots 覆盖;目录不存在则跳过。
|
|
9
9
|
roots: ['~/space', '~/workspace', '~/tasks'],
|
|
10
10
|
};
|
|
11
11
|
|
|
@@ -45,9 +45,8 @@ export const WARMUP_GRACE_SECONDS = 180;
|
|
|
45
45
|
// 帽与墙钟帽双出口,高负载拖慢 setTimeout 也不会把一轮撑过间隔。
|
|
46
46
|
export const WARMUP_WAIT_MS = 24_000;
|
|
47
47
|
export const WARMUP_POLL_MS = 3_000;
|
|
48
|
-
// window
|
|
49
|
-
//
|
|
50
|
-
// WARMUP_GRACE 同值,结构上对齐「3 分钟定生死」的既有心智。
|
|
48
|
+
// window 回收判据一:界面静默超过此时长,作为本代触发后空闲的启发式,
|
|
49
|
+
// 不是任务完成状态的证明。取 WARMUP_GRACE 同值,结构上对齐既有心智。
|
|
51
50
|
export const SESSION_RECYCLE_IDLE_SECONDS = WARMUP_GRACE_SECONDS;
|
|
52
51
|
// window 回收判据二:距该任务下次「需要会话在场」至少还有这么多秒。回收
|
|
53
52
|
// 后下轮(≤INTERVAL)发现并 spawn,进程即在场,盖印在该轮等待期内完成;
|
|
@@ -73,7 +72,7 @@ export const PRUNE_DIRS = new Set([
|
|
|
73
72
|
]);
|
|
74
73
|
|
|
75
74
|
// Claude Code 会话投递定时任务时的抖动参数,两处使用:list/总览预测
|
|
76
|
-
//
|
|
75
|
+
// 「预计投递时间」;巡检把「落点之后、延迟投递点之前」这段投递尾窗
|
|
77
76
|
// 也算作需要会话在场,避免在任务真正投递前把预热回收。逆向自
|
|
78
77
|
// Claude Code v2.1.285
|
|
79
78
|
// (darwin-arm64 原生二进制,2026-09-30 安装)内部 createCronScheduler 的
|
|
@@ -81,8 +80,7 @@ export const PRUNE_DIRS = new Set([
|
|
|
81
80
|
// recurringCapMs);一次性任务在 :00/:30 网格点提前 0~oneShotMaxMs。
|
|
82
81
|
// 这些值没有稳定性承诺——版本升级可能改,运行时还可能被远程配置键
|
|
83
82
|
// tengu_kairos_cron_config(60 秒刷新,校验失败回退默认)覆盖。预测与
|
|
84
|
-
// 实际系统性对不上时,按
|
|
85
|
-
// claude-code-scheduled-task-jitter 的排查步骤复核新版本。
|
|
83
|
+
// 实际系统性对不上时,按 docs/internals.md 的版本证据与复核步骤核对新版本。
|
|
86
84
|
export const CC_JITTER = {
|
|
87
85
|
recurringFrac: 0.5,
|
|
88
86
|
recurringCapMs: 30 * 60 * 1000,
|
|
@@ -91,14 +89,6 @@ export const CC_JITTER = {
|
|
|
91
89
|
oneShotMinuteMod: 30,
|
|
92
90
|
};
|
|
93
91
|
|
|
94
|
-
// autoMinId 改小 id 的目标投递延迟上界。周期延迟 = hash(id)·frac·周期,
|
|
95
|
-
// 按每日周期(86400000ms,周期最短、要求最严)反解 id 数值上界:
|
|
96
|
-
// maxN = floor(目标延迟 / (frac·每日ms) · 2^32) ≈ 5_965_232 (0x005b05b0)
|
|
97
|
-
// id 数值不超过它的每日任务延迟 ≤ 60 秒;周/月任务周期更长,同阈值下延迟
|
|
98
|
-
// 更小。改小逻辑(src/tasks.mjs 的 minifyIds)从这里与 CC_JITTER 现算
|
|
99
|
-
// maxN,常量只保留「目标延迟」这一个可调项。
|
|
100
|
-
export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
|
|
101
|
-
|
|
102
92
|
// 投递尾窗判定的回看/前探窗口(wanted 与 nextDelivery 共用,见
|
|
103
93
|
// src/cron.mjs 的 recurringTailWindow):找上一落点用 prevAtOrBefore、算
|
|
104
94
|
// 周期用 nextAfter,两个方向都要罩住月/年任务之外的 2 月 29 日——相邻落
|