cron-up 26.10.32 → 26.10.33

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
@@ -45,9 +45,9 @@ cron-up install --roots ~/code,~/work
45
45
  ### 平台要求
46
46
 
47
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 目录与当前安装形态解析进启动脚本。
48
+ - **Claude Code 2.1 以上**(在 2.1.302 实测;7 天判定与归属字段从 2.1.300 起核对,逐任务谓词与调度锁核对自 2.1.302)。fnm、bun、volta、Homebrew、官网 pkg 与其他版本管理器均支持——安装时把 node 与实际 cron-up 的 bin 目录解析进启动脚本。
49
49
  - Node.js 22 以上(零运行时依赖,巡检本身只调系统自带工具)。
50
- - 无人值守时的工具权限:后台会话没有人在场点授权确认。后台会话以 bypass permissions 模式启动,命令在无人授权下照常执行;目录没走过信任确认、或工具需要二次授权,会话会一直停在提问界面,预热失败后连续失败进入冷却。只把 roots 指向你本人信任的目录。
50
+ - 无人值守时的工具权限:后台会话没有人在场点授权确认。后台会话通过 `claude --permission-mode bypassPermissions` 显式以 bypass permissions 模式启动,命令在无人授权下照常执行;目录没走过信任确认、或工具需要二次授权,会话会一直停在提问界面,预热失败后连续失败进入冷却。只把 roots 指向你本人信任的目录。
51
51
  - 建议在任务描述末尾固定加一句任务结束会话的指令(见下)。
52
52
 
53
53
  ## 日常使用
@@ -66,13 +66,12 @@ cron-up uninstall # 停止全部预热会话并卸载(配置日
66
66
 
67
67
  ### 自动维护(每轮无条件进行,没有开关)
68
68
 
69
- 巡检每轮会顺手维护扫描到的任务文件,全部合并在一次带乐观锁的原子写里:
69
+ 巡检每轮会顺手维护扫描到的任务文件,全部合并在一次带 mtime 乐观检测的原子写里:
70
70
 
71
71
  - **自动续期**:给显式周期任务补 `permanent: true`,绕开 Claude Code 的 7 天周期任务回收。
72
- - **自动改小任务 id**:把周期任务的随机大 id 改成分文件不冲突的小序号,投递抖动压到 1 分钟以内,观感近乎准点。只改显式周期任务、幂等;一次性任务不碰。
73
- - **自动任务级盖印**:把窗口内死主任务的三个归属字段盖成各自预热会话的身份。只改 `createdBySessionId/createdByPid/createdByProcStart`,不碰 `prompt`、`cron`、`id`(除上面的改小)与 `createdInProject`。
72
+ - **自动任务级盖印**:把窗口内死主任务的三个归属字段盖成各自预热会话的身份。只改 `createdBySessionId/createdByPid/createdByProcStart`,不碰 `prompt`、`cron`、`id` 与 `createdInProject`;内容相同但 id 不同的任务仍逐条定位,不会共用盖印目标。
74
73
 
75
- 三轮维护与 Claude Code 自己对文件的写入(更新 `lastFiredAt`、执行后删一次性任务)用 mtime 乐观锁互斥:撞上就整轮跳过写入,下轮再来,绝不覆盖它的更新。
74
+ 这些维护与 Claude Code 自己对文件的写入(更新 `lastFiredAt`、执行后删一次性任务)用 mtime 做乐观检测:发现撞车就暂缓本轮该文件的写入,下轮再来。它能降低并发覆盖概率,但不是与 Claude Code 共用的互斥锁;最后一次检查与原子替换之间的极小竞态见「已知限制」。
76
75
 
77
76
  ### 让一次性任务结束后自动退出会话
78
77
 
@@ -92,7 +91,7 @@ cron-up uninstall # 停止全部预热会话并卸载(配置日
92
91
  - **卡死退休**:会话进程活了 3 分钟仍没在会话登记里出现(停在信任确认或工具授权提问上),终止并按失败处理。
93
92
  - **终端接管**:你随时可以 `claude --resume <预热会话id>` 把一个预热拉回终端。cron-up 发现原后台进程没了、但同一 sessionId 还活着,就停止管理它:不再另拉、绝不碰你的终端进程;该会话关闭后,任务恢复自动预热。
94
93
  - **孤儿认领**:state 记录丢失时,巡检能按会话日志路径从进程表里把仍在运行的预热重新认回来。
