cron-up 26.10.30 → 26.10.31

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,8 @@
1
- // 一轮巡检:发现工作区 → 自动续期 → 保证该有会话的地方有一个健康会话。
2
- // 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。状态机覆盖:失败
3
- // 冷却、卡死退休、超龄换代、孤儿接管、空任务回收。
1
+ // 一轮巡检:发现工作区 → 自动维护任务文件(续期/改 id/盖印)→ 保证每个
2
+ // 「创建者会话已死且处于预热窗口」的任务都有一个只属于它的健康会话。
3
+ // 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。状态机覆盖:认亲、
4
+ // 卡死退休、失败冷却、执行完回收、resume 接管脱离、孤儿认领、任务消失回
5
+ // 收、旧版目录级 state 迁移。
4
6
 
5
7
  import fs from 'node:fs';
6
8
  import path from 'node:path';
@@ -11,16 +13,17 @@ import {
11
13
  COOLDOWN_SECONDS,
12
14
  FAIL_TTL_SECONDS,
13
15
  WARMUP_GRACE_SECONDS,
14
- SESSION_MAX_AGE_SECONDS,
15
- SESSION_IDLE_SECONDS,
16
16
  SESSION_RECYCLE_IDLE_SECONDS,
17
- SESSION_FIRE_MARGIN_SECONDS,
17
+ SESSION_RECYCLE_MARGIN_SECONDS,
18
18
  PATROL_LOG_ROTATE_BYTES,
19
19
  HEARTBEAT_STALE_FACTOR,
20
+ INTERVAL_SECONDS,
21
+ LEAD_SECONDS,
20
22
  } from './constants.mjs';
21
23
  import {
22
24
  loadConfig,
23
25
  loadState,
26
+ loadLegacyEntries,
24
27
  saveState,
25
28
  loadHeartbeat,
26
29
  saveHeartbeat,
@@ -31,23 +34,24 @@ import {
31
34
  maintainWorkspace,
32
35
  TaskFileChanged,
33
36
  taskFileExists,
37
+ taskKey,
38
+ taskSig,
34
39
  } from './tasks.mjs';
35
40
  import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
36
41
  import {
37
42
  sessionLogPath,
38
43
  sessionLogIdleSeconds,
39
44
  scriptProcLogPath,
45
+ isTaskSessionLogName,
40
46
  rotateOnce,
41
47
  } from './sessions.mjs';
42
48
  import { TASK_REL } from './paths.mjs';
43
49
  import { elapsedZh, pad2 } from './display.mjs';
44
50
 
45
- // 事件类型 → 轮末汇总里的中文标签。patrolWorkspace 与 cmdRun 往同一个
46
- // stats 计数器里记账,平静轮也会打出「本轮无动作」。
51
+ // 事件类型 → 轮末汇总里的中文标签。平静轮也会打出「本轮无动作」。
47
52
  const EVENT_LABELS = [
48
53
  ['spawn', '启动'],
49
54
  ['recycle', '回收'],
50
- ['ageout', '换代'],
51
55
  ['exit', '自行退出'],
52
56
  ['stuck', '卡死退休'],
53
57
  ['adopt', '接管'],
@@ -159,55 +163,34 @@ export function rotatePatrolLogs() {
159
163
  }
160
164
  }
161
165
 
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;
176
- }
177
- return horizon;
166
+ // 该任务下次「需要会话在场」的时刻(ms):现在已在窗口(落点尾窗、一次
167
+ // 性错过待补)视为立刻需要;窗口外取 cron 裸落点(刻意不取抖动后投递时
168
+ // 刻:落点更早,按落点算余量更小、回收更保守,是安全方向)。cron 无效
169
+ // 返回 Infinity,永不回收。
170
+ function taskNeedMs(task, now) {
171
+ const cron = parseCronOrNone(task?.cron);
172
+ if (cron === null) return Infinity;
173
+ if (wanted(cron, task, now, 0)) return now.getTime();
174
+ return cron.nextAfter(now)?.getTime() ?? Infinity;
178
175
  }
179
176
 
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 };
177
+ // 一个预热 entry 是否已认到登记里的活 claude 会话。
178
+ function entryClaimed(ent) {
179
+ return Boolean(ent?.sessionId && ent.claudeProcStart);
189
180
  }
