cron-up 26.10.23 → 26.10.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,6 +83,7 @@ cron-up install --force --no-auto-renew
83
83
  | 启动脚本 | `~/.local/share/cron-up/cron-up-service`(launchd 拉起的入口,PATH 里写有 node 与 cron-up 所在的 bin 目录;后台列表显示名即来自它) |
84
84
  | 配置文件 | `~/.local/share/cron-up/config.json` |
85
85
  | 运行状态 | 同目录下的 `state.json` |
86
+ | 巡检心跳 | 同目录下的 `heartbeat`(每轮成功跑完写一次,总览页据此判断巡检是否在按时跑) |
86
87
  | 日志 | `~/Library/Logs/cron-up/`(巡检日志;各后台会话的界面记录在 `sessions/` 子目录下,该目录权限 700、日志文件 600) |
87
88
 
88
89
  ### 安装前的三个前提
@@ -163,8 +164,9 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
163
164
  - **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。「需要会话」按计入投递抖动(见上节)的**预计实际触发时刻**计算:某任务的 cron 落点已过、但还没到它的延迟投递点的这段尾窗(落点后最多 30 分钟)里会话照样保留,尾窗内若会话意外死亡也会立即重拉——否则同目录两个落点相近的任务,较早的执行完后会话被回收,较晚的还没到投递点就会漏跑(周期任务没有补执行)。效果:**错开约 30 分钟以上(已含投递抖动、静默判定与余量)的任务才必然各自拿到全新会话**,更近的共享同一会话(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
164
165
  - **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
165
166
  - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;同目录当时还有你本人打开的会话时除外,见「已知限制」)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
166
- - **日志大小**:后台会话的界面输出是持续的全屏渲染流。`script(1)` 每次启动会清空旧日志,因此每次拉起该目录的会话时,上一代的界面记录先轮转为 `.log.1`(只保留一代,总量上界两代)——否则 window 模式下上一次任务执行的记录会随下一代拉起被抹掉。活着的一代日志总量由会话生命周期约束(window 模式一代即一次执行;`always` 模式由 7 天换代约束),唯一无法覆盖的是 `always` 模式下连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)只在有事件时才有输出(安静的一轮巡检一行都不写),超过 1MB 时同样轮转为 `.1`,只保留上一份。
167
+ - **日志大小**:后台会话的界面输出是持续的全屏渲染流。`script(1)` 每次启动会清空旧日志,因此每次拉起该目录的会话时,上一代的界面记录先轮转为 `.log.1`(只保留一代,总量上界两代)——否则 window 模式下上一次任务执行的记录会随下一代拉起被抹掉。活着的一代日志总量由会话生命周期约束(window 模式一代即一次执行;`always` 模式由 7 天换代约束),唯一无法覆盖的是 `always` 模式下连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)每轮固定两行心跳(`巡检开始…`/`巡检结束…`,后者带「发现 N 个工作区、活动交互会话 N 个、动作汇总与耗时」),事件行只在有事发生时才打:启动、主动回收、换代、卡死、冷却、接管孤儿、启动失败(含 spawn 异步错误原因)、会话非巡检主动结束(带存活时长)、登记格式漂移告警。每轮都有心跳,所以 launchd 配置没坏但就是没在跑时,从日志断档和总览页都能发现;距上轮超过两个间隔还会提示可能漏轮(常见于电脑休眠后 launchd 合并错过的间隔)。超过 1MB 时同样轮转为 `.1`,只保留上一份。
167
168
  - **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有三重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、或 interactive 登记的 pid/工作目录字段集体无法解读时,「需要留意」会明确提示登记格式疑似变更;看到这条后,在重新核对目录结构之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
169
+ - **巡检心跳与沉默告警**:每轮巡检成功跑完都会刷新 `~/.local/share/cron-up/heartbeat`。总览页的巡检区显示「上次巡检:N 分钟前」;launchd 显示已加载、但心跳超过两个轮次间隔没刷新时,「需要留意」会报「巡检已沉默」——启动脚本静态检查全绿但运行期拉不起来(或轮次持续崩在半道)只有靠它发现。轮首发现距上轮过久也会在巡检日志里记一条漏轮提示(电脑休眠后 launchd 会合并非错过的间隔,醒来只补一轮,属正常现象)。
168
170
 