95
- - **升级迁移**:从目录级单预热的旧版本升级时,首轮巡检会结束旧版预热,之后按任务重新拉起。
94
+ - **升级迁移**:从目录级单预热的旧版本升级时,首轮巡检会结束能核实身份的旧版预热,之后按任务重新拉起。缺少进程启动指纹的旧条目不会被贸然发信号;若对应进程仍在运行,巡检会明确告警,需人工核查,不会假装已经回收。
96
95
 
97
96
  ## 恢复或接管后台会话
98
97
 
@@ -106,15 +105,15 @@ claude --resume <sessionId> # 或 claude -r 在列表中选择
106
105
 
107
106
  ## 投递抖动:设定时间与实际触发时间
108
107
 
109
- Claude Code 任务的实际投递不在 cron 落点,而是落点之后加一个按任务 id 确定的偏移(会话内部每秒轮询,实际到点有秒级尾差)。周期任务的延迟 = `min(哈希(id) × 0.5 × 周期, 30 分钟)`,每日任务在不改 id 时几乎必然固定晚到约 30 分钟;一次性任务在 :00/:30 整点最多提前 90 秒。`cron-up list` 的状态列和总览的「最近」显示的是计入抖动后的预计实际时间;自动改小 id 把每日任务的这段延迟压到 1 分钟内。
108
+ Claude Code 任务的实际投递不在 cron 落点,而是落点之后加一个按任务 id 确定的偏移(会话内部每秒轮询,实际到点有秒级尾差)。周期任务的延迟 = `min(哈希(id) × 0.5 × 周期, 30 分钟)`,每日任务使用原生随机 id 时几乎必然固定晚到约 30 分钟;一次性任务在 :00/:30 整点最多提前 90 秒。`cron-up list` 的状态列和总览的「最近」显示的是计入抖动后的预计实际时间;cron-up 不改写任务 id,也不会消除这段上游抖动。
110
109
 
111
110
  巡检判定「任务现在是否需要会话」时,把周期任务落点之后、抖动投递点之前的这段尾窗也算作需要在场,会话不会在任务真正投递前被回收。这些参数逆向自 Claude Code 内部实现,没有稳定性承诺(远程配置键 `tengu_kairos_cron_config` 还能覆盖);预测与实际系统性偏离时说明上游算法已变。
112
111
 
113
112
  ## 安全说明
114
113
 
115
114
  - **任务文件是执行输入,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 乐观锁互斥。
115
+ - **预热会话拥有你在 Claude Code 中的全部权限**:后台会话通过 `--permission-mode bypassPermissions` 显式以 bypass permissions 模式无人值守运行。roots 的边界就是无人授权写权限的边界。
116
+ - **归属盖印会改写任务文件**:cron-up 会改窗口内死主任务的 `createdBySessionId/Pid/ProcStart` 字段。写入采用原子替换(临时文件 0600、保留原文件权限),并用 mtime 乐观检测降低与 Claude Code 并发写入互相覆盖的概率。
118
117
  - **会话日志可能包含任务输出**:后台会话的界面记录写到 `~/Library/Logs/cron-up/sessions/<工作区>--<任务id>.log`,权限 600、目录 700,每个任务独立文件、轮转保留一代;巡检日志在 `~/Library/Logs/cron-up/`。
119
118
  - **扫描不深入符号链接的子目录**,默认还排除系统目录与依赖目录。
120
119
  - **巡检、预热与你的 claude 会话之间不发起网络请求**,也不依赖 npm 注册表;网络活动全部来自被启动的会话本身。唯一的网络发生在安装/升级时访问 npm registry 拉包。
@@ -125,6 +124,7 @@ Claude Code 任务的实际投递不在 cron 落点,而是落点之后加一
125
124
  - 每个窗口内的死主任务对应一个后台会话:同一分钟内有大量任务同时到期时,会同时存在相应数量的会话(每个数百 MB 内存)。时间错开的日常任务只在各自的 6 分钟窗口内占用(一次性任务至多 7.5 分钟)。
126
125
  - 手工创建的任务若缺少 `createdAt`,错过补执行判定不生效。`cron-up list` 对缺字段的手写任务仍显示「已错过/设定时间」,但预热与补执行依赖 Claude Code 与 cron-up 的字段纪律。
