cron-up 26.10.25 → 26.10.27
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/args.mjs +36 -0
- package/src/cli.mjs +6 -39
- package/src/config.mjs +47 -34
- package/src/constants.mjs +8 -8
- package/src/cron.mjs +34 -34
- package/src/display.mjs +26 -26
- package/src/internals.mjs +37 -0
- package/src/logs.mjs +7 -14
- package/src/overview.mjs +21 -22
- package/src/paths.mjs +35 -35
- package/src/patrol.mjs +78 -61
- package/src/registry.mjs +9 -28
- package/src/service.mjs +39 -25
- package/src/sessions.mjs +16 -17
- package/src/tasks.mjs +41 -43
package/src/overview.mjs
CHANGED
|
@@ -5,16 +5,18 @@
|
|
|
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, packageVersion } from './internals.mjs';
|
|
9
9
|
import {
|
|
10
10
|
loadConfig,
|
|
11
11
|
loadState,
|
|
12
12
|
validateConfig,
|
|
13
13
|
loadHeartbeat,
|
|
14
|
+
heartbeatStale,
|
|
15
|
+
isCooling,
|
|
16
|
+
cooldownEtaMinutes,
|
|
14
17
|
} from './config.mjs';
|
|
15
|
-
import {
|
|
18
|
+
import { taskFileExists } from './tasks.mjs';
|
|
16
19
|
import { serviceLine, launcherHealth } from './service.mjs';
|
|
17
|
-
import { HEARTBEAT_STALE_FACTOR } from './constants.mjs';
|
|
18
20
|
import {
|
|
19
21
|
humanDelta,
|
|
20
22
|
clip,
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
elapsedZh,
|
|
26
28
|
fmtMDHM,
|
|
27
29
|
fmtHM,
|
|
30
|
+
sameMinute,
|
|
28
31
|
} from './display.mjs';
|
|
29
32
|
|
|
30
33
|
// 总览页任务盘点。返回 [告警, 已在告警里说明冷却的工作区集合, 本轮发现
|
|
@@ -47,7 +50,7 @@ function overviewTasks(cfg, cols, consumers, state) {
|
|
|
47
50
|
if (tasks === null) {
|
|
48
51
|
// 与巡检同一条规则:文件还在却读不出,总览页要把它摆进「需要留
|
|
49
52
|
// 意」,而不是装作这个工作区不存在。
|
|
50
|
-
if (
|
|
53
|
+
if (taskFileExists(ws)) {
|
|
51
54
|
alerts.push(`${ws} 的任务文件读不出:其中的定时任务一个都不会执行`);
|
|
52
55
|
}
|
|
53
56
|
continue;
|
|
@@ -80,11 +83,10 @@ function overviewTasks(cfg, cols, consumers, state) {
|
|
|
80
83
|
}
|
|
81
84
|
if (wanted && !consumers.has(ws)) {
|
|
82
85
|
const ent = state[ws] ?? {};
|
|
83
|
-
|
|
84
|
-
if (cooldown > cur) {
|
|
86
|
+
if (isCooling(ent, cur)) {
|
|
85
87
|
// 与「下轮会自动启动」互斥:冷却中就是不会启动,两条同现会自相
|
|
86
88
|
// 矛盾,这里合并成一条。
|
|
87
|
-
const eta =
|
|
89
|
+
const eta = cooldownEtaMinutes(ent, cur);
|
|
88
90
|
alerts.push(
|
|
89
91
|
`${ws} 有任务即将执行(或错过待补执行),但保活会话连续失败 `
|
|
90
92
|
+ `${ent.fails ?? '?'} 次正处冷却中,约 ${eta} 分钟后才会重试;`
|
|
@@ -94,7 +96,7 @@ function overviewTasks(cfg, cols, consumers, state) {
|
|
|
94
96
|
// 失败计数放在句子中部:总览告警按终端宽度截尾,计数比尾部的
|
|
95
97
|
// 「下轮会自动启动」更该活下来;满额后的去向由「冷却中」那条合并
|
|
96
98
|
// 告警解释,这里不重复。
|
|
97
|
-
const fails =
|
|
99
|
+
const fails = ent.fails ?? 0;
|
|
98
100
|
const streak = fails > 0 ? `,近期已失败 ${fails} 次` : '';
|
|
99
101
|
alerts.push(`${ws} 有任务即将执行(或错过待补执行)${streak},`
|
|
100
102
|
+ '但当前没有交互会话,下轮巡检会自动启动');
|
|
@@ -108,8 +110,7 @@ function overviewTasks(cfg, cols, consumers, state) {
|
|
|
108
110
|
if (soonest) {
|
|
109
111
|
const [fire, ws, summary, nxt] = soonest;
|
|
110
112
|
// 预计与设定不在同一分钟时,附注 cron 落点,解释两个时间的差。
|
|
111
|
-
const setTag = (nxt && (
|
|
112
|
-
|| fire.getDate() !== nxt.getDate()))
|
|
113
|
+
const setTag = (nxt && !sameMinute(fire, nxt))
|
|
113
114
|
? `,设定 ${fmtHM(nxt)}` : '';
|
|
114
115
|
const line = `最近:${fmtMDHM(fire)}(${humanDelta(fire.getTime() - now.getTime())}`
|
|
115
116
|
+ `${setTag}) ${path.basename(ws)} · ${summary}`;
|
|
@@ -124,15 +125,14 @@ 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 = [];
|
|
132
132
|
const cooling = [];
|
|
133
133
|
const dead = [];
|
|
134
134
|
for (const [ws, ent] of Object.entries(state)) {
|
|
135
|
-
if ((ent
|
|
135
|
+
if (isCooling(ent, cur)) cooling.push([ws, ent]);
|
|
136
136
|
else if (deps.trackedAlive(ent)) live.push([ws, ent]);
|
|
137
137
|
else dead.push(ws); // 记录还在、进程已没:带失败计数的保留至老化,其余下轮清理
|
|
138
138
|
}
|
|
@@ -151,7 +151,7 @@ function overviewSessions(cols, state, suppressCooling = new Set(),
|
|
|
151
151
|
}
|
|
152
152
|
for (const [ws, ent] of cooling) {
|
|
153
153
|
if (suppressCooling.has(ws)) continue; // 任务区已给过带上下文的合并告警
|
|
154
|
-
const eta =
|
|
154
|
+
const eta = cooldownEtaMinutes(ent, cur);
|
|
155
155
|
alerts.push(`${ws} 保活连续失败 ${ent.fails ?? '?'} 次,冷却中,约 ${eta} `
|
|
156
156
|
+ '分钟后重试');
|
|
157
157
|
}
|
|
@@ -205,7 +205,7 @@ export async function cmdOverview(args) {
|
|
|
205
205
|
const state = loadState();
|
|
206
206
|
const curOv = Math.floor(Date.now() / 1000);
|
|
207
207
|
const heartbeat = loadHeartbeat();
|
|
208
|
-
|
|
208
|
+
let coolingWarned = new Set();
|
|
209
209
|
let discovered = null;
|
|
210
210
|
|
|
211
211
|
if (cfgError !== null) {
|
|
@@ -221,7 +221,7 @@ export async function cmdOverview(args) {
|
|
|
221
221
|
const minIdTag = cfg.autoMinId ? ',id 自动改小' : ',id 保持原样';
|
|
222
222
|
const retainTag = cfg.sessionRetain === 'always'
|
|
223
223
|
? ',会话常驻' : ',会话按执行回收';
|
|
224
|
-
if (actualIv
|
|
224
|
+
if (actualIv == null) {
|
|
225
225
|
deps.print(`巡检:配置为每 ${cfg.intervalSeconds} 秒一轮(服务未加载),`
|
|
226
226
|
+ `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${minIdTag}${retainTag}`);
|
|
227
227
|
} else {
|
|
@@ -243,20 +243,19 @@ 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
|
-
const [taskAlerts,
|
|
255
|
+
const [taskAlerts, coolingWarnedNow, found] = overviewTasks(
|
|
256
|
+
cfg, cols, consumers, state);
|
|
258
257
|
alerts.push(...taskAlerts);
|
|
259
|
-
|
|
258
|
+
coolingWarned = coolingWarnedNow;
|
|
260
259
|
discovered = found;
|
|
261
260
|
}
|
|
262
261
|
|
package/src/paths.mjs
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
6
|
|
|
9
7
|
export const LABEL = 'local.cron-up';
|
|
10
8
|
|
|
@@ -12,9 +10,42 @@ const HOME = os.homedir();
|
|
|
12
10
|
// 被预热的 agent 工作区里,定时任务文件的相对路径。
|
|
13
11
|
export const TASK_REL = path.join('.claude', 'scheduled_tasks.json');
|
|
14
12
|
|
|
13
|
+
// 派生 getter 只写这一份:生产 paths 与测试重定向用的 tmpPaths
|
|
14
|
+
// (test-support/helpers.mjs)共用同一工厂,派生路径改布局不会出现两处各
|
|
15
|
+
// 改一半。
|
|
16
|
+
export function makePaths({ home, dataDir, logDir, plistPath, sessionDir }) {
|
|
17
|
+
return {
|
|
18
|
+
home,
|
|
19
|
+
dataDir,
|
|
20
|
+
logDir,
|
|
21
|
+
plistPath,
|
|
22
|
+
sessionDir,
|
|
23
|
+
|
|
24
|
+
get sessionLogDir() {
|
|
25
|
+
return path.join(this.logDir, 'sessions');
|
|
26
|
+
},
|
|
27
|
+
get configPath() {
|
|
28
|
+
return path.join(this.dataDir, 'config.json');
|
|
29
|
+
},
|
|
30
|
+
get statePath() {
|
|
31
|
+
return path.join(this.dataDir, 'state.json');
|
|
32
|
+
},
|
|
33
|
+
// 巡检心跳:每轮成功跑完写一次,总览页据此判断上次巡检多久前。
|
|
34
|
+
get heartbeatPath() {
|
|
35
|
+
return path.join(this.dataDir, 'heartbeat');
|
|
36
|
+
},
|
|
37
|
+
// launchd 拉起的启动脚本。ProgramArguments[0] 直接指向它而非 node:
|
|
38
|
+
// macOS 后台项目列表按可执行文件名显示条目,直连 node 会归到「Node.js
|
|
39
|
+
// Foundation」名下(node 的签名者),自有脚本则显示脚本自己的名字。
|
|
40
|
+
get serviceScriptPath() {
|
|
41
|
+
return path.join(this.dataDir, 'cron-up-service');
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
15
46
|
// 路径对象整体可替换(deps.paths):测试把产物目录重定向到 tmp,不碰真实
|
|
16
47
|
// HOME。getter 让 sessionLogDir/configPath/statePath 跟随替换后的前缀。
|
|
17
|
-
export const paths = {
|
|
48
|
+
export const paths = makePaths({
|
|
18
49
|
home: HOME,
|
|
19
50
|
// 数据目录用 XDG 风格的 ~/.local/share/cron-up:config、state 与启动脚本
|
|
20
51
|
// 集中一处,purge 一删全清。
|
|
@@ -22,35 +53,4 @@ export const paths = {
|
|
|
22
53
|
logDir: path.join(HOME, 'Library', 'Logs', 'cron-up'),
|
|
23
54
|
plistPath: path.join(HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`),
|
|
24
55
|
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
|
-
};
|
|
46
|
-
|
|
47
|
-
// 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
|
|
48
|
-
// 版本实验特性的依赖);读不到返回 null,总览页降级显示。
|
|
49
|
-
export function packageVersion() {
|
|
50
|
-
try {
|
|
51
|
-
const pkg = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
52
|
-
return JSON.parse(fs.readFileSync(pkg, 'utf-8')).version ?? null;
|
|
53
|
-
} catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
56
|
+
});
|
package/src/patrol.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, packageVersion } from './internals.mjs';
|
|
9
9
|
import {
|
|
10
10
|
FAIL_LIMIT,
|
|
11
11
|
COOLDOWN_SECONDS,
|
|
@@ -24,16 +24,23 @@ import {
|
|
|
24
24
|
saveState,
|
|
25
25
|
loadHeartbeat,
|
|
26
26
|
saveHeartbeat,
|
|
27
|
+
heartbeatStale,
|
|
28
|
+
isCooling,
|
|
27
29
|
} from './config.mjs';
|
|
28
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
maintainWorkspace,
|
|
32
|
+
TaskFileChanged,
|
|
33
|
+
taskFileExists,
|
|
34
|
+
} from './tasks.mjs';
|
|
29
35
|
import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
|
|
30
36
|
import {
|
|
31
37
|
sessionLogPath,
|
|
32
38
|
sessionLogIdleSeconds,
|
|
33
39
|
scriptProcLogPath,
|
|
40
|
+
rotateOnce,
|
|
34
41
|
} from './sessions.mjs';
|
|
35
|
-
import { TASK_REL
|
|
36
|
-
import { elapsedZh } from './display.mjs';
|
|
42
|
+
import { TASK_REL } from './paths.mjs';
|
|
43
|
+
import { elapsedZh, pad2 } from './display.mjs';
|
|
37
44
|
|
|
38
45
|
// 事件类型 → 轮末汇总里的中文标签。patrolWorkspace 与 cmdRun 往同一个
|
|
39
46
|
// stats 计数器里记账,平静轮也会打出「本轮无动作」。
|
|
@@ -56,10 +63,9 @@ function bump(stats, kind) {
|
|
|
56
63
|
|
|
57
64
|
export function log(msg) {
|
|
58
65
|
const d = new Date();
|
|
59
|
-
const p = (n) => String(n).padStart(2, '0');
|
|
60
66
|
deps.print(
|
|
61
|
-
`${d.getFullYear()}-${
|
|
62
|
-
+ `${
|
|
67
|
+
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} `
|
|
68
|
+
+ `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} ${msg}`,
|
|
63
69
|
);
|
|
64
70
|
}
|
|
65
71
|
|
|
@@ -124,7 +130,7 @@ export function acquireRunLock() {
|
|
|
124
130
|
return null;
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
|
|
133
|
+
function releaseLock(handle) {
|
|
128
134
|
if (!handle?.lockPath) return;
|
|
129
135
|
try {
|
|
130
136
|
fs.unlinkSync(handle.lockPath);
|
|
@@ -137,15 +143,14 @@ export function releaseLock(handle) {
|
|
|
137
143
|
|
|
138
144
|
export function rotatePatrolLogs() {
|
|
139
145
|
// launchd 按路径每次重开文件,轮初改名是安全的:本轮 fd 继续写改名后
|
|
140
|
-
// 的 inode
|
|
146
|
+
// 的 inode,下轮打开新文件。超阈值才轮转,与会话日志的「无条件保留一
|
|
147
|
+
// 代」共用 rotateOnce 的 rm+rename 顺序。
|
|
141
148
|
for (const name of ['launchd.out.log', 'launchd.err.log']) {
|
|
142
149
|
const file = path.join(deps.paths.logDir, name);
|
|
143
150
|
try {
|
|
144
151
|
if (fs.existsSync(file)
|
|
145
152
|
&& fs.statSync(file).size > PATROL_LOG_ROTATE_BYTES) {
|
|
146
|
-
|
|
147
|
-
fs.rmSync(old, { force: true });
|
|
148
|
-
fs.renameSync(file, old);
|
|
153
|
+
rotateOnce(file);
|
|
149
154
|
}
|
|
150
155
|
} catch {
|
|
151
156
|
// 轮转失败不阻断巡检。
|
|
@@ -154,7 +159,11 @@ export function rotatePatrolLogs() {
|
|
|
154
159
|
}
|
|
155
160
|
|
|
156
161
|
// 下次「需要会话在场」的时刻(ms):各任务下次触发的最小值;有错过待补
|
|
157
|
-
// 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity
|
|
162
|
+
// 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity。锚点刻意取
|
|
163
|
+
// cron 裸落点而非 nextDelivery 的抖动后投递时刻:落点总不晚于投递时刻,
|
|
164
|
+
// 按落点算余量更小、回收更保守,是安全方向;真正的重拉闸门是下面的
|
|
165
|
+
// wanted(leadMs),margin 只需保住新会话登记。若改用 fire,落点后、投递
|
|
166
|
+
// 前的整段延迟窗内都不会回收,回收频率被悄悄改变。
|
|
158
167
|
function needHorizonMs(tasks, now) {
|
|
159
168
|
let horizon = Infinity;
|
|
160
169
|
for (const t of tasks) {
|
|
@@ -168,9 +177,11 @@ function needHorizonMs(tasks, now) {
|
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
// 保证一个工作区里有一个健康的消费者会话。原地修改 state。retain 来自
|
|
171
|
-
// cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。
|
|
180
|
+
// cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。scriptProcs
|
|
181
|
+
// 是进程表快照或 null:null 时仅在需要认领孤儿(state 丢失的兜底分支)当
|
|
182
|
+
// 下才拍快照,平静轮不付全量 ps 的代价。
|
|
172
183
|
export async function patrolWorkspace(
|
|
173
|
-
ws, state, now, leadMs, cur, consumers, scriptProcs =
|
|
184
|
+
ws, state, now, leadMs, cur, consumers, scriptProcs = null,
|
|
174
185
|
retain = 'always', stats = null,
|
|
175
186
|
) {
|
|
176
187
|
const tasks = deps.readTasks(ws);
|
|
@@ -186,7 +197,7 @@ export async function patrolWorkspace(
|
|
|
186
197
|
// 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
|
|
187
198
|
// 不出意味着这个目录的定时任务一个都不会执行。文件已消失则是正常竞
|
|
188
199
|
// 态(一次性任务执行完被删),不报。
|
|
189
|
-
if (
|
|
200
|
+
if (taskFileExists(ws)) {
|
|
190
201
|
note('warn', `任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
|
|
191
202
|
+ `其中 ${TASK_REL} 里的定时任务都不会执行`);
|
|
192
203
|
}
|
|
@@ -206,24 +217,28 @@ export async function patrolWorkspace(
|
|
|
206
217
|
return;
|
|
207
218
|
}
|
|
208
219
|
|
|
220
|
+
// 结束本代会话的完整收尾:停掉 → 摘 state → 重扫消费者。换代与 window
|
|
221
|
+
// 回收是同一个动作,差别只在触发条件与文案;消费者快照拍于轮初,刚结束
|
|
222
|
+
// 的这代可能正是快照里的消费者,不重扫会让陈旧登记挡住下面的重拉。
|
|
223
|
+
let mineAlive = deps.trackedAlive(ent);
|
|
224
|
+
const retire = async (kind, msg) => {
|
|
225
|
+
await deps.stopSession(ent);
|
|
226
|
+
note(kind, msg);
|
|
227
|
+
delete state[ws];
|
|
228
|
+
ent = null;
|
|
229
|
+
mineAlive = false;
|
|
230
|
+
consumers = (await deps.scanSessions())[0];
|
|
231
|
+
};
|
|
232
|
+
|
|
209
233
|
// 换代:超龄且日志静默(无任务在执行的证据)的会话主动结束,需要时下
|
|
210
234
|
// 文自然重拉。换代是维护,不计失败。
|
|
211
|
-
let mineAlive = deps.trackedAlive(ent);
|
|
212
235
|
if (ent !== null && mineAlive) {
|
|
213
236
|
const age = cur - (ent.startedAt ?? cur);
|
|
214
237
|
if (age > SESSION_MAX_AGE_SECONDS
|
|
215
238
|
&& sessionLogIdleSeconds(
|
|
216
239
|
ent.log || sessionLogPath(ws), cur) > SESSION_IDLE_SECONDS) {
|
|
217
|
-
await
|
|
218
|
-
note('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
|
|
240
|
+
await retire('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
|
|
219
241
|
+ `${Math.floor(age / 86400)} 天且近期无输出,结束后按需重拉`);
|
|
220
|
-
delete state[ws];
|
|
221
|
-
ent = null;
|
|
222
|
-
mineAlive = false;
|
|
223
|
-
// 消费者快照拍于轮初,被换代的这代可能正是快照里的消费者:重扫一
|
|
224
|
-
// 次,别让陈旧登记挡住下面的重拉。
|
|
225
|
-
const [fresh] = await deps.scanSessions();
|
|
226
|
-
consumers = fresh;
|
|
227
242
|
}
|
|
228
243
|
}
|
|
229
244
|
|
|
@@ -243,17 +258,9 @@ export async function patrolWorkspace(
|
|
|
243
258
|
> SESSION_RECYCLE_IDLE_SECONDS
|
|
244
259
|
&& needHorizonMs(tasks, now) - now.getTime()
|
|
245
260
|
> SESSION_FIRE_MARGIN_SECONDS * 1000) {
|
|
246
|
-
|
|
247
|
-
await
|
|
248
|
-
|
|
249
|
-
+ '结束会话,下次需要时重拉');
|
|
250
|
-
delete state[ws];
|
|
251
|
-
ent = null;
|
|
252
|
-
mineAlive = false;
|
|
253
|
-
// 同上:快照里的消费者可能正是刚结束的这代。用户自己的会话若在场,
|
|
254
|
-
// 重扫依旧看见,不会在同目录双开。
|
|
255
|
-
const [fresh] = await deps.scanSessions();
|
|
256
|
-
consumers = fresh;
|
|
261
|
+
// 用户自己的会话若在场,retire 的重扫依旧看见,不会在同目录双开。
|
|
262
|
+
await retire('recycle', `回收 ${ws} pid=${ent.pid}:本代任务已执行完毕且`
|
|
263
|
+
+ '界面静默,结束会话,下次需要时重拉');
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
// 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
|
|
@@ -263,17 +270,16 @@ export async function patrolWorkspace(
|
|
|
263
270
|
ent.fails = 0;
|
|
264
271
|
}
|
|
265
272
|
|
|
266
|
-
|
|
273
|
+
// 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
|
|
274
|
+
// 盖),也可能是用户的交互会话——后者放手:不在这里删死条目,失败计数
|
|
275
|
+
// 要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),由轮
|
|
276
|
+
// 末 kept 过滤统一处理。先于 wanted 判定返回:有消费者在场的平稳态不必
|
|
277
|
+
// 为整组任务跑一遍 cron 求值。
|
|
278
|
+
if (consumers.has(ws)) return;
|
|
267
279
|
|
|
268
|
-
|
|
280
|
+
if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
|
|
269
281
|
|
|
270
|
-
|
|
271
|
-
// 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
|
|
272
|
-
// 盖),也可能是用户的交互会话——后者放手:不在这里删死条目,失败计数
|
|
273
|
-
// 要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),由轮
|
|
274
|
-
// 末 kept 过滤统一处理。
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
282
|
+
const cooling = isCooling(ent, cur);
|
|
277
283
|
|
|
278
284
|
// 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
|
|
279
285
|
// 应答的提问界面,永不登记——退休它并推进失败计数。
|
|
@@ -316,7 +322,7 @@ export async function patrolWorkspace(
|
|
|
316
322
|
// 不会出现在登记里,直接再拉就是同目录双开。认回孤儿,重新纳入正常
|
|
317
323
|
// 生命周期(卡死的 3 分钟后照退休处理)。
|
|
318
324
|
const logPath = sessionLogPath(ws);
|
|
319
|
-
for (const [procPid, procArgs] of scriptProcs) {
|
|
325
|
+
for (const [procPid, procArgs] of scriptProcs ?? deps.listScriptProcesses()) {
|
|
320
326
|
// alive 复核:快照拍于本轮开头,可能含着刚被换代/回收杀掉的 pid——
|
|
321
327
|
// 把死人认回来会白记一次失败。
|
|
322
328
|
if (scriptProcLogPath(procArgs) === logPath && deps.alive(procPid)) {
|
|
@@ -367,13 +373,22 @@ export async function cmdRun(args) {
|
|
|
367
373
|
return false;
|
|
368
374
|
}
|
|
369
375
|
// 包一层 stopSession 记下本轮主动结束的 pid:轮末凭它区分「我们回收的」
|
|
370
|
-
//
|
|
376
|
+
// 与「会话自行退出/被外部杀掉的」,后者以前是无声丢失。同样包一层
|
|
377
|
+
// procStartedAt:同一 pid 的 lstart 一轮内不会变,而轮初分区、各工作区
|
|
378
|
+
// 的 trackedAlive、轮末复核与 kept 过滤都会查指纹,缓存后每个 pid 只
|
|
379
|
+
// spawn 一次 ps。finally 一并还原。
|
|
371
380
|
const prevStopSession = deps.stopSession;
|
|
372
381
|
const stoppedPids = new Set();
|
|
373
382
|
deps.stopSession = async (ent) => {
|
|
374
383
|
if (ent?.pid) stoppedPids.add(ent.pid);
|
|
375
384
|
return prevStopSession(ent);
|
|
376
385
|
};
|
|
386
|
+
const prevProcStartedAt = deps.procStartedAt;
|
|
387
|
+
const lstartCache = new Map();
|
|
388
|
+
deps.procStartedAt = (pid) => {
|
|
389
|
+
if (!lstartCache.has(pid)) lstartCache.set(pid, prevProcStartedAt(pid));
|
|
390
|
+
return lstartCache.get(pid);
|
|
391
|
+
};
|
|
377
392
|
try {
|
|
378
393
|
const startedAtMs = Date.now();
|
|
379
394
|
const stats = {};
|
|
@@ -388,8 +403,7 @@ export async function cmdRun(args) {
|
|
|
388
403
|
// 时日志会一片寂静,无法和「平安无事」区分。上轮心跳过老说明中间漏
|
|
389
404
|
// 轮——休眠期间错过的 StartInterval,launchd 醒来只补跑一轮。
|
|
390
405
|
const hb = loadHeartbeat();
|
|
391
|
-
if (hb
|
|
392
|
-
&& cur - hb.ranAt > cfg.intervalSeconds * HEARTBEAT_STALE_FACTOR) {
|
|
406
|
+
if (heartbeatStale(hb, cfg.intervalSeconds, cur)) {
|
|
393
407
|
bump(stats, 'warn');
|
|
394
408
|
deps.log(`距上次巡检已 ${elapsedZh(cur - hb.ranAt)},超过 `
|
|
395
409
|
+ `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${cfg.intervalSeconds} 秒),`
|
|
@@ -404,27 +418,29 @@ export async function cmdRun(args) {
|
|
|
404
418
|
}
|
|
405
419
|
|
|
406
420
|
// 轮初拍一份「上轮末仍相信活着」的会话快照(entry 复制,本轮原地改写
|
|
407
|
-
//
|
|
421
|
+
// 不影响),一遍分成「轮初已没气」与「轮初还活着」两组。不能只收此刻
|
|
408
422
|
// kill -0 成功的——两轮之间崩溃正是最典型的被动死亡,本轮开始时它早
|
|
409
423
|
// 已没气;带 deadSince 的是上轮已报过死亡的旧条目,跳过以免每轮重复报。
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
424
|
+
const prevDead = [];
|
|
425
|
+
const prevAlive = [];
|
|
426
|
+
for (const [ws, ent] of Object.entries(state)) {
|
|
427
|
+
if (!ent.pid || ent.deadSince) continue;
|
|
428
|
+
const copy = [ws, { ...ent }];
|
|
429
|
+
(deps.trackedAlive(copy[1]) ? prevAlive : prevDead).push(copy);
|
|
430
|
+
}
|
|
415
431
|
const exitLine = (ws, old) => `会话退出 ${ws} pid=${old.pid}:非巡检主动结束`
|
|
416
432
|
+ `(已存活 ${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或`
|
|
417
433
|
+ '被外部终止;若反复出现请用 cron-up logs '
|
|
418
434
|
+ `${path.basename(ws)} 排查启动过程`;
|
|
419
435
|
|
|
420
436
|
// 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
|
|
421
|
-
//
|
|
437
|
+
// 属这里。进程表快照不在这里拍:只有孤儿认领分支需要,按需现拍(见
|
|
438
|
+
// patrolWorkspace 的 scriptProcs 形参)。
|
|
422
439
|
const [consumers, registryAlert] = await deps.scanSessions();
|
|
423
440
|
if (registryAlert) {
|
|
424
441
|
bump(stats, 'warn');
|
|
425
442
|
deps.log(`告警:${registryAlert}`);
|
|
426
443
|
}
|
|
427
|
-
const scriptProcs = deps.listScriptProcesses();
|
|
428
444
|
|
|
429
445
|
// 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
|
|
430
446
|
// 日志时间线就是「退出 → 重拉」,而不是反过来。
|
|
@@ -444,7 +460,7 @@ export async function cmdRun(args) {
|
|
|
444
460
|
const { renewed, minified } = maintainWorkspace(taskfile, {
|
|
445
461
|
renew: cfg.autoRenew,
|
|
446
462
|
minify: cfg.autoMinId,
|
|
447
|
-
}) ?? {
|
|
463
|
+
}) ?? {};
|
|
448
464
|
if (renewed) {
|
|
449
465
|
deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
|
|
450
466
|
+ '不再受 7 天过期限制');
|
|
@@ -467,7 +483,7 @@ export async function cmdRun(args) {
|
|
|
467
483
|
}
|
|
468
484
|
try {
|
|
469
485
|
await patrolWorkspace(
|
|
470
|
-
ws, state, now, leadMs, cur, consumers,
|
|
486
|
+
ws, state, now, leadMs, cur, consumers, null,
|
|
471
487
|
cfg.sessionRetain, stats);
|
|
472
488
|
} catch (e) {
|
|
473
489
|
bump(stats, 'error');
|
|
@@ -494,7 +510,7 @@ export async function cmdRun(args) {
|
|
|
494
510
|
const kept = Object.fromEntries(
|
|
495
511
|
Object.entries(state).filter(([, ent]) => {
|
|
496
512
|
if (deps.trackedAlive(ent)) return true;
|
|
497
|
-
if ((ent
|
|
513
|
+
if (isCooling(ent, cur)) return true;
|
|
498
514
|
if ((ent.fails ?? 0) > 0) {
|
|
499
515
|
ent.deadSince ??= cur;
|
|
500
516
|
return cur - ent.deadSince < FAIL_TTL_SECONDS;
|
|
@@ -525,6 +541,7 @@ export async function cmdRun(args) {
|
|
|
525
541
|
return true;
|
|
526
542
|
} finally {
|
|
527
543
|
deps.stopSession = prevStopSession;
|
|
544
|
+
deps.procStartedAt = prevProcStartedAt;
|
|
528
545
|
releaseLock(lock);
|
|
529
546
|
}
|
|
530
547
|
}
|
package/src/registry.mjs
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
|
|
9
|
-
import { deps } from './internals.mjs';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { deps, isPlainObject } from './internals.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
|
-
|
|
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 = [];
|
|
@@ -83,7 +73,7 @@ export async function scanSessions() {
|
|
|
83
73
|
const interactive = [];
|
|
84
74
|
for (const f of files) {
|
|
85
75
|
const s = await readSessionFile(f);
|
|
86
|
-
if (s &&
|
|
76
|
+
if (isPlainObject(s) && s.kind === 'interactive') {
|
|
87
77
|
interactive.push(s);
|
|
88
78
|
}
|
|
89
79
|
}
|
|
@@ -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 {
|