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/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 { packageVersion, TASK_REL } from './paths.mjs';
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 (fs.existsSync(path.join(ws, TASK_REL))) {
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
- const cooldown = ent.cooldownUntil ?? 0;
84
- if (cooldown > cur) {
86
+ if (isCooling(ent, cur)) {
85
87
  // 与「下轮会自动启动」互斥:冷却中就是不会启动,两条同现会自相
86
88
  // 矛盾,这里合并成一条。
87
- const eta = Math.max(0, Math.floor((cooldown - cur) / 60));
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 = (state[ws] ?? {}).fails ?? 0;
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 && (fmtHM(fire) !== fmtHM(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 = new Set(),
128
- discovered = null) {
128
+ function overviewSessions(cols, state, suppressCooling, discovered) {
129
129
  const alerts = [];
130
130
  const cur = Math.floor(Date.now() / 1000);
131
131
  const live = [];
132
132
  const cooling = [];
133
133
  const dead = [];
134
134
  for (const [ws, ent] of Object.entries(state)) {
135
- if ((ent.cooldownUntil ?? 0) > cur) cooling.push([ws, 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 = Math.max(0, Math.floor(((ent.cooldownUntil ?? 0) - cur) / 60));
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
- const coolingWarned = new Set();
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 === null || actualIv === undefined) {
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, cooled, found] = overviewTasks(cfg, cols, consumers, state);
255
+ const [taskAlerts, coolingWarnedNow, found] = overviewTasks(
256
+ cfg, cols, consumers, state);
258
257
  alerts.push(...taskAlerts);
259
- for (const w of cooled) coolingWarned.add(w);
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 { maintainWorkspace, TaskFileChanged } from './tasks.mjs';
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, packageVersion } from './paths.mjs';
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()}-${p(d.getMonth() + 1)}-${p(d.getDate())} `
62
- + `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())} ${msg}`,
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
- export function releaseLock(handle) {
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
- const old = `${file}.1`;
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 (fs.existsSync(path.join(ws, TASK_REL))) {
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 deps.stopSession(ent);
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
- const pid = ent.pid;
247
- await deps.stopSession(ent);
248
- note('recycle', `回收 ${ws} pid=${pid}:本代任务已执行完毕且界面静默,`
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
- if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
273
+ // 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
274
+ // 盖),也可能是用户的交互会话——后者放手:不在这里删死条目,失败计数
275
+ // 要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),由轮
276
+ // 末 kept 过滤统一处理。先于 wanted 判定返回:有消费者在场的平稳态不必
277
+ // 为整组任务跑一遍 cron 求值。
278
+ if (consumers.has(ws)) return;
267
279
 
268
- const cooling = Boolean(ent && (ent.cooldownUntil ?? 0) > cur);
280
+ if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
269
281
 
270
- if (consumers.has(ws)) {
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
- // 与「会话自行退出/被外部杀掉的」,后者以前是无声丢失。finally 还原。
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 !== null
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 prevTracked = Object.entries(state)
411
- .filter(([, ent]) => ent.pid && !ent.deadSince)
412
- .map(([ws, ent]) => [ws, { ...ent }]);
413
- const prevDead = prevTracked.filter(([, ent]) => !deps.trackedAlive(ent));
414
- const prevAlive = prevTracked.filter(([, ent]) => deps.trackedAlive(ent));
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
- }) ?? { renewed: null, minified: null };
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, scriptProcs,
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.cooldownUntil ?? 0) > cur) return true;
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 { lenientInt } from './config.mjs';
11
- import { PID_T_MAX } from './constants.mjs';
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
- export async function readSessionFile(file) {
44
+ async function readSessionFile(file) {
45
45
  for (let attempt = 0; attempt < 2; attempt++) {
46
46
  let text;
47
47
  try {
@@ -60,16 +60,6 @@ export async function readSessionFile(file) {
60
60
  return null;
61
61
  }
62
62
 
63
- // 宽容版 realpath:fs.realpathSync 对不存在的路径抛 ENOENT,这里失败时退
64
- // 回词法绝对路径(调用方已保证输入是绝对路径)。
65
- function lexReal(p) {
66
- try {
67
- return fs.realpathSync(p);
68
- } catch {
69
- return path.resolve(p);
70
- }
71
- }
72
-
73
63
  // 返回 [拥有活消费者会话的工作区集合, 告警字符串|null]。
74
64
  export async function scanSessions() {
75
65
  let files = [];
@@ -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 && typeof s === 'object' && s.kind === 'interactive') {
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
- let pid;
103
- try {
104
- pid = lenientInt(s.pid);
105
- } catch {
92
+ const pid = parsePid(s.pid);
93
+ if (pid === null) {
106
94
  suspect += 1; // 正常登记必带可解析 pid:缺失/变质即字段漂移
107
95
  continue;
108
96
  }
109
- if (!(pid > 0 && pid <= PID_T_MAX)) {
110
- suspect += 1;
111
- continue;
112
- }
113
97
  if (!deps.alive(pid)) continue; // 陈旧登记(崩溃留下):正常,不计
114
98
  if (!commCache.has(pid)) {
115
99
  commCache.set(pid, deps.pidIsClaude(pid)); // ps 调用按 pid 缓存
@@ -120,11 +104,8 @@ export async function scanSessions() {
120
104
  // 热被静默跳过。
121
105
  const cwd = s.cwd;
122
106
  if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
123
- const real = lexReal(cwd);
124
- if (real) {
125
- consumers.add(real);
126
- continue;
127
- }
107
+ consumers.add(lexReal(cwd));
108
+ continue;
128
109
  }
129
110
  suspect += 1; // 活着的 claude 却没有可用 cwd:cwd 字段漂移
130
111
  } else {