127
126
  - 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除或移出 roots)后,仍在运行的预热不会被回收,总览会列出这类会话,不需要时手动结束。
127
+ - 任务文件的 mtime 乐观检测不是跨进程 CAS:另一个写者若恰好赶在最后一次检查之后、原子替换之前写入,仍可能被覆盖。这个窗口无法由 cron-up 单方面彻底关闭;需要绝对保证时,应由上游提供共享写锁或公开写接口。
128
128
  - 预热依据的逐任务谓词、调度锁与三代进程结构是未公开内部实现:新版若调整归属/锁语义,任务级隔离的前提需要重新核对。cron-up 保留了会话登记格式的烟雾告警,格式漂移时在总览页明示。
129
129
 
130
130
  ## 故障排查
@@ -184,7 +184,7 @@ npm link # 本机把开发版链接成全局 cron-up
184
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
185
  - npm 经 GitHub OIDC 验证「确为 `adaex/cron-up` 仓库 `v` 触发的运行」后放行,仓库里没有 NPM_TOKEN,token 泄漏这条路不存在。
186
186
  - **发版收尾**(每次发版必做):按上述方式升级后,跑一次无参数的 `cron-up`,确认首行是刚发的版本号、服务已加载且上次退出码 0、「需要留意」为空。
187
- - **npm 传播延迟**:publish 成功约两分钟才完成处理,本机缓存各自滞后,装到旧版且毫无提示——等两分钟后用显式版本号安装,装完以总览首行版本号为准。
187
+ - **npm 传播延迟**:publish 成功后到 registry 可查通常两分钟、偶发七八分钟(带 provenance 要等透明日志与后端入库),本机元数据缓存还会再滞后,直接 `npm i -g cron-up` 可能装回旧版且毫无提示——用显式版本号加 `--prefer-online` 安装(`npm i -g cron-up@<新版本> --prefer-online`),装完以总览首行版本号为准。
188
188
  - **trusted publishing**:npm 包设置页里已登记 GitHub 仓库(repository `adaex/cron-up`,workflow `release.yml`),一次性配置,没了见 Trusted Publisher 文档。
189
189
 
190
190
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.10.32",
3
+ "version": "26.10.33",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  // 子命令选项规格与选项名转换。独立于 cli:选项表是唯一事实源,新增选项
2
2
  // 时只改这里。
3
3
 
4
- // 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无
5
- // 值开关。
4
+ // 选项类型:string 吃一个值;bool 是无值开关,--flag=value 一律拒绝。
6
5
  export const SPECS = {
7
6
  install: {
8
7
  options: {
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  // 先引全量入口,保证所有业务模块都已向 deps 自注册,再引命令函数。
6
6
  import './index.mjs';
7
- import { deps, ExitError, isDecInt } from './internals.mjs';
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.f === 'bool') name = 'follow';
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 / int 吃一个值(内联或下一 token)。int 类型现已无选项使用,
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)] = kind === 'int' ? cliInt(value) : value;
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 in SPECS)) usageExit(`未知子命令:${tok}`);
81
+ if (!Object.hasOwn(SPECS, tok)) usageExit(`未知子命令:${tok}`);
90
82
  command = tok;
91
83
  i += 1;
92
84
  continue;
@@ -144,5 +136,7 @@ export async function main(argv = process.argv) {
144
136
  return cmdRun(args);
145
137
  case 'logs':
146
138
  return cmdLogs({ ...args, workspace: parsed.positional[0] });
139
+ default:
140
+ return usageExit(`未知子命令:${parsed.command}`);
147
141
  }
148
142
  }
package/src/config.mjs CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  isDecInt,
12
12
  ensurePrivateDir,
13
13
  } from './internals.mjs';