169
171
  ## 安全说明
170
172
 
@@ -199,6 +201,8 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
199
201
  | 总览提示「启动脚本已失效 / 内容无法解读」 | 启动脚本(`~/.local/share/cron-up/cron-up-service`)被删或手改,重跑 `cron-up install` 覆盖重生成 |
200
202
  | 总览提示「PATH 目录里找不到 cron-up」 | 切换了默认 Node 版本,新版本里还没装包:在新版本里 `npm i -g cron-up` 即可 |
201
203
  | 总览提示「PATH 目录里找不到 node」 | 启动脚本 PATH 指向的版本目录被删或移动(多见于手动删除 nvm/fnm 版本),重跑 `cron-up install` 重新解析 |
204
+ | 总览提示「巡检已沉默」 | launchd 显示已加载但最近没有成功跑完的轮次:先 `cron-up logs` 看轮次心跳是否断档、`launchd.err.log` 有没有崩溃记录,再对照「启动脚本已失效/PATH 找不到 node」两条;都正常则手动 `cron-up run` 验证,必要时重跑 install |
205
+ | 日志里出现「会话退出 … 非巡检主动结束」 | 会话自行崩溃或被外部终止(不是 cron-up 的回收/换代/卡死流程)。偶发且下轮自动重拉可忽略;同一目录反复出现时,按提示 `cron-up logs <目录名>` 看该会话的启动与退出过程 |
202
206
  | 日志里出现「卡死」 | 会话进程起来了但没能变成可用会话,绝大多数是停在信任确认或授权提问。先手动 `cd <目录> && claude` 走完确认,再等下一轮巡检 |
203
207
  | 日志里出现「任务文件读不出 \<目录\>」 | 该目录的 `.claude/scheduled_tasks.json` 存在却解析不了(格式损坏),巡检跳过这一个目录,**其中的定时任务一个都不会执行**;其余目录照常。检查该文件 |
204
208
  | 日志里出现「错误 \<目录\>」 | 巡检该目录时抛出未预期的异常,已按目录隔离、不影响其余目录。这通常意味着 cron-up 自身的 bug,请带上该行反馈 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.10.23",
3
+ "version": "26.10.24",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -187,6 +187,24 @@ export function saveState(state) {
187
187
  atomicSave(deps.paths.statePath, state);
188
188
  }
189
189
 
190
+ // 巡检心跳:每轮成功跑完覆盖写一次。轮首读它发现漏轮(距上次超过两个间
191
+ // 隔),总览页读它报「服务在但巡检没在跑」。缺失/损坏/形状不对一律读作
192
+ // 没有心跳——旧版本升级后的首轮、与总览读到半截文件都按此降级。
193
+ export function loadHeartbeat() {
194
+ let doc;
195
+ try {
196
+ doc = JSON.parse(fs.readFileSync(deps.paths.heartbeatPath, 'utf-8'));
197
+ } catch {
198
+ return null;
199
+ }
200
+ if (!isPlainObject(doc) || !Number.isFinite(doc.ranAt)) return null;
201
+ return doc;
202
+ }
203
+
204
+ export function saveHeartbeat(doc) {
205
+ atomicSave(deps.paths.heartbeatPath, doc);
206
+ }
207
+
190
208
  export function saveConfig(cfg) {
191
209
  atomicSave(deps.paths.configPath, cfg);
192
210
  }
package/src/constants.mjs CHANGED
@@ -55,9 +55,12 @@ export const SESSION_RECYCLE_IDLE_SECONDS = 180;
55
55
  // 回收后同轮重拉的新会话约 10 秒完成登记,余量保的是登记抖动;更近的触
56
56
  // 发直接复用现有会话(时间紧邻的任务共享)。
57
57
  export const SESSION_FIRE_MARGIN_SECONDS = 120;
58
- // 巡检日志每轮几行,但跑几年的机器也该有上限。launchd 每次按路径重开,
59
- // 轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
58
+ // 巡检日志每轮两行心跳加零星事件,跑几年的机器也该有上限。launchd 每次按
59
+ // 路径重开,轮初改名是安全的:本轮 fd 继续写改名后的 inode,下轮开新文件。
60
60
  export const PATROL_LOG_ROTATE_BYTES = 1024 * 1024;
