cron-up 26.10.25 → 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/package.json +1 -1
- package/src/cli.mjs +5 -5
- package/src/config.mjs +25 -22
- package/src/constants.mjs +7 -7
- package/src/cron.mjs +44 -34
- package/src/display.mjs +24 -25
- package/src/internals.mjs +12 -0
- package/src/logs.mjs +5 -13
- package/src/overview.mjs +8 -10
- package/src/paths.mjs +35 -22
- package/src/patrol.mjs +52 -38
- package/src/registry.mjs +7 -26
- package/src/service.mjs +32 -18
- package/src/sessions.mjs +14 -10
- package/src/tasks.mjs +37 -33
package/package.json
CHANGED
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
|
-
//
|
|
44
|
-
//
|
|
43
|
+
// 严格十进制整数命令行值(谓词 isDecInt 见 internals):
|
|
44
|
+
// Number("0x10")、parseInt("5x") 都会静默给出错误结果,必须显式拒绝。
|
|
45
45
|
function cliInt(s) {
|
|
46
|
-
if (
|
|
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
|
-
|
|
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' &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
53
|
-
|
|
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
|
// 发直接复用现有会话(时间紧邻的任务共享)。
|
|
@@ -111,5 +113,3 @@ export const SEARCH_DAYS = 7;
|
|
|
111
113
|
// list/总览回答「下次何时触发」,要看到一年后:年度任务必须显示真实日期,
|
|
112
114
|
// 不能误报成「无安排」。nextAfter 的日历快进让长窗口依然便宜。
|
|
113
115
|
export const DISPLAY_SEARCH_DAYS = 366;
|
|
114
|
-
// list 问「一次性任务是否被错过」,与巡检同一个补执行判定,只是不带前瞻。
|
|
115
|
-
export const ZERO_LEAD_MS = 0;
|
package/src/cron.mjs
CHANGED
|
@@ -7,14 +7,15 @@ import {
|
|
|
7
7
|
CC_JITTER,
|
|
8
8
|
TAIL_WINDOW_DAYS,
|
|
9
9
|
} from './constants.mjs';
|
|
10
|
+
import { isDecInt } from './internals.mjs';
|
|
10
11
|
|
|
11
12
|
const MS_DAY = 86400_000;
|
|
12
13
|
const MS_MINUTE = 60_000;
|
|
13
14
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
15
|
+
// 严格十进制整数(isDecInt 的谓词见 internals):parseInt("5x") 会静默返回
|
|
16
|
+
// 5,必须自己卡。
|
|
16
17
|
function strictInt(s) {
|
|
17
|
-
if (
|
|
18
|
+
if (!isDecInt(s)) throw new Error(`不是整数:${s}`);
|
|
18
19
|
return parseInt(s, 10);
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -126,7 +127,7 @@ export class Cron {
|
|
|
126
127
|
// 不晚于 t 所在分钟的最近一次匹配(含当前分钟);withinDays 天前还没有
|
|
127
128
|
// 则 null。与 nextAfter 对称做整月/整小时快进,366 天窗口也便宜。用于
|
|
128
129
|
// 判断「现在是否落在某次触发的投递延迟尾窗里」。
|
|
129
|
-
prevAtOrBefore(t, withinDays
|
|
130
|
+
prevAtOrBefore(t, withinDays) {
|
|
130
131
|
if (!this.satisfiable) return null;
|
|
131
132
|
const cur = new Date(t.getTime());
|
|
132
133
|
cur.setSeconds(0, 0);
|
|
@@ -162,7 +163,7 @@ export class Cron {
|
|
|
162
163
|
|
|
163
164
|
// CronCreate 产生的一次性任务形状像 'M H DoM Mon *',作为没有 recurring
|
|
164
165
|
// 标志时的兜底识别法。
|
|
165
|
-
|
|
166
|
+
function cronLooksOneshot(expr) {
|
|
166
167
|
const parts = expr.split(/\s+/);
|
|
167
168
|
return parts.length === 5 && parts[4] === '*'
|
|
168
169
|
&& parts.slice(0, 4).every((p) => /^\d+$/.test(p));
|
|
@@ -212,8 +213,7 @@ function wanted(cron, task, now, leadMs) {
|
|
|
212
213
|
new Date(created.getTime() - MS_MINUTE),
|
|
213
214
|
span,
|
|
214
215
|
);
|
|
215
|
-
|
|
216
|
-
return false;
|
|
216
|
+
return first !== null && first.getTime() <= now.getTime();
|
|
217
217
|
}
|
|
218
218
|
// 周期任务:投递不是在落点,而是落点 + 按 id 确定的抖动(每日任务通常
|
|
219
219
|
// 30 分)。落点已过、抖动投递时刻未到的这段「投递尾窗」里会话仍必须在
|
|
@@ -244,50 +244,60 @@ export function taskIdHash(taskId) {
|
|
|
244
244
|
return Number.isFinite(n) ? n : 0;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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) {
|
|
257
256
|
if (taskIsOneshot(task)) {
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
257
|
+
const slot = cron.nextAfter(now, withinDays);
|
|
258
|
+
if (slot === null) return { slot: null, fire: null };
|
|
260
259
|
// 复刻 Wsn():只有落点分钟落在 :00/:30 网格上才提前一个按哈希缩放的
|
|
261
260
|
// 小量(0~90 秒),其余分钟整点不抖;提前量不越过 now。
|
|
262
|
-
if (
|
|
261
|
+
if (slot.getMinutes() % CC_JITTER.oneShotMinuteMod !== 0) {
|
|
262
|
+
return { slot, fire: slot };
|
|
263
|
+
}
|
|
264
|
+
const h = taskIdHash(task?.id);
|
|
263
265
|
const ahead = CC_JITTER.oneShotFloorMs
|
|
264
266
|
+ h * (CC_JITTER.oneShotMaxMs - CC_JITTER.oneShotFloorMs);
|
|
265
|
-
return new Date(Math.max(
|
|
267
|
+
return { slot, fire: new Date(Math.max(slot.getTime() - ahead, now.getTime())) };
|
|
266
268
|
}
|
|
267
269
|
|
|
268
|
-
// 尾窗内的周期任务:下一次真实投递是「上一落点 + 抖动」,数分钟内就
|
|
269
|
-
// 到,不是 nextAfter 给出的下个落点——后者严格在未来,会把展示拐到明
|
|
270
|
-
// 天(list 的落点列由 taskView 用 recurringTailWindow 对齐同一落点)。
|
|
271
|
-
// 先于窗口检查:2 月 29 日这类长周期任务的下一落点可能在展示窗口之
|
|
272
|
-
// 外,正在等待的投递却依然成立。
|
|
273
270
|
const tail = recurringTailWindow(cron, task?.id, now);
|
|
274
|
-
if (tail !== null) return new Date(tail[1]);
|
|
271
|
+
if (tail !== null) return { slot: tail[0], fire: new Date(tail[1]) };
|
|
275
272
|
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
273
|
+
const slot = cron.nextAfter(now, withinDays);
|
|
274
|
+
if (slot === null) return { slot: null, fire: null };
|
|
278
275
|
// 复刻 bOt():抖动按「本落点到下一落点」的周期比例缩放并封顶。nextAfter
|
|
279
276
|
// 语义是严格晚于入参(内部进位一分钟),传 slot 本身即取下一落点;窗口
|
|
280
277
|
// 边缘取不到时与上游一致:视为无后续,不抖动。
|
|
281
|
-
return
|
|
278
|
+
return {
|
|
279
|
+
slot,
|
|
280
|
+
fire: new Date(slot.getTime() + recurringDelayMs(cron, slot, task?.id, withinDays)),
|
|
281
|
+
};
|
|
282
282
|
// 注:上游 bOt 另有一个 cacheLeadMs 分支——仅当相邻落点间隔落在
|
|
283
283
|
// [300000, 315000)ms(约 5 分钟周期的高频 cron,如 */5)且表达式匹配
|
|
284
284
|
// 其内部步进正则时,调度时刻提前 15 秒。日/周/月任务周期远大于此,不
|
|
285
285
|
// 经过该分支,故不予复刻。
|
|
286
286
|
}
|
|
287
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
|
+
|
|
288
298
|
// 周期任务从某个落点起的投递延迟(ms):hash(id)·frac·周期,封顶 30 分。
|
|
289
299
|
// slot 后取不到下一落点(窗口边缘、不可满足)时返回 0。
|
|
290
|
-
|
|
300
|
+
function recurringDelayMs(cron, slot, taskId, withinDays) {
|
|
291
301
|
const next = cron.nextAfter(new Date(slot.getTime()), withinDays);
|
|
292
302
|
if (next === null) return 0;
|
|
293
303
|
const period = next.getTime() - slot.getTime();
|
|
@@ -299,9 +309,9 @@ export function recurringDelayMs(cron, slot, taskId, withinDays = SEARCH_DAYS) {
|
|
|
299
309
|
|
|
300
310
|
// 周期任务的「投递尾窗」判定:上一落点已过、按 id 算的延迟投递时刻未
|
|
301
311
|
// 到。返回 [落点, 投递时刻 ms];不在尾窗、找不到上一或下一落点返回
|
|
302
|
-
// null。wanted(保活判定)与
|
|
303
|
-
//
|
|
304
|
-
|
|
312
|
+
// null。wanted(保活判定)与 nextDelivery(展示)共用这一口径,两边不会
|
|
313
|
+
// 各说各话;窗口取 TAIL_WINDOW_DAYS,理由见 constants。
|
|
314
|
+
function recurringTailWindow(cron, taskId, now) {
|
|
305
315
|
const prev = cron.prevAtOrBefore(now, TAIL_WINDOW_DAYS);
|
|
306
316
|
if (prev === null) return null;
|
|
307
317
|
const fireMs = prev.getTime()
|
package/src/display.mjs
CHANGED
|
@@ -4,10 +4,8 @@ import {
|
|
|
4
4
|
parseCronOrNone,
|
|
5
5
|
taskIsOneshot,
|
|
6
6
|
wanted,
|
|
7
|
-
|
|
8
|
-
recurringTailWindow,
|
|
7
|
+
nextDelivery,
|
|
9
8
|
} from './cron.mjs';
|
|
10
|
-
import { DISPLAY_SEARCH_DAYS, ZERO_LEAD_MS } from './constants.mjs';
|
|
11
9
|
|
|
12
10
|
// 等宽终端里的宽字符区间(CJK 文字、全角标点、谚文、常用 emoji):启发式
|
|
13
11
|
// 只覆盖任务摘要里真实出现的形态,区间外一律按 1 列——判错的代价仅是对
|
|
@@ -64,7 +62,7 @@ export function humanDelta(ms) {
|
|
|
64
62
|
|
|
65
63
|
// list/总览上代表任务身份的一句话:prompt 的首个非空行。真实任务第一行
|
|
66
64
|
// 通常自带标题;取不到时如实标注,不留空白格。
|
|
67
|
-
|
|
65
|
+
function taskSummary(task) {
|
|
68
66
|
const prompt = task?.prompt;
|
|
69
67
|
if (typeof prompt !== 'string' || !prompt.trim()) return '(无任务描述)';
|
|
70
68
|
const first = prompt.split('\n').map((l) => l.trim()).find((l) => l) ?? '';
|
|
@@ -74,7 +72,7 @@ export function taskSummary(task) {
|
|
|
74
72
|
// 把一个任务整理成展示数据。字段来自别的程序写的文件,畸形也不抛。
|
|
75
73
|
// 传 leadMs 时顺带在同一个已解析 cron 上回答「现在是否需要预热会话」
|
|
76
74
|
// (wanted),总览页就不必为同一任务再解析一遍 cron。
|
|
77
|
-
export function taskView(task, now, leadMs
|
|
75
|
+
export function taskView(task, now, leadMs) {
|
|
78
76
|
const expr = task?.cron;
|
|
79
77
|
const cron = parseCronOrNone(expr);
|
|
80
78
|
if (cron === null || !cron.satisfiable) {
|
|
@@ -92,23 +90,21 @@ export function taskView(task, now, leadMs = undefined) {
|
|
|
92
90
|
}
|
|
93
91
|
const oneshot = taskIsOneshot(task);
|
|
94
92
|
// nxt 按定义不晚于展示语境里的下一次投递,「已错过」完全由补执行判定
|
|
95
|
-
//
|
|
96
|
-
const missed = oneshot && wanted(cron, task, now,
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
const tail = oneshot ? null : recurringTailWindow(cron, task?.id, now);
|
|
102
|
-
const nxt = tail ? tail[0] : cron.nextAfter(now, DISPLAY_SEARCH_DAYS);
|
|
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);
|
|
103
99
|
return {
|
|
104
100
|
valid: true,
|
|
105
101
|
// 已错过的一次性任务没有未来触发:nxt 置 null,否则「一年内无」的判
|
|
106
102
|
// 断和总览「最近」(fire ?? nxt)会把它当成明年同刻的安排。
|
|
107
|
-
nxt: missed ? null :
|
|
103
|
+
nxt: missed ? null : slot,
|
|
108
104
|
missed,
|
|
109
105
|
kind: oneshot ? '一次性' : '周期',
|
|
110
106
|
permanent: Boolean(task.permanent) && !oneshot,
|
|
111
|
-
cadence:
|
|
107
|
+
cadence: String(expr),
|
|
112
108
|
summary: taskSummary(task),
|
|
113
109
|
// wanted 恒为 null 或布尔:没传 leadMs(不问)与 cron 无效(问不了)
|
|
114
110
|
// 都是 null,键的形状不随参数变化。
|
|
@@ -116,7 +112,7 @@ export function taskView(task, now, leadMs = undefined) {
|
|
|
116
112
|
// 计入 Claude Code 投递抖动后的预计实际触发时刻;已错过的一次性任务
|
|
117
113
|
// 没有未来触发,为 null;尾窗内的周期任务是即将到来的这次延迟投递。
|
|
118
114
|
// 与 nxt 同为 Date 或 null。
|
|
119
|
-
fire: missed ? null :
|
|
115
|
+
fire: missed ? null : fire,
|
|
120
116
|
};
|
|
121
117
|
}
|
|
122
118
|
|
|
@@ -131,6 +127,12 @@ function sameMinute(a, b) {
|
|
|
131
127
|
&& a.getMinutes() === b.getMinutes();
|
|
132
128
|
}
|
|
133
129
|
|
|
130
|
+
// 两位数字补零:时间戳格式的公共零件,巡检日志的时间戳(patrol 的 log)
|
|
131
|
+
// 也复用。
|
|
132
|
+
export function pad2(n) {
|
|
133
|
+
return String(n).padStart(2, '0');
|
|
134
|
+
}
|
|
135
|
+
|
|
134
136
|
// 「设定落点 → 预计实际触发」文案。抖动四舍五入到秒(调度器秒级轮询):
|
|
135
137
|
// 同日省略第二个日期;预计值带非零秒时显示 HH:MM:SS(如 05:15:37);落
|
|
136
138
|
// 在同一分钟(无抖动)则只显示落点,不画无意义的箭头。
|
|
@@ -140,25 +142,22 @@ export function fmtFireRange(nxt, fire) {
|
|
|
140
142
|
}
|
|
141
143
|
const r = new Date(Math.round(fire.getTime() / 1000) * 1000);
|
|
142
144
|
if (sameMinute(nxt, r)) return fmtMDHM(nxt);
|
|
143
|
-
const p = (n) => String(n).padStart(2, '0');
|
|
144
145
|
const tail = sameDay(nxt, r)
|
|
145
|
-
? `${
|
|
146
|
-
+ (r.getSeconds() ? `:${
|
|
147
|
-
: `${fmtMDHM(r)}${r.getSeconds() ? `:${
|
|
146
|
+
? `${pad2(r.getHours())}:${pad2(r.getMinutes())}`
|
|
147
|
+
+ (r.getSeconds() ? `:${pad2(r.getSeconds())}` : '')
|
|
148
|
+
: `${fmtMDHM(r)}${r.getSeconds() ? `:${pad2(r.getSeconds())}` : ''}`;
|
|
148
149
|
return `${fmtMDHM(nxt)} → ${tail}`;
|
|
149
150
|
}
|
|
150
151
|
|
|
151
152
|
// 仅 HH:MM(总览「(设定 HH:MM)」附注用)。
|
|
152
153
|
export function fmtHM(d) {
|
|
153
|
-
|
|
154
|
-
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
154
|
+
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
// MM-DD HH:MM(任务触发点的统一展示格式)。
|
|
158
158
|
export function fmtMDHM(d) {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
+ `${p(d.getMinutes())}`;
|
|
159
|
+
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} `
|
|
160
|
+
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
162
161
|
}
|
|
163
162
|
|
|
164
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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 {
|
|
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 (
|
|
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 =
|
|
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
|
|
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 {
|
|
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()}-${
|
|
62
|
-
+ `${
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
}) ?? {
|
|
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,
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
+
// 读不出(损坏 JSON):没有可继承的旧值。
|
|
300
306
|
}
|
|
301
|
-
// 手改成非对象(123、["a"]
|
|
302
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
109
|
+
// 不存在按非文件处理。
|
|
100
110
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
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 });
|