cron-up 26.9.1

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.
@@ -0,0 +1,260 @@
1
+ // 无子命令时的总览页:一眼看清服务、配置、任务、保活会话与需要留意的
2
+ // 事。它回答「现在一切正常吗」:除汇总现状外,还把巡检视角的异常集中成
3
+ // 一个「需要留意」清单。
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { deps } from './internals.mjs';
9
+ import { loadConfig, loadState, validateConfig, isInt } from './config.mjs';
10
+ import { packageVersion, TASK_REL } from './paths.mjs';
11
+ import { serviceLine, launcherHealth } from './service.mjs';
12
+ import {
13
+ humanDelta,
14
+ clip,
15
+ pad,
16
+ dispWidth,
17
+ taskView,
18
+ sessionAgeZh,
19
+ fmtMDHM,
20
+ } from './display.mjs';
21
+
22
+ // 总览页任务盘点。返回 [告警, 已在告警里说明冷却的工作区集合, 本轮发现
23
+ // 的工作区集合](第三个供会话部分识别「已移出巡检视野」的会话)。
24
+ function overviewTasks(cfg, cols, consumers, state) {
25
+ const alerts = [];
26
+ const coolingWarned = new Set();
27
+ const discovered = new Set();
28
+ const now = new Date();
29
+ const cur = Math.floor(now.getTime() / 1000);
30
+ const leadMs = cfg.leadSeconds * 1000;
31
+
32
+ let nWs = 0;
33
+ let nTasks = 0;
34
+ let unrenewed = 0;
35
+ let soonest = null;
36
+ for (const [ws] of deps.discover(cfg.roots, cfg.maxDepth)) {
37
+ discovered.add(ws);
38
+ let tasks;
39
+ try {
40
+ tasks = deps.readTasks(ws);
41
+ } catch {
42
+ continue;
43
+ }
44
+ if (tasks === null) {
45
+ // 与巡检同一条规则:文件还在却读不出,总览页要把它摆进「需要留
46
+ // 意」,而不是装作这个工作区不存在。
47
+ if (fs.existsSync(path.join(ws, TASK_REL))) {
48
+ alerts.push(`${ws} 的任务文件读不出:其中的定时任务一个都不会执行`);
49
+ }
50
+ continue;
51
+ }
52
+ if (tasks.length === 0) continue;
53
+ nWs += 1;
54
+ nTasks += tasks.length;
55
+ let bad = 0;
56
+ let wanted = false;
57
+ for (const t of tasks) {
58
+ // 与 renew 同一条字段规则:显式周期任务却没有 truthy permanent,仍
59
+ // 受 7 天过期约束。从原始任务计数,与 cron 合法性无关,坏 cron 也照
60
+ // 提醒。
61
+ if (t.recurring && !t.permanent) unrenewed += 1;
62
+ const v = taskView(t, now, leadMs);
63
+ if (!v.valid) {
64
+ bad += 1;
65
+ continue;
66
+ }
67
+ if (v.nxt && (!soonest || v.nxt.getTime() < soonest[0].getTime())) {
68
+ soonest = [v.nxt, ws, v.summary];
69
+ }
70
+ if (v.wanted) wanted = true;
71
+ }
72
+ if (bad > 0) {
73
+ alerts.push(`${ws} 有 ${bad} 个任务的 cron 无法解析,`
74
+ + `巡检会跳过${bad > 1 ? '它们' : '它'}`);
75
+ }
76
+ if (wanted && !consumers.has(ws)) {
77
+ const ent = state[ws] ?? {};
78
+ const cooldown = ent.cooldownUntil ?? 0;
79
+ if (cooldown > cur) {
80
+ // 与「下轮会自动启动」互斥:冷却中就是不会启动,两条同现会自相
81
+ // 矛盾,这里合并成一条。
82
+ const eta = Math.max(0, Math.floor((cooldown - cur) / 60));
83
+ alerts.push(
84
+ `${ws} 有任务即将执行(或错过待补执行),但保活会话连续失败 `
85
+ + `${ent.fails ?? '?'} 次正处冷却中,约 ${eta} 分钟后才会重试;`
86
+ + `请运行 cron-up logs ${path.basename(ws)} 排查`);
87
+ coolingWarned.add(ws);
88
+ } else {
89
+ alerts.push(`${ws} 有任务即将执行(或错过待补执行),但当前没有交互`
90
+ + '会话,下轮巡检会自动启动');
91
+ }
92
+ }
93
+ }
94
+
95
+ deps.print(nTasks > 0
96
+ ? `任务:${nTasks} 个,分布在 ${nWs} 个工作区`
97
+ : '任务:暂无');
98
+ if (soonest) {
99
+ const [nxt, ws, summary] = soonest;
100
+ const line = `最近:${fmtMDHM(nxt)}(${humanDelta(nxt.getTime() - now.getTime())}) `
101
+ + `${path.basename(ws)} · ${summary}`;
102
+ deps.print(clip(line, cols));
103
+ }
104
+ if (!cfg.autoRenew && unrenewed > 0) {
105
+ alerts.push(
106
+ `自动续期已关闭:${unrenewed} 个周期任务尚未标记 permanent,创建满 7 `
107
+ + '天后会末次执行并被删除。运行 cron-up renew 立即续期,或在配置中'
108
+ + '开启 autoRenew');
109
+ }
110
+ return [alerts, coolingWarned, discovered];
111
+ }
112
+
113
+ function overviewSessions(cols, state, suppressCooling = new Set(),
114
+ discovered = null) {
115
+ const alerts = [];
116
+ const cur = Math.floor(Date.now() / 1000);
117
+ const live = [];
118
+ const cooling = [];
119
+ const dead = [];
120
+ for (const [ws, ent] of Object.entries(state)) {
121
+ if ((ent.cooldownUntil ?? 0) > cur) cooling.push([ws, ent]);
122
+ else if (deps.trackedAlive(ent)) live.push([ws, ent]);
123
+ else dead.push(ws); // 记录还在、进程已没:下轮巡检即清理
124
+ }
125
+ let tail = '';
126
+ if (cooling.length) tail += `,冷却 ${cooling.length} 个`;
127
+ if (dead.length) tail += `,已失效 ${dead.length} 个`;
128
+ if (live.length) {
129
+ const desc = live.map(([ws, ent]) =>
130
+ `${path.basename(ws)}(pid ${ent.pid},已运行 `
131
+ + `${sessionAgeZh(ent.startedAt ?? cur, cur)})`).join('、');
132
+ const head = `会话:保活 ${live.length} 个(`;
133
+ const fixed = dispWidth(head) + dispWidth(tail) + dispWidth(')');
134
+ deps.print(`${head}${clip(desc, cols - fixed)})${tail}`);
135
+ } else {
136
+ deps.print(`会话:无保活会话${tail}`);
137
+ }
138
+ for (const [ws, ent] of cooling) {
139
+ if (suppressCooling.has(ws)) continue; // 任务区已给过带上下文的合并告警
140
+ const eta = Math.max(0, Math.floor(((ent.cooldownUntil ?? 0) - cur) / 60));
141
+ alerts.push(`${ws} 保活连续失败 ${ent.fails ?? '?'} 次,冷却中,约 ${eta} `
142
+ + '分钟后重试');
143
+ }
144
+ if (discovered !== null) {
145
+ for (const [ws, ent] of live) {
146
+ if (!discovered.has(ws)) {
147
+ alerts.push(
148
+ `${ws} 的保活会话(pid ${ent.pid})仍在运行,但该目录已不在巡检范`
149
+ + '围内(任务文件被删或已移出 roots),cron-up 不会再回收或换代'
150
+ + '它;不需要时请自行结束');
151
+ }
152
+ }
153
+ }
154
+ return alerts;
155
+ }
156
+
157
+ const COMMANDS = [
158
+ ['list', '逐个列出任务的内容摘要、执行时间与会话状态'],
159
+ ['logs [目录片段] [-f]', '查看巡检日志或后台会话记录'],
160
+ ['run', '立即手动巡检一轮'],
161
+ ['renew', '立即给所有周期任务补 permanent(跳过 7 天过期)'],
162
+ ['install --force', '更新配置并重新安装(全部参数见 --help)'],
163
+ ];
164
+
165
+ export async function cmdOverview(args) {
166
+ const cols = deps.terminalWidth();
167
+ const v = packageVersion();
168
+ deps.print(`cron-up ${v ?? ''} —— 为定时任务提前备好交互会话`);
169
+
170
+ const cfgPath = args?.config ?? deps.paths.configPath;
171
+ let cfg = null;
172
+ let cfgError = null;
173
+ try {
174
+ cfg = loadConfig(cfgPath, { missingOk: true, corruptOk: true });
175
+ } catch (e) {
176
+ if (e.code !== 'ENOENT') cfgError = e;
177
+ }
178
+ // 数值字段被手改成非整数:巡检入口会以退出码 2 失败,总览若不先挡下会
179
+ // 在 leadMs 计算处炸掉。判定式与 cmdRun 的守卫保持一致。
180
+ const badFields = [];
181
+ if (cfg !== null) {
182
+ if (!isInt(cfg.intervalSeconds) || cfg.intervalSeconds <= 0) {
183
+ badFields.push('intervalSeconds');
184
+ }
185
+ if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
186
+ badFields.push('leadSeconds');
187
+ }
188
+ }
189
+
190
+ const info = deps.launchctlInfo();
191
+ deps.print(serviceLine(info, fs.existsSync(deps.paths.plistPath)));
192
+
193
+ const alerts = [];
194
+ // plist 写死的 node/入口路径失效(Node 版本目录被清)时 launchd 每轮
195
+ // 都静默拉不起来,这是总览唯一能发现它的地方。
196
+ const health = launcherHealth();
197
+ if (health) alerts.push(health);
198
+ const [consumers, registryAlert] = await deps.scanSessions();
199
+ if (registryAlert) alerts.push(registryAlert);
200
+ const state = loadState();
201
+ const coolingWarned = new Set();
202
+ let discovered = null;
203
+
204
+ if (cfgError !== null) {
205
+ // 页面不能死在半路:损坏配置是「需要留意」的一种;巡检本身仍会以退出
206
+ // 码 2 失败,详情见 cron-up logs。
207
+ deps.print(`配置:文件损坏,不是合法 JSON:${cfgPath}`);
208
+ alerts.push(`配置损坏,当前每轮巡检都会失败退出;修复 ${cfgPath} 或重新`
209
+ + `运行 cron-up install。解析错误:${cfgError.message}`);
210
+ } else if (cfg === null) {
211
+ deps.print('配置:缺失,运行 cron-up install 生成');
212
+ } else if (badFields.length) {
213
+ const vals = badFields.map((k) => `${k}=${JSON.stringify(cfg[k])}`).join('、');
214
+ deps.print(`配置:${badFields.join('、')} 应为整数秒,当前 ${vals}`);
215
+ alerts.push(`配置字段类型不对(${vals}),请修复 ${cfgPath};leadSeconds `
216
+ + '不对时每轮巡检都会以退出码 2 失败');
217
+ } else {
218
+ const actualIv = info ? info.interval : null;
219
+ const renewTag = cfg.autoRenew ? ',自动续期开' : ',自动续期关';
220
+ if (actualIv === null || actualIv === undefined) {
221
+ deps.print(`巡检:配置为每 ${cfg.intervalSeconds} 秒一轮(服务未加载),`
222
+ + `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}`);
223
+ } else {
224
+ deps.print(`巡检:每 ${actualIv} 秒一轮,提前 ${cfg.leadSeconds} 秒启动`
225
+ + `会话${renewTag}`);
226
+ if (actualIv !== cfg.intervalSeconds) {
227
+ alerts.push(`launchd 实际间隔 ${actualIv} 秒与配置 ${cfg.intervalSeconds} `
228
+ + '秒不一致,重新运行 cron-up install 后生效');
229
+ }
230
+ }
231
+ const suffix = `(深度 ${cfg.maxDepth})`;
232
+ const rootsText = cfg.roots.join('、');
233
+ const prefix = '扫描:';
234
+ deps.print(prefix
235
+ + clip(rootsText, cols - dispWidth(prefix) - dispWidth(suffix))
236
+ + suffix);
237
+ alerts.push(...validateConfig(cfg, () => {}));
238
+ deps.print('');
239
+ const [taskAlerts, cooled, found] = overviewTasks(cfg, cols, consumers, state);
240
+ alerts.push(...taskAlerts);
241
+ for (const w of cooled) coolingWarned.add(w);
242
+ discovered = found;
243
+ }
244
+
245
+ // 会话状态来自 state.json,与配置无关:未安装/配置损坏时也可能有残留保
246
+ // 活会话,照常展示。
247
+ alerts.push(...overviewSessions(cols, state, coolingWarned, discovered));
248
+
249
+ if (alerts.length) {
250
+ deps.print('需要留意:');
251
+ for (const a of alerts) deps.print(` · ${clip(a, cols - 4)}`);
252
+ deps.print('');
253
+ }
254
+
255
+ deps.print('常用命令:');
256
+ const nameW = Math.max(...COMMANDS.map(([n]) => dispWidth(n)));
257
+ for (const [name, desc] of COMMANDS) {
258
+ deps.print(` cron-up ${pad(name, nameW)} ${desc}`);
259
+ }
260
+ }
package/src/paths.mjs ADDED
@@ -0,0 +1,45 @@
1
+ // HOME 派生的全部产物路径。这些是另一个程序(launchd/Claude Code)也会
2
+ // 触达的契约面,改名要慎重——因此集中在一个地方。
3
+
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ export const LABEL = 'local.cron-up';
10
+ export const OLD_LABEL = 'local.doorman';
11
+
12
+ const HOME = os.homedir();
13
+ // 被预热的 agent 工作区里,定时任务文件的相对路径。
14
+ export const TASK_REL = path.join('.claude', 'scheduled_tasks.json');
15
+
16
+ // 路径对象整体可替换(deps.paths):测试把产物目录重定向到 tmp,不碰真实
17
+ // HOME。getter 让 sessionLogDir/configPath/statePath 跟随替换后的前缀。
18
+ export const paths = {
19
+ home: HOME,
20
+ appSupport: path.join(HOME, 'Library', 'Application Support', 'cron-up'),
21
+ logDir: path.join(HOME, 'Library', 'Logs', 'cron-up'),
22
+ plistPath: path.join(HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`),
23
+ sessionDir: path.join(HOME, '.claude', 'sessions'),
24
+
25
+ get sessionLogDir() {
26
+ return path.join(this.logDir, 'sessions');
27
+ },
28
+ get configPath() {
29
+ return path.join(this.appSupport, 'config.json');
30
+ },
31
+ get statePath() {
32
+ return path.join(this.appSupport, 'state.json');
33
+ },
34
+ };
35
+
36
+ // 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
37
+ // 版本实验特性的依赖);读不到返回 null,总览页降级显示。
38
+ export function packageVersion() {
39
+ try {
40
+ const pkg = fileURLToPath(new URL('../package.json', import.meta.url));
41
+ return JSON.parse(fs.readFileSync(pkg, 'utf-8')).version ?? null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
package/src/patrol.mjs ADDED
@@ -0,0 +1,396 @@
1
+ // 一轮巡检:发现工作区 → 自动续期 → 保证该有会话的地方有一个健康会话。
2
+ // 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。状态机覆盖:失败
3
+ // 冷却、卡死退休、超龄换代、孤儿接管、空任务回收。
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+
8
+ import { deps, ExitError } from './internals.mjs';
9
+ import {
10
+ FAIL_LIMIT,
11
+ COOLDOWN_SECONDS,
12
+ WARMUP_GRACE_SECONDS,
13
+ SESSION_MAX_AGE_SECONDS,
14
+ SESSION_IDLE_SECONDS,
15
+ PATROL_LOG_ROTATE_BYTES,
16
+ } from './constants.mjs';
17
+ import { loadConfig, loadState, saveState, isInt } from './config.mjs';
18
+ import { renewWorkspace, TaskFileChanged } from './tasks.mjs';
19
+ import { taskWanted } from './cron.mjs';
20
+ import {
21
+ sessionLogPath,
22
+ sessionLogIdleSeconds,
23
+ } from './sessions.mjs';
24
+ import { TASK_REL } from './paths.mjs';
25
+
26
+ export function log(msg) {
27
+ const d = new Date();
28
+ const p = (n) => String(n).padStart(2, '0');
29
+ deps.print(
30
+ `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
31
+ + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${msg}`,
32
+ );
33
+ }
34
+
35
+ // ---- 巡检互斥锁 ----
36
+ // flock 的替代:O_EXCL 创建 + 持锁 pid。内核不会在持有者死亡时自动释放
37
+ // 这种锁,所以抢锁失败要读 pid 判活,死锁/半成品文件按 stale 清理后有
38
+ // 限重试。对 300 秒一轮的单机 launchd,残余的 pid 复用窗口最坏只跳过一
39
+ // 轮,可接受。
40
+
41
+ function tryCreateLock(lockPath) {
42
+ try {
43
+ return fs.openSync(lockPath, 'wx', 0o600);
44
+ } catch (e) {
45
+ if (e.code === 'EEXIST') return null;
46
+ throw e;
47
+ }
48
+ }
49
+
50
+ function readLockPid(lockPath) {
51
+ let text;
52
+ try {
53
+ text = fs.readFileSync(lockPath, 'utf-8').trim();
54
+ } catch {
55
+ return null;
56
+ }
57
+ return /^\d+$/.test(text) ? parseInt(text, 10) : null;
58
+ }
59
+
60
+ export function acquireRunLock() {
61
+ fs.mkdirSync(deps.paths.appSupport, { recursive: true });
62
+ const lockPath = path.join(deps.paths.appSupport, 'run.lock');
63
+ for (let attempt = 0; attempt < 3; attempt++) {
64
+ const fd = tryCreateLock(lockPath);
65
+ if (fd !== null) {
66
+ try {
67
+ fs.writeFileSync(fd, `${process.pid}\n`);
68
+ fs.fsyncSync(fd);
69
+ } finally {
70
+ fs.closeSync(fd);
71
+ }
72
+ return { lockPath };
73
+ }
74
+ const holder = readLockPid(lockPath);
75
+ if (holder !== null) {
76
+ let live = false;
77
+ try {
78
+ process.kill(holder, 0);
79
+ live = true;
80
+ } catch (e) {
81
+ // EPERM:进程存在但不是我们的——当作活锁,不删别人的锁。
82
+ live = e.code === 'EPERM';
83
+ }
84
+ if (live) return null;
85
+ }
86
+ // 持有者已死 / 空文件 / pid 损坏:当作 stale 清掉重试。
87
+ try {
88
+ fs.unlinkSync(lockPath);
89
+ } catch {
90
+ return null; // 别人抢先重建了锁
91
+ }
92
+ }
93
+ return null;
94
+ }
95
+
96
+ export function releaseLock(handle) {
97
+ if (!handle?.lockPath) return;
98
+ try {
99
+ fs.unlinkSync(handle.lockPath);
100
+ } catch {
101
+ // 锁文件已不在即达成目的。
102
+ }
103
+ }
104
+
105
+ // ---- 日志轮转 ----
106
+
107
+ export function rotatePatrolLogs() {
108
+ // launchd 按路径每次重开文件,轮初改名是安全的:本轮 fd 继续写改名后
109
+ // 的 inode,下轮打开新文件。
110
+ for (const name of ['launchd.out.log', 'launchd.err.log']) {
111
+ const file = path.join(deps.paths.logDir, name);
112
+ try {
113
+ if (fs.existsSync(file)
114
+ && fs.statSync(file).size > PATROL_LOG_ROTATE_BYTES) {
115
+ const old = `${file}.1`;
116
+ fs.rmSync(old, { force: true });
117
+ fs.renameSync(file, old);
118
+ }
119
+ } catch {
120
+ // 轮转失败不阻断巡检。
121
+ }
122
+ }
123
+ }
124
+
125
+ // 保证一个工作区里有一个健康的消费者会话。原地修改 state。
126
+ export async function patrolWorkspace(
127
+ ws, state, now, leadMs, cur, consumers, scriptProcs = [],
128
+ ) {
129
+ const tasks = deps.readTasks(ws);
130
+ let ent = state[ws] ?? null;
131
+
132
+ if (tasks === null) {
133
+ // 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
134
+ // 不出意味着这个目录的定时任务一个都不会执行。文件已消失则是正常竞
135
+ // 态(一次性任务执行完被删),不报。
136
+ if (fs.existsSync(path.join(ws, TASK_REL))) {
137
+ deps.log(`任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
138
+ + `其中 ${TASK_REL} 里的定时任务都不会执行`);
139
+ }
140
+ return;
141
+ }
142
+ if (tasks.length === 0) {
143
+ // 文件完好但任务清空:一次性任务触发后被删、周期任务过期、或用户手
144
+ // 删。我们拉起的会话没理由继续留着;用户自己开的会话不在 state 里,
145
+ // 永不触碰。
146
+ if (ent !== null) {
147
+ if (deps.trackedAlive(ent)) {
148
+ await deps.stopSession(ent);
149
+ deps.log(`回收 ${ws} pid=${ent.pid}:任务已全部清空,结束保活会话`);
150
+ }
151
+ delete state[ws];
152
+ }
153
+ return;
154
+ }
155
+
156
+ // 换代:超龄且日志静默(无任务在执行的证据)的会话主动结束,需要时下
157
+ // 文自然重拉。换代是维护,不计失败。
158
+ let mineAlive = deps.trackedAlive(ent);
159
+ if (ent !== null && mineAlive) {
160
+ const age = cur - (ent.startedAt ?? cur);
161
+ if (age > SESSION_MAX_AGE_SECONDS
162
+ && sessionLogIdleSeconds(
163
+ ent.log || sessionLogPath(ws), cur) > SESSION_IDLE_SECONDS) {
164
+ await deps.stopSession(ent);
165
+ deps.log(`换代 ${ws} pid=${ent.pid}:会话已连续运行 `
166
+ + `${Math.floor(age / 86400)} 天且近期无输出,结束后按需重拉`);
167
+ delete state[ws];
168
+ ent = null;
169
+ mineAlive = false;
170
+ }
171
+ }
172
+
173
+ // 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
174
+ // 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
175
+ // 却,连一次新尝试都不给。
176
+ if (ent !== null && mineAlive && consumers.has(ws)) {
177
+ ent.fails = 0;
178
+ }
179
+
180
+ if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
181
+
182
+ const cooling = Boolean(ent && (ent.cooldownUntil ?? 0) > cur);
183
+
184
+ if (consumers.has(ws)) {
185
+ // 任务正在被消费。区分我们的预热会话与用户的交互会话:前者继续跟踪
186
+ // (活消费者证明健康 → 计数清零),后者彻底放手。
187
+ if (ent !== null) {
188
+ if (mineAlive) {
189
+ ent.fails = 0;
190
+ state[ws] = ent;
191
+ } else if (!cooling) {
192
+ delete state[ws];
193
+ }
194
+ }
195
+ return;
196
+ }
197
+
198
+ // 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
199
+ // 应答的提问界面,永不登记——退休它并推进失败计数。
200
+ if (ent !== null && mineAlive) {
201
+ const age = cur - (ent.startedAt ?? cur);
202
+ if (age <= WARMUP_GRACE_SECONDS) return;
203
+ await deps.stopSession(ent);
204
+ deps.log(`卡死 ${ws} pid=${ent.pid}:进程已存活 ${age} 秒仍未登记会话,`
205
+ + '已终止(排查:cron-up logs '
206
+ + `${path.basename(ws)})`);
207
+ }
208
+
209
+ if (cooling) return;
210
+
211
+ // 没有消费者,我们跟踪的会话(若有)已死或刚作为卡死退休。
212
+ let fails;
213
+ if (ent && ent.cooldownUntil) {
214
+ // 冷却刚结束:给一次干净的缓刑拉起,而不是立刻再次顶满限制。
215
+ fails = 0;
216
+ } else {
217
+ fails = (ent?.fails ?? 0) + (ent ? 1 : 0);
218
+ }
219
+
220
+ if (fails >= FAIL_LIMIT) {
221
+ state[ws] = {
222
+ pid: null,
223
+ fails,
224
+ cooldownUntil: cur + COOLDOWN_SECONDS,
225
+ };
226
+ deps.log(`冷却 ${ws}:仍有任务需要会话,但保活会话连续失败 ${fails} 次,`
227
+ + `暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
228
+ return;
229
+ }
230
+
231
+ if (ent === null) {
232
+ // state 里没有记录不等于没有会话:state.json 丢失(手删、purge 时保留
233
+ // 会话)后进程表里可能还躺着这个工作区的 script 会话,卡在提问界面就
234
+ // 不会出现在登记里,直接再拉就是同目录双开。认回孤儿,重新纳入正常
235
+ // 生命周期(卡死的 3 分钟后照退休处理)。
236
+ const logPath = sessionLogPath(ws);
237
+ for (const [procPid, procArgs] of scriptProcs) {
238
+ // alive 复核:快照拍于本轮开头,可能含着刚被换代/回收杀掉的 pid——
239
+ // 把死人认回来会白记一次失败。
240
+ if (procArgs.includes(`-q ${logPath} `) && deps.alive(procPid)) {
241
+ state[ws] = {
242
+ pid: procPid,
243
+ startedAt: cur,
244
+ procStart: deps.procStartedAt(procPid),
245
+ fails: 0,
246
+ log: logPath,
247
+ };
248
+ deps.log(`接管 ${ws} pid=${procPid}:state 记录缺失,但进程表里仍有`
249
+ + '本工作区的保活会话,重新纳入跟踪');
250
+ return;
251
+ }
252
+ }
253
+ }
254
+
255
+ const { pid, procStart } = deps.spawnSession(ws);
256
+ state[ws] = {
257
+ pid,
258
+ startedAt: cur,
259
+ procStart,
260
+ fails,
261
+ log: sessionLogPath(ws),
262
+ };
263
+ deps.log(`已启动 ${ws} pid=${pid}(连续失败计数 ${fails})`);
264
+ }
265
+
266
+ // 跑一轮巡检;抢不到锁返回 false。
267
+ export async function cmdRun(args) {
268
+ const lock = deps.acquireRunLock();
269
+ if (lock === null) {
270
+ deps.log('已有一轮巡检在进行,本轮跳过');
271
+ return false;
272
+ }
273
+ try {
274
+ deps.rotatePatrolLogs();
275
+ const cfg = loadConfig(args?.config ?? deps.paths.configPath);
276
+ if (!isInt(cfg.leadSeconds) || cfg.leadSeconds < 0) {
277
+ deps.printErr('配置错误:leadSeconds 必须是非负整数秒,当前为 '
278
+ + `${cfg.leadSeconds}`);
279
+ throw new ExitError(2);
280
+ }
281
+ const now = new Date();
282
+ const leadMs = cfg.leadSeconds * 1000;
283
+ const state = loadState();
284
+ if (cfg.roots.length === 0) {
285
+ deps.log('警告:扫描目录 roots 为空,巡检不会发现任何工作区;'
286
+ + '请检查配置文件或用 install --roots 指定');
287
+ }
288
+
289
+ const cur = Math.floor(Date.now() / 1000);
290
+ // 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
291
+ // 属这里。
292
+ const [consumers, registryAlert] = await deps.scanSessions();
293
+ if (registryAlert) deps.log(`告警:${registryAlert}`);
294
+ const scriptProcs = deps.listScriptProcesses();
295
+
296
+ for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
297
+ // 一个工作区读不动不能中断整轮:discover 是生成器,这里抛了会跳过
298
+ // 后面所有工作区。续期与保活各自独立守卫——续期失败绝不压制该工作
299
+ // 区的会话保活。
300
+ if (cfg.autoRenew) {
301
+ try {
302
+ const n = renewWorkspace(taskfile);
303
+ if (n) {
304
+ deps.log(`已续期 ${ws}:${n} 个周期任务标记为 permanent,`
305
+ + '不再受 7 天过期限制');
306
+ }
307
+ } catch (e) {
308
+ if (e instanceof TaskFileChanged) {
309
+ deps.log(`续期暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
310
+ + '本轮不覆盖,下轮再续');
311
+ } else {
312
+ deps.log(`续期错误 ${ws}:${e.constructor.name}:${e.message}`);
313
+ }
314
+ }
315
+ }
316
+ try {
317
+ await patrolWorkspace(
318
+ ws, state, now, leadMs, cur, consumers, scriptProcs);
319
+ } catch (e) {
320
+ deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
321
+ }
322
+ }
323
+
324
+ // 丢掉已死且不处冷却的条目;保留冷却与活会话。
325
+ const kept = Object.fromEntries(
326
+ Object.entries(state).filter(([, ent]) =>
327
+ deps.trackedAlive(ent) || (ent.cooldownUntil ?? 0) > cur),
328
+ );
329
+ saveState(kept);
330
+ return true;
331
+ } finally {
332
+ releaseLock(lock);
333
+ }
334
+ }
335
+
336
+ // 立即给所有周期任务补 permanent。忽略 cfg.autoRenew——手动跑命令本身就
337
+ // 是显式指令。与巡检共用同一把锁。
338
+ export async function cmdRenew(args) {
339
+ const lock = deps.acquireRunLock();
340
+ if (lock === null) {
341
+ deps.print('已有一轮巡检(或另一次 renew)在进行,请稍后重试');
342
+ return;
343
+ }
344
+ try {
345
+ const cfg = loadConfig(args?.config ?? deps.paths.configPath);
346
+ let files = 0;
347
+ let tagged = 0;
348
+ let unchanged = 0;
349
+ const skipped = [];
350
+ for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
351
+ files += 1;
352
+ let n;
353
+ try {
354
+ n = renewWorkspace(taskfile);
355
+ } catch (e) {
356
+ if (e instanceof TaskFileChanged) {
357
+ deps.print(`暂缓 ${ws}:任务文件刚被 Claude Code 改动,本次未写入,`
358
+ + '可再跑一次');
359
+ skipped.push(ws);
360
+ continue;
361
+ }
362
+ deps.print(`跳过 ${ws}:(${e.constructor.name}:${e.message})`);
363
+ skipped.push(ws);
364
+ continue;
365
+ }
366
+ if (n === null) {
367
+ deps.print(`跳过 ${ws}:任务文件读不出或格式损坏`);
368
+ skipped.push(ws);
369
+ } else if (n) {
370
+ tagged += n;
371
+ deps.print(`已续期 ${ws}:${n} 个周期任务标记为 permanent`);
372
+ } else {
373
+ unchanged += 1;
374
+ deps.print(`已是最新 ${ws}:周期任务均已 permanent,无需改动`);
375
+ }
376
+ }
377
+ deps.print('');
378
+ let line = `扫描 ${files} 个任务文件:本次续期 ${tagged} 个任务,`
379
+ + `${unchanged} 个文件无需改动`;
380
+ if (skipped.length) line += `,${skipped.length} 个跳过`;
381
+ deps.print(line);
382
+ if (tagged) {
383
+ deps.print('这些周期任务此后不再受 7 天过期限制;为它们保活的后台会话'
384
+ + '会长期常驻。');
385
+ }
386
+ } finally {
387
+ releaseLock(lock);
388
+ }
389
+ }
390
+
391
+ Object.assign(deps, {
392
+ log,
393
+ runPatrol: cmdRun,
394
+ rotatePatrolLogs,
395
+ acquireRunLock,
396
+ });