61
+ // 心跳沉默多少倍轮次间隔算异常:轮首据此提示漏轮(电脑休眠时 launchd 会
62
+ // 合并错过的间隔,醒来只补一轮),总览页据此报「服务在但巡检没在跑」。
63
+ export const HEARTBEAT_STALE_FACTOR = 2;
61
64
 
62
65
  // 不进入这些目录:VCS/依赖内部与 macOS 家目录系统文件夹——agent 工作区不
63
66
  // 会在里面,光一个 ~/Library 就足以让家目录扫描爆炸。
package/src/display.mjs CHANGED
@@ -157,3 +157,13 @@ export function sessionAgeZh(startedAt, cur) {
157
157
  if (secs >= 86400) return `${Math.floor(secs / 86400)} 天`;
158
158
  return `${Math.floor(secs / 60)} 分钟`;
159
159
  }
160
+
161
+ // 已过去时长的粗略中文说法,秒级分辨率:巡检漏轮间隔、被动退出会话的存
162
+ // 活时长、总览页的「上次巡检」用它。
163
+ export function elapsedZh(secs) {
164
+ secs = Math.max(0, Math.floor(secs));
165
+ if (secs < 60) return `${secs} 秒`;
166
+ if (secs < 3600) return `${Math.floor(secs / 60)} 分钟`;
167
+ if (secs < 86400) return `${Math.floor(secs / 3600)} 小时`;
168
+ return `${Math.floor(secs / 86400)} 天`;
169
+ }
package/src/overview.mjs CHANGED
@@ -6,9 +6,15 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
8
  import { deps } from './internals.mjs';
9
- import { loadConfig, loadState, validateConfig } from './config.mjs';
9
+ import {
10
+ loadConfig,
11
+ loadState,
12
+ validateConfig,
13
+ loadHeartbeat,
14
+ } from './config.mjs';
10
15
  import { packageVersion, TASK_REL } from './paths.mjs';
11
16
  import { serviceLine, launcherHealth } from './service.mjs';
17
+ import { HEARTBEAT_STALE_FACTOR } from './constants.mjs';
12
18
  import {
13
19
  humanDelta,
14
20
  clip,
@@ -16,6 +22,7 @@ import {
16
22
  dispWidth,
17
23
  taskView,
18
24
  sessionAgeZh,
25
+ elapsedZh,
19
26
  fmtMDHM,
20
27
  fmtHM,
21
28
  } from './display.mjs';
@@ -196,6 +203,8 @@ export async function cmdOverview(args) {
196
203
  const [consumers, registryAlert] = await deps.scanSessions();
197
204
  if (registryAlert) alerts.push(registryAlert);
198
205
  const state = loadState();
206
+ const curOv = Math.floor(Date.now() / 1000);
207
+ const heartbeat = loadHeartbeat();
199
208
  const coolingWarned = new Set();
200
209
  let discovered = null;
201
210
 
@@ -229,6 +238,20 @@ export async function cmdOverview(args) {
229
238
  deps.print(prefix
230
239
  + clip(rootsText, cols - dispWidth(prefix) - dispWidth(suffix))
231
240
  + suffix);
241
+ // 心跳回答「巡检最近真的在跑吗」:launchd 已加载但启动脚本运行期失
242
+ // 效时,静态健康检查(launcherHealth)全绿而轮次早已停摆,只有沉默的
243
+ // 心跳能戳穿。服务未加载(info 为 null)时不告警,服务行已说明未安装。
244
+ if (heartbeat) {
245
+ deps.print(`上次巡检:${elapsedZh(curOv - heartbeat.ranAt)}前`);
246
+ if (info
247
+ && curOv - heartbeat.ranAt
248
+ > cfg.intervalSeconds * HEARTBEAT_STALE_FACTOR) {
249
+ alerts.push(`巡检已沉默 ${elapsedZh(curOv - heartbeat.ranAt)}:launchd `
250
+ + `显示服务已加载(配置每 ${cfg.intervalSeconds} 秒一轮),但最近没有`
251
+ + '成功跑完的轮次,启动链可能已失效;请运行 cron-up logs 查看巡检'
252
+ + '日志,必要时重跑 cron-up install');
253
+ }
254
+ }
232
255
  alerts.push(...validateConfig(cfg, () => {}));
233
256
  deps.print('');
234
257
  const [taskAlerts, cooled, found] = overviewTasks(cfg, cols, consumers, state);
package/src/paths.mjs CHANGED
@@ -32,6 +32,10 @@ export const paths = {
32
32
  get statePath() {
33
33
  return path.join(this.dataDir, 'state.json');
34
34
  },
35
+ // 巡检心跳:每轮成功跑完写一次,总览页据此判断上次巡检多久前。
36
+ get heartbeatPath() {
37
+ return path.join(this.dataDir, 'heartbeat');
38
+ },
35
39
  // launchd 拉起的启动脚本。ProgramArguments[0] 直接指向它而非 node:
36
40
  // macOS 后台项目列表按可执行文件名显示条目,直连 node 会归到「Node.js
37
41
  // Foundation」名下(node 的签名者),自有脚本则显示脚本自己的名字。
package/src/patrol.mjs CHANGED
@@ -16,8 +16,15 @@ import {
16
16
  SESSION_RECYCLE_IDLE_SECONDS,
17
17
  SESSION_FIRE_MARGIN_SECONDS,
18
18
  PATROL_LOG_ROTATE_BYTES,
19
+ HEARTBEAT_STALE_FACTOR,
19
20
  } from './constants.mjs';
20
- import { loadConfig, loadState, saveState } from './config.mjs';
21
+ import {
22
+ loadConfig,
23
+ loadState,
24
+ saveState,
25
+ loadHeartbeat,
26
+ saveHeartbeat,
27
+ } from './config.mjs';
21
28
  import { maintainWorkspace, TaskFileChanged } from './tasks.mjs';
22
29
  import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
23
30
  import {
@@ -25,7 +32,27 @@ import {
25
32
  sessionLogIdleSeconds,
26
33
  scriptProcLogPath,
27
34
  } from './sessions.mjs';
28
- import { TASK_REL } from './paths.mjs';
35
+ import { TASK_REL, packageVersion } from './paths.mjs';
36
+ import { elapsedZh } from './display.mjs';
37
+
38
+ // 事件类型 → 轮末汇总里的中文标签。patrolWorkspace 与 cmdRun 往同一个
39
+ // stats 计数器里记账,平静轮也会打出「本轮无动作」。
40
+ const EVENT_LABELS = [
41
+ ['spawn', '启动'],
42
+ ['recycle', '回收'],
43
+ ['ageout', '换代'],
44
+ ['exit', '自行退出'],
45
+ ['stuck', '卡死退休'],
46
+ ['adopt', '接管'],
47
+ ['fail', '启动失败'],
48
+ ['cool', '进入冷却'],
49
+ ['warn', '告警'],
50
+ ['error', '错误'],
51
+ ];
52
+
53
+ function bump(stats, kind) {
54
+ if (stats) stats[kind] = (stats[kind] ?? 0) + 1;
55
+ }
29
56
 
30
57
  export function log(msg) {
31
58
  const d = new Date();
@@ -144,17 +171,23 @@ function needHorizonMs(tasks, now) {
144
171
  // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。
145
172
  export async function patrolWorkspace(
146
173
  ws, state, now, leadMs, cur, consumers, scriptProcs = [],
147
- retain = 'always',
174
+ retain = 'always', stats = null,
148
175
  ) {
149
176
  const tasks = deps.readTasks(ws);
150
177
  let ent = state[ws] ?? null;
178
+ // 边记日志边给轮末汇总记账:日志给人翻,stats 给轮首/轮末心跳行一个
179
+ // 紧凑全貌。stats 为 null(直接单测)时退化为纯日志。
180
+ const note = (kind, msg) => {
181
+ bump(stats, kind);
182
+ deps.log(msg);
183
+ };
151
184
 
152
185
  if (tasks === null) {
153
186
  // 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
154
187
  // 不出意味着这个目录的定时任务一个都不会执行。文件已消失则是正常竞
155
188
  // 态(一次性任务执行完被删),不报。
156
189
  if (fs.existsSync(path.join(ws, TASK_REL))) {
157
- deps.log(`任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
190
+ note('warn', `任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
158
191
  + `其中 ${TASK_REL} 里的定时任务都不会执行`);
159
192
  }
160
193
  return;
@@ -166,7 +199,7 @@ export async function patrolWorkspace(
166
199
  if (ent !== null) {
167
200
  if (deps.trackedAlive(ent)) {
168
201
  await deps.stopSession(ent);
169
- deps.log(`回收 ${ws} pid=${ent.pid}:任务已全部清空,结束保活会话`);
202
+ note('recycle', `回收 ${ws} pid=${ent.pid}:任务已全部清空,结束保活会话`);
170
203
  }
171
204
  delete state[ws];
172
205
  }
@@ -182,7 +215,7 @@ export async function patrolWorkspace(
182
215
  && sessionLogIdleSeconds(
183
216
  ent.log || sessionLogPath(ws), cur) > SESSION_IDLE_SECONDS) {
184
217
  await deps.stopSession(ent);
185
- deps.log(`换代 ${ws} pid=${ent.pid}:会话已连续运行 `
218
+ note('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
186
219
  + `${Math.floor(age / 86400)} 天且近期无输出,结束后按需重拉`);
187
220
  delete state[ws];
188
221
  ent = null;
@@ -212,7 +245,7 @@ export async function patrolWorkspace(
212
245
  > SESSION_FIRE_MARGIN_SECONDS * 1000) {
213
246
  const pid = ent.pid;
214
247
  await deps.stopSession(ent);
215
- deps.log(`回收 ${ws} pid=${pid}:本代任务已执行完毕且界面静默,`
248
+ note('recycle', `回收 ${ws} pid=${pid}:本代任务已执行完毕且界面静默,`
216
249
  + '结束会话,下次需要时重拉');
217
250
  delete state[ws];
218
251
  ent = null;
@@ -248,7 +281,7 @@ export async function patrolWorkspace(
248
281
  const age = cur - (ent.startedAt ?? cur);
249
282
  if (age <= WARMUP_GRACE_SECONDS) return;
250
283
  await deps.stopSession(ent);
251
- deps.log(`卡死 ${ws} pid=${ent.pid}:进程已存活 ${age} 秒仍未登记会话,`
284
+ note('stuck', `卡死 ${ws} pid=${ent.pid}:进程已存活 ${age} 秒仍未登记会话,`
252
285
  + '已终止(排查:cron-up logs '
253
286
  + `${path.basename(ws)})`);
254
287
  }
@@ -272,7 +305,7 @@ export async function patrolWorkspace(
272
305
  fails,
273
306
  cooldownUntil: cur + COOLDOWN_SECONDS,
274
307
  };
275
- deps.log(`冷却 ${ws}:仍有任务需要会话,但保活会话连续失败 ${fails} 次,`
308
+ note('cool', `冷却 ${ws}:仍有任务需要会话,但保活会话连续失败 ${fails} 次,`
276
309
  + `暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
277
310
  return;
278
311
  }
@@ -294,7 +327,7 @@ export async function patrolWorkspace(
294
327
  fails: 0,
295
328
  log: logPath,
296
329
  };
297
- deps.log(`接管 ${ws} pid=${procPid}:state 记录缺失,但进程表里仍有`
330
+ note('adopt', `接管 ${ws} pid=${procPid}:state 记录缺失,但进程表里仍有`
298
331
  + '本工作区的保活会话,重新纳入跟踪');
299
332
  return;
300
333
  }
@@ -313,7 +346,7 @@ export async function patrolWorkspace(
313
346
  fails: newFails,
314
347
  log: sessionLogPath(ws),
315
348
  };
316
- deps.log(`启动失败 ${ws}:会话进程没有拉起(连续失败计数 ${newFails})`);
349
+ note('fail', `启动失败 ${ws}:会话进程没有拉起(连续失败计数 ${newFails})`);
317
350
  return;
318
351
  }
319
352
  state[ws] = {
@@ -323,7 +356,7 @@ export async function patrolWorkspace(
323
356
  fails,
324
357
  log: sessionLogPath(ws),
325
358
  };
326
- deps.log(`已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
359
+ note('spawn', `已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
327
360
  }
328
361
 
329
362
  // 跑一轮巡检;抢不到锁返回 false。
@@ -333,25 +366,76 @@ export async function cmdRun(args) {
333
366
  deps.log('已有一轮巡检在进行,本轮跳过');
334
367
  return false;
335
368
  }
369
+ // 包一层 stopSession 记下本轮主动结束的 pid:轮末凭它区分「我们回收的」
370
+ // 与「会话自行退出/被外部杀掉的」,后者以前是无声丢失。finally 还原。
371
+ const prevStopSession = deps.stopSession;
372
+ const stoppedPids = new Set();
373
+ deps.stopSession = async (ent) => {
374
+ if (ent?.pid) stoppedPids.add(ent.pid);
375
+ return prevStopSession(ent);
376
+ };
336
377
  try {
378
+ const startedAtMs = Date.now();
379
+ const stats = {};
337
380
  deps.rotatePatrolLogs();
338
381
  const cfg = loadConfig(args?.config ?? deps.paths.configPath);
339
382
  const now = new Date();
340
383
  const leadMs = cfg.leadSeconds * 1000;
341
384
  const state = loadState();
385
+ const cur = Math.floor(Date.now() / 1000);
386
+
387
+ // 轮首心跳:没有这两行,launchd 拉不起巡检(启动脚本失效、node 缺失)
388
+ // 时日志会一片寂静,无法和「平安无事」区分。上轮心跳过老说明中间漏
389
+ // 轮——休眠期间错过的 StartInterval,launchd 醒来只补跑一轮。
390
+ const hb = loadHeartbeat();
391
+ if (hb !== null
392
+ && cur - hb.ranAt > cfg.intervalSeconds * HEARTBEAT_STALE_FACTOR) {
393
+ bump(stats, 'warn');
394
+ deps.log(`距上次巡检已 ${elapsedZh(cur - hb.ranAt)},超过 `
395
+ + `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${cfg.intervalSeconds} 秒),`
396
+ + '期间 launchd 可能漏轮(常见于电脑休眠)');
397
+ }
398
+ deps.log(`巡检开始:${cfg.sessionRetain} 模式,扫描根目录 `
399
+ + `${cfg.roots.length} 个`);
342
400
  if (cfg.roots.length === 0) {
401
+ bump(stats, 'warn');
343
402
  deps.log('警告:扫描目录 roots 为空,巡检不会发现任何工作区;'
344
403
  + '请检查配置文件或用 install --roots 指定');
345
404
  }
346
405
 
347
- const cur = Math.floor(Date.now() / 1000);
406
+ // 轮初拍一份「上轮末仍相信活着」的会话快照(entry 复制,本轮原地改写
407
+ // 不影响),分成「轮初已没气」与「轮初还活着」两组。不能只收此刻
408
+ // kill -0 成功的——两轮之间崩溃正是最典型的被动死亡,本轮开始时它早
409
+ // 已没气;带 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));
415
+ const exitLine = (ws, old) => `会话退出 ${ws} pid=${old.pid}:非巡检主动结束`
416
+ + `(已存活 ${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或`
417
+ + '被外部终止;若反复出现请用 cron-up logs '
418
+ + `${path.basename(ws)} 排查启动过程`;
419
+
348
420
  // 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
349
421
  // 属这里。
350
422
  const [consumers, registryAlert] = await deps.scanSessions();
351
- if (registryAlert) deps.log(`告警:${registryAlert}`);
423
+ if (registryAlert) {
424
+ bump(stats, 'warn');
425
+ deps.log(`告警:${registryAlert}`);
426
+ }
352
427
  const scriptProcs = deps.listScriptProcesses();
353
428
 
429
+ // 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
430
+ // 日志时间线就是「退出 → 重拉」,而不是反过来。
431
+ for (const [ws, old] of prevDead) {
432
+ bump(stats, 'exit');
433
+ deps.log(exitLine(ws, old));
434
+ }
435
+
436
+ let found = 0;
354
437
  for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
438
+ found += 1;
355
439
  // 一个工作区读不动不能中断整轮:discover 是生成器,这里抛了会跳过
356
440
  // 后面所有工作区。续期/改 id 与保活各自独立守卫——维护失败绝不压制
357
441
  // 该工作区的会话保活。两者合并为同一次原子写(见 maintainWorkspace)。
@@ -372,6 +456,7 @@ export async function cmdRun(args) {
372
456
  + 'always 模式换代)后才读到新 id');
373
457
  }
374
458
  } catch (e) {
459
+ bump(stats, e instanceof TaskFileChanged ? 'warn' : 'error');
375
460
  if (e instanceof TaskFileChanged) {
376
461
  deps.log(`维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
377
462
  + '本轮不覆盖,下轮再来');
@@ -383,12 +468,25 @@ export async function cmdRun(args) {
383
468
  try {
384
469
  await patrolWorkspace(
385
470
  ws, state, now, leadMs, cur, consumers, scriptProcs,
386
- cfg.sessionRetain);
471
+ cfg.sessionRetain, stats);
387
472
  } catch (e) {
473
+ bump(stats, 'error');
388
474
  deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
389
475
  }
390
476
  }
391
477
 
478
+ // 轮中死亡:轮初还活着、轮末复核没气、又不是本轮主动 stop 的。指纹复
479
+ // 核由 trackedAlive 完成(带 procStart 指纹,pid 回收不会误报)。主动
480
+ // 回收/换代/卡死有各自的日志,这里只补轮中自行消亡那一类;轮间死亡刚
481
+ // 才已在循环前报过。报过的死条目随后由 kept 过滤打上 deadSince,后续
482
+ // 轮次不会重复报。
483
+ for (const [ws, old] of prevAlive) {
484
+ if (stoppedPids.has(old.pid)) continue;
485
+ if (deps.trackedAlive(old)) continue;
486
+ bump(stats, 'exit');
487
+ deps.log(exitLine(ws, old));
488
+ }
489
+
392
490
  // 丢掉已死且不处冷却的条目;保留冷却、活会话,以及带失败计数的死条
393
491
  // 目——计数跨任务窗口累积才能数满 FAIL_LIMIT(提前窗口常常只覆盖一两
394
492
  // 轮巡检),首次发现死亡时打 deadSince,超过 FAIL_TTL 后连同计数一起
@@ -405,8 +503,28 @@ export async function cmdRun(args) {
405
503
  }),
406
504
  );
407
505
  saveState(kept);
506
+
507
+ // 轮末汇总:一行交代这轮看见了什么、动了什么、花了多久,随后更新心跳。
508
+ const actions = EVENT_LABELS
509
+ .filter(([kind]) => stats[kind])
510
+ .map(([kind, label]) => `${label} ${stats[kind]}`)
511
+ .join('、');
512
+ const durationMs = Date.now() - startedAtMs;
513
+ const durationZh = durationMs < 1000
514
+ ? `${durationMs} 毫秒` : `${(durationMs / 1000).toFixed(1)} 秒`;
515
+ deps.log(`巡检结束:发现 ${found} 个工作区,活动交互会话 ${consumers.size} `
516
+ + `个;本轮${actions || '无动作'},耗时 ${durationZh}`);
517
+ saveHeartbeat({
518
+ ranAt: cur,
519
+ version: packageVersion(),
520
+ intervalSeconds: cfg.intervalSeconds,
521
+ sessionRetain: cfg.sessionRetain,
522
+ durationMs,
523
+ events: stats,
524
+ });
408
525
  return true;
409
526
  } finally {
527
+ deps.stopSession = prevStopSession;
410
528
  releaseLock(lock);
411
529
  }
412
530
  }
package/src/sessions.mjs CHANGED
@@ -126,8 +126,13 @@ export function spawnSession(ws) {
126
126
  cwd: ws,
127
127
  env: buildChildEnv(process.env),
128
128
  });
129
- // unref 后即使异步启动失败也不要变成 uncaught(监听器不影响 loop 退出)。
130
- child.on('error', () => {});
129
+ // unref 后即使异步启动失败也不要变成 uncaught(监听器不影响 loop 退出);
130
+ // 但错误原因要留进巡检日志——spawn 同步返回成功、子进程 exec 阶段才失败
131
+ // (ENOENT/EAGAIN 等)时,上层只能看到会话随后死亡,没有任何原因可查。
132
+ child.on('error', (e) => {
133
+ deps.log(`会话进程拉起异常 ${ws}:${e?.code ? `${e.code}:` : ''}`
134
+ + `${e?.message ?? e}`);
135
+ });
131
136
  child.unref();
132
137
  // 拉不起来(spawn 当场失败,pid 是 undefined)时返回 null:没有进程可跟
133
138
  // 踪,调用方按启动失败计数,不把 pid=undefined 写进 state。