cron-up 26.10.24 → 26.10.26

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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.10.24",
3
+ "version": "26.10.26",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  // 先引全量入口,保证所有业务模块都已向 deps 自注册,再引命令函数。
6
6
  import './index.mjs';
7
- import { deps, ExitError } from './internals.mjs';
7
+ import { deps, ExitError, isDecInt } from './internals.mjs';
8
8
  import { cmdRun, cmdRenew } from './patrol.mjs';
9
9
  import { cmdInstall, cmdUninstall } from './service.mjs';
10
10
  import { cmdList } from './list.mjs';
@@ -40,10 +40,10 @@ const SPECS = {
40
40
  },
41
41
  };
42
42
 
43
- // 严格十进制整数命令行值:Number("0x10")、parseInt("5x") 都会静默给出错
44
- // 误结果,必须显式拒绝。
43
+ // 严格十进制整数命令行值(谓词 isDecInt 见 internals):
44
+ // Number("0x10")、parseInt("5x") 都会静默给出错误结果,必须显式拒绝。
45
45
  function cliInt(s) {
46
- if (!/^-?\d+$/.test(s)) {
46
+ if (!isDecInt(s)) {
47
47
  throw new ExitError(2, `需要整数却得到:${s}`);
48
48
  }
49
49
  return parseInt(s, 10);
@@ -157,7 +157,7 @@ export function parseCli(argv) {
157
157
  return result;
158
158
  }
159
159
 
160
- export const USAGE = `cron-up —— 为定时任务提前备好交互会话
160
+ const USAGE = `cron-up —— 为定时任务提前备好交互会话
161
161
 
162
162
  用法:
163
163
  cron-up 查看总览(服务、配置、任务、会话)
package/src/config.mjs CHANGED
@@ -4,9 +4,9 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
- import { deps, ExitError } from './internals.mjs';
8
- import { DEFAULT_CONFIG, PID_T_MAX } from './constants.mjs';
9
- import { expandHome } from './tasks.mjs';
7
+ import { deps, ExitError, isPlainObject, isDecInt } from './internals.mjs';
8
+ import { DEFAULT_CONFIG, PID_T_MAX, HEARTBEAT_STALE_FACTOR } from './constants.mjs';
9
+ import { expandHome, isDir } from './tasks.mjs';
10
10
 
11
11
  // Shell 的 tilde 展开只发生在词首:"--roots ~/a,~/b" 穿过 shell 后第二个
12
12
  // 条目前的 ~ 还在。读和写两边都规范化,配置里永远存绝对路径。
@@ -22,10 +22,6 @@ export function normalizeRoots(roots) {
22
22
  return out;
23
23
  }
24
24
 
25
- function isPlainObject(v) {
26
- return v !== null && typeof v === 'object' && !Array.isArray(v);
27
- }
28
-
29
25
  // 配置数值校验用:JSON 的 true 是布尔不能冒充秒数/深度
30
26
  // (Number.isInteger(true) === false,天然挡住)。
31
27
  export function isInt(x) {
@@ -41,12 +37,25 @@ export function lenientInt(v) {
41
37
  if (!Number.isFinite(v)) throw new Error('数值越界');
42
38
  return Math.trunc(v); // 1.5 → 1
43
39
  }
44
- if (typeof v === 'string' && /^-?\d+$/.test(v.trim())) {
40
+ if (typeof v === 'string' && isDecInt(v.trim())) {
45
41
  return parseInt(v.trim(), 10);
46
42
  }
47
43
  throw new Error(`无法整型化:${String(v)}`);
48
44
  }
49
45
 
46
+ // pid 清洗:宽容解析后卡 pid_t 值域,不合法返回 null。state.json 与会话
47
+ // 登记两个外部来源共用同一条入口规则——超大整数能穿过类型转换直到
48
+ // process.kill 才抛,负数 pid 会被 kill 当成进程组号误伤无辜进程组。
49
+ export function parsePid(v) {
50
+ let pid;
51
+ try {
52
+ pid = lenientInt(v);
53
+ } catch {
54
+ return null;
55
+ }
56
+ return pid > 0 && pid <= PID_T_MAX ? pid : null;
57
+ }
58
+
50
59
  // 配置字段的类型规则,loadConfig 与 install --force 继承旧配置共用。返回
51
60
  // [字段名, 人话错误][]:手改把字段写坏时绝不静默回退——回退的两个可能方向
52
61
  // (roots 坏 → 回扫整个家目录、autoRenew 坏 → 回开续期)都比停机更危险。
@@ -136,13 +145,7 @@ export function loadState() {
136
145
  for (const [ws, raw] of Object.entries(state)) {
137
146
  if (!isPlainObject(raw)) continue;
138
147
  const ent = { ...raw };
139
- let pid = null;
140
- try {
141
- pid = lenientInt(ent.pid);
142
- } catch {
143
- pid = null;
144
- }
145
- ent.pid = pid && pid > 0 && pid <= PID_T_MAX ? pid : null;
148
+ ent.pid = parsePid(ent.pid);
146
149
  for (const k of ['startedAt', 'cooldownUntil', 'fails', 'deadSince']) {
147
150
  if (ent[k] !== null && ent[k] !== undefined) {
148
151
  try {
@@ -205,6 +208,12 @@ export function saveHeartbeat(doc) {
205
208
  atomicSave(deps.paths.heartbeatPath, doc);
206
209
  }
207
210
 
211
+ // 心跳是否已沉默超过 HEARTBEAT_STALE_FACTOR 个轮次间隔:轮首的漏轮提示与
212
+ // 总览页的「服务在但巡检没在跑」共用同一判据,两边不会各说各话。
213
+ export function heartbeatStale(hb, intervalSeconds, nowSec) {
214
+ return hb !== null && nowSec - hb.ranAt > intervalSeconds * HEARTBEAT_STALE_FACTOR;
215
+ }
216
+
208
217
  export function saveConfig(cfg) {
209
218
  atomicSave(deps.paths.configPath, cfg);
210
219
  }
@@ -224,13 +233,7 @@ export function validateConfig(cfg, announce = () => {}) {
224
233
  + '两轮巡检之间到期而来不及提前启动会话,建议 lead ≥ interval');
225
234
  }
226
235
  for (const root of cfg.roots ?? []) {
227
- let dir = false;
228
- try {
229
- dir = fs.statSync(root).isDirectory();
230
- } catch {
231
- dir = false;
232
- }
233
- if (!dir) warnings.push(`扫描目录不存在,巡检不会覆盖它:${root}`);
236
+ if (!isDir(root)) warnings.push(`扫描目录不存在,巡检不会覆盖它:${root}`);
234
237
  }
235
238
  for (const w of warnings) announce(`警告:${w}`);
236
239
  return warnings;
package/src/constants.mjs CHANGED
@@ -36,8 +36,9 @@ export const COOLDOWN_SECONDS = 1800;
36
36
  // 稠密任务生效。保留 48 小时让稀疏任务跨窗口累积;老化防止陈年计数误伤
37
37
  // 已修好的环境。
38
38
  export const FAIL_TTL_SECONDS = 48 * 3600;
39
- // pid 合法值域。state.json 与会话登记两个外部来源都在入口处按它校验:
40
- // 超大整数能穿过类型转换,直到 process.kill 才抛,所以必须入口卡范围。
39
+ // pid 合法值域。state.json 与会话登记两个外部来源的 pid 都在入口处
40
+ // (config 的 parsePid)按它统一校验:超大整数能穿过类型转换,直到
41
+ // process.kill 才抛,所以必须入口卡范围。
41
42
  export const PID_T_MAX = 2 ** 31 - 1;
42
43
  // 健康会话约 10 秒内完成登记。超时仍存活却没登记,是卡在无人应答的提问
43
44
  // 界面(目录信任、工具授权);只看存活会把它当健康,只有这个时限兜得住。
@@ -48,9 +49,10 @@ export const WARMUP_GRACE_SECONDS = 180;
48
49
  export const SESSION_MAX_AGE_SECONDS = 7 * 86400;
49
50
  export const SESSION_IDLE_SECONDS = 3600;
50
51
  // window 模式回收判据一:界面静默超过此时长视为「本次执行已结束」。TUI
51
- // 工作时持续渲染(毫秒级刷新 typescript),静默 3 分钟不可能是在执行;与
52
- // WARMUP_GRACE 同值,对齐「3 分钟定生死」的既有心智。
53
- export const SESSION_RECYCLE_IDLE_SECONDS = 180;
52
+ // 工作时持续渲染(毫秒级刷新 typescript),静默 3 分钟不可能是在执行;取
53
+ // WARMUP_GRACE 同值,结构上对齐「3 分钟定生死」的既有心智(调一个跟着
54
+ // 动)。
55
+ export const SESSION_RECYCLE_IDLE_SECONDS = WARMUP_GRACE_SECONDS;
54
56
  // window 模式回收判据二:距下次「需要会话在场」的时刻至少还有这么多秒。
55
57
  // 回收后同轮重拉的新会话约 10 秒完成登记,余量保的是登记抖动;更近的触
56
58
  // 发直接复用现有会话(时间紧邻的任务共享)。
@@ -98,11 +100,16 @@ export const CC_JITTER = {
98
100
  // maxN,常量只保留「目标延迟」这一个可调项。
99
101
  export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
100
102
 
103
+ // 投递尾窗判定的回看/前探窗口(wanted 与 predictedFire 共用,见
104
+ // src/cron.mjs 的 recurringTailWindow):找上一落点用 prevAtOrBefore、算
105
+ // 周期用 nextAfter,两个方向都要罩住月/年任务之外的 2 月 29 日——相邻落
106
+ // 点隔 4 年,世纪非闰年(2100)隔 8 年;窗口小了尾窗会塌缩成零:落点已
107
+ // 过、投递未到,死亡却不重拉。日历快进按月跳,十年窗口也只有百余次迭代。
108
+ export const TAIL_WINDOW_DAYS = 366 * 10 + 1;
109
+
101
110
  // 定时任务 7 天过期(周期任务被调度器删除,一次性任务早就触发过),巡检
102
111
  // 不需要看得更远。展示页另算。
103
112
  export const SEARCH_DAYS = 7;
104
113
  // list/总览回答「下次何时触发」,要看到一年后:年度任务必须显示真实日期,
105
114
  // 不能误报成「无安排」。nextAfter 的日历快进让长窗口依然便宜。
106
115
  export const DISPLAY_SEARCH_DAYS = 366;
107
- // list 问「一次性任务是否被错过」,与巡检同一个补执行判定,只是不带前瞻。
108
- export const ZERO_LEAD_MS = 0;
package/src/cron.mjs CHANGED
@@ -1,15 +1,21 @@
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';
10
+ import { isDecInt } from './internals.mjs';
5
11
 
6
12
  const MS_DAY = 86400_000;
7
13
  const MS_MINUTE = 60_000;
8
14
 
9
- // 严格十进制整数:parseInt("5x") 会静默返回 5,必须自己卡。仅接受可选负号
10
- // 加纯数字。
15
+ // 严格十进制整数(isDecInt 的谓词见 internals):parseInt("5x") 会静默返回
16
+ // 5,必须自己卡。
11
17
  function strictInt(s) {
12
- if (!/^-?\d+$/.test(s)) throw new Error(`不是整数:${s}`);
18
+ if (!isDecInt(s)) throw new Error(`不是整数:${s}`);
13
19
  return parseInt(s, 10);
14
20
  }
15
21
 
@@ -24,8 +30,12 @@ export function parseCronField(expr, lo, hi) {
24
30
  const slash = token.indexOf('/');
25
31
  if (slash >= 0) {
26
32
  rng = token.slice(0, slash);
27
- step = strictInt(token.slice(slash + 1)); // "/0" 必须抛:步进 0 会死循环
28
- if (step === 0) throw new Error('cron 步进不能为 0');
33
+ step = strictInt(token.slice(slash + 1));
34
+ // 步进必须显式卡正数:strictInt 接受负号,0 让步进循环原地打转、
35
+ // 负数让 v+=step 递减、v<=end 恒真——两者都是永不退出的死循环,
36
+ // 而一个含 "/-5" 的任务文件就能挂死整轮巡检。拒绝后由
37
+ // parseCronOrNone 读作「cron 无效」。
38
+ if (!(step > 0)) throw new Error('cron 步进必须是正整数');
29
39
  } else {
30
40
  rng = token;
31
41
  step = 1;
@@ -117,7 +127,7 @@ export class Cron {
117
127
  // 不晚于 t 所在分钟的最近一次匹配(含当前分钟);withinDays 天前还没有
118
128
  // 则 null。与 nextAfter 对称做整月/整小时快进,366 天窗口也便宜。用于
119
129
  // 判断「现在是否落在某次触发的投递延迟尾窗里」。
120
- prevAtOrBefore(t, withinDays = SEARCH_DAYS) {
130
+ prevAtOrBefore(t, withinDays) {
121
131
  if (!this.satisfiable) return null;
122
132
  const cur = new Date(t.getTime());
123
133
  cur.setSeconds(0, 0);
@@ -153,7 +163,7 @@ export class Cron {
153
163
 
154
164
  // CronCreate 产生的一次性任务形状像 'M H DoM Mon *',作为没有 recurring
155
165
  // 标志时的兜底识别法。
156
- export function cronLooksOneshot(expr) {
166
+ function cronLooksOneshot(expr) {
157
167
  const parts = expr.split(/\s+/);
158
168
  return parts.length === 5 && parts[4] === '*'
159
169
  && parts.slice(0, 4).every((p) => /^\d+$/.test(p));
@@ -203,21 +213,14 @@ function wanted(cron, task, now, leadMs) {
203
213
  new Date(created.getTime() - MS_MINUTE),
204
214
  span,
205
215
  );
206
- if (first && first.getTime() <= now.getTime()) return true;
207
- return false;
216
+ return first !== null && first.getTime() <= now.getTime();
208
217
  }
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,38 +244,60 @@ 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
- export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
248
- const cron = parseCronOrNone(task?.cron);
249
- if (cron === null || !cron.satisfiable) return null;
250
- const i = cron.nextAfter(now, withinDays);
251
- if (i === null) return null;
252
- const h = taskIdHash(task?.id);
253
-
247
+ // 任务在展示语境里的「落点 → 预计实际投递」对:一次性任务是落点加网格
248
+ // 提前量;周期任务在投递尾窗内属于上一落点(即将到来的这次延迟投递,
249
+ // 不是 nextAfter 给出的下个落点——后者严格在未来,会把展示拐到明天),
250
+ // 否则是下一落点 + 按 id 的抖动。先于窗口检查尾窗:2 月 29 日这类长周期
251
+ // 任务的下一落点可能在展示窗口之外,正在等待的投递却依然成立。taskView
252
+ // 的 nxt/fire 与 predictedFire 共用这一个口径,落点与投递同源,两边不会
253
+ // 各说各话。返回 {slot, fire},均为 Date 或 null(窗口内没有落点时双双
254
+ // 为 null)。cron 须已解析且 satisfiable。
255
+ export function nextDelivery(cron, task, now, withinDays = DISPLAY_SEARCH_DAYS) {
254
256
  if (taskIsOneshot(task)) {
257
+ const slot = cron.nextAfter(now, withinDays);
258
+ if (slot === null) return { slot: null, fire: null };
255
259
  // 复刻 Wsn():只有落点分钟落在 :00/:30 网格上才提前一个按哈希缩放的
256
260
  // 小量(0~90 秒),其余分钟整点不抖;提前量不越过 now。
257
- if (i.getMinutes() % CC_JITTER.oneShotMinuteMod !== 0) return i;
261
+ if (slot.getMinutes() % CC_JITTER.oneShotMinuteMod !== 0) {
262
+ return { slot, fire: slot };
263
+ }
264
+ const h = taskIdHash(task?.id);
258
265
  const ahead = CC_JITTER.oneShotFloorMs
259
266
  + h * (CC_JITTER.oneShotMaxMs - CC_JITTER.oneShotFloorMs);
260
- return new Date(Math.max(i.getTime() - ahead, now.getTime()));
267
+ return { slot, fire: new Date(Math.max(slot.getTime() - ahead, now.getTime())) };
261
268
  }
262
269
 
270
+ const tail = recurringTailWindow(cron, task?.id, now);
271
+ if (tail !== null) return { slot: tail[0], fire: new Date(tail[1]) };
272
+
273
+ const slot = cron.nextAfter(now, withinDays);
274
+ if (slot === null) return { slot: null, fire: null };
263
275
  // 复刻 bOt():抖动按「本落点到下一落点」的周期比例缩放并封顶。nextAfter
264
276
  // 语义是严格晚于入参(内部进位一分钟),传 slot 本身即取下一落点;窗口
265
277
  // 边缘取不到时与上游一致:视为无后续,不抖动。
266
- return new Date(i.getTime() + recurringDelayMs(cron, i, task?.id, withinDays));
278
+ return {
279
+ slot,
280
+ fire: new Date(slot.getTime() + recurringDelayMs(cron, slot, task?.id, withinDays)),
281
+ };
267
282
  // 注:上游 bOt 另有一个 cacheLeadMs 分支——仅当相邻落点间隔落在
268
283
  // [300000, 315000)ms(约 5 分钟周期的高频 cron,如 */5)且表达式匹配
269
284
  // 其内部步进正则时,调度时刻提前 15 秒。日/周/月任务周期远大于此,不
270
285
  // 经过该分支,故不予复刻。
271
286
  }
272
287
 
288
+ // 任务按 Claude Code 的抖动规则预计的实际触发时刻(nextDelivery 的
289
+ // fire);cron 无效、不在尾窗内、窗口内没有落点时返回 null。基准为 now
290
+ // (上游调度器以上次触发时刻为基准,展示只关心从现在起的下一次,两者算
291
+ // 出的落点一致)。
292
+ export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
293
+ const cron = parseCronOrNone(task?.cron);
294
+ if (cron === null || !cron.satisfiable) return null;
295
+ return nextDelivery(cron, task, now, withinDays).fire;
296
+ }
297
+
273
298
  // 周期任务从某个落点起的投递延迟(ms):hash(id)·frac·周期,封顶 30 分。
274
299
  // slot 后取不到下一落点(窗口边缘、不可满足)时返回 0。
275
- export function recurringDelayMs(cron, slot, taskId, withinDays = SEARCH_DAYS) {
300
+ function recurringDelayMs(cron, slot, taskId, withinDays) {
276
301
  const next = cron.nextAfter(new Date(slot.getTime()), withinDays);
277
302
  if (next === null) return 0;
278
303
  const period = next.getTime() - slot.getTime();
@@ -281,3 +306,16 @@ export function recurringDelayMs(cron, slot, taskId, withinDays = SEARCH_DAYS) {
281
306
  CC_JITTER.recurringCapMs,
282
307
  );
283
308
  }
309
+
310
+ // 周期任务的「投递尾窗」判定:上一落点已过、按 id 算的延迟投递时刻未
311
+ // 到。返回 [落点, 投递时刻 ms];不在尾窗、找不到上一或下一落点返回
312
+ // null。wanted(保活判定)与 nextDelivery(展示)共用这一口径,两边不会
313
+ // 各说各话;窗口取 TAIL_WINDOW_DAYS,理由见 constants。
314
+ function recurringTailWindow(cron, taskId, now) {
315
+ const prev = cron.prevAtOrBefore(now, TAIL_WINDOW_DAYS);
316
+ if (prev === null) return null;
317
+ const fireMs = prev.getTime()
318
+ + recurringDelayMs(cron, prev, taskId, TAIL_WINDOW_DAYS);
319
+ if (now.getTime() >= fireMs) return null;
320
+ return [prev, fireMs];
321
+ }
package/src/display.mjs CHANGED
@@ -4,9 +4,8 @@ import {
4
4
  parseCronOrNone,
5
5
  taskIsOneshot,
6
6
  wanted,
7
- predictedFire,
7
+ nextDelivery,
8
8
  } from './cron.mjs';
9
- import { DISPLAY_SEARCH_DAYS, ZERO_LEAD_MS } from './constants.mjs';
10
9
 
11
10
  // 等宽终端里的宽字符区间(CJK 文字、全角标点、谚文、常用 emoji):启发式
12
11
  // 只覆盖任务摘要里真实出现的形态,区间外一律按 1 列——判错的代价仅是对
@@ -63,7 +62,7 @@ export function humanDelta(ms) {
63
62
 
64
63
  // list/总览上代表任务身份的一句话:prompt 的首个非空行。真实任务第一行
65
64
  // 通常自带标题;取不到时如实标注,不留空白格。
66
- export function taskSummary(task) {
65
+ function taskSummary(task) {
67
66
  const prompt = task?.prompt;
68
67
  if (typeof prompt !== 'string' || !prompt.trim()) return '(无任务描述)';
69
68
  const first = prompt.split('\n').map((l) => l.trim()).find((l) => l) ?? '';
@@ -73,7 +72,7 @@ export function taskSummary(task) {
73
72
  // 把一个任务整理成展示数据。字段来自别的程序写的文件,畸形也不抛。
74
73
  // 传 leadMs 时顺带在同一个已解析 cron 上回答「现在是否需要预热会话」
75
74
  // (wanted),总览页就不必为同一任务再解析一遍 cron。
76
- export function taskView(task, now, leadMs = undefined) {
75
+ export function taskView(task, now, leadMs) {
77
76
  const expr = task?.cron;
78
77
  const cron = parseCronOrNone(expr);
79
78
  if (cron === null || !cron.satisfiable) {
@@ -89,24 +88,31 @@ export function taskView(task, now, leadMs = undefined) {
89
88
  fire: null,
90
89
  };
91
90
  }
92
- const nxt = cron.nextAfter(now, DISPLAY_SEARCH_DAYS);
93
91
  const oneshot = taskIsOneshot(task);
94
- // nxt 按定义严格晚于 now,「已错过」完全由补执行判定回答。
95
- const missed = oneshot && wanted(cron, task, now, ZERO_LEAD_MS);
92
+ // nxt 按定义不晚于展示语境里的下一次投递,「已错过」完全由补执行判定
93
+ // 回答:lead 传 0(不带前瞻),与巡检的补执行判定同一口径。
94
+ const missed = oneshot && wanted(cron, task, now, 0);
95
+ // 落点与投递由 nextDelivery 同源算出:尾窗内的周期任务即将到来的是上
96
+ // 一落点的这次延迟投递,箭头才画得出「09:00 → 09:30」,而不是从明天
97
+ // 的落点拐回来(2 月 29 日任务尾窗内 nextAfter 甚至找不到下一落点)。
98
+ const { slot, fire } = nextDelivery(cron, task, now);
96
99
  return {
97
100
  valid: true,
98
- nxt,
101
+ // 已错过的一次性任务没有未来触发:nxt 置 null,否则「一年内无」的判
102
+ // 断和总览「最近」(fire ?? nxt)会把它当成明年同刻的安排。
103
+ nxt: missed ? null : slot,
99
104
  missed,
100
105
  kind: oneshot ? '一次性' : '周期',
101
106
  permanent: Boolean(task.permanent) && !oneshot,
102
- cadence: typeof expr === 'string' ? expr : String(expr),
107
+ cadence: String(expr),
103
108
  summary: taskSummary(task),
104
109
  // wanted 恒为 null 或布尔:没传 leadMs(不问)与 cron 无效(问不了)
105
110
  // 都是 null,键的形状不随参数变化。
106
111
  wanted: leadMs !== undefined ? wanted(cron, task, now, leadMs) : null,
107
112
  // 计入 Claude Code 投递抖动后的预计实际触发时刻;已错过的一次性任务
108
- // 没有未来触发,为 null。与 nxt 同为 Date 或 null。
109
- fire: missed ? null : predictedFire(task, now),
113
+ // 没有未来触发,为 null;尾窗内的周期任务是即将到来的这次延迟投递。
114
+ // 与 nxt 同为 Date 或 null。
115
+ fire: missed ? null : fire,
110
116
  };
111
117
  }
112
118
 
@@ -121,6 +127,12 @@ function sameMinute(a, b) {
121
127
  && a.getMinutes() === b.getMinutes();
122
128
  }
123
129
 
130
+ // 两位数字补零:时间戳格式的公共零件,巡检日志的时间戳(patrol 的 log)
131
+ // 也复用。
132
+ export function pad2(n) {
133
+ return String(n).padStart(2, '0');
134
+ }
135
+
124
136
  // 「设定落点 → 预计实际触发」文案。抖动四舍五入到秒(调度器秒级轮询):
125
137
  // 同日省略第二个日期;预计值带非零秒时显示 HH:MM:SS(如 05:15:37);落
126
138
  // 在同一分钟(无抖动)则只显示落点,不画无意义的箭头。
@@ -130,25 +142,22 @@ export function fmtFireRange(nxt, fire) {
130
142
  }
131
143
  const r = new Date(Math.round(fire.getTime() / 1000) * 1000);
132
144
  if (sameMinute(nxt, r)) return fmtMDHM(nxt);
133
- const p = (n) => String(n).padStart(2, '0');
134
145
  const tail = sameDay(nxt, r)
135
- ? `${p(r.getHours())}:${p(r.getMinutes())}`
136
- + (r.getSeconds() ? `:${p(r.getSeconds())}` : '')
137
- : `${fmtMDHM(r)}${r.getSeconds() ? `:${p(r.getSeconds())}` : ''}`;
146
+ ? `${pad2(r.getHours())}:${pad2(r.getMinutes())}`
147
+ + (r.getSeconds() ? `:${pad2(r.getSeconds())}` : '')
148
+ : `${fmtMDHM(r)}${r.getSeconds() ? `:${pad2(r.getSeconds())}` : ''}`;
138
149
  return `${fmtMDHM(nxt)} → ${tail}`;
139
150
  }
140
151
 
141
152
  // 仅 HH:MM(总览「(设定 HH:MM)」附注用)。
142
153
  export function fmtHM(d) {
143
- const p = (n) => String(n).padStart(2, '0');
144
- return `${p(d.getHours())}:${p(d.getMinutes())}`;
154
+ return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
145
155
  }
146
156
 
147
157
  // MM-DD HH:MM(任务触发点的统一展示格式)。
148
158
  export function fmtMDHM(d) {
149
- const p = (n) => String(n).padStart(2, '0');
150
- return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:`
151
- + `${p(d.getMinutes())}`;
159
+ return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} `
160
+ + `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
152
161
  }
153
162
 
154
163
  // 会话年龄的粗粒度中文:长驻按天计,否则按分钟。
package/src/internals.mjs CHANGED
@@ -18,6 +18,18 @@ export class ExitError extends Error {
18
18
  }
19
19
  }
20
20
 
21
+ // 外部程序写的 JSON 顶层形状守卫:config/state/任务文件三个入口共用,要收紧
22
+ // (如拒绝类实例)只改这一处。
23
+ export function isPlainObject(v) {
24
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
25
+ }
26
+
27
+ // 严格十进制整数(可选负号 + 纯数字):parseInt("5x")、Number("0x10") 都会
28
+ // 静默给出错误结果,来自外部字符串的整数一律先过这个谓词再 parseInt。
29
+ export function isDecInt(s) {
30
+ return /^-?\d+$/.test(s);
31
+ }
32
+
21
33
  // 非 0 退出不抛:调用 ps/launchctl 的地方只关心 status 与输出,抛了反而
22
34
  // 要在每个调用点 catch。返回 {status, stdout, stderr}。
23
35
  function execFile(file, args, opts = {}) {
package/src/logs.mjs CHANGED
@@ -6,8 +6,9 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  import { deps, ExitError } from './internals.mjs';
9
- import { onDiskCase } from './tasks.mjs';
9
+ import { onDiskCase, lexReal } from './tasks.mjs';
10
10
  import { sessionLogPath } from './sessions.mjs';
11
+ import { fmtMDHM } from './display.mjs';
11
12
 
12
13
  export function resolveLogPath(workspace, announce) {
13
14
  const say = announce ?? ((m) => deps.printErr(m));
@@ -15,13 +16,7 @@ export function resolveLogPath(workspace, announce) {
15
16
  if (path.isAbsolute(workspace)) {
16
17
  // 日志名由工作区路径折成,而 spawn 记的是 discover 规范化后的真实
17
18
  // 大小写:敲错大小写的绝对路径必须认回同一个文件。
18
- let real;
19
- try {
20
- real = fs.realpathSync(workspace);
21
- } catch {
22
- real = workspace;
23
- }
24
- const file = sessionLogPath(onDiskCase(real));
19
+ const file = sessionLogPath(onDiskCase(lexReal(workspace)));
25
20
  return fs.existsSync(file) ? file : null;
26
21
  }
27
22
  // 只匹配当前日志(*.log):.log.1 若参与匹配,轮转过一次的工作区会永
@@ -48,11 +43,8 @@ export function resolveLogPath(workspace, announce) {
48
43
  if (!fs.existsSync(out)) return err;
49
44
  try {
50
45
  if (fs.statSync(err).size > 0) {
51
- const mtime = fs.statSync(err).mtime;
52
- const p = (n) => String(n).padStart(2, '0');
53
- const stamp = `${p(mtime.getMonth() + 1)}-${p(mtime.getDate())} `
54
- + `${p(mtime.getHours())}:${p(mtime.getMinutes())}`;
55
- say(`注:${err} 有内容(最后写入 ${stamp}),巡检若异常先看它`);
46
+ say(`注:${err} 有内容(最后写入 ${fmtMDHM(fs.statSync(err).mtime)}),`
47
+ + '巡检若异常先看它');
56
48
  }
57
49
  } catch {
58
50
  // err 不存在:无事。
package/src/overview.mjs CHANGED
@@ -11,10 +11,11 @@ import {
11
11
  loadState,
12
12
  validateConfig,
13
13
  loadHeartbeat,
14
+ heartbeatStale,
14
15
  } from './config.mjs';
15
- import { packageVersion, TASK_REL } from './paths.mjs';
16
+ import { taskFileExists } from './tasks.mjs';
17
+ import { packageVersion } from './paths.mjs';
16
18
  import { serviceLine, launcherHealth } from './service.mjs';
17
- import { HEARTBEAT_STALE_FACTOR } from './constants.mjs';
18
19
  import {
19
20
  humanDelta,
20
21
  clip,
@@ -47,7 +48,7 @@ function overviewTasks(cfg, cols, consumers, state) {
47
48
  if (tasks === null) {
48
49
  // 与巡检同一条规则:文件还在却读不出,总览页要把它摆进「需要留
49
50
  // 意」,而不是装作这个工作区不存在。
50
- if (fs.existsSync(path.join(ws, TASK_REL))) {
51
+ if (taskFileExists(ws)) {
51
52
  alerts.push(`${ws} 的任务文件读不出:其中的定时任务一个都不会执行`);
52
53
  }
53
54
  continue;
@@ -94,7 +95,7 @@ function overviewTasks(cfg, cols, consumers, state) {
94
95
  // 失败计数放在句子中部:总览告警按终端宽度截尾,计数比尾部的
95
96
  // 「下轮会自动启动」更该活下来;满额后的去向由「冷却中」那条合并
96
97
  // 告警解释,这里不重复。
97
- const fails = (state[ws] ?? {}).fails ?? 0;
98
+ const fails = ent.fails ?? 0;
98
99
  const streak = fails > 0 ? `,近期已失败 ${fails} 次` : '';
99
100
  alerts.push(`${ws} 有任务即将执行(或错过待补执行)${streak},`
100
101
  + '但当前没有交互会话,下轮巡检会自动启动');
@@ -124,8 +125,7 @@ function overviewTasks(cfg, cols, consumers, state) {
124
125
  return [alerts, coolingWarned, discovered];
125
126
  }
126
127
 
127
- function overviewSessions(cols, state, suppressCooling = new Set(),
128
- discovered = null) {
128
+ function overviewSessions(cols, state, suppressCooling, discovered) {
129
129
  const alerts = [];
130
130
  const cur = Math.floor(Date.now() / 1000);
131
131
  const live = [];
@@ -243,16 +243,14 @@ export async function cmdOverview(args) {
243
243
  // 心跳能戳穿。服务未加载(info 为 null)时不告警,服务行已说明未安装。
244
244
  if (heartbeat) {
245
245
  deps.print(`上次巡检:${elapsedZh(curOv - heartbeat.ranAt)}前`);
246
- if (info
247
- && curOv - heartbeat.ranAt
248
- > cfg.intervalSeconds * HEARTBEAT_STALE_FACTOR) {
246
+ if (info && heartbeatStale(heartbeat, cfg.intervalSeconds, curOv)) {
249
247
  alerts.push(`巡检已沉默 ${elapsedZh(curOv - heartbeat.ranAt)}:launchd `
250
248
  + `显示服务已加载(配置每 ${cfg.intervalSeconds} 秒一轮),但最近没有`
251
249
  + '成功跑完的轮次,启动链可能已失效;请运行 cron-up logs 查看巡检'
252
250
  + '日志,必要时重跑 cron-up install');
253
251
  }
254
252
  }
255
- alerts.push(...validateConfig(cfg, () => {}));
253
+ alerts.push(...validateConfig(cfg));
256
254
  deps.print('');
257
255
  const [taskAlerts, cooled, found] = overviewTasks(cfg, cols, consumers, state);
258
256
  alerts.push(...taskAlerts);
package/src/paths.mjs CHANGED
@@ -12,9 +12,42 @@ const HOME = os.homedir();
12
12
  // 被预热的 agent 工作区里,定时任务文件的相对路径。
13
13
  export const TASK_REL = path.join('.claude', 'scheduled_tasks.json');
14
14
 
15
+ // 派生 getter 只写这一份:生产 paths 与测试重定向用的 tmpPaths
16
+ // (test-support/helpers.mjs)共用同一工厂,派生路径改布局不会出现两处各
17
+ // 改一半。
18
+ export function makePaths({ home, dataDir, logDir, plistPath, sessionDir }) {
19
+ return {
20
+ home,
21
+ dataDir,
22
+ logDir,
23
+ plistPath,
24
+ sessionDir,
25
+
26
+ get sessionLogDir() {
27
+ return path.join(this.logDir, 'sessions');
28
+ },
29
+ get configPath() {
30
+ return path.join(this.dataDir, 'config.json');
31
+ },
32
+ get statePath() {
33
+ return path.join(this.dataDir, 'state.json');
34
+ },
35
+ // 巡检心跳:每轮成功跑完写一次,总览页据此判断上次巡检多久前。
36
+ get heartbeatPath() {
37
+ return path.join(this.dataDir, 'heartbeat');
38
+ },
39
+ // launchd 拉起的启动脚本。ProgramArguments[0] 直接指向它而非 node:
40
+ // macOS 后台项目列表按可执行文件名显示条目,直连 node 会归到「Node.js
41
+ // Foundation」名下(node 的签名者),自有脚本则显示脚本自己的名字。
42
+ get serviceScriptPath() {
43
+ return path.join(this.dataDir, 'cron-up-service');
44
+ },
45
+ };
46
+ }
47
+
15
48
  // 路径对象整体可替换(deps.paths):测试把产物目录重定向到 tmp,不碰真实
16
49
  // HOME。getter 让 sessionLogDir/configPath/statePath 跟随替换后的前缀。
17
- export const paths = {
50
+ export const paths = makePaths({
18
51
  home: HOME,
19
52
  // 数据目录用 XDG 风格的 ~/.local/share/cron-up:config、state 与启动脚本
20
53
  // 集中一处,purge 一删全清。
@@ -22,27 +55,7 @@ export const paths = {
22
55
  logDir: path.join(HOME, 'Library', 'Logs', 'cron-up'),
23
56
  plistPath: path.join(HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`),
24
57
  sessionDir: path.join(HOME, '.claude', 'sessions'),
25
-
26
- get sessionLogDir() {
27
- return path.join(this.logDir, 'sessions');
28
- },
29
- get configPath() {
30
- return path.join(this.dataDir, 'config.json');
31
- },
32
- get statePath() {
33
- return path.join(this.dataDir, 'state.json');
34
- },
35
- // 巡检心跳:每轮成功跑完写一次,总览页据此判断上次巡检多久前。
36
- get heartbeatPath() {
37
- return path.join(this.dataDir, 'heartbeat');
38
- },
39
- // launchd 拉起的启动脚本。ProgramArguments[0] 直接指向它而非 node:
40
- // macOS 后台项目列表按可执行文件名显示条目,直连 node 会归到「Node.js
41
- // Foundation」名下(node 的签名者),自有脚本则显示脚本自己的名字。
42
- get serviceScriptPath() {
43
- return path.join(this.dataDir, 'cron-up-service');
44
- },
45
- };
58
+ });
46
59
 
47
60
  // 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
48
61
  // 版本实验特性的依赖);读不到返回 null,总览页降级显示。
package/src/patrol.mjs CHANGED
@@ -24,16 +24,22 @@ import {
24
24
  saveState,
25
25
  loadHeartbeat,
26
26
  saveHeartbeat,
27
+ heartbeatStale,
27
28
  } from './config.mjs';
28
- import { maintainWorkspace, TaskFileChanged } from './tasks.mjs';
29
+ import {
30
+ maintainWorkspace,
31
+ TaskFileChanged,
32
+ taskFileExists,
33
+ } from './tasks.mjs';
29
34
  import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
30
35
  import {
31
36
  sessionLogPath,
32
37
  sessionLogIdleSeconds,
33
38
  scriptProcLogPath,
39
+ rotateOnce,
34
40
  } from './sessions.mjs';
35
41
  import { TASK_REL, packageVersion } from './paths.mjs';
36
- import { elapsedZh } from './display.mjs';
42
+ import { elapsedZh, pad2 } from './display.mjs';
37
43
 
38
44
  // 事件类型 → 轮末汇总里的中文标签。patrolWorkspace 与 cmdRun 往同一个
39
45
  // stats 计数器里记账,平静轮也会打出「本轮无动作」。
@@ -56,10 +62,9 @@ function bump(stats, kind) {
56
62
 
57
63
  export function log(msg) {
58
64
  const d = new Date();
59
- const p = (n) => String(n).padStart(2, '0');
60
65
  deps.print(
61
- `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
62
- + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${msg}`,
66
+ `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} `
67
+ + `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} ${msg}`,
63
68
  );
64
69
  }
65
70
 
@@ -137,15 +142,14 @@ export function releaseLock(handle) {
137
142
 
138
143
  export function rotatePatrolLogs() {
139
144
  // launchd 按路径每次重开文件,轮初改名是安全的:本轮 fd 继续写改名后
140
- // 的 inode,下轮打开新文件。
145
+ // 的 inode,下轮打开新文件。超阈值才轮转,与会话日志的「无条件保留一
146
+ // 代」共用 rotateOnce 的 rm+rename 顺序。
141
147
  for (const name of ['launchd.out.log', 'launchd.err.log']) {
142
148
  const file = path.join(deps.paths.logDir, name);
143
149
  try {
144
150
  if (fs.existsSync(file)
145
151
  && fs.statSync(file).size > PATROL_LOG_ROTATE_BYTES) {
146
- const old = `${file}.1`;
147
- fs.rmSync(old, { force: true });
148
- fs.renameSync(file, old);
152
+ rotateOnce(file);
149
153
  }
150
154
  } catch {
151
155
  // 轮转失败不阻断巡检。
@@ -168,9 +172,11 @@ function needHorizonMs(tasks, now) {
168
172
  }
169
173
 
170
174
  // 保证一个工作区里有一个健康的消费者会话。原地修改 state。retain 来自
171
- // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。
175
+ // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。scriptProcs
176
+ // 是进程表快照或 null:null 时仅在需要认领孤儿(state 丢失的兜底分支)当
177
+ // 下才拍快照,平静轮不付全量 ps 的代价。
172
178
  export async function patrolWorkspace(
173
- ws, state, now, leadMs, cur, consumers, scriptProcs = [],
179
+ ws, state, now, leadMs, cur, consumers, scriptProcs = null,
174
180
  retain = 'always', stats = null,
175
181
  ) {
176
182
  const tasks = deps.readTasks(ws);
@@ -186,7 +192,7 @@ export async function patrolWorkspace(
186
192
  // 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
187
193
  // 不出意味着这个目录的定时任务一个都不会执行。文件已消失则是正常竞
188
194
  // 态(一次性任务执行完被删),不报。
189
- if (fs.existsSync(path.join(ws, TASK_REL))) {
195
+ if (taskFileExists(ws)) {
190
196
  note('warn', `任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
191
197
  + `其中 ${TASK_REL} 里的定时任务都不会执行`);
192
198
  }
@@ -222,8 +228,7 @@ export async function patrolWorkspace(
222
228
  mineAlive = false;
223
229
  // 消费者快照拍于轮初,被换代的这代可能正是快照里的消费者:重扫一
224
230
  // 次,别让陈旧登记挡住下面的重拉。
225
- const [fresh] = await deps.scanSessions();
226
- consumers = fresh;
231
+ consumers = (await deps.scanSessions())[0];
227
232
  }
228
233
  }
229
234
 
@@ -252,8 +257,7 @@ export async function patrolWorkspace(
252
257
  mineAlive = false;
253
258
  // 同上:快照里的消费者可能正是刚结束的这代。用户自己的会话若在场,
254
259
  // 重扫依旧看见,不会在同目录双开。
255
- const [fresh] = await deps.scanSessions();
256
- consumers = fresh;
260
+ consumers = (await deps.scanSessions())[0];
257
261
  }
258
262
 
259
263
  // 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
@@ -263,18 +267,17 @@ export async function patrolWorkspace(
263
267
  ent.fails = 0;
264
268
  }
265
269
 
270
+ // 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
271
+ // 盖),也可能是用户的交互会话——后者放手:不在这里删死条目,失败计数
272
+ // 要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),由轮
273
+ // 末 kept 过滤统一处理。先于 wanted 判定返回:有消费者在场的平稳态不必
274
+ // 为整组任务跑一遍 cron 求值。
275
+ if (consumers.has(ws)) return;
276
+
266
277
  if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
267
278
 
268
279
  const cooling = Boolean(ent && (ent.cooldownUntil ?? 0) > cur);
269
280
 
270
- if (consumers.has(ws)) {
271
- // 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
272
- // 盖),也可能是用户的交互会话——后者放手:不在这里删死条目,失败计数
273
- // 要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),由轮
274
- // 末 kept 过滤统一处理。
275
- return;
276
- }
277
-
278
281
  // 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
279
282
  // 应答的提问界面,永不登记——退休它并推进失败计数。
280
283
  if (ent !== null && mineAlive) {
@@ -316,7 +319,7 @@ export async function patrolWorkspace(
316
319
  // 不会出现在登记里,直接再拉就是同目录双开。认回孤儿,重新纳入正常
317
320
  // 生命周期(卡死的 3 分钟后照退休处理)。
318
321
  const logPath = sessionLogPath(ws);
319
- for (const [procPid, procArgs] of scriptProcs) {
322
+ for (const [procPid, procArgs] of scriptProcs ?? deps.listScriptProcesses()) {
320
323
  // alive 复核:快照拍于本轮开头,可能含着刚被换代/回收杀掉的 pid——
321
324
  // 把死人认回来会白记一次失败。
322
325
  if (scriptProcLogPath(procArgs) === logPath && deps.alive(procPid)) {
@@ -367,13 +370,22 @@ export async function cmdRun(args) {
367
370
  return false;
368
371
  }
369
372
  // 包一层 stopSession 记下本轮主动结束的 pid:轮末凭它区分「我们回收的」
370
- // 与「会话自行退出/被外部杀掉的」,后者以前是无声丢失。finally 还原。
373
+ // 与「会话自行退出/被外部杀掉的」,后者以前是无声丢失。同样包一层
374
+ // procStartedAt:同一 pid 的 lstart 一轮内不会变,而轮初分区、各工作区
375
+ // 的 trackedAlive、轮末复核与 kept 过滤都会查指纹,缓存后每个 pid 只
376
+ // spawn 一次 ps。finally 一并还原。
371
377
  const prevStopSession = deps.stopSession;
372
378
  const stoppedPids = new Set();
373
379
  deps.stopSession = async (ent) => {
374
380
  if (ent?.pid) stoppedPids.add(ent.pid);
375
381
  return prevStopSession(ent);
376
382
  };
383
+ const prevProcStartedAt = deps.procStartedAt;
384
+ const lstartCache = new Map();
385
+ deps.procStartedAt = (pid) => {
386
+ if (!lstartCache.has(pid)) lstartCache.set(pid, prevProcStartedAt(pid));
387
+ return lstartCache.get(pid);
388
+ };
377
389
  try {
378
390
  const startedAtMs = Date.now();
379
391
  const stats = {};
@@ -388,8 +400,7 @@ export async function cmdRun(args) {
388
400
  // 时日志会一片寂静,无法和「平安无事」区分。上轮心跳过老说明中间漏
389
401
  // 轮——休眠期间错过的 StartInterval,launchd 醒来只补跑一轮。
390
402
  const hb = loadHeartbeat();
391
- if (hb !== null
392
- && cur - hb.ranAt > cfg.intervalSeconds * HEARTBEAT_STALE_FACTOR) {
403
+ if (heartbeatStale(hb, cfg.intervalSeconds, cur)) {
393
404
  bump(stats, 'warn');
394
405
  deps.log(`距上次巡检已 ${elapsedZh(cur - hb.ranAt)},超过 `
395
406
  + `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${cfg.intervalSeconds} 秒),`
@@ -404,27 +415,29 @@ export async function cmdRun(args) {
404
415
  }
405
416
 
406
417
  // 轮初拍一份「上轮末仍相信活着」的会话快照(entry 复制,本轮原地改写
407
- // 不影响),分成「轮初已没气」与「轮初还活着」两组。不能只收此刻
418
+ // 不影响),一遍分成「轮初已没气」与「轮初还活着」两组。不能只收此刻
408
419
  // kill -0 成功的——两轮之间崩溃正是最典型的被动死亡,本轮开始时它早
409
420
  // 已没气;带 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));
421
+ const prevDead = [];
422
+ const prevAlive = [];
423
+ for (const [ws, ent] of Object.entries(state)) {
424
+ if (!ent.pid || ent.deadSince) continue;
425
+ const copy = [ws, { ...ent }];
426
+ (deps.trackedAlive(copy[1]) ? prevAlive : prevDead).push(copy);
427
+ }
415
428
  const exitLine = (ws, old) => `会话退出 ${ws} pid=${old.pid}:非巡检主动结束`
416
429
  + `(已存活 ${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或`
417
430
  + '被外部终止;若反复出现请用 cron-up logs '
418
431
  + `${path.basename(ws)} 排查启动过程`;
419
432
 
420
433
  // 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
421
- // 属这里。
434
+ // 属这里。进程表快照不在这里拍:只有孤儿认领分支需要,按需现拍(见
435
+ // patrolWorkspace 的 scriptProcs 形参)。
422
436
  const [consumers, registryAlert] = await deps.scanSessions();
423
437
  if (registryAlert) {
424
438
  bump(stats, 'warn');
425
439
  deps.log(`告警:${registryAlert}`);
426
440
  }
427
- const scriptProcs = deps.listScriptProcesses();
428
441
 
429
442
  // 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
430
443
  // 日志时间线就是「退出 → 重拉」,而不是反过来。
@@ -444,7 +457,7 @@ export async function cmdRun(args) {
444
457
  const { renewed, minified } = maintainWorkspace(taskfile, {
445
458
  renew: cfg.autoRenew,
446
459
  minify: cfg.autoMinId,
447
- }) ?? { renewed: null, minified: null };
460
+ }) ?? {};
448
461
  if (renewed) {
449
462
  deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
450
463
  + '不再受 7 天过期限制');
@@ -467,7 +480,7 @@ export async function cmdRun(args) {
467
480
  }
468
481
  try {
469
482
  await patrolWorkspace(
470
- ws, state, now, leadMs, cur, consumers, scriptProcs,
483
+ ws, state, now, leadMs, cur, consumers, null,
471
484
  cfg.sessionRetain, stats);
472
485
  } catch (e) {
473
486
  bump(stats, 'error');
@@ -525,6 +538,7 @@ export async function cmdRun(args) {
525
538
  return true;
526
539
  } finally {
527
540
  deps.stopSession = prevStopSession;
541
+ deps.procStartedAt = prevProcStartedAt;
528
542
  releaseLock(lock);
529
543
  }
530
544
  }
package/src/registry.mjs CHANGED
@@ -7,8 +7,8 @@ import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
 
9
9
  import { deps } from './internals.mjs';
10
- import { lenientInt } from './config.mjs';
11
- import { PID_T_MAX } from './constants.mjs';
10
+ import { parsePid } from './config.mjs';
11
+ import { lexReal } from './tasks.mjs';
12
12
 
13
13
  // npm/bun 形态的 claude 是解释器跑的脚本:进程 comm 是 node/bun/deno,
14
14
  // 只能从完整命令行认 claude 入口(bin/claude 符号链接或包目录 cli.js)。
@@ -41,7 +41,7 @@ export function pidIsClaude(pid) {
41
41
  // 读一个登记文件;读不出返回 null。Claude Code 会就地(非原子)重写登
42
42
  // 记,读写相撞读到半个 JSON 时睡一拍重读一次;文件消失不是竞态,不重
43
43
  // 试。
44
- export async function readSessionFile(file) {
44
+ async function readSessionFile(file) {
45
45
  for (let attempt = 0; attempt < 2; attempt++) {
46
46
  let text;
47
47
  try {
@@ -60,16 +60,6 @@ export async function readSessionFile(file) {
60
60
  return null;
61
61
  }
62
62
 
63
- // 宽容版 realpath:fs.realpathSync 对不存在的路径抛 ENOENT,这里失败时退
64
- // 回词法绝对路径(调用方已保证输入是绝对路径)。
65
- function lexReal(p) {
66
- try {
67
- return fs.realpathSync(p);
68
- } catch {
69
- return path.resolve(p);
70
- }
71
- }
72
-
73
63
  // 返回 [拥有活消费者会话的工作区集合, 告警字符串|null]。
74
64
  export async function scanSessions() {
75
65
  let files = [];
@@ -99,17 +89,11 @@ export async function scanSessions() {
99
89
  // 数都读不出活进程。这段代码在 per-workspace 保护圈之外,一个畸形登
100
90
  // 记抛出去就是整轮巡检失败;缺失时兜底 -1 还会被 kill(-1,0)(对全
101
91
  // 部进程的权限探测,不是存在性检查)误读为存活。
102
- let pid;
103
- try {
104
- pid = lenientInt(s.pid);
105
- } catch {
92
+ const pid = parsePid(s.pid);
93
+ if (pid === null) {
106
94
  suspect += 1; // 正常登记必带可解析 pid:缺失/变质即字段漂移
107
95
  continue;
108
96
  }
109
- if (!(pid > 0 && pid <= PID_T_MAX)) {
110
- suspect += 1;
111
- continue;
112
- }
113
97
  if (!deps.alive(pid)) continue; // 陈旧登记(崩溃留下):正常,不计
114
98
  if (!commCache.has(pid)) {
115
99
  commCache.set(pid, deps.pidIsClaude(pid)); // ps 调用按 pid 缓存
@@ -120,11 +104,8 @@ export async function scanSessions() {
120
104
  // 热被静默跳过。
121
105
  const cwd = s.cwd;
122
106
  if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
123
- const real = lexReal(cwd);
124
- if (real) {
125
- consumers.add(real);
126
- continue;
127
- }
107
+ consumers.add(lexReal(cwd));
108
+ continue;
128
109
  }
129
110
  suspect += 1; // 活着的 claude 却没有可用 cwd:cwd 字段漂移
130
111
  } else {
package/src/service.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
 
7
- import { deps, ExitError } from './internals.mjs';
7
+ import { deps, ExitError, isPlainObject } from './internals.mjs';
8
8
  import { paths, LABEL } from './paths.mjs';
9
9
  // paths.home 仅用于 resolveLauncher 的默认 HOME;其余产物路径一律走
10
10
  // deps.paths(测试要整体重定向)。
@@ -22,7 +22,7 @@ import {
22
22
  configFieldErrors,
23
23
  } from './config.mjs';
24
24
 
25
- export function guiTarget() {
25
+ function guiTarget() {
26
26
  return `gui/${process.getuid()}`;
27
27
  }
28
28
 
@@ -51,7 +51,7 @@ const LAUNCHD_STATES = new Map([
51
51
  ['not running', '空闲中(按间隔触发)'],
52
52
  ]);
53
53
 
54
- export function launchdStateZh(state) {
54
+ function launchdStateZh(state) {
55
55
  // launchd 的 "not running" 是间隔触发型服务两轮之间的正常空闲态,直译
56
56
  // 会让人以为服务停了;未识别的值原样保留以便排查。
57
57
  if (!state) return '未知';
@@ -243,6 +243,17 @@ export function launcherHealth() {
243
243
 
244
244
  // ---- install / uninstall ----
245
245
 
246
+ // install 的可覆盖项(配置键 → 命令行旗标):--force 缺席时逐项点名「未
247
+ // 生效的参数」,与 SPECS 的选项表保持同一批名字。
248
+ const IGNORED_INSTALL_FLAGS = [
249
+ ['roots', '--roots'],
250
+ ['interval', '--interval'],
251
+ ['lead', '--lead'],
252
+ ['autoRenew', '--auto-renew'],
253
+ ['autoMinId', '--auto-min-id'],
254
+ ['sessionRetain', '--session-retain'],
255
+ ];
256
+
246
257
  export async function cmdInstall(args) {
247
258
  // --config 在这里只会造成误解:install 写盘、launchd 每轮读取的都是固定
248
259
  // 路径的配置,指定别的路径不改变任何行为——静默忽略等于让人以为装到了
@@ -275,13 +286,8 @@ export async function cmdInstall(args) {
275
286
  deps.print(`保留现有配置:${deps.paths.configPath}(需要更新时加 --force)`);
276
287
  // 不带 --force 时配置原样保留,命令行显式给的参数一律不生效:点名说清
277
288
  // 被忽略的是哪些,而不是让用户以为已经改了。
278
- const ignored = [];
279
- if (args.roots !== undefined) ignored.push('--roots');
280
- if (args.interval !== undefined) ignored.push('--interval');
281
- if (args.lead !== undefined) ignored.push('--lead');
282
- if (args.autoRenew !== undefined) ignored.push('--auto-renew');
283
- if (args.autoMinId !== undefined) ignored.push('--auto-min-id');
284
- if (args.sessionRetain !== undefined) ignored.push('--session-retain');
289
+ const ignored = IGNORED_INSTALL_FLAGS
290
+ .filter(([k]) => args[k] !== undefined).map(([, flag]) => flag);
285
291
  if (ignored.length > 0) {
286
292
  deps.print(`警告:参数 ${ignored.join('、')} 未生效(现有配置保留);`
287
293
  + '要更新这些字段请带 --force 重跑,--force 只覆盖显式给出的字段');
@@ -296,10 +302,10 @@ export async function cmdInstall(args) {
296
302
  try {
297
303
  old = JSON.parse(fs.readFileSync(deps.paths.configPath, 'utf-8'));
298
304
  } catch {
299
- old = null;
305
+ // 读不出(损坏 JSON):没有可继承的旧值。
300
306
  }
301
- // 手改成非对象(123、["a"]):没有可继承的旧值,静默按默认走。
302
- if (old && typeof old === 'object' && !Array.isArray(old)) {
307
+ // 手改成非对象(123、["a"]):同样没有可继承的旧值,静默按默认走。
308
+ if (isPlainObject(old)) {
303
309
  // 类型坏的字段不继承(回到默认值)并当面说明,其余照旧——force 不
304
310
  // 该把类型错误写进新配置,也不该因一个字段坏了丢掉其余设置。
305
311
  const errs = configFieldErrors({ ...DEFAULT_CONFIG, ...old });
@@ -359,8 +365,9 @@ export async function cmdInstall(args) {
359
365
  validateConfig(cfg, (m) => deps.print(m));
360
366
  if (JSON.stringify(cfg.roots)
361
367
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
362
- // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。
363
- deps.print('提示:未指定 --roots,默认扫描 ~/space、~/workspace、~/tasks'
368
+ // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。默认目
369
+ // 录清单从 DEFAULT_CONFIG 插值:改默认值时提示不会留在旧清单上。
370
+ deps.print(`提示:未指定 --roots,默认扫描 ${DEFAULT_CONFIG.roots.join('、')}`
364
371
  + '(不存在的目录跳过);今后 clone 进这些容器的仓库也会进入巡检,'
365
372
  + '自动续期会把其中的周期任务永久化。目录布局不同或需收窄时用 '
366
373
  + '--roots 指定(见 README 安全说明)');
@@ -406,25 +413,32 @@ export async function cmdUninstall(args) {
406
413
  if (r.status === 0) deps.print('已从 launchd 卸载');
407
414
 
408
415
  // 服务没了就没有回收者:默认结束自己拉起的保活会话,不留以 bypass 权限
409
- // 空转的孤儿。--keep-sessions 显式保留(例如马上重装)。
416
+ // 空转的孤儿。--keep-sessions 显式保留(例如马上重装)。各会话的停轮询
417
+ // (SIGTERM→等→SIGKILL)互不依赖,先逐个发信号再并行等齐,N 个会话不
418
+ // 用串行吃 N 轮等待。
410
419
  if (!args.keepSessions) {
420
+ const stopping = [];
421
+ const handled = new Set();
411
422
  for (const [ws, ent] of Object.entries(loadState())) {
412
423
  if (deps.trackedAlive(ent)) {
424
+ handled.add(ent.pid);
413
425
  deps.print(`正在结束保活会话 ${ws} pid=${ent.pid}`);
414
- await deps.stopSession(ent);
426
+ stopping.push(deps.stopSession(ent));
415
427
  }
416
428
  }
417
429
  // state 丢失时的兜底:没进登记的孤儿会话(卡在提问界面就不会出现在
418
430
  // state 里)从进程表按日志目录精确认领。
419
431
  for (const [pid, procArgs] of deps.listScriptProcesses()) {
432
+ if (handled.has(pid)) continue; // state 循环已发起,别对同 pid 二次发信号
420
433
  const log = scriptProcLogPath(procArgs);
421
434
  if (log === null
422
435
  || !log.startsWith(`${deps.paths.sessionLogDir}${path.sep}`)) continue;
423
436
  const ent = { pid, procStart: deps.procStartedAt(pid) };
424
437
  if (!deps.trackedAlive(ent)) continue;
425
438
  deps.print(`正在结束保活会话(state 已丢失) pid=${pid}`);
426
- await deps.stopSession(ent);
439
+ stopping.push(deps.stopSession(ent));
427
440
  }
441
+ await Promise.all(stopping);
428
442
  }
429
443
 
430
444
  if (fs.existsSync(deps.paths.plistPath)) {
package/src/sessions.mjs CHANGED
@@ -6,9 +6,6 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  import { deps } from './internals.mjs';
9
- import {
10
- SESSION_IDLE_SECONDS,
11
- } from './constants.mjs';
12
9
 
13
10
  // pid 是否对应活进程。走到这儿的 pid 已经过入口范围校验。
14
11
  export function alive(pid) {
@@ -88,6 +85,15 @@ export function buildChildEnv(parentEnv) {
88
85
  return env;
89
86
  }
90
87
 
88
+ // 日志保留一代(.1):删旧代后把现役改名顶上。「.1」命名与 rm-先-于-
89
+ // rename 的顺序是两处调用(会话 typescript、launchd 巡检日志)共同的承重
90
+ // 约定——script(1) 打开即截断、launchd 按路径重开,轮转失败都不阻断调用方。
91
+ export function rotateOnce(file) {
92
+ const old = `${file}.1`;
93
+ fs.rmSync(old, { force: true });
94
+ fs.renameSync(file, old);
95
+ }
96
+
91
97
  // 拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
92
98
  export function spawnSession(ws) {
93
99
  const logPath = sessionLogPath(ws);
@@ -102,11 +108,7 @@ export function spawnSession(ws) {
102
108
  // 一代(.1)——总量上界两代,活着的一代增长再快也只影响自身,不会经
103
109
  // 轮转放大。
104
110
  try {
105
- if (fs.existsSync(logPath)) {
106
- const old = `${logPath}.1`;
107
- fs.rmSync(old, { force: true });
108
- fs.renameSync(logPath, old);
109
- }
111
+ if (fs.existsSync(logPath)) rotateOnce(logPath);
110
112
  } catch {
111
113
  // 轮转失败不阻断拉起。
112
114
  }
@@ -165,12 +167,14 @@ export async function stopSession(ent) {
165
167
  }
166
168
  }
167
169
 
168
- // typescript 多少秒没被写。文件缺失读作「没有活动证据」,不挡轮转。
170
+ // typescript 多少秒没被写。文件缺失读作「没有任何活动证据」:Infinity 大
171
+ // 于任何静默阈值,天然不挡换代与回收(哨兵值曾绑定 SESSION_IDLE_SECONDS
172
+ // + 1,阈值调低后会悄悄翻转语义)。
169
173
  export function sessionLogIdleSeconds(logPath, cur) {
170
174
  try {
171
175
  return Math.max(0, cur - Math.floor(fs.statSync(logPath).mtimeMs / 1000));
172
176
  } catch {
173
- return SESSION_IDLE_SECONDS + 1;
177
+ return Infinity;
174
178
  }
175
179
  }
176
180
 
package/src/tasks.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { deps } from './internals.mjs';
8
+ import { deps, isPlainObject } from './internals.mjs';
9
9
  import { TASK_REL } from './paths.mjs';
10
10
  import {
11
11
  PRUNE_DIRS,
@@ -20,7 +20,7 @@ export function expandHome(p) {
20
20
  return p; // ~user 形式不支持(macOS 实践中不会出现)
21
21
  }
22
22
 
23
- function isDir(p) {
23
+ export function isDir(p) {
24
24
  try {
25
25
  return fs.statSync(p).isDirectory();
26
26
  } catch {
@@ -36,22 +36,33 @@ function realpathOrNull(p) {
36
36
  }
37
37
  }
38
38
 
39
+ // 宽容版 realpath:失败时退回词法绝对路径(输入须已绝对)。与
40
+ // realpathOrNull 的「失败即 null」是两种降级口径:会话登记匹配(registry)
41
+ // 与日志定位(logs)要的是一个还能用的路径,而不是 null。
42
+ export function lexReal(p) {
43
+ try {
44
+ return fs.realpathSync(p);
45
+ } catch {
46
+ return path.resolve(p);
47
+ }
48
+ }
49
+
50
+ // 任务文件是否还在:readTasks 读出 null 时区分「文件已消失」(一次性任务
51
+ // 执行完被删的正常竞态)与「文件在但损坏」(要告警)。路径拼法归任务层,
52
+ // 巡检与总览不必各自重建。
53
+ export function taskFileExists(ws) {
54
+ return fs.existsSync(path.join(ws, TASK_REL));
55
+ }
56
+
39
57
  // 路径在磁盘上真实存储的大小写形态;任何一段读不出时原样返回。
40
58
  // macOS 默认文件系统大小写不敏感,而 realpath 不规范化大小写:敲错大小
41
59
  // 写的路径照样通过 isDirectory,却与会话登记里真实大小写的 cwd 永远匹配
42
60
  // 不上。在扫描边界认回一次真实写法,下游就不必各自提防。
43
61
  export function onDiskCase(p) {
44
- const parts = [];
45
- let cur = path.resolve(p).replace(/\/+$/, '') || '/';
46
- while (true) {
47
- const head = path.dirname(cur);
48
- const tail = path.basename(cur);
49
- if (!tail || head === cur) break;
50
- parts.push(tail);
51
- cur = head;
52
- }
53
- // cur 停在根,自顶向下逐段认回磁盘上的真实条目名。
54
- for (const name of parts.reverse()) {
62
+ // resolve 的输出不含双斜杠与尾部斜杠(根除外),按段拆开自顶向下认回。
63
+ const parts = path.resolve(p).split('/').filter(Boolean);
64
+ let cur = '/';
65
+ for (const name of parts) {
55
66
  let entries;
56
67
  try {
57
68
  entries = fs.readdirSync(cur);
@@ -91,16 +102,19 @@ export function* discover(roots, maxDepth) {
91
102
  ? []
92
103
  : entries.filter((e) => e.isDirectory() && !PRUNE_DIRS.has(e.name));
93
104
  const candidate = path.join(dirpath, TASK_REL);
94
- const ws = realpathOrNull(dirpath);
95
105
  let isFile = false;
96
106
  try {
97
107
  isFile = fs.statSync(candidate).isFile();
98
108
  } catch {
99
- isFile = false;
109
+ // 不存在按非文件处理。
100
110
  }
101
- if (ws && isFile && !seen.has(ws)) {
102
- seen.add(ws);
103
- yield [ws, candidate];
111
+ // 先判有没有任务文件再 realpath:绝大多数目录没有,省一次 syscall。
112
+ if (isFile) {
113
+ const ws = realpathOrNull(dirpath);
114
+ if (ws && !seen.has(ws)) {
115
+ seen.add(ws);
116
+ yield [ws, candidate];
117
+ }
104
118
  }
105
119
  for (const d of subdirs) {
106
120
  yield* walk(path.join(dirpath, d.name));
@@ -110,10 +124,6 @@ export function* discover(roots, maxDepth) {
110
124
  }
111
125
  }
112
126
 
113
- function isPlainObject(v) {
114
- return v !== null && typeof v === 'object' && !Array.isArray(v);
115
- }
116
-
117
127
  // 读一个工作区的任务列表,坏形状一律返回 null。
118
128
  export function readTasks(ws) {
119
129
  let doc;
@@ -245,11 +255,11 @@ export function minifyIds(tasks) {
245
255
  }
246
256
 
247
257
  // 一轮巡检对一个任务文件做的维护:renew(补 permanent)与 minify(id 改
248
- // 小)合并为同一次受 mtime 乐观锁保护的原子写。外壳与旧 renewWorkspace
249
- // 完全一致:先取 mtimeNs、清残留 .cron-up-tmp、loadTaskDoc round-trip 整
250
- // 文档;两项计数都为 0 时字节级不动。返回 {renewed,minified};null 表示
251
- // 文件缺失/损坏;读到写间被改抛 TaskFileChanged。
252
- export function maintainWorkspace(taskfile, { renew = false, minify = false } = {}) {
258
+ // 小)合并为同一次受 mtime 乐观锁保护的原子写。先取 mtimeNs、清残留
259
+ // .cron-up-tmp、loadTaskDoc round-trip 整文档;两项计数都为 0 时字节级
260
+ // 不动。返回 {renewed,minified};null 表示文件缺失/损坏;读到写间被改抛
261
+ // TaskFileChanged。
262
+ export function maintainWorkspace(taskfile, { renew = false, minify = false }) {
253
263
  let mtimeNs;
254
264
  try {
255
265
  mtimeNs = fs.statSync(taskfile, { bigint: true }).mtimeNs;
@@ -285,10 +295,4 @@ export function maintainWorkspace(taskfile, { renew = false, minify = false } =
285
295
  return { renewed, minified };
286
296
  }
287
297
 
288
- // 手动 renew 的薄封装:只补 permanent(不改 id),保持 null/0/N 三态契约。
289
- export function renewWorkspace(taskfile) {
290
- const r = maintainWorkspace(taskfile, { renew: true });
291
- return r === null ? null : r.renewed;
292
- }
293
-
294
298
  Object.assign(deps, { discover, readTasks, loadTaskDoc });