cron-up 26.10.30 → 26.10.32

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/patrol.mjs CHANGED
@@ -1,6 +1,10 @@
1
- // 一轮巡检:发现工作区 → 自动续期 → 保证该有会话的地方有一个健康会话。
2
- // 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。状态机覆盖:失败
3
- // 冷却、卡死退休、超龄换代、孤儿接管、空任务回收。
1
+ // 一轮巡检:发现工作区 → 自动维护任务文件(续期/改 id/盖印)→ 保证每个
2
+ // 「创建者会话已死且处于预热窗口」的任务都有一个只属于它的健康会话。
3
+ // 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。本轮新拉起的会话
4
+ // 在所有工作区决策完后轮内等待登记(至多 WARMUP_WAIT_MS),认亲同一轮
5
+ // 补盖归属印;等不到的留给跨轮宽限,绝不当轮判死。状态机还覆盖:卡死
6
+ // 退休、失败冷却、执行完回收、resume 接管脱离、孤儿认领、任务消失回收、
7
+ // 旧版目录级 state 迁移。
4
8
 
5
9
  import fs from 'node:fs';
6
10
  import path from 'node:path';
@@ -11,16 +15,19 @@ import {
11
15
  COOLDOWN_SECONDS,
12
16
  FAIL_TTL_SECONDS,
13
17
  WARMUP_GRACE_SECONDS,
14
- SESSION_MAX_AGE_SECONDS,
15
- SESSION_IDLE_SECONDS,
18
+ WARMUP_WAIT_MS,
19
+ WARMUP_POLL_MS,
16
20
  SESSION_RECYCLE_IDLE_SECONDS,
17
- SESSION_FIRE_MARGIN_SECONDS,
21
+ SESSION_RECYCLE_MARGIN_SECONDS,
18
22
  PATROL_LOG_ROTATE_BYTES,
19
23
  HEARTBEAT_STALE_FACTOR,
24
+ INTERVAL_SECONDS,
25
+ LEAD_SECONDS,
20
26
  } from './constants.mjs';