190
181
 
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 回收余量。
182
+ // 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/改
183
+ // id/盖印维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共
184
+ // 享的进程表快照容器({value: 数组|null}),只在孤儿认领时拍一次。
199
185
  export async function patrolWorkspace(
200
- ws, state, now, leadMs, cur, consumers, scriptProcs = null,
201
- retain = 'always', stats = null, stamp = null,
186
+ ws, bucket, taskfile, now, leadMs, cur, details, scriptProcsRef, stats,
202
187
  ) {
203
188
  const tasks = deps.readTasks(ws);
204
- let ent = state[ws] ?? null;
205
- // 边记日志边给轮末汇总记账:日志给人翻,stats 给轮首/轮末心跳行一个
206
- // 紧凑全貌。stats 为 null(直接单测)时退化为纯日志。
207
189
  const note = (kind, msg) => {
208
190
  bump(stats, kind);
209
191
  deps.log(msg);
210
192
  };
193
+ const liveSids = deps.liveSessionIds(details, ws);
211
194
 
212
195
  if (tasks === null) {
213
196
  // 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
@@ -219,224 +202,374 @@ export async function patrolWorkspace(
219
202
  }
220
203
  return;
221
204
  }
205
+
206
+ const stopEntry = async (ent, kind, msg) => {
207
+ await deps.stopSession(ent);
208
+ note(kind, msg);
209
+ };
210
+
222
211
  if (tasks.length === 0) {
223
212
  // 文件完好但任务清空:一次性任务触发后被删、周期任务过期、或用户手
224
- // 删。我们拉起的会话没理由继续留着;用户自己开的会话不在 state 里,
225
- // 永不触碰。
226
- if (ent !== null) {
213
+ // 删。我们为这些任务拉起的会话没理由继续留着;detached(用户 resume
214
+ // 接管)的 stop 是 no-op,条目照删——sid 没了任务也没了,无需再管。
215
+ for (const [key, ent] of Object.entries(bucket)) {
227
216
  if (deps.trackedAlive(ent)) {
228
- await deps.stopSession(ent);
229
- note('recycle', `回收 ${ws} pid=${ent.pid}:任务已全部清空,结束保活会话`);
217
+ await stopEntry(ent, 'recycle',
218
+ `回收 ${ws} 任务 ${key} pid=${ent.pid}:任务已全部清空,结束预热会话`);
230
219
  }
231
- delete state[ws];
220
+ delete bucket[key];
232
221
  }
233
222
  return;
234
223
  }
235
224
 
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];
225
+ // 第零步:id 改键的崩溃窗口治愈。任务文件里旧键已不存在(上轮 minify
226
+ // 改小了 id、state 却没来得及存盘),而内容签名唯一匹配某个当前任务、
227
+ // 目标键又无主:先把条目挪到新键,让下面的决策把它当作连续的一代——
228
+ // 否则会按「旧任务删除 + 新任务」错杀活会话再重拉一个。必须在逐任务
229
+ // 决策之前做,spawn 才不会抢先占掉目标键。
230
+ let sigToKeys = new Map();
231
+ let currentKeys = new Set();
232
+ for (const t of tasks) {
233
+ const k = taskKey(t);
234
+ currentKeys.add(k);
235
+ const list0 = sigToKeys.get(taskSig(t)) ?? [];
236
+ list0.push(k);
237
+ sigToKeys.set(taskSig(t), list0);
238
+ }
239
+ for (const [key, ent] of Object.entries({ ...bucket })) {
240
+ if (currentKeys.has(key) || !ent.sig) continue;
241
+ const candidates = sigToKeys.get(ent.sig) ?? [];
242
+ if (candidates.length === 1 && !bucket[candidates[0]]) {
243
+ bucket[candidates[0]] = { ...ent, taskId: candidates[0] };
244
+ delete bucket[key];
259
245
  }
260
- };
246
+ }
261
247
 
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)} 天且近期无输出,结束后按需重拉`);
248
+ // 第一步:认亲。spawn 只知道 script pid,登记里是 claude 孙进程;沿轮
249
+ // 初快照的祖先链找到我们这代的登记详情,回填 sessionId/claudePid/
250
+ // claudeProcStart。认到 = 最强健康证据,当场清零失败计数。认不到下轮
251
+ // 再认,不阻断任何决策。
252
+ for (const ent of Object.values(bucket)) {
253
+ if (!deps.trackedAlive(ent)) continue;
254
+ const d = deps.claimWarm(details, ws, ent.pid);
255
+ if (!d) continue;
256
+ ent.fails = 0;
257
+ if (d.sessionId && d.procStart) {
258
+ ent.claudePid = d.pid;
259
+ ent.sessionId = d.sessionId;
260
+ ent.claudeProcStart = d.procStart;
271
261
  }
272
262
  }
273
263
 
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
- + '界面静默,结束会话,下次需要时重拉');
302
- }
264
+ // 第二步:逐任务决策。stamps 收本轮要盖的归属印(sig → 预热身份),
265
+ // 第三步合并进同一次原子写。stampConflicts 记「同内容签名被多个预热
266
+ // 声称」的病态任务(同毫秒连建的重复任务):一律不盖,交回调度锁原
267
+ // 生规则,避免错盖后没拿到任务的预热永不满足回收条件而空转泄漏。
268
+ const stamps = new Map();
269
+ const stampConflicts = new Set();
270
+ for (const t of tasks) {
271
+ const key = taskKey(t);
272
+ const sig = taskSig(t);
273
+ let ent = bucket[key] ?? null;
274
+ const logPath = sessionLogPath(ws, key);
275
+ const ownerIsOurs = entryClaimed(ent)
276
+ && typeof t.createdBySessionId === 'string'
277
+ && t.createdBySessionId === ent.sessionId;
278
+
279
+ // (a) 任务的创建者会话还开着:最高优先级,任务铁定投进它,我们不预
280
+ // 热、不盖印。ownerIsOurs 除外(归属本就是我们这代预热,sid 在活集
281
+ // 合里是理所当然)。若此前已为它拉过预热,结束多余的那条:未认亲超
282
+ // 宽限是卡垃圾,已认亲则是归属被外部改回活创建者,两者都不计失败。
283
+ if (!ownerIsOurs && deps.creatorSessionAlive(t, liveSids)) {
284
+ if (ent && deps.trackedAlive(ent)
285
+ && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS
286
+ && !entryClaimed(ent)) {
287
+ await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
288
+ + '进程已存活仍未登记会话,已终止;任务的创建者会话仍在场可承接,'
289
+ + '不另拉预热');
290
+ delete bucket[key];
291
+ } else if (ent && deps.trackedAlive(ent) && entryClaimed(ent)) {
292
+ await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
293
+ + '任务创建者会话已在场,结束多余的预热会话');
294
+ delete bucket[key];
295
+ }
296
+ continue;
297
+ }
303
298
 
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
- };
299
+ // (b) 用户在终端 --resume 接管:script 层已死,但同一 sessionId 的
300
+ // 裸 claude 还活着。冻结条目:不重拉(双开)、不计数、不碰用户进
301
+ // 程;sid 消失后再按死主任务正常重拉。
302
+ if (ent && !deps.trackedAlive(ent)
303
+ && typeof ent.sessionId === 'string'
304
+ && liveSids.has(ent.sessionId)) {
305
+ if (!ent.notified) {
306
+ ent.notified = true;
307
+ deps.log(`接管脱离 ${ws} 任务 ${key}:预热会话已在终端被 --resume `
308
+ + '打开(sessionId 仍存活),停止管理它,不再另拉预热;该会话关闭'
309
+ + '后巡检会重新拉起');
310
+ }
311
+ continue;
312
+ }
321
313
 
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
- }
314
+ // (c) window 回收:不要求任务此刻在窗口内——恰恰是执行完、已离开窗
315
+ // 口的会话要被收掉。三条件缺一不可:本任务在本代执行过(lastFiredAt
316
+ // 晚于 startedAt——区分「执行完的空闲」与「等触发的空闲」,后者是预
317
+ // 热缓存在干活)、本任务日志静默超时、距本任务下次需要还有两步余量
318
+ // (下轮重拉、再下轮盖印;needMs 在窗口内返回 now,天然挡住「回收后
319
+ // 立刻又需要」)。
320
+ if (ent && deps.trackedAlive(ent) && entryClaimed(ent)
321
+ && typeof t.lastFiredAt === 'number'
322
+ && t.lastFiredAt > (ent.startedAt ?? 0) * 1000
323
+ && sessionLogIdleSeconds(ent.log || logPath, cur)
324
+ > SESSION_RECYCLE_IDLE_SECONDS
325
+ && taskNeedMs(t, now) - now.getTime()
326
+ > SESSION_RECYCLE_MARGIN_SECONDS * 1000) {
327
+ await stopEntry(ent, 'recycle',
328
+ `回收 ${ws} 任务 ${key} pid=${ent.pid}:本任务已执行完毕且界面静默,`
329
+ + '结束会话,下次窗口重新预热');
330
+ delete bucket[key];
331
+ continue;
332
+ }
340
333
 
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
- }
334
+ // 此后只服务「死主 + 在预热窗口内」的任务。
335
+ if (!taskWanted(t, now, leadMs)) continue;
352
336
 
353
- if (!wantedNow) return;
337
+ // (d) 卡死退休:script 活着、超登记宽限仍未认亲——停在信任/授权提
338
+ // 问界面的垃圾进程。杀掉后继续走失败计数/冷却/重拉。
339
+ if (ent && deps.trackedAlive(ent) && !entryClaimed(ent)
340
+ && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS) {
341
+ await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
342
+ + '进程已存活仍未登记会话,已终止'
343
+ + `(排查:cron-up logs ${path.basename(ws)}--${key})`);
344
+ }
354
345
 
355
- const cooling = isCooling(ent, cur);
346
+ // (e) resume 接管结束:sid 已从登记消失。整代身份作废(script 已
347
+ // 死、认亲信息失效),fails 清零(用户使用期间的退出不算我们的启动
348
+ // 失败),作为全新一条落入下面的重拉。
349
+ if (ent && !deps.trackedAlive(ent)
350
+ && typeof ent.sessionId === 'string'
351
+ && !liveSids.has(ent.sessionId)) {
352
+ ent.pid = null;
353
+ delete ent.procStart;
354
+ delete ent.claudePid;
355
+ delete ent.sessionId;
356
+ delete ent.claudeProcStart;
357
+ delete ent.notified;
358
+ ent.fails = 0;
359
+ }
356
360
 
357
- // 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
358
- // 应答的提问界面,永不登记——退休它并推进失败计数。宽限内的年轻会话
359
- // 直接返回;warmStuck 已含超宽限判定。
360
- if (ent !== null && mineAlive) {
361
- if (!warmStuck) return;
362
- await killStuckWarm(false);
363
- }
361
+ // 盖印:已认亲且任务归属还没盖到本代预热(活创建者已在 (a) 排
362
+ // 除)。当轮新 spawn 的会话没认亲,下轮才盖——lead 覆盖两轮巡检。
363
+ if (entryClaimed(ent)
364
+ && (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
365
+ const target = {
366
+ sessionId: ent.sessionId,
367
+ pid: ent.claudePid,
368
+ procStart: ent.claudeProcStart,
369
+ };
370
+ if (stamps.has(sig)) {
371
+ if (stamps.get(sig).sessionId !== ent.sessionId) {
372
+ stampConflicts.add(sig);
373
+ stamps.delete(sig);
374
+ }
375
+ } else if (!stampConflicts.has(sig)) {
376
+ stamps.set(sig, target);
377
+ }
378
+ }
364
379
 
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
- }
380
+ // warm 未到回收 / spawning 宽限内:本轮无事。
381
+ if (ent && deps.trackedAlive(ent)) continue;
377
382
 
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
- }
383
+ if (isCooling(ent, cur)) continue;
384
+
385
+ // 失败计数:冷却刚结束给一次干净缓刑;只有真拉起过(pid 非空)的条
386
+ // 目死亡才算一次新失败,当场没拉起的尝试已在写入时计过。
387
+ let fails;
388
+ if (ent?.cooldownUntil) {
389
+ fails = 0;
390
+ } else {
391
+ fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
392
+ }
393
+
394
+ if (fails >= FAIL_LIMIT) {
395
+ bucket[key] = {
396
+ ...ent,
397
+ ws,
398
+ taskId: key,
399
+ sig,
400
+ pid: null,
401
+ fails,
402
+ cooldownUntil: cur + COOLDOWN_SECONDS,
403
+ log: ent?.log ?? logPath,
404
+ };
405
+ note('cool', `冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
406
+ + `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
407
+ continue;
408
+ }
388
409
 
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] = {
410
+ // 孤儿认领:state 丢了但进程表里还有为本任务拉起的 script(卡在提问
411
+ // 界面就不会出现在登记里)。按任务专属日志路径精确相认,alive 复核
412
+ // 防把快照里刚被杀的 pid 认回来。
413
+ if (ent === null) {
414
+ if (scriptProcsRef.value === null) {
415
+ scriptProcsRef.value = deps.listScriptProcesses();
416
+ }
417
+ const adopted = scriptProcsRef.value.find(
418
+ ([procPid, procArgs]) =>
419
+ scriptProcLogPath(procArgs) === logPath && deps.alive(procPid));
420
+ if (adopted) {
421
+ const [procPid] = adopted;
422
+ bucket[key] = {
423
+ ws,
424
+ taskId: key,
425
+ sig,
400
426
  pid: procPid,
401
427
  startedAt: cur,
402
428
  procStart: deps.procStartedAt(procPid),
403
429
  fails: 0,
404
430
  log: logPath,
405
431
  };
406
- note('adopt', `接管 ${ws} pid=${procPid}:state 记录缺失,但进程表里仍有`
407
- + '本工作区的保活会话,重新纳入跟踪');
408
- return;
432
+ note('adopt', `接管 ${ws} 任务 ${key} pid=${procPid}:state 记录缺失,`
433
+ + '但进程表里仍有本该任务的预热会话,重新纳入跟踪');
434
+ continue;
409
435
  }
410
436
  }
411
- }
412
437
 
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,
438
+ // spawn:成败只认有没有拿到 pid。当场失败立即计入;本轮就顶满失败上
439
+ // 限的,直接进冷却(不必再空跑一轮)。
440
+ const spawned = deps.spawnSession(ws, key);
441
+ if (spawned?.pid == null) {
442
+ const newFails = fails + 1;
443
+ if (newFails >= FAIL_LIMIT) {
444
+ bucket[key] = {
445
+ ws, taskId: key, sig, pid: null, fails: newFails,
446
+ cooldownUntil: cur + COOLDOWN_SECONDS, log: logPath,
447
+ };
448
+ note('cool', `冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
449
+ + `${newFails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
450
+ continue;
451
+ }
452
+ bucket[key] = {
453
+ ws,
454
+ taskId: key,
455
+ sig,
456
+ pid: null,
457
+ startedAt: cur,
458
+ fails: newFails,
459
+ log: logPath,
460
+ };
461
+ note('fail', `启动失败 ${ws} 任务 ${key}:会话进程没有拉起`
462
+ + `(连续失败计数 ${newFails})`);
463
+ continue;
464
+ }
465
+ bucket[key] = {
466
+ ws,
467
+ taskId: key,
468
+ sig,
469
+ pid: spawned.pid,
421
470
  startedAt: cur,
422
- fails: newFails,
423
- log: sessionLogPath(ws),
471
+ procStart: spawned.procStart,
472
+ fails,
473
+ log: logPath,
424
474
  };
425
- note('fail', `启动失败 ${ws}:会话进程没有拉起(连续失败计数 ${newFails})`);
426
- return;
475
+ note('spawn', `已启动 ${ws} 任务 ${key} pid=${spawned.pid}`
476
+ + `(连续失败计数 ${fails})`);
427
477
  }
428
- state[ws] = {
429
- pid: spawned.pid,
430
- startedAt: cur,
431
- procStart: spawned.procStart,
432
- fails,
433
- log: sessionLogPath(ws),
434
- };
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})`);
478
+
479
+ // 第三步:任务文件维护(续期 + 改 id + 盖印)合并为一次带乐观锁的原子
480
+ // 写。撞车(Claude Code 刚写了 lastFiredAt/删了任务)整轮放弃写与
481
+ // reap,下轮基于新文件重算——已 spawn 的条目留下轮处理,不受影响。
482
+ let result;
483
+ try {
484
+ result = maintainWorkspace(taskfile, { reown: stamps });
485
+ } catch (e) {
486
+ if (e instanceof TaskFileChanged) {
487
+ note('warn', `维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
488
+ + '下轮再来');
489
+ return;
490
+ }
491
+ throw e;
492
+ }
493
+ if (result === null) return; // 与 readTasks 不一致的极端竞态:保守不动
494
+ if (result.renewed) {
495
+ deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
496
+ + '不再受 7 天过期限制');
497
+ }
498
+ if (result.minified) {
499
+ deps.log(`已改小 id ${ws}:${result.minified} 个周期任务的 id 改为小哈希`
500
+ + '值,投递延迟降到约 1 分钟内');
501
+ }
502
+ if (result.reowned) {
503
+ bump(stats, 'reown');
504
+ deps.log(`已过户 ${ws}:${result.reowned} 个窗口内任务的归属改到各自的`
505
+ + '预热会话,这些任务将只在对应会话触发');
506
+ }
507
+ // id 改小同轮把 state 条目跟着改键:不改的话下轮会把换 id 的任务误判
508
+ // 成「旧任务删除 + 新任务」,错杀刚拉起的会话再重拉一个。
509
+ for (const { from, to } of result.idChanges) {
510
+ if (bucket[from] && !bucket[to]) {
511
+ bucket[to] = { ...bucket[from], taskId: to };
512
+ delete bucket[from];
513
+ }
514
+ }
515
+
516
+ // 第四步:任务消失的条目回收。第零步已做过签名治愈;这里重建当前键索
517
+ // 引——注意内存里的 tasks 是维护前的快照,minify 改的是重新读盘的
518
+ // doc,所以键必须经 idChanges 修正,否则刚改到新键的条目会被旧 id 视
519
+ // 图误当成「签名可治愈的旧键」再改回去。剩下对不上的键就是真的被删
520
+ // 了。冷却中的条目也删——任务没了,冷却没有意义。
521
+ const renamed = new Map(result.idChanges.map((c) => [c.from, c.to]));
522
+ sigToKeys = new Map();
523
+ currentKeys = new Set();
524
+ for (const t of tasks) {
525
+ const k = renamed.get(taskKey(t)) ?? taskKey(t);
526
+ currentKeys.add(k);
527
+ const list = sigToKeys.get(taskSig(t)) ?? [];
528
+ list.push(k);
529
+ sigToKeys.set(taskSig(t), list);
530
+ }
531
+ for (const [key, ent] of Object.entries({ ...bucket })) {
532
+ if (currentKeys.has(key)) continue;
533
+ const candidates = ent.sig ? (sigToKeys.get(ent.sig) ?? []) : [];
534
+ if (candidates.length === 1 && !bucket[candidates[0]]) {
535
+ const to = candidates[0];
536
+ bucket[to] = { ...ent, taskId: to };
537
+ delete bucket[key];
538
+ continue;
539
+ }
540
+ if (deps.trackedAlive(ent)) {
541
+ await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
542
+ + '对应任务已删除,结束预热会话');
543
+ }
544
+ delete bucket[key];
545
+ }
546
+ }
547
+
548
+ // 升级轮:旧版目录级 state 的预热逐个结束(不迁移归属,新流程按任务重
549
+ // 拉);state.json 已丢的旧孤儿 script(日志是旧命名,不含任务键段)从
550
+ // 进程表兜底清扫。只在 loadLegacyEntries 非空(确在跨版本)时执行。
551
+ async function retireLegacy(legacy, stats) {
552
+ for (const ent of legacy) {
553
+ if (deps.trackedAlive(ent)) {
554
+ await deps.stopSession(ent);
555
+ bump(stats, 'recycle');
556
+ deps.log(`旧版迁移 ${ent.ws} pid=${ent.pid}:结束目录级预热会话,`
557
+ + '升级后按任务重新预热');
558
+ }
559
+ }
560
+ const handled = new Set(legacy.filter((e) => e.pid).map((e) => e.pid));
561
+ for (const [pid, procArgs] of deps.listScriptProcesses()) {
562
+ if (handled.has(pid)) continue;
563
+ const file = scriptProcLogPath(procArgs);
564
+ if (file === null) continue;
565
+ if (path.dirname(file) !== deps.paths.sessionLogDir) continue;
566
+ if (isTaskSessionLogName(path.basename(file))) continue; // 新一代命名
567
+ const ent = { pid, procStart: deps.procStartedAt(pid) };
568
+ if (!deps.trackedAlive(ent)) continue;
569
+ await deps.stopSession(ent);
570
+ bump(stats, 'recycle');
571
+ deps.log(`旧版迁移 pid=${pid}:结束目录级预热孤儿会话(日志 ${file}),`
572
+ + '升级后按任务重新预热');
440
573
  }
441
574
  }
442
575
 
@@ -448,10 +581,9 @@ export async function cmdRun(args) {
448
581
  return false;
449
582
  }
450
583
  // 包一层 stopSession 记下本轮主动结束的 pid:轮末凭它区分「我们回收的」
451
- // 与「会话自行退出/被外部杀掉的」,后者以前是无声丢失。同样包一层
452
- // procStartedAt:同一 pid 的 lstart 一轮内不会变,而轮初分区、各工作区
453
- // 的 trackedAlive、轮末复核与 kept 过滤都会查指纹,缓存后每个 pid 只
454
- // spawn 一次 ps。finally 一并还原。
584
+ // 与「会话自行退出/被外部杀掉的」。同样包一层 procStartedAt:同一 pid
585
+ // 的 lstart 一轮内不会变,缓存后每个 pid 只 spawn 一次 ps。finally 还
586
+ // 原。
455
587
  const prevStopSession = deps.stopSession;
456
588
  const stoppedPids = new Set();
457
589
  deps.stopSession = async (ent) => {
@@ -470,174 +602,126 @@ export async function cmdRun(args) {
470
602
  deps.rotatePatrolLogs();
471
603
  const cfg = loadConfig(args?.config ?? deps.paths.configPath);
472
604
  const now = new Date();
473
- const leadMs = cfg.leadSeconds * 1000;
605
+ const leadMs = LEAD_SECONDS * 1000;
474
606
  const state = loadState();
607
+ const legacy = loadLegacyEntries();
475
608
  const cur = Math.floor(Date.now() / 1000);
476
609
 
477
610
  // 轮首心跳:没有这两行,launchd 拉不起巡检(启动脚本失效、node 缺失)
478
611
  // 时日志会一片寂静,无法和「平安无事」区分。上轮心跳过老说明中间漏
479
612
  // 轮——休眠期间错过的 StartInterval,launchd 醒来只补跑一轮。
480
613
  const hb = loadHeartbeat();
481
- if (heartbeatStale(hb, cfg.intervalSeconds, cur)) {
614
+ if (heartbeatStale(hb, INTERVAL_SECONDS, cur)) {
482
615
  bump(stats, 'warn');
483
616
  deps.log(`距上次巡检已 ${elapsedZh(cur - hb.ranAt)},超过 `
484
- + `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${cfg.intervalSeconds} 秒),`
617
+ + `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${INTERVAL_SECONDS} 秒),`
485
618
  + '期间 launchd 可能漏轮(常见于电脑休眠)');
486
619
  }
487
- deps.log(`巡检开始:${cfg.sessionRetain} 模式,扫描根目录 `
488
- + `${cfg.roots.length} 个`);
620
+ deps.log(`巡检开始:扫描根目录 ${cfg.roots.length} 个`);
489
621
  if (cfg.roots.length === 0) {
490
622
  bump(stats, 'warn');
491
623
  deps.log('警告:扫描目录 roots 为空,巡检不会发现任何工作区;'
492
- + '请检查配置文件或用 install --roots 指定');
624
+ + '请检查配置文件或用 cron-up install --roots 指定');
493
625
  }
494
626
 
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 });
627
+ // 登记每轮只读一次(含祖先链快照):认亲与活创建者判定都靠它。
628
+ const [consumers, registryAlert, details] = await deps.scanSessions();
518
629
  if (registryAlert) {
519
630
  bump(stats, 'warn');
520
631
  deps.log(`告警:${registryAlert}`);
521
632
  }
522
633
 
523
- // stampOwner 的结论依赖任务内容(活创建者判定要看窗口任务的
524
- // createdBySessionId),所以在下面 discover 循环里、读到任务后逐工
525
- // 作区现算;这里只留全轮常量。
526
- const recycleMarginSec = SESSION_FIRE_MARGIN_SECONDS
527
- + cfg.intervalSeconds;
634
+ // 旧版收尾必须先于一切 spawn 决策(state 轮末被新形状整体覆盖)。
635
+ if (legacy.length > 0) await retireLegacy(legacy, stats);
528
636
 
529
- // 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
530
- // 日志时间线就是「退出 → 重拉」,而不是反过来。
531
- for (const [ws, old] of prevDead) {
637
+ // 轮初把上轮末仍相信活着的条目分成三组:活、resume 脱离(script 已
638
+ // 死但 sid 还在,不报退出)、真死(立即报)。带 deadSince 的旧报条
639
+ // 目跳过。不能只收此刻 kill -0 成功的——两轮之间崩溃正是最典型的被
640
+ // 动死亡。
641
+ const prevDead = [];
642
+ const prevAlive = [];
643
+ const sidInDetails = (ws, sid) =>
644
+ (details.get(ws) ?? []).some((d) => d.sessionId === sid);
645
+ for (const [ws, bucket] of Object.entries(state)) {
646
+ for (const [taskId, ent] of Object.entries(bucket)) {
647
+ if (!ent.pid || ent.deadSince) continue;
648
+ const copy = { ...ent };
649
+ if (deps.trackedAlive(copy)) {
650
+ prevAlive.push([ws, taskId, copy]);
651
+ } else if (typeof ent.sessionId === 'string'
652
+ && sidInDetails(ws, ent.sessionId)) {
653
+ // detached:静默。
654
+ } else {
655
+ prevDead.push([ws, taskId, copy]);
656
+ }
657
+ }
658
+ }
659
+ const exitLine = (ws, taskId, old) => `会话退出 ${ws} 任务 ${taskId} `
660
+ + `pid=${old.pid}:非巡检主动结束(已存活 `
661
+ + `${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或被外部`
662
+ + '终止;若反复出现请用 cron-up logs '
663
+ + `${path.basename(ws)}--${taskId} 排查启动过程`;
664
+ for (const [ws, taskId, old] of prevDead) {
532
665
  bump(stats, 'exit');
533
- deps.log(exitLine(ws, old));
666
+ deps.log(exitLine(ws, taskId, old));
534
667
  }
535
668
 
536
669
  let found = 0;
537
- for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
670
+ const scriptProcsRef = { value: null };
671
+ for (const [ws, taskfile] of deps.discover(cfg.roots)) {
538
672
  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
- }
673
+ if (!state[ws]) state[ws] = {};
674
+ // 一个工作区读不动/写不动不能中断整轮:discover 是生成器,抛了会
675
+ // 跳过后面所有工作区。
604
676
  try {
605
677
  await patrolWorkspace(
606
- ws, state, now, leadMs, cur, consumers, null,
607
- cfg.sessionRetain, stats, stampArg);
678
+ ws, state[ws], taskfile, now, leadMs, cur, details,
679
+ scriptProcsRef, stats);
608
680
  } catch (e) {
609
681
  bump(stats, 'error');
610
682
  deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
611
683
  }
684
+ if (Object.keys(state[ws]).length === 0) delete state[ws];
612
685
  }
613
686
 
614
- // 轮中死亡:轮初还活着、轮末复核没气、又不是本轮主动 stop 的。指纹复
615
- // 核由 trackedAlive 完成(带 procStart 指纹,pid 回收不会误报)。主动
616
- // 回收/换代/卡死有各自的日志,这里只补轮中自行消亡那一类;轮间死亡刚
617
- // 才已在循环前报过。报过的死条目随后由 kept 过滤打上 deadSince,后续
618
- // 轮次不会重复报。
619
- for (const [ws, old] of prevAlive) {
687
+ // 轮中死亡复核:轮初活、轮末没气、又不是本轮主动 stop 的。轮中刚被
688
+ // --resume 接管(sid 换 pid 存活)的不报。
689
+ for (const [ws, taskId, old] of prevAlive) {
620
690
  if (stoppedPids.has(old.pid)) continue;
621
691
  if (deps.trackedAlive(old)) continue;
692
+ if (typeof old.sessionId === 'string'
693
+ && deps.sessionAliveNow(old.sessionId, ws)) continue;
622
694
  bump(stats, 'exit');
623
- deps.log(exitLine(ws, old));
695
+ deps.log(exitLine(ws, taskId, old));
624
696
  }
625
697
 
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;
698
+ // kept 过滤(嵌套):活会话、resume 脱离(sid 仍在登记)、冷却中、
699
+ // 带失败计数的死条目(计数跨任务窗口累积,48 小时 TTL 老化)保留;
700
+ // 其余丢掉。首次发现死亡打 deadSince。
701
+ const kept = {};
702
+ for (const [ws, bucket] of Object.entries(state)) {
703
+ const out = {};
704
+ for (const [taskId, ent] of Object.entries(bucket)) {
705
+ if (deps.trackedAlive(ent)) {
706
+ out[taskId] = ent;
707
+ continue;
708
+ }
709
+ if (typeof ent.sessionId === 'string'
710
+ && sidInDetails(ws, ent.sessionId)) {
711
+ out[taskId] = ent;
712
+ continue;
713
+ }
714
+ if (isCooling(ent, cur)) {
715
+ out[taskId] = ent;
716
+ continue;
717
+ }
634
718
  if ((ent.fails ?? 0) > 0) {
635
719
  ent.deadSince ??= cur;
636
- return cur - ent.deadSince < FAIL_TTL_SECONDS;
720
+ if (cur - ent.deadSince < FAIL_TTL_SECONDS) out[taskId] = ent;
637
721
  }
638
- return false;
639
- }),
640
- );
722
+ }
723
+ if (Object.keys(out).length > 0) kept[ws] = out;
724
+ }
641
725
  saveState(kept);
642
726
 
643
727
  // 轮末汇总:一行交代这轮看见了什么、动了什么、花了多久,随后更新心跳。
@@ -653,8 +737,7 @@ export async function cmdRun(args) {
653
737
  saveHeartbeat({
654
738
  ranAt: cur,
655
739
  version: packageVersion(),
656
- intervalSeconds: cfg.intervalSeconds,
657
- sessionRetain: cfg.sessionRetain,
740
+ intervalSeconds: INTERVAL_SECONDS,
658
741
  durationMs,
659
742
  events: stats,
660
743
  });
@@ -666,73 +749,6 @@ export async function cmdRun(args) {
666
749
  }
667
750
  }
668
751
 
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
752
  Object.assign(deps, {
737
753
  log,
738
754
  runPatrol: cmdRun,