cron-up 26.10.24 → 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 +1 -0
- package/package.json +1 -1
- package/src/constants.mjs +7 -0
- package/src/cron.mjs +45 -17
- package/src/display.mjs +15 -5
package/README.md
CHANGED
|
@@ -184,6 +184,7 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
184
184
|
- 仅支持 macOS。电脑休眠期间错过的周期任务不会补执行;唤醒后 launchd 会立即跑一轮巡检,错过的**一次性**任务会在后台会话启动时由 Claude Code 补执行。
|
|
185
185
|
- Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
|
|
186
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` 可完全避开。
|
|
187
188
|
- 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」,同目录多会话还有重复执行的风险。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。真正的并发任务级隔离需要 Claude Code 官方支持任务路由。
|
|
188
189
|
- 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
|
|
189
190
|
- 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
|
package/package.json
CHANGED
package/src/constants.mjs
CHANGED
|
@@ -98,6 +98,13 @@ export const CC_JITTER = {
|
|
|
98
98
|
// maxN,常量只保留「目标延迟」这一个可调项。
|
|
99
99
|
export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
|
|
100
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
|
+
|
|
101
108
|
// 定时任务 7 天过期(周期任务被调度器删除,一次性任务早就触发过),巡检
|
|
102
109
|
// 不需要看得更远。展示页另算。
|
|
103
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 {
|
|
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));
|
|
28
|
-
|
|
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
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
109
|
-
|
|
117
|
+
// 没有未来触发,为 null;尾窗内的周期任务是即将到来的这次延迟投递。
|
|
118
|
+
// 与 nxt 同为 Date 或 null。
|
|
119
|
+
fire: missed ? null : (tail ? new Date(tail[1]) : predictedFire(task, now)),
|
|
110
120
|
};
|
|
111
121
|
}
|
|
112
122
|
|