14
- import { DEFAULT_CONFIG, PID_T_MAX, HEARTBEAT_STALE_FACTOR } from './constants.mjs';
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
- // 配置数值校验用:JSON 的 true 是布尔不能冒充秒数/深度
32
- // (Number.isInteger(true) === false,天然挡住)。
33
- export function isInt(x) {
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
- const merged = { ...DEFAULT_CONFIG, ...cfg };
103
- const errs = configFieldErrors(merged);
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(merged.roots) };
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
- // 旧版顶层标记字段:旧 state 是 {ws: entry},新 state 是 {ws: {taskId:
119
- // entry}}。taskId 只可能是 8 位十六进制或 anon: 前缀,永不与这些名字冲
120
- // 突,因此「顶层值自身是否带 entry 标记」就是两代形状的区分判据。
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) && LEGACY_MARKERS.some((k) => k in 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,7 +1,7 @@
1
1
  // 全部调参常量集中一处,改动时便于通盘核对。
2
2
 
3
- // 配置文件只有 roots 一个字段:巡检间隔、提前量、扫描深度、续期、改 id、
4
- // 任务归属盖印都是不可关闭的唯一行为(见 README),常量化在本文件。
3
+ // 配置文件只有 roots 一个字段:巡检间隔、提前量、扫描深度、续期与任务归属
4
+ // 盖印都是不可关闭的唯一行为(见 README),常量化在本文件。
5
5
  export const DEFAULT_CONFIG = {
6
6
  // 默认只扫本人的三个任务制容器,不扫整个家目录——他人可写仓库里的周期
7
7
  // 任务会被自动续期永久化,默认范围必须是用户完全掌控的领地(见 README
@@ -91,14 +91,6 @@ export const CC_JITTER = {
91
91
  oneShotMinuteMod: 30,
92
92
  };
93
93
 
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
94
  // 投递尾窗判定的回看/前探窗口(wanted 与 nextDelivery 共用,见
103
95
  // src/cron.mjs 的 recurringTailWindow):找上一落点用 prevAtOrBefore、算
104
96
  // 周期用 nextAfter,两个方向都要罩住月/年任务之外的 2 月 29 日——相邻落
package/src/cron.mjs CHANGED
@@ -12,11 +12,13 @@ import { isDecInt } from './internals.mjs';
12
12
  const MS_DAY = 86400_000;
13
13
  const MS_MINUTE = 60_000;
14
14
 
15
- // 严格十进制整数(isDecInt 的谓词见 internals):parseInt("5x") 会静默返回
16
- // 5,必须自己卡。
15
+ // 严格十进制安全整数(isDecInt 的谓词见 internals):parseInt("5x") 会
16
+ // 静默返回 5;达到 2^53 后 v+=1 不再改变数值,区间循环会永久挂住。
17
17
  function strictInt(s) {
18
18
  if (!isDecInt(s)) throw new Error(`不是整数:${s}`);
19
- return parseInt(s, 10);
19
+ const n = parseInt(s, 10);
20
+ if (!Number.isSafeInteger(n)) throw new Error(`整数超出安全范围:${s}`);
21
+ return n;
20
22
  }
21
23
 
22
24
  // 解析一个 cron 字段为它命名的合法值域集合。
@@ -53,9 +55,16 @@ export function parseCronField(expr, lo, hi) {
53
55
  start = end = strictInt(rng);
54
56
  if (slash >= 0) end = hi; // vixie:"5/10" 表示 5,15,25,… 直到 hi
55
57
  }
56
- for (let v = start; v <= end; v += step) {
57
- if (v >= lo && v <= hi) values.add(v);
58
- }
58
+ if (start > end || end < lo || start > hi) continue;
59
+ // 只迭代合法值域内的至多 hi-lo+1 个值。起点低于 lo 时用步进算出
60
+ // 首个候选,不能直接钳到 lo,否则 0-20/3 在 lo=1 时会从 3,6,…
61
+ // 变成 1,4,…;终点直接钳到 hi,超大区间不会拖死整轮巡检。
62
+ const first = start < lo
63
+ ? start + Math.ceil((lo - start) / step) * step
64
+ : start;
65
+ if (!Number.isSafeInteger(first)) continue;
66
+ const last = Math.min(end, hi);
67
+ for (let v = first; v <= last; v += step) values.add(v);
59
68
  }
60
69
  return values;
61
70
  }
package/src/logs.mjs CHANGED
@@ -72,7 +72,8 @@ export function cmdLogs(args) {
72
72
  + `「${args.workspace}」匹配的会话日志`);
73
73
  throw new ExitError(1);
74
74
  }
75
- const tailArgs = args.follow ? ['-f'] : ['-n', '100'];
75
+ // -F 同时按名字与描述符跟随:日志 rename 后原路径重建也能接上。
76
+ const tailArgs = args.follow ? ['-F'] : ['-n', '100'];
76
77
  tailArgs.push(file);
77
78
  const r = deps.spawnSync('/usr/bin/tail', tailArgs, { stdio: 'inherit' });
78
79
  if (r.error) throw r.error;