21
27
  import {
22
28
  loadConfig,
23
29
  loadState,
30
+ loadLegacyEntries,
24
31
  saveState,
25
32
  loadHeartbeat,
26
33
  saveHeartbeat,
@@ -31,23 +38,24 @@ import {
31
38
  maintainWorkspace,
32
39
  TaskFileChanged,
33
40
  taskFileExists,
41
+ taskKey,
42
+ taskSig,
34
43
  } from './tasks.mjs';
35
- import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
44
+ import { taskWanted, parseCronOrNone, wantedNow } from './cron.mjs';
36
45
  import {
37
46
  sessionLogPath,
38
47
  sessionLogIdleSeconds,
39
48
  scriptProcLogPath,
49
+ isTaskSessionLogName,
40
50
  rotateOnce,
41
51
  } from './sessions.mjs';
42
52
  import { TASK_REL } from './paths.mjs';
43
53
  import { elapsedZh, pad2 } from './display.mjs';
44
54
 
45
- // 事件类型 → 轮末汇总里的中文标签。patrolWorkspace 与 cmdRun 往同一个
46
- // stats 计数器里记账,平静轮也会打出「本轮无动作」。
55
+ // 事件类型 → 轮末汇总里的中文标签。平静轮也会打出「本轮无动作」。
47
56
  const EVENT_LABELS = [
48
57
  ['spawn', '启动'],
49
58
  ['recycle', '回收'],
50
- ['ageout', '换代'],
51
59
  ['exit', '自行退出'],
52
60
  ['stuck', '卡死退休'],
53
61
  ['adopt', '接管'],
@@ -159,55 +167,90 @@ export function rotatePatrolLogs() {
159
167
  }
160
168
  }
161
169
 
162
- // 下次「需要会话在场」的时刻(ms):各任务下次触发的最小值;有错过待补
163
- // 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity。锚点刻意取
164
- // cron 裸落点而非 nextDelivery 的抖动后投递时刻:落点总不晚于投递时刻,
165
- // 按落点算余量更小、回收更保守,是安全方向;真正的重拉闸门是下面的
166
- // wanted(leadMs),margin 只需保住新会话登记。若改用 fire,落点后、投递
167
- // 前的整段延迟窗内都不会回收,回收频率被悄悄改变。
168
- function needHorizonMs(tasks, now) {
169
- let horizon = Infinity;
170
- for (const t of tasks) {
171
- const cron = parseCronOrNone(t?.cron);
172
- if (cron === null) continue;
173
- if (wanted(cron, t, now, 0)) return now.getTime();
174
- const nxt = cron.nextAfter(now)?.getTime();
175
- if (nxt !== undefined && nxt < horizon) horizon = nxt;
170
+ // 该任务下次「需要会话在场」的时刻(ms):现在已在窗口(落点尾窗、一次
171
+ // 性错过待补)视为立刻需要;窗口外取 cron 裸落点(周期任务投递晚于落
172
+ // 点,按落点算余量更小、回收更保守;一次性网格落点反而提前至多 90 秒投
173
+ // 递,这段提前量由回收余量 SESSION_FIRE_MARGIN_SECONDS 罩住)。cron
174
+ // 无效返回 Infinity,永不回收。
175
+ function taskNeedMs(task, now) {
176
+ const cron = parseCronOrNone(task?.cron);
177
+ if (cron === null) return Infinity;
178
+ if (wantedNow(cron, task, now)) return now.getTime();
179
+ return cron.nextAfter(now)?.getTime() ?? Infinity;
180
+ }
181
+
182
+ // 一个预热 entry 是否已认到登记里的活 claude 会话。
183
+ function entryClaimed(ent) {
184
+ return Boolean(ent?.sessionId && ent.claudeProcStart);
185
+ }
186
+
187
+ // 沿登记快照把一个活 script pid 认成登记里的 claude 孙进程:认到则回填
188
+ // 身份、清零失败计数并返回登记详情,否则返回 null。轮初认亲与轮内等待
189
+ // 认亲共用这唯一一份回填。
190
+ function claimEntry(ent, details, ws) {
191
+ const d = deps.claimWarm(details, ws, ent.pid);
192
+ if (!d) return null;
193
+ ent.fails = 0;
194
+ if (d.sessionId && d.procStart) {
195
+ ent.claudePid = d.pid;
196
+ ent.sessionId = d.sessionId;
197
+ ent.claudeProcStart = d.procStart;
176
198
  }
177
- return horizon;
199
+ return d;
178
200
  }
179
201
 
180
- // stampOwner 的活创建者判定,一处备齐 classifyWorkspace 需要的输入:
181
- // 返回本目录活会话 id 集合与「窗口内是否还有任务的创建者会话活着」。
182
- // 逐任务的 creatorSessionAlive 是本原(盖印逐个任务判定时复用同一谓词),
183
- // 聚合布尔只是它的派生。
184
- function liveCreatorWanted(tasks, details, ws, now, leadMs) {
185
- const liveSids = deps.liveSessionIds(details, ws);
186
- const creatorAlive = tasks.some((t) =>
187
- taskWanted(t, now, leadMs) && deps.creatorSessionAlive(t, liveSids));
188
- return { liveSids, creatorAlive };
202
+ // 盖印目标三字段(tasks.mjs maintainWorkspace reown 映射的值形状)。
203
+ const reownTarget = (ent) => ({
204
+ sessionId: ent.sessionId,
205
+ pid: ent.claudePid,
206
+ procStart: ent.claudeProcStart,
207
+ });
208
+
209
+ // 同 sig 的归属仲裁:先到先入;同 sig 被不同会话认亲(同毫秒连建的重复
210
+ // 任务)则双双撤出、都不盖。
211
+ function mergeStamp(stamps, conflicts, sig, target) {
212
+ const prev = stamps.get(sig);
213
+ if (prev) {
214
+ if (prev.sessionId !== target.sessionId) {
215
+ conflicts.add(sig);
216
+ stamps.delete(sig);
217
+ }
218
+ } else if (!conflicts.has(sig)) {
219
+ stamps.set(sig, target);
220
+ }
189
221
  }
190
222
 
191
- // 保证一个工作区里有一个健康的消费者会话。原地修改 state。retain 来自
192
- // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。scriptProcs
193
- // 是进程表快照或 null:null 时仅在需要认领孤儿(state 丢失的兜底分支)当
194
- // 下才拍快照,平静轮不付全量 ps 的代价。
195
- // stamp=null 表示 stampOwner 关(一切旧行为);开启时为
196
- // {verdict, recycleMarginSec}:verdict 是 classifyWorkspace 对轮初消费者
197
- // 快照的认亲结论(own/creatorAlive/ambiguous/user,换代或回收导致快照变
198
- // 化时在 retire 内用新快照重算),recycleMarginSec 是 window 回收余量。
223
+ // 过户后的统一计数与日志(巡逻第三步与等待期补盖共用)。
224
+ function noteReowned(ws, count, stats) {
225
+ bump(stats, 'reown');
226
+ deps.log(`已过户 ${ws}:${count} 个窗口内任务的归属改到各自的`
227
+ + '预热会话,这些任务将只在对应会话触发');
228
+ }
229
+
230
+ // 失败顶限时的统一冷却迁移:置冷却截止、计数、日志。cooldownUntil 之外
231
+ // 的条目形状由各调用点按自身语境构造。
232
+ function enterCooldown(ent, ws, key, fails, cur, stats) {
233
+ ent.cooldownUntil = cur + COOLDOWN_SECONDS;
234
+ bump(stats, 'cool');
235
+ deps.log(`冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
236
+ + `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
237
+ }
238
+
239
+ // 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/改
240
+ // id/盖印维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共
241
+ // 享的进程表快照容器({value: 数组|null}),只在孤儿认领时拍一次。
242
+ // 返回 pending:本轮新纳入跟踪、尚未认亲的活 script(新 spawn 与孤儿认
243
+ // 领)的 {ws,key,pid} 列表,cmdRun 据此轮内等待认亲并补盖印。
199
244
  export async function patrolWorkspace(
200
- ws, state, now, leadMs, cur, consumers, scriptProcs = null,
201
- retain = 'always', stats = null, stamp = null,
245
+ ws, bucket, taskfile, now, leadMs, cur, details, scriptProcsRef, stats,
202
246
  ) {
247
+ const pending = [];
203
248
  const tasks = deps.readTasks(ws);
204
- let ent = state[ws] ?? null;
205
- // 边记日志边给轮末汇总记账:日志给人翻,stats 给轮首/轮末心跳行一个
206
- // 紧凑全貌。stats 为 null(直接单测)时退化为纯日志。
207
249
  const note = (kind, msg) => {
208
250
  bump(stats, kind);
209
251
  deps.log(msg);
210
252
  };
253
+ const liveSids = deps.liveSessionIds(details, ws);
211
254
 
212
255
  if (tasks === null) {
213
256
  // 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
@@ -217,226 +260,440 @@ export async function patrolWorkspace(
217
260
  note('warn', `任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
218
261
  + `其中 ${TASK_REL} 里的定时任务都不会执行`);
219
262
  }
220
- return;
263
+ return [];
221
264
  }
265
+
266
+ const stopEntry = async (ent, kind, msg) => {
267
+ await deps.stopSession(ent);
268
+ note(kind, msg);
269
+ };
270
+
222
271
  if (tasks.length === 0) {
223
272
  // 文件完好但任务清空:一次性任务触发后被删、周期任务过期、或用户手
224
- // 删。我们拉起的会话没理由继续留着;用户自己开的会话不在 state 里,
225
- // 永不触碰。
226
- if (ent !== null) {
273
+ // 删。我们为这些任务拉起的会话没理由继续留着;detached(用户 resume
274
+ // 接管)的 stop 是 no-op,条目照删——sid 没了任务也没了,无需再管。
275
+ for (const [key, ent] of Object.entries(bucket)) {
227
276
  if (deps.trackedAlive(ent)) {
228
- await deps.stopSession(ent);
229
- note('recycle', `回收 ${ws} pid=${ent.pid}:任务已全部清空,结束保活会话`);
277
+ await stopEntry(ent, 'recycle',
278
+ `回收 ${ws} 任务 ${key} pid=${ent.pid}:任务已全部清空,结束预热会话`);
230
279
  }
231
- delete state[ws];
280
+ delete bucket[key];
232
281
  }
233
- return;
282
+ return [];
234
283
  }
235
284
 
236
- // 结束本代会话的完整收尾:停掉 → 摘 state → 重扫消费者。换代与 window
237
- // 回收是同一个动作,差别只在触发条件与文案;消费者快照拍于轮初,刚结束
238
- // 的这代可能正是快照里的消费者,不重扫会让陈旧登记挡住下面的重拉。
239
- let mineAlive = deps.trackedAlive(ent);
240
- // verdict 是轮初快照的认亲结论;换代/回收杀掉本代后消费者集合变了,
241
- // 必须按重扫结果重算而不是手改布尔——幸存下来的断链会话(进程组杀不
242
- // 掉的用户会话)仍应是歧义态,否则会被误当普通用户会话、在它旁边双开。
243
- let verdict = stamp?.verdict ?? null;
244
- const retire = async (kind, msg) => {
245
- await deps.stopSession(ent);
246
- note(kind, msg);
247
- delete state[ws];
248
- ent = null;
249
- mineAlive = false;
250
- if (stamp) {
251
- const [nextConsumers, , nextDetails] = await deps.scanSessions({ lineage: true });
252
- consumers = nextConsumers;
253
- // ent 已没(ownPid 给 null);creatorAlive 也必须按新快照重算——
254
- // 重扫本身就承认换代这几秒现场会变,活创建者可能恰好开关会话。
255
- verdict = deps.classifyWorkspace(nextDetails, ws, null,
256
- liveCreatorWanted(tasks, nextDetails, ws, now, leadMs).creatorAlive);
257
- } else {
258
- consumers = (await deps.scanSessions())[0];
259
- }
260
- };
261
-
262
- // 换代:超龄且日志静默(无任务在执行的证据)的会话主动结束,需要时下
263
- // 文自然重拉。换代是维护,不计失败。
264
- if (ent !== null && mineAlive) {
265
- const age = cur - (ent.startedAt ?? cur);
266
- if (age > SESSION_MAX_AGE_SECONDS
267
- && sessionLogIdleSeconds(
268
- ent.log || sessionLogPath(ws), cur) > SESSION_IDLE_SECONDS) {
269
- await retire('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
270
- + `${Math.floor(age / 86400)} 天且近期无输出,结束后按需重拉`);
285
+ // 第零步:id 改键的崩溃窗口治愈。任务文件里旧键已不存在(上轮 minify
286
+ // 改小了 id、state 却没来得及存盘),而内容签名唯一匹配某个当前任务、
287
+ // 目标键又无主:先把条目挪到新键,让下面的决策把它当作连续的一代——
288
+ // 否则会按「旧任务删除 + 新任务」错杀活会话再重拉一个。必须在逐任务
289
+ // 决策之前做,spawn 才不会抢先占掉目标键。
290
+ let sigToKeys = new Map();
291
+ let currentKeys = new Set();
292
+ for (const t of tasks) {
293
+ const k = taskKey(t);
294
+ currentKeys.add(k);
295
+ const list0 = sigToKeys.get(taskSig(t)) ?? [];
296
+ list0.push(k);
297
+ sigToKeys.set(taskSig(t), list0);
298
+ }
299
+ for (const [key, ent] of Object.entries({ ...bucket })) {
300
+ if (currentKeys.has(key) || !ent.sig) continue;
301
+ const candidates = sigToKeys.get(ent.sig) ?? [];
302
+ if (candidates.length === 1 && !bucket[candidates[0]]) {
303
+ bucket[candidates[0]] = { ...ent, taskId: candidates[0] };
304
+ delete bucket[key];
271
305
  }
272
306
  }
273
307
 
274
- // window 模式回收:会话生命周期对齐「一次执行」而非「常驻」。三个条件
275
- // 缺一不可:本代会话期间真的有任务触发过(lastFiredAt 晚于 startedAt
276
- // ——区分「执行完的空闲」与「等待触发的空闲」,后者是预热缓存在干活,
277
- // 回收它只会每轮空转重拉);界面静默超时(TUI 执行期间持续渲染,静默
278
- // 即不在干活);距下次需要会话还有余量(回收后下轮巡检重拉的新会话来
279
- // 得及登记,不会漏掉到点任务)。时间上重叠或紧邻的任务仍共享会话:任
280
- // 务路由在 Claude Code 手里,同目录并发多会话无法指定归属,还有重复
281
- // 执行的风险。回收不计失败——任务成功执行过是最强的健康证据,陈年计
282
- // 数一并清零。
283
- // 余量取值:默认只保登记抖动(约 2 分钟);stampOwner 开启时必须再留
284
- // 出一个巡检间隔——回收后要靠「下轮重拉→再下轮过户」两步把 owner 盖
285
- // 到新会话,余量小于间隔就可能在过户完成前到点(cmdRun 传入
286
- // SESSION_FIRE_MARGIN_SECONDS + intervalSeconds)。
287
- const fireMarginSec = stamp
288
- ? stamp.recycleMarginSec
289
- : SESSION_FIRE_MARGIN_SECONDS;
290
- if (ent !== null && mineAlive && retain === 'window'
291
- && tasks.some((t) => typeof t?.lastFiredAt === 'number'
292
- && t.lastFiredAt > (ent.startedAt ?? 0) * 1000)
293
- && sessionLogIdleSeconds(ent.log || sessionLogPath(ws), cur)
294
- > SESSION_RECYCLE_IDLE_SECONDS
295
- && needHorizonMs(tasks, now) - now.getTime()
296
- > fireMarginSec * 1000) {
297
- // stampOwner 关时:用户自己的会话若在场,retire 的重扫依旧看见,不
298
- // 会在同目录双开;开启时:旧代整组结束后重扫只可能剩用户会话,下面
299
- // 的逻辑会按需另拉新预热。
300
- await retire('recycle', `回收 ${ws} pid=${ent.pid}:本代任务已执行完毕且`
301
- + '界面静默,结束会话,下次需要时重拉');
308
+ // 第一步:认亲。认到 = 最强健康证据,当场回填身份、清零失败计数。本
309
+ // 轮新 spawn 的不在快照里,认亲由 cmdRun 的轮内等待补做;其余条目认
310
+ // 不到下轮再认,不阻断任何决策。
311
+ for (const ent of Object.values(bucket)) {
312
+ if (deps.trackedAlive(ent)) claimEntry(ent, details, ws);
302
313
  }
303
314
 
304
- const wantedNow = tasks.some((t) => taskWanted(t, now, leadMs));
305
- const warmAge = ent !== null ? cur - (ent.startedAt ?? cur) : 0;
306
- // 我们跟踪的预热活着、超过登记宽限、且没被认成自己(own 不可能卡死):
307
- // 卡在提问界面的垃圾进程。两条善后路径共用这一个判定:
308
- // - 有别的会话可承接(consumerHolds):只杀掉,不摘 state、不计数、不
309
- // 重拉——是垃圾回收,不是一次启动失败;
310
- // - 没人承接:杀掉后继续走失败计数/冷却/重拉。
311
- const warmStuck = ent !== null && mineAlive && wantedNow
312
- && warmAge > WARMUP_GRACE_SECONDS
313
- && (!stamp || verdict.kind !== 'own');
314
- const killStuckWarm = async (held) => {
315
- await deps.stopSession(ent);
316
- note('stuck', `卡死 ${ws} pid=${ent.pid}:进程已存活 ${warmAge} 秒仍未登记`
317
- + '会话,已终止'
318
- + (held ? ';同目录已有会话可承接任务,不另拉预热' : '')
319
- + `(排查:cron-up logs ${path.basename(ws)})`);
320
- };
315
+ // 第二步:逐任务决策。stamps 收本轮要盖的归属印(sig → 预热身份),
316
+ // 第三步合并进同一次原子写。stampConflicts 记「同内容签名被多个预热
317
+ // 声称」的病态任务(同毫秒连建的重复任务):一律不盖,交回调度锁原
318
+ // 生规则,避免错盖后没拿到任务的预热永不满足回收条件而空转泄漏。
319
+ const stamps = new Map();
320
+ const stampConflicts = new Set();
321
+ for (const t of tasks) {
322
+ const key = taskKey(t);
323
+ const sig = taskSig(t);
324
+ let ent = bucket[key] ?? null;
325
+ const logPath = sessionLogPath(ws, key);
326
+ const ownerIsOurs = entryClaimed(ent)
327
+ && typeof t.createdBySessionId === 'string'
328
+ && t.createdBySessionId === ent.sessionId;
329
+
330
+ // (a) 任务的创建者会话还开着:最高优先级,任务铁定投进它,我们不预
331
+ // 热、不盖印。ownerIsOurs 除外(归属本就是我们这代预热,sid 在活集
332
+ // 合里是理所当然)。若此前已为它拉过预热,结束多余的那条:未认亲超
333
+ // 宽限是卡垃圾,已认亲则是归属被外部改回活创建者,两者都不计失败。
334
+ if (!ownerIsOurs && deps.creatorSessionAlive(t, liveSids)) {
335
+ if (ent && deps.trackedAlive(ent)
336
+ && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS
337
+ && !entryClaimed(ent)) {
338
+ await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
339
+ + '进程已存活仍未登记会话,已终止;任务的创建者会话仍在场可承接,'
340
+ + '不另拉预热');
341
+ delete bucket[key];
342
+ } else if (ent && deps.trackedAlive(ent) && entryClaimed(ent)) {
343
+ await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
344
+ + '任务创建者会话已在场,结束多余的预热会话');
345
+ delete bucket[key];
346
+ }
347
+ continue;
348
+ }
321
349
 
322
- // 在场的消费者足以让我们放手吗?功能关时任何消费者都算;开启时除 user
323
- // 外的三态都算——认到自己的预热(own)、窗口任务的创建者会话还活着
324
- // (creatorAlive:用户显式要求尊重,绝不另拉)、父链有歧义无法排除是
325
- // 自己(ambiguous)。仅 user 不算:创建者已关闭/崩溃,下面继续走到
326
- // wanted→卡死退休/冷却→spawn,在同目录另起独立预热承接(全程只 stop
327
- // 自己的 ent,不碰用户会话)。
328
- const consumerHolds = consumers.has(ws)
329
- && (!stamp || verdict.kind !== 'user');
330
-
331
- // 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
332
- // 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
333
- // 却,连一次新尝试都不给。只有真的认到自己(own)或歧义态才清零:
334
- // creatorAlive 时在场的是活创建者、我们的预热可能正卡着没登记,不能替
335
- // 它清零。
336
- if (ent !== null && mineAlive && consumers.has(ws)
337
- && (!stamp || verdict.kind === 'own' || verdict.kind === 'ambiguous')) {
338
- ent.fails = 0;
339
- }
350
+ // (b) 用户在终端 --resume 接管:script 层已死,但同一 sessionId 的
351
+ // 裸 claude 还活着。冻结条目:不重拉(双开)、不计数、不碰用户进
352
+ // 程;sid 消失后再按死主任务正常重拉。
353
+ if (ent && !deps.trackedAlive(ent)
354
+ && typeof ent.sessionId === 'string'
355
+ && liveSids.has(ent.sessionId)) {
356
+ if (!ent.notified) {
357
+ ent.notified = true;
358
+ deps.log(`接管脱离 ${ws} 任务 ${key}:预热会话已在终端被 --resume `
359
+ + '打开(sessionId 仍存活),停止管理它,不再另拉预热;该会话关闭'
360
+ + '后巡检会重新拉起');
361
+ }
362
+ continue;
363
+ }
340
364
 
341
- // 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
342
- // 盖),也可能是用户的交互会话——后者默认放手:不在这里删死条目,失败
343
- // 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
344
- // 由轮末 kept 过滤统一处理。creatorAlive/ambiguous 返回前顺手清掉卡死
345
- // 的垃圾预热;平稳态在此提前返回,不必继续后面的流程。
346
- if (consumerHolds) {
347
- // 默认配置(stamp 关)沿用旧行为:用户会话在场时「掩护」卡死预热,
348
- // 不在这里杀,交给 7 天换代——所以 holds 路径只在 stamp 开启时清垃圾。
349
- if (stamp && warmStuck) await killStuckWarm(true);
350
- return;
351
- }
365
+ // (c) window 回收:不要求任务此刻在窗口内——恰恰是执行完、已离开窗
366
+ // 口的会话要被收掉。三条件缺一不可:本任务在本代执行过(lastFiredAt
367
+ // 晚于 startedAt——区分「执行完的空闲」与「等触发的空闲」,后者是预
368
+ // 热缓存在干活)、本任务日志静默超时、距本任务下次需要还有一个巡检
369
+ // 间隔加余量(下轮发现并 spawn、当轮等待认亲盖印;needMs 在窗口内
370
+ // 返回 now,天然挡住「回收后立刻又需要」)。
371
+ if (ent && deps.trackedAlive(ent) && entryClaimed(ent)
372
+ && typeof t.lastFiredAt === 'number'
373
+ && t.lastFiredAt > (ent.startedAt ?? 0) * 1000
374
+ && sessionLogIdleSeconds(ent.log || logPath, cur)
375
+ > SESSION_RECYCLE_IDLE_SECONDS
376
+ && taskNeedMs(t, now) - now.getTime()
377
+ > SESSION_RECYCLE_MARGIN_SECONDS * 1000) {
378
+ await stopEntry(ent, 'recycle',
379
+ `回收 ${ws} 任务 ${key} pid=${ent.pid}:本任务已执行完毕且界面静默,`
380
+ + '结束会话,下次窗口重新预热');
381
+ delete bucket[key];
382
+ continue;
383
+ }
352
384
 
353
- if (!wantedNow) return;
385
+ // 此后只服务「死主 + 在预热窗口内」的任务。
386
+ if (!taskWanted(t, now, leadMs)) continue;
354
387
 
355
- const cooling = isCooling(ent, cur);
388
+ // (d) 卡死退休:script 活着、超登记宽限仍未认亲——停在信任/授权提
389
+ // 问界面的垃圾进程。杀掉后继续走失败计数/冷却/重拉。
390
+ if (ent && deps.trackedAlive(ent) && !entryClaimed(ent)
391
+ && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS) {
392
+ await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
393
+ + '进程已存活仍未登记会话,已终止'
394
+ + `(排查:cron-up logs ${path.basename(ws)}--${key})`);
395
+ }
356
396
 
357
- // 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
358
- // 应答的提问界面,永不登记——退休它并推进失败计数。宽限内的年轻会话
359
- // 直接返回;warmStuck 已含超宽限判定。
360
- if (ent !== null && mineAlive) {
361
- if (!warmStuck) return;
362
- await killStuckWarm(false);
363
- }
397
+ // (e) resume 接管结束:sid 已从登记消失。整代身份作废(script 已
398
+ // 死、认亲信息失效),fails 清零(用户使用期间的退出不算我们的启动
399
+ // 失败),作为全新一条落入下面的重拉。
400
+ if (ent && !deps.trackedAlive(ent)
401
+ && typeof ent.sessionId === 'string'
402
+ && !liveSids.has(ent.sessionId)) {
403
+ ent.pid = null;
404
+ delete ent.procStart;
405
+ delete ent.claudePid;
406
+ delete ent.sessionId;
407
+ delete ent.claudeProcStart;
408
+ delete ent.notified;
409
+ ent.fails = 0;
410
+ }
364
411
 
365
- if (cooling) return;
366
-
367
- // 没有消费者,我们跟踪的会话(若有)已死或刚作为卡死退休。
368
- let fails;
369
- if (ent && ent.cooldownUntil) {
370
- // 冷却刚结束:给一次干净的缓刑拉起,而不是立刻再次顶满限制。
371
- fails = 0;
372
- } else {
373
- // 只有真的拉起过(pid 非空)的条目死亡才算一次新失败;pid 为空的条目
374
- // 是当场就没拉起的尝试,那次失败已在写入时计过,不再重复累计。
375
- fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
376
- }
412
+ // 盖印(跨轮老条目):已认亲且任务归属还没盖到本代预热(活创建者
413
+ // 已在 (a) 排除)。当轮新 spawn 的会话在第三步之后的轮内等待里认
414
+ // 亲,由 cmdRun 的 stampClaimed 补盖,不走这里。
415
+ if (entryClaimed(ent)
416
+ && (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
417
+ mergeStamp(stamps, stampConflicts, sig, reownTarget(ent));
418
+ }
377
419
 
378
- if (fails >= FAIL_LIMIT) {
379
- state[ws] = {
380
- pid: null,
381
- fails,
382
- cooldownUntil: cur + COOLDOWN_SECONDS,
383
- };
384
- note('cool', `冷却 ${ws}:仍有任务需要会话,但保活会话连续失败 ${fails} 次,`
385
- + `暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
386
- return;
387
- }
420
+ // warm 未到回收 / spawning 宽限内:本轮无事。
421
+ if (ent && deps.trackedAlive(ent)) continue;
388
422
 
389
- if (ent === null) {
390
- // state 里没有记录不等于没有会话:state.json 丢失(手删、purge 时保留
391
- // 会话)后进程表里可能还躺着这个工作区的 script 会话,卡在提问界面就
392
- // 不会出现在登记里,直接再拉就是同目录双开。认回孤儿,重新纳入正常
393
- // 生命周期(卡死的 3 分钟后照退休处理)。
394
- const logPath = sessionLogPath(ws);
395
- for (const [procPid, procArgs] of scriptProcs ?? deps.listScriptProcesses()) {
396
- // alive 复核:快照拍于本轮开头,可能含着刚被换代/回收杀掉的 pid——
397
- // 把死人认回来会白记一次失败。
398
- if (scriptProcLogPath(procArgs) === logPath && deps.alive(procPid)) {
399
- state[ws] = {
423
+ if (isCooling(ent, cur)) continue;
424
+
425
+ // 失败计数:冷却刚结束给一次干净缓刑;只有真拉起过(pid 非空)的条
426
+ // 目死亡才算一次新失败,当场没拉起的尝试已在写入时计过。
427
+ let fails;
428
+ if (ent?.cooldownUntil) {
429
+ fails = 0;
430
+ } else {
431
+ fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
432
+ }
433
+
434
+ if (fails >= FAIL_LIMIT) {
435
+ bucket[key] = {
436
+ ...ent,
437
+ ws,
438
+ taskId: key,
439
+ sig,
440
+ pid: null,
441
+ fails,
442
+ log: ent?.log ?? logPath,
443
+ };
444
+ enterCooldown(bucket[key], ws, key, fails, cur, stats);
445
+ continue;
446
+ }
447
+
448
+ // 孤儿认领:state 丢了但进程表里还有为本任务拉起的 script(卡在提问
449
+ // 界面就不会出现在登记里)。按任务专属日志路径精确相认,alive 复核
450
+ // 防把快照里刚被杀的 pid 认回来。
451
+ if (ent === null) {
452
+ if (scriptProcsRef.value === null) {
453
+ scriptProcsRef.value = deps.listScriptProcesses();
454
+ }
455
+ const adopted = scriptProcsRef.value.find(
456
+ ([procPid, procArgs]) =>
457
+ scriptProcLogPath(procArgs) === logPath && deps.alive(procPid));
458
+ if (adopted) {
459
+ const [procPid] = adopted;
460
+ bucket[key] = {
461
+ ws,
462
+ taskId: key,
463
+ sig,
400
464
  pid: procPid,
401
465
  startedAt: cur,
402
466
  procStart: deps.procStartedAt(procPid),
403
467
  fails: 0,
404
468
  log: logPath,
405
469
  };
406
- note('adopt', `接管 ${ws} pid=${procPid}:state 记录缺失,但进程表里仍有`
407
- + '本工作区的保活会话,重新纳入跟踪');
408
- return;
470
+ note('adopt', `接管 ${ws} 任务 ${key} pid=${procPid}:state 记录缺失,`
471
+ + '但进程表里仍有本该任务的预热会话,重新纳入跟踪');
472
+ pending.push({ ws, key, pid: procPid });
473
+ continue;
409
474
  }
410
475
  }
411
- }
412
476
 
413
- // 成败只认一个事实——有没有拿到 pid:null、undefined、无 pid 的对象都算
414
- // 没拉起,计数与日志同源,不会各说各话。
415
- const spawned = deps.spawnSession(ws);
416
- if (spawned?.pid == null) {
417
- // 本次尝试已当场失败,立即计入(成功拉起则本次尚待观察,沿用 fails)。
418
- const newFails = fails + 1;
419
- state[ws] = {
420
- pid: null,
477
+ // spawn:成败只认有没有拿到 pid。当场失败立即计入;本轮就顶满失败上
478
+ // 限的,直接进冷却(不必再空跑一轮)。
479
+ const spawned = deps.spawnSession(ws, key);
480
+ if (spawned?.pid == null) {
481
+ const newFails = fails + 1;
482
+ if (newFails >= FAIL_LIMIT) {
483
+ bucket[key] = {
484
+ ws, taskId: key, sig, pid: null, fails: newFails, log: logPath,
485
+ };
486
+ enterCooldown(bucket[key], ws, key, newFails, cur, stats);
487
+ continue;
488
+ }
489
+ bucket[key] = {
490
+ ws,
491
+ taskId: key,
492
+ sig,
493
+ pid: null,
494
+ startedAt: cur,
495
+ fails: newFails,
496
+ log: logPath,
497
+ };
498
+ note('fail', `启动失败 ${ws} 任务 ${key}:会话进程没有拉起`
499
+ + `(连续失败计数 ${newFails})`);
500
+ continue;
501
+ }
502
+ bucket[key] = {
503
+ ws,
504
+ taskId: key,
505
+ sig,
506
+ pid: spawned.pid,
421
507
  startedAt: cur,
422
- fails: newFails,
423
- log: sessionLogPath(ws),
508
+ procStart: spawned.procStart,
509
+ fails,
510
+ log: logPath,
424
511
  };
425
- note('fail', `启动失败 ${ws}:会话进程没有拉起(连续失败计数 ${newFails})`);
426
- return;
512
+ note('spawn', `已启动 ${ws} 任务 ${key} pid=${spawned.pid}`
513
+ + `(连续失败计数 ${fails})`);
514
+ pending.push({ ws, key, pid: spawned.pid });
515
+ }
516
+
517
+ // 第三步:任务文件维护(续期 + 改 id + 盖印)合并为一次带乐观锁的原子
518
+ // 写。撞车(Claude Code 刚写了 lastFiredAt/删了任务)放弃本轮老条目维
519
+ // 护,但已 spawn 的会话仍返回给 cmdRun 等待认亲,盖印收尾会再写一次。
520
+ let result;
521
+ try {
522
+ result = maintainWorkspace(taskfile, { reown: stamps });
523
+ } catch (e) {
524
+ if (e instanceof TaskFileChanged) {
525
+ note('warn', `维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
526
+ + '稍后重试');
527
+ return pending;
528
+ }
529
+ throw e;
427
530
  }
428
- state[ws] = {
429
- pid: spawned.pid,
430
- startedAt: cur,
431
- procStart: spawned.procStart,
432
- fails,
433
- log: sessionLogPath(ws),
531
+ // 与 readTasks 不一致的极端竞态:保守不动,新 spawn 也全留跨轮路径。
532
+ if (result === null) return [];
533
+ if (result.renewed) {
534
+ deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
535
+ + '不再受 7 天过期限制');
536
+ }
537
+ if (result.minified) {
538
+ deps.log(`已改小 id ${ws}:${result.minified} 个周期任务的 id 改为小哈希`
539
+ + '值,投递延迟降到约 1 分钟内');
540
+ }
541
+ if (result.reowned) noteReowned(ws, result.reowned, stats);
542
+ // id 改小同轮把 state 条目跟着改键:不改的话下轮会把换 id 的任务误判
543
+ // 成「旧任务删除 + 新任务」,错杀刚拉起的会话再重拉一个。pending 是本
544
+ // 轮 spawn 的返回快照,键也要跟着改,否则轮内等待按旧键找不到条目,新
545
+ // 任务的盖印被白白推到下轮。
546
+ for (const { from, to } of result.idChanges) {
547
+ if (bucket[from] && !bucket[to]) {
548
+ const movedPid = bucket[from].pid;
549
+ bucket[to] = { ...bucket[from], taskId: to };
550
+ delete bucket[from];
551
+ for (const p of pending) {
552
+ if (p.key === from && p.pid === movedPid) p.key = to;
553
+ }
554
+ }
555
+ }
556
+
557
+ // 第四步:任务消失的条目回收。第零步已做过签名治愈;这里重建当前键索
558
+ // 引——注意内存里的 tasks 是维护前的快照,minify 改的是重新读盘的
559
+ // doc,所以键必须经 idChanges 修正,否则刚改到新键的条目会被旧 id 视
560
+ // 图误当成「签名可治愈的旧键」再改回去。剩下对不上的键就是真的被删
561
+ // 了。冷却中的条目也删——任务没了,冷却没有意义。
562
+ const renamed = new Map(result.idChanges.map((c) => [c.from, c.to]));
563
+ sigToKeys = new Map();
564
+ currentKeys = new Set();
565
+ for (const t of tasks) {
566
+ const k = renamed.get(taskKey(t)) ?? taskKey(t);
567
+ currentKeys.add(k);
568
+ const list = sigToKeys.get(taskSig(t)) ?? [];
569
+ list.push(k);
570
+ sigToKeys.set(taskSig(t), list);
571
+ }
572
+ for (const [key, ent] of Object.entries({ ...bucket })) {
573
+ if (currentKeys.has(key)) continue;
574
+ const candidates = ent.sig ? (sigToKeys.get(ent.sig) ?? []) : [];
575
+ if (candidates.length === 1 && !bucket[candidates[0]]) {
576
+ const to = candidates[0];
577
+ bucket[to] = { ...ent, taskId: to };
578
+ delete bucket[key];
579
+ continue;
580
+ }
581
+ if (deps.trackedAlive(ent)) {
582
+ await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
583
+ + '对应任务已删除,结束预热会话');
584
+ }
585
+ delete bucket[key];
586
+ }
587
+ return pending;
588
+ }
589
+
590
+ // 等待拍数是墙钟帽的派生:假时钟(测试)下墙钟不走,拍数帽单独兜底。
591
+ const MAX_WARMUP_POLLS = WARMUP_WAIT_MS / WARMUP_POLL_MS;
592
+
593
+ // 轮内等待本轮新 spawn/认领的会话完成登记,认亲走与轮初同一个
594
+ // claimEntry。这是盖印加速,不是判死:等不到的活会话原样留下,判死只
595
+ // 走跨轮 WARMUP_GRACE 路径。先扫后睡——进入时各工作区决策已串行花掉数
596
+ // 秒,健康会话可能已登记;任一 pending 认亲或死亡都移出集合,全部移出
597
+ // 即提前结束。返回最新登记快照、按 ws 分组的认亲条目(供补盖印)与等
598
+ // 待期内死亡的 pending(供退出日志;其 bucket 条目已计失败)。
599
+ async function awaitWarmups(pending, state, cur, stats) {
600
+ const claimedByWs = new Map();
601
+ const died = [];
602
+ let details;
603
+ const deadline = Date.now() + WARMUP_WAIT_MS;
604
+ const recognize = () => {
605
+ const rest = [];
606
+ for (const p of pending) {
607
+ const ent = state[p.ws]?.[p.key];
608
+ if (!ent || ent.pid !== p.pid) continue;
609
+ if (!deps.trackedAlive(ent)) {
610
+ // 启动即崩的典型窗口:真拉起过却死掉,计一次失败并置空 pid,条
611
+ // 目靠 fails 走 48 小时 TTL 保留、下轮重拉;满三次当轮进冷却。
612
+ // 只打日志丢弃会让 fails 永远从零起,坏掉的环境每轮重拉却永不
613
+ // 冷却。
614
+ ent.fails = (ent.fails ?? 0) + 1;
615
+ ent.pid = null;
616
+ delete ent.procStart;
617
+ if (ent.fails >= FAIL_LIMIT) {
618
+ enterCooldown(ent, p.ws, p.key, ent.fails, cur, stats);
619
+ }
620
+ died.push(p);
621
+ continue;
622
+ }
623
+ if (claimEntry(ent, details, p.ws)) {
624
+ if (!claimedByWs.has(p.ws)) claimedByWs.set(p.ws, []);
625
+ claimedByWs.get(p.ws).push(ent);
626
+ } else {
627
+ rest.push(p);
628
+ }
629
+ }
630
+ pending = rest;
434
631
  };
435
- if (stamp && consumers.has(ws) && verdict?.kind === 'user') {
436
- note('spawn', `已另启动预热会话 ${ws} pid=${spawned.pid}:你的交互会话仍在`
437
- + `,窗口内任务将改由预热会话承接(连续失败计数 ${fails})`);
438
- } else {
439
- note('spawn', `已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
632
+ // 轮初快照拍于决策之前、spawn 尚未发生,认亲必然落空;进入先重扫一
633
+ // 拍再开始睡。之后睡一拍、扫一拍。
634
+ [, , details] = await deps.scanSessions();
635
+ recognize();
636
+ for (let polls = 0;
637
+ pending.length > 0 && polls < MAX_WARMUP_POLLS && Date.now() < deadline;
638
+ polls += 1) {
639
+ await deps.sleep(WARMUP_POLL_MS);
640
+ [, , details] = await deps.scanSessions();
641
+ recognize();
642
+ }
643
+ return { details, claimedByWs, died };
644
+ }
645
+
646
+ // 等待期认到亲的会话补盖归属印:claimedByWs 的每个 ent 已是 bucket 条
647
+ // 目,sig/认亲身份都在上面。无认亲的 ws 不调用、不写盘;撞 mtime 乐观
648
+ // 锁或极端竞态(result=null)跳过,下轮再来。
649
+ function stampClaimed(claimedByWs, taskfiles, stats) {
650
+ for (const [ws, ents] of claimedByWs) {
651
+ const stamps = new Map();
652
+ const conflicts = new Set();
653
+ for (const ent of ents) {
654
+ if (entryClaimed(ent)) mergeStamp(stamps, conflicts, ent.sig, reownTarget(ent));
655
+ }
656
+ if (stamps.size === 0) continue;
657
+ try {
658
+ const result = maintainWorkspace(taskfiles.get(ws), { reown: stamps });
659
+ if (result?.reowned) noteReowned(ws, result.reowned, stats);
660
+ } catch (e) {
661
+ if (e instanceof TaskFileChanged) {
662
+ bump(stats, 'warn');
663
+ deps.log(`盖印暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
664
+ + '下轮再来');
665
+ continue;
666
+ }
667
+ throw e;
668
+ }
669
+ }
670
+ }
671
+
672
+ // 升级轮:旧版目录级 state 的预热逐个结束(不迁移归属,新流程按任务重
673
+ // 拉);state.json 已丢的旧孤儿 script(日志是旧命名,不含任务键段)从
674
+ // 进程表兜底清扫。只在 loadLegacyEntries 非空(确在跨版本)时执行。
675
+ async function retireLegacy(legacy, stats) {
676
+ for (const ent of legacy) {
677
+ if (deps.trackedAlive(ent)) {
678
+ await deps.stopSession(ent);
679
+ bump(stats, 'recycle');
680
+ deps.log(`旧版迁移 ${ent.ws} pid=${ent.pid}:结束目录级预热会话,`
681
+ + '升级后按任务重新预热');
682
+ }
683
+ }
684
+ const handled = new Set(legacy.filter((e) => e.pid).map((e) => e.pid));
685
+ for (const [pid, procArgs] of deps.listScriptProcesses()) {
686
+ if (handled.has(pid)) continue;
687
+ const file = scriptProcLogPath(procArgs);
688
+ if (file === null) continue;
689
+ if (path.dirname(file) !== deps.paths.sessionLogDir) continue;
690
+ if (isTaskSessionLogName(path.basename(file))) continue; // 新一代命名
691
+ const ent = { pid, procStart: deps.procStartedAt(pid) };
692
+ if (!deps.trackedAlive(ent)) continue;
693
+ await deps.stopSession(ent);
694
+ bump(stats, 'recycle');
695
+ deps.log(`旧版迁移 pid=${pid}:结束目录级预热孤儿会话(日志 ${file}),`
696
+ + '升级后按任务重新预热');
440
697
  }
441
698
  }
442
699
 
@@ -448,10 +705,9 @@ export async function cmdRun(args) {
448
705
  return false;
449
706
  }
450
707
  // 包一层 stopSession 记下本轮主动结束的 pid:轮末凭它区分「我们回收的」
451
- // 与「会话自行退出/被外部杀掉的」,后者以前是无声丢失。同样包一层
452
- // procStartedAt:同一 pid 的 lstart 一轮内不会变,而轮初分区、各工作区
453
- // 的 trackedAlive、轮末复核与 kept 过滤都会查指纹,缓存后每个 pid 只
454
- // spawn 一次 ps。finally 一并还原。
708
+ // 与「会话自行退出/被外部杀掉的」。同样包一层 procStartedAt:同一 pid
709
+ // 的 lstart 一轮内不会变,缓存后每个 pid 只 spawn 一次 ps。finally 还
710
+ // 原。
455
711
  const prevStopSession = deps.stopSession;
456
712
  const stoppedPids = new Set();
457
713
  deps.stopSession = async (ent) => {
@@ -470,174 +726,158 @@ export async function cmdRun(args) {
470
726
  deps.rotatePatrolLogs();
471
727
  const cfg = loadConfig(args?.config ?? deps.paths.configPath);
472
728
  const now = new Date();
473
- const leadMs = cfg.leadSeconds * 1000;
729
+ const leadMs = LEAD_SECONDS * 1000;
474
730
  const state = loadState();
731
+ const legacy = loadLegacyEntries();
475
732
  const cur = Math.floor(Date.now() / 1000);
476
733
 
477
734
  // 轮首心跳:没有这两行,launchd 拉不起巡检(启动脚本失效、node 缺失)
478
735
  // 时日志会一片寂静,无法和「平安无事」区分。上轮心跳过老说明中间漏
479
736
  // 轮——休眠期间错过的 StartInterval,launchd 醒来只补跑一轮。
480
737
  const hb = loadHeartbeat();
481
- if (heartbeatStale(hb, cfg.intervalSeconds, cur)) {
738
+ if (heartbeatStale(hb, INTERVAL_SECONDS, cur)) {
482
739
  bump(stats, 'warn');
483
740
  deps.log(`距上次巡检已 ${elapsedZh(cur - hb.ranAt)},超过 `
484
- + `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${cfg.intervalSeconds} 秒),`
741
+ + `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${INTERVAL_SECONDS} 秒),`
485
742
  + '期间 launchd 可能漏轮(常见于电脑休眠)');
486
743
  }
487
- deps.log(`巡检开始:${cfg.sessionRetain} 模式,扫描根目录 `
488
- + `${cfg.roots.length} 个`);
744
+ deps.log(`巡检开始:扫描根目录 ${cfg.roots.length} 个`);
489
745
  if (cfg.roots.length === 0) {
490
746
  bump(stats, 'warn');
491
747
  deps.log('警告:扫描目录 roots 为空,巡检不会发现任何工作区;'
492
- + '请检查配置文件或用 install --roots 指定');
748
+ + '请检查配置文件或用 cron-up install --roots 指定');
493
749
  }
494
750
 
495
- // 轮初拍一份「上轮末仍相信活着」的会话快照(entry 复制,本轮原地改写
496
- // 不影响),一遍分成「轮初已没气」与「轮初还活着」两组。不能只收此刻
497
- // kill -0 成功的——两轮之间崩溃正是最典型的被动死亡,本轮开始时它早
498
- // 已没气;带 deadSince 的是上轮已报过死亡的旧条目,跳过以免每轮重复报。
499
- const prevDead = [];
500
- const prevAlive = [];
501
- for (const [ws, ent] of Object.entries(state)) {
502
- if (!ent.pid || ent.deadSince) continue;
503
- const copy = [ws, { ...ent }];
504
- (deps.trackedAlive(copy[1]) ? prevAlive : prevDead).push(copy);
505
- }
506
- const exitLine = (ws, old) => `会话退出 ${ws} pid=${old.pid}:非巡检主动结束`
507
- + `(已存活 ${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或`
508
- + '被外部终止;若反复出现请用 cron-up logs '
509
- + `${path.basename(ws)} 排查启动过程`;
510
-
511
- // 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
512
- // 属这里。进程表快照不在这里拍:只有孤儿认领分支需要,按需现拍(见
513
- // patrolWorkspace 的 scriptProcs 形参)。
514
- // lineage 只在 stampOwner 开启时拍:祖先链要一次全机 ps,关闭时
515
- // scanSessions 不付这个代价(见 registry 的 scanSessions 注释)。
516
- const [consumers, registryAlert, details] =
517
- await deps.scanSessions({ lineage: cfg.stampOwner });
751
+ // 登记轮初读一次(含祖先链快照):认亲与活创建者判定都靠它。有新
752
+ // spawn 时等待期会重扫,details 随之刷新到最新快照(轮末 kept 判定
753
+ // 与消费者计数都用刷新后的版本)。
754
+ let [consumers, registryAlert, details] = await deps.scanSessions();
518
755
  if (registryAlert) {
519
756
  bump(stats, 'warn');
520
757
  deps.log(`告警:${registryAlert}`);
521
758
  }
522
759
 
523
- // stampOwner 的结论依赖任务内容(活创建者判定要看窗口任务的
524
- // createdBySessionId),所以在下面 discover 循环里、读到任务后逐工
525
- // 作区现算;这里只留全轮常量。
526
- const recycleMarginSec = SESSION_FIRE_MARGIN_SECONDS
527
- + cfg.intervalSeconds;
760
+ // 旧版收尾必须先于一切 spawn 决策(state 轮末被新形状整体覆盖)。
761
+ if (legacy.length > 0) await retireLegacy(legacy, stats);
528
762
 
529
- // 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
530
- // 日志时间线就是「退出 → 重拉」,而不是反过来。
531
- for (const [ws, old] of prevDead) {
763
+ // 轮初把上轮末仍相信活着的条目分成三组:活、resume 脱离(script 已
764
+ // 死但 sid 还在,不报退出)、真死(立即报)。带 deadSince 的旧报条
765
+ // 目跳过。不能只收此刻 kill -0 成功的——两轮之间崩溃正是最典型的被
766
+ // 动死亡。
767
+ const prevDead = [];
768
+ const prevAlive = [];
769
+ const sidInDetails = (ws, sid) =>
770
+ (details.get(ws) ?? []).some((d) => d.sessionId === sid);
771
+ for (const [ws, bucket] of Object.entries(state)) {
772
+ for (const [taskId, ent] of Object.entries(bucket)) {
773
+ if (!ent.pid || ent.deadSince) continue;
774
+ const copy = { ...ent };
775
+ if (deps.trackedAlive(copy)) {
776
+ prevAlive.push([ws, taskId, copy]);
777
+ } else if (typeof ent.sessionId === 'string'
778
+ && sidInDetails(ws, ent.sessionId)) {
779
+ // detached:静默。
780
+ } else {
781
+ prevDead.push([ws, taskId, copy]);
782
+ }
783
+ }
784
+ }
785
+ const exitLine = (ws, taskId, old) => `会话退出 ${ws} 任务 ${taskId} `
786
+ + `pid=${old.pid}:非巡检主动结束(已存活 `
787
+ + `${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或被外部`
788
+ + '终止;若反复出现请用 cron-up logs '
789
+ + `${path.basename(ws)}--${taskId} 排查启动过程`;
790
+ for (const [ws, taskId, old] of prevDead) {
532
791
  bump(stats, 'exit');
533
- deps.log(exitLine(ws, old));
792
+ deps.log(exitLine(ws, taskId, old));
534
793
  }
535
794
 
536
795
  let found = 0;
537
- for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
796
+ const scriptProcsRef = { value: null };
797
+ const pendingAll = [];
798
+ const taskfiles = new Map();
799
+ for (const [ws, taskfile] of deps.discover(cfg.roots)) {
538
800
  found += 1;
539
- // 一个工作区读不动不能中断整轮:discover 是生成器,这里抛了会跳过
540
- // 后面所有工作区。续期/改 id/过户与保活各自独立守卫——维护失败绝不
541
- // 压制该工作区的会话保活。三者合并为同一次原子写(见
542
- // maintainWorkspace)。
543
- //
544
- // stampOwner 的判定在这里现算(依赖任务内容):窗口内是否还有任务
545
- // 的创建者会话活着——活创建者拥有否决权,不另拉、不盖印。
546
- let stampArg = null;
547
- let liveSids = null;
548
- if (cfg.stampOwner) {
549
- const wsTasks = deps.readTasks(ws) ?? []; // readTasks 已滤掉非对象
550
- const creator = liveCreatorWanted(wsTasks, details, ws, now, leadMs);
551
- liveSids = creator.liveSids;
552
- const entNow = state[ws] ?? null;
553
- const ownPid = entNow && deps.trackedAlive(entNow) ? entNow.pid : null;
554
- stampArg = {
555
- verdict: deps.classifyWorkspace(details, ws, ownPid,
556
- creator.creatorAlive),
557
- recycleMarginSec,
558
- };
559
- }
560
- const target = stampArg?.verdict?.target ?? null;
561
- // 盖印谓词只盖「窗口内且创建者已不活」的任务:活创建者的任务永远
562
- // 不碰(即便本目录另有我们的预热在承接别的死主任务);已盖给本代
563
- // 预热的任务因 owner 就在活会话集合里而自然跳过,幂等不靠另写比较。
564
- if (cfg.autoRenew || cfg.autoMinId || target) {
565
- try {
566
- const { renewed, minified, reowned } = maintainWorkspace(taskfile, {
567
- renew: cfg.autoRenew,
568
- minify: cfg.autoMinId,
569
- // target 多出的祖先链字段 tasks 层不读,展开即可,不必手抄
570
- // 三个盖印字段。
571
- reown: target
572
- ? {
573
- ...target,
574
- isWanted: (t) => taskWanted(t, now, leadMs)
575
- && !deps.creatorSessionAlive(t, liveSids),
576
- }
577
- : null,
578
- }) ?? {};
579
- if (renewed) {
580
- deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
581
- + '不再受 7 天过期限制');
582
- }
583
- if (minified) {
584
- deps.log(`已改小 id ${ws}:${minified} 个周期任务的 id 改为小哈希`
585
- + '值,投递延迟降到约 1 分钟内;下次预热拉起的新会话起生效,'
586
- + '已在运行的会话要等其结束重拉(window 模式执行完即回收、'
587
- + 'always 模式换代)后才读到新 id');
588
- }
589
- if (reowned) {
590
- bump(stats, 'reown');
591
- deps.log(`已过户 ${ws}:${reowned} 个窗口内任务的归属改到预热会话`
592
- + ` pid=${target.pid},这些任务将只在该会话触发`);
593
- }
594
- } catch (e) {
595
- bump(stats, e instanceof TaskFileChanged ? 'warn' : 'error');
596
- if (e instanceof TaskFileChanged) {
597
- deps.log(`维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
598
- + '本轮不覆盖,下轮再来');
599
- } else {
600
- deps.log(`维护错误 ${ws}:${e.constructor.name}:${e.message}`);
601
- }
602
- }
603
- }
801
+ if (!state[ws]) state[ws] = {};
802
+ taskfiles.set(ws, taskfile);
803
+ // 一个工作区读不动/写不动不能中断整轮:discover 是生成器,抛了会
804
+ // 跳过后面所有工作区。
604
805
  try {
605
- await patrolWorkspace(
606
- ws, state, now, leadMs, cur, consumers, null,
607
- cfg.sessionRetain, stats, stampArg);
806
+ pendingAll.push(...await patrolWorkspace(
807
+ ws, state[ws], taskfile, now, leadMs, cur, details,
808
+ scriptProcsRef, stats));
608
809
  } catch (e) {
609
810
  bump(stats, 'error');
610
811
  deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
611
812
  }
813
+ if (Object.keys(state[ws]).length === 0) delete state[ws];
814
+ }
815
+
816
+ // 本轮有新会话拉起才等待:绝大多数轮次 pending 为空,零额外 ps、零睡
817
+ // 眠。认亲后同一轮补盖归属印(否则要等下一轮,提前量就得兜两轮)。
818
+ // 等待只认亲、不判死;等不到的下轮按 WARMUP_GRACE 跨轮处理。
819
+ if (pendingAll.length > 0) {
820
+ try {
821
+ const { details: latest, claimedByWs, died } = await awaitWarmups(
822
+ pendingAll, state, cur, stats);
823
+ details = latest;
824
+ consumers = new Set(details.keys());
825
+ // 等待期内自然死亡的是本轮新 spawn 的会话,不在轮初 prevAlive 分
826
+ // 组里,单独补一条退出日志,否则启动即崩将完全静默。死亡条目
827
+ // fails≥1,轮末 kept 必保留,此刻仍在 bucket 里(pid 已置空,
828
+ // 用 pending 记的 pid 补回给日志)。
829
+ for (const d of died) {
830
+ bump(stats, 'exit');
831
+ deps.log(exitLine(d.ws, d.key,
832
+ { ...state[d.ws]?.[d.key], pid: d.pid }));
833
+ }
834
+ stampClaimed(claimedByWs, taskfiles, stats);
835
+ } catch (e) {
836
+ // 等待或补盖失败不能丢轮末落盘:最坏只是盖印晚一轮,会话进程已
837
+ // 在场,目录调度锁仍兜底。
838
+ bump(stats, 'error');
839
+ deps.log(`等待预热登记出错:${e.constructor.name}:${e.message}`);
840
+ }
612
841
  }
613
842
 
614
- // 轮中死亡:轮初还活着、轮末复核没气、又不是本轮主动 stop 的。指纹复
615
- // 核由 trackedAlive 完成(带 procStart 指纹,pid 回收不会误报)。主动
616
- // 回收/换代/卡死有各自的日志,这里只补轮中自行消亡那一类;轮间死亡刚
617
- // 才已在循环前报过。报过的死条目随后由 kept 过滤打上 deadSince,后续
618
- // 轮次不会重复报。
619
- for (const [ws, old] of prevAlive) {
843
+ // 轮中死亡复核:轮初活、轮末没气、又不是本轮主动 stop 的。轮中刚被
844
+ // --resume 接管(sid 换 pid 存活)的不报。
845
+ for (const [ws, taskId, old] of prevAlive) {
620
846
  if (stoppedPids.has(old.pid)) continue;
621
847
  if (deps.trackedAlive(old)) continue;
848
+ if (typeof old.sessionId === 'string'
849
+ && deps.sessionAliveNow(old.sessionId, ws)) continue;
622
850
  bump(stats, 'exit');
623
- deps.log(exitLine(ws, old));
851
+ deps.log(exitLine(ws, taskId, old));
624
852
  }
625
853
 
626
- // 丢掉已死且不处冷却的条目;保留冷却、活会话,以及带失败计数的死条
627
- // 目——计数跨任务窗口累积才能数满 FAIL_LIMIT(提前窗口常常只覆盖一两
628
- // 轮巡检),首次发现死亡时打 deadSince,超过 FAIL_TTL 后连同计数一起
629
- // 老化丢弃。
630
- const kept = Object.fromEntries(
631
- Object.entries(state).filter(([, ent]) => {
632
- if (deps.trackedAlive(ent)) return true;
633
- if (isCooling(ent, cur)) return true;
854
+ // kept 过滤(嵌套):活会话、resume 脱离(sid 仍在登记)、冷却中、
855
+ // 带失败计数的死条目(计数跨任务窗口累积,48 小时 TTL 老化)保留;
856
+ // 其余丢掉。首次发现死亡打 deadSince。
857
+ const kept = {};
858
+ for (const [ws, bucket] of Object.entries(state)) {
859
+ const out = {};
860
+ for (const [taskId, ent] of Object.entries(bucket)) {
861
+ if (deps.trackedAlive(ent)) {
862
+ out[taskId] = ent;
863
+ continue;
864
+ }
865
+ if (typeof ent.sessionId === 'string'
866
+ && sidInDetails(ws, ent.sessionId)) {
867
+ out[taskId] = ent;
868
+ continue;
869
+ }
870
+ if (isCooling(ent, cur)) {
871
+ out[taskId] = ent;
872
+ continue;
873
+ }
634
874
  if ((ent.fails ?? 0) > 0) {
635
875
  ent.deadSince ??= cur;
636
- return cur - ent.deadSince < FAIL_TTL_SECONDS;
876
+ if (cur - ent.deadSince < FAIL_TTL_SECONDS) out[taskId] = ent;
637
877
  }
638
- return false;
639
- }),
640
- );
878
+ }
879
+ if (Object.keys(out).length > 0) kept[ws] = out;
880
+ }
641
881
  saveState(kept);
642
882
 
643
883
  // 轮末汇总:一行交代这轮看见了什么、动了什么、花了多久,随后更新心跳。
@@ -653,8 +893,7 @@ export async function cmdRun(args) {
653
893
  saveHeartbeat({
654
894
  ranAt: cur,
655
895
  version: packageVersion(),
656
- intervalSeconds: cfg.intervalSeconds,
657
- sessionRetain: cfg.sessionRetain,
896
+ intervalSeconds: INTERVAL_SECONDS,
658
897
  durationMs,
659
898
  events: stats,
660
899
  });
@@ -666,73 +905,6 @@ export async function cmdRun(args) {
666
905
  }
667
906
  }
668
907
 
669
- // 立即给所有周期任务补 permanent 并把 id 改小。忽略 cfg.autoRenew /
670
- // cfg.autoMinId——手动跑命令本身就是显式指令(想只续期不改 id 的场景极
671
- // 少,真需要可临时关掉配置后跑巡检)。与巡检共用同一把锁。
672
- export async function cmdRenew(args) {
673
- const lock = deps.acquireRunLock();
674
- if (lock === null) {
675
- deps.print('已有一轮巡检(或另一次 renew)在进行,请稍后重试');
676
- return;
677
- }
678
- try {
679
- const cfg = loadConfig(args?.config ?? deps.paths.configPath);
680
- let files = 0;
681
- let tagged = 0;
682
- let minified = 0;
683
- let unchanged = 0;
684
- const skipped = [];
685
- for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
686
- files += 1;
687
- let r;
688
- try {
689
- r = maintainWorkspace(taskfile, { renew: true, minify: true });
690
- } catch (e) {
691
- if (e instanceof TaskFileChanged) {
692
- deps.print(`暂缓 ${ws}:任务文件刚被 Claude Code 改动,本次未写入,`
693
- + '可再跑一次');
694
- skipped.push(ws);
695
- continue;
696
- }
697
- deps.print(`跳过 ${ws}:(${e.constructor.name}:${e.message})`);
698
- skipped.push(ws);
699
- continue;
700
- }
701
- if (r === null) {
702
- deps.print(`跳过 ${ws}:任务文件读不出或格式损坏`);
703
- skipped.push(ws);
704
- continue;
705
- }
706
- if (r.renewed || r.minified) {
707
- if (r.renewed) {
708
- tagged += r.renewed;
709
- deps.print(`已续期 ${ws}:${r.renewed} 个周期任务标记为 permanent`);
710
- }
711
- if (r.minified) {
712
- minified += r.minified;
713
- deps.print(`已改小 id ${ws}:${r.minified} 个周期任务投递延迟降到约`
714
- + ' 1 分钟内(下次预热的新会话起生效)');
715
- }
716
- } else {
717
- unchanged += 1;
718
- deps.print(`已是最新 ${ws}:周期任务均已 permanent 且 id 已足够小,`
719
- + '无需改动');
720
- }
721
- }
722
- deps.print('');
723
- let line = `扫描 ${files} 个任务文件:本次续期 ${tagged} 个、改小 id `
724
- + `${minified} 个任务,${unchanged} 个文件无需改动`;
725
- if (skipped.length) line += `,${skipped.length} 个跳过`;
726
- deps.print(line);
727
- if (tagged) {
728
- deps.print('这些周期任务此后不再受 7 天过期限制;为它们保活的后台会话'
729
- + '会长期常驻。');
730
- }
731
- } finally {
732
- releaseLock(lock);
733
- }
734
- }
735
-
736
908
  Object.assign(deps, {
737
909
  log,
738
910
  runPatrol: cmdRun,