cron-up 26.10.31 → 26.10.33

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,18 +1,23 @@
1
- // 一轮巡检:发现工作区 → 自动维护任务文件(续期/改 id/盖印)→ 保证每个
1
+ // 一轮巡检:发现工作区 → 自动维护任务文件(续期/盖印)→ 保证每个
2
2
  // 「创建者会话已死且处于预热窗口」的任务都有一个只属于它的健康会话。
3
- // 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。状态机覆盖:认亲、
4
- // 卡死退休、失败冷却、执行完回收、resume 接管脱离、孤儿认领、任务消失回
5
- // 收、旧版目录级 state 迁移。
3
+ // 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。本轮新拉起的会话
4
+ // 在所有工作区决策完后轮内等待登记(至多 WARMUP_WAIT_MS),认亲同一轮
5
+ // 补盖归属印;等不到的留给跨轮宽限,绝不当轮判死。状态机还覆盖:卡死
6
+ // 退休、失败冷却、执行完回收、resume 接管脱离、孤儿认领、任务消失回收、
7
+ // 旧版目录级 state 迁移。
6
8
 
7
9
  import fs from 'node:fs';
8
10
  import path from 'node:path';
11
+ import { randomUUID } from 'node:crypto';
9
12
 
10
- import { deps, packageVersion } from './internals.mjs';
13
+ import { deps, packageVersion, isPlainObject } from './internals.mjs';
11
14
  import {
12
15
  FAIL_LIMIT,
13
16
  COOLDOWN_SECONDS,
14
17
  FAIL_TTL_SECONDS,
15
18
  WARMUP_GRACE_SECONDS,
19
+ WARMUP_WAIT_MS,
20
+ WARMUP_POLL_MS,
16
21
  SESSION_RECYCLE_IDLE_SECONDS,
17
22
  SESSION_RECYCLE_MARGIN_SECONDS,
18
23
  PATROL_LOG_ROTATE_BYTES,
@@ -29,15 +34,15 @@ import {
29
34
  saveHeartbeat,
30
35
  heartbeatStale,
31
36
  isCooling,
37
+ parsePid,
32
38
  } from './config.mjs';
33
39
  import {
34
40
  maintainWorkspace,
35
41
  TaskFileChanged,
36
42
  taskFileExists,
37
- taskKey,
38
- taskSig,
43
+ groupTasksByKey,
39
44
  } from './tasks.mjs';
40
- import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
45
+ import { taskWanted, parseCronOrNone, wantedNow } from './cron.mjs';
41
46
  import {
42
47
  sessionLogPath,
43
48
  sessionLogIdleSeconds,
@@ -75,10 +80,11 @@ export function log(msg) {
75
80
  }
76
81
 
77
82
  // ---- 巡检互斥锁 ----
78
- // flock 的替代:O_EXCL 创建 + 持锁 pid。内核不会在持有者死亡时自动释放
79
- // 这种锁,所以抢锁失败要读 pid 判活,死锁/半成品文件按 stale 清理后有
80
- // 限重试。对 300 秒一轮的单机 launchd,残余的 pid 复用窗口最坏只跳过一
81
- // 轮,可接受。
83
+ // flock 的替代:O_EXCL 创建 + 完整 JSON 锁记录。token 防止释放/清理时误删
84
+ // 已被别人替换的文件,pid + 进程启动指纹区分 pid 复用。创建后写记录有极短
85
+ // 空窗,所以无效/空文件要等初始化宽限过后才能按 stale 清理。
86
+
87
+ const RUN_LOCK_INIT_GRACE_MS = 10_000;
82
88
 
83
89
  function tryCreateLock(lockPath) {
84
90
  try {
@@ -89,59 +95,137 @@ function tryCreateLock(lockPath) {
89
95
  }
90
96
  }
91
97
 
92
- function readLockPid(lockPath) {
93
- let text;
98
+ // 文本与 stat 一起作为删除前的比对快照:除了内容,inode/mtime 也能识别
99
+ // 「删掉旧锁后又恰好建了同样内容的新锁」。
100
+ function readLockSnapshot(lockPath) {
101
+ try {
102
+ return {
103
+ text: fs.readFileSync(lockPath, 'utf-8'),
104
+ stat: fs.statSync(lockPath, { bigint: true }),
105
+ };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function parseLockRecord(snapshot) {
112
+ let record;
94
113
  try {
95
- text = fs.readFileSync(lockPath, 'utf-8').trim();
114
+ record = JSON.parse(snapshot.text);
96
115
  } catch {
97
116
  return null;
98
117
  }
99
- return /^\d+$/.test(text) ? parseInt(text, 10) : null;
118
+ if (!isPlainObject(record)
119
+ || !Number.isSafeInteger(record.pid) || record.pid <= 0
120
+ || typeof record.token !== 'string' || record.token === '') {
121
+ return null;
122
+ }
123
+ return record;
124
+ }
125
+
126
+ function sameLockSnapshot(a, b) {
127
+ return a.text === b.text
128
+ && a.stat.ino === b.stat.ino
129
+ && a.stat.mtimeNs === b.stat.mtimeNs
130
+ && a.stat.size === b.stat.size;
131
+ }
132
+
133
+ // 只能删除读取时看到的那个文件。比对失败说明别人释放/重建过,宁可本轮不
134
+ // 抢锁,也绝不能把新持有者的锁 unlink 掉。
135
+ function removeLockSnapshot(lockPath, snapshot) {
136
+ const latest = readLockSnapshot(lockPath);
137
+ if (latest === null || !sameLockSnapshot(snapshot, latest)) return false;
138
+ try {
139
+ fs.unlinkSync(lockPath);
140
+ return true;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ function staleLock(snapshot) {
147
+ const record = parseLockRecord(snapshot);
148
+ if (record === null) {
149
+ // 旧版锁只有十进制 pid。升级交错时它可能仍属于活巡检;没有指纹可核,
150
+ // 因此活着就保守保留,明确死亡才允许清理。
151
+ const legacyPid = parsePid(snapshot.text);
152
+ if (legacyPid !== null) {
153
+ try {
154
+ return !deps.alive(legacyPid);
155
+ } catch {
156
+ return false;
157
+ }
158
+ }
159
+ // 包含 O_EXCL 成功后、JSON 写完前的空文件。mtime 新于宽限的一律保留。
160
+ return Date.now() - Number(snapshot.stat.mtimeMs) >= RUN_LOCK_INIT_GRACE_MS;
161
+ }
162
+ let living;
163
+ try {
164
+ living = deps.alive(record.pid);
165
+ } catch {
166
+ return false; // 活判读不出时保守保留。
167
+ }
168
+ if (!living) return true;
169
+ // 活 pid 但缺记录指纹,或当前指纹读不出:都无法排除锁主仍在,不能删。
170
+ if (typeof record.procStart !== 'string' || record.procStart === '') return false;
171
+ let currentStart;
172
+ try {
173
+ currentStart = deps.procStartedAt(record.pid);
174
+ } catch {
175
+ return false;
176
+ }
177
+ if (!currentStart) return false;
178
+ return currentStart !== record.procStart;
100
179
  }
101
180
 
102
181
  export function acquireRunLock() {
103
182
  fs.mkdirSync(deps.paths.dataDir, { recursive: true });
104
183
  const lockPath = path.join(deps.paths.dataDir, 'run.lock');
184
+ let procStart;
185
+ try {
186
+ procStart = deps.procStartedAt(process.pid);
187
+ } catch (e) {
188
+ throw new Error('读取当前进程启动指纹失败,不能安全创建巡检锁:'
189
+ + `${e?.message ?? e}`);
190
+ }
191
+ // 无指纹锁一旦被崩溃留下,pid 复用时永远无法安全清理;宁可本轮响亮
192
+ // 失败,也不制造这种永久自锁。
193
+ if (!procStart) {
194
+ throw new Error('未读到当前进程启动指纹,不能安全创建巡检锁;'
195
+ + '请检查 /bin/ps 后重试');
196
+ }
197
+ const token = randomUUID();
198
+ const text = `${JSON.stringify({
199
+ pid: process.pid,
200
+ procStart,
201
+ token,
202
+ })}\n`;
105
203
  for (let attempt = 0; attempt < 3; attempt++) {
106
204
  const fd = tryCreateLock(lockPath);
107
205
  if (fd !== null) {
108
206
  try {
109
- fs.writeFileSync(fd, `${process.pid}\n`);
207
+ fs.writeFileSync(fd, text);
110
208
  fs.fsyncSync(fd);
111
209
  } finally {
112
210
  fs.closeSync(fd);
113
211
  }
114
- return { lockPath };
115
- }
116
- const holder = readLockPid(lockPath);
117
- if (holder !== null) {
118
- let live = false;
119
- try {
120
- process.kill(holder, 0);
121
- live = true;
122
- } catch (e) {
123
- // EPERM:进程存在但不是我们的——当作活锁,不删别人的锁。
124
- live = e.code === 'EPERM';
125
- }
126
- if (live) return null;
127
- }
128
- // 持有者已死 / 空文件 / pid 损坏:当作 stale 清掉重试。
129
- try {
130
- fs.unlinkSync(lockPath);
131
- } catch {
132
- return null; // 别人抢先重建了锁
212
+ return { lockPath, token };
133
213
  }
214
+ const snapshot = readLockSnapshot(lockPath);
215
+ if (snapshot === null) continue; // 持有者恰好释放,直接重试创建。
216
+ if (!staleLock(snapshot)) return null;
217
+ if (!removeLockSnapshot(lockPath, snapshot)) return null;
134
218
  }
135
219
  return null;
136
220
  }
137
221
 
138
- function releaseLock(handle) {
139
- if (!handle?.lockPath) return;
140
- try {
141
- fs.unlinkSync(handle.lockPath);
142
- } catch {
143
- // 锁文件已不在即达成目的。
144
- }
222
+ export function releaseRunLock(handle) {
223
+ if (!handle?.lockPath || typeof handle.token !== 'string') return;
224
+ const snapshot = readLockSnapshot(handle.lockPath);
225
+ if (snapshot === null) return;
226
+ const record = parseLockRecord(snapshot);
227
+ if (record?.token !== handle.token) return;
228
+ removeLockSnapshot(handle.lockPath, snapshot);
145
229
  }
146
230
 
147
231
  // ---- 日志轮转 ----
@@ -164,13 +248,14 @@ export function rotatePatrolLogs() {
164
248
  }
165
249
 
166
250
  // 该任务下次「需要会话在场」的时刻(ms):现在已在窗口(落点尾窗、一次
167
- // 性错过待补)视为立刻需要;窗口外取 cron 裸落点(刻意不取抖动后投递时
168
- // 刻:落点更早,按落点算余量更小、回收更保守,是安全方向)。cron 无效
169
- // 返回 Infinity,永不回收。
251
+ // 性错过待补)视为立刻需要;窗口外取 cron 裸落点(周期任务投递晚于落
252
+ // 点,按落点算余量更小、回收更保守;一次性网格落点反而提前至多 90 秒投
253
+ // 递,这段提前量由回收余量 SESSION_FIRE_MARGIN_SECONDS 罩住)。cron
254
+ // 无效返回 Infinity,永不回收。
170
255
  function taskNeedMs(task, now) {
171
256
  const cron = parseCronOrNone(task?.cron);
172
257
  if (cron === null) return Infinity;
173
- if (wanted(cron, task, now, 0)) return now.getTime();
258
+ if (wantedNow(cron, task, now)) return now.getTime();
174
259
  return cron.nextAfter(now)?.getTime() ?? Infinity;
175
260
  }
176
261
 
@@ -179,12 +264,53 @@ function entryClaimed(ent) {
179
264
  return Boolean(ent?.sessionId && ent.claudeProcStart);
180
265
  }
181
266
 
182
- // 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/改
183
- // id/盖印维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共
184
- // 享的进程表快照容器({value: 数组|null}),只在孤儿认领时拍一次。
267
+ // 沿登记快照把一个活 script pid 认成登记里的 claude 孙进程:认到则回填
268
+ // 身份、清零失败计数并返回登记详情,否则返回 null。轮初认亲与轮内等待
269
+ // 认亲共用这唯一一份回填。
270
+ function claimEntry(ent, details, ws) {
271
+ const d = deps.claimWarm(details, ws, ent.pid);
272
+ if (!d) return null;
273
+ ent.fails = 0;
274
+ if (d.sessionId && d.procStart) {
275
+ ent.claudePid = d.pid;
276
+ ent.sessionId = d.sessionId;
277
+ ent.claudeProcStart = d.procStart;
278
+ }
279
+ return d;
280
+ }
281
+
282
+ // 盖印目标三字段(tasks.mjs maintainWorkspace reown 映射的值形状)。
283
+ const reownTarget = (ent) => ({
284
+ sessionId: ent.sessionId,
285
+ pid: ent.claudePid,
286
+ procStart: ent.claudeProcStart,
287
+ });
288
+
289
+ // 过户后的统一计数与日志(巡逻第三步与等待期补盖共用)。
290
+ function noteReowned(ws, count, stats) {
291
+ bump(stats, 'reown');
292
+ deps.log(`已过户 ${ws}:${count} 个窗口内任务的归属改到各自的`
293
+ + '预热会话,这些任务将只在对应会话触发');
294
+ }
295
+
296
+ // 失败顶限时的统一冷却迁移:置冷却截止、计数、日志。cooldownUntil 之外
297
+ // 的条目形状由各调用点按自身语境构造。
298
+ function enterCooldown(ent, ws, key, fails, cur, stats) {
299
+ ent.cooldownUntil = cur + COOLDOWN_SECONDS;
300
+ bump(stats, 'cool');
301
+ deps.log(`冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
302
+ + `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
303
+ }
304
+
305
+ // 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/盖印
306
+ // 维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共享的进程表
307
+ // 快照容器({value: 数组|null}),只在孤儿认领时拍一次。
308
+ // 返回 pending:本轮新纳入跟踪、尚未认亲的活 script(新 spawn 与孤儿认
309
+ // 领)的 {ws,key,pid} 列表,cmdRun 据此轮内等待认亲并补盖印。
185
310
  export async function patrolWorkspace(
186
311
  ws, bucket, taskfile, now, leadMs, cur, details, scriptProcsRef, stats,
187
312
  ) {
313
+ const pending = [];
188
314
  const tasks = deps.readTasks(ws);
189
315
  const note = (kind, msg) => {
190
316
  bump(stats, kind);
@@ -200,12 +326,20 @@ export async function patrolWorkspace(
200
326
  note('warn', `任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
201
327
  + `其中 ${TASK_REL} 里的定时任务都不会执行`);
202
328
  }
203
- return;
329
+ return [];
204
330
  }
205
331
 
206
- const stopEntry = async (ent, kind, msg) => {
207
- await deps.stopSession(ent);
332
+ // false 是 stopSession 的「活进程未能安全停止」:不能记回收、删 state
333
+ // 或继续重拉。生产与测试桩都返回严格布尔值。
334
+ const stopEntry = async (ent, key, kind, msg) => {
335
+ const stopped = await deps.stopSession(ent);
336
+ if (!stopped) {
337
+ note('warn', `无法安全停止 ${ws} 任务 ${key} pid=${ent.pid}:`
338
+ + '记录的进程仍存活;保留跟踪,本轮不删除条目也不另拉会话');
339
+ return false;
340
+ }
208
341
  note(kind, msg);
342
+ return true;
209
343
  };
210
344
 
211
345
  if (tasks.length === 0) {
@@ -214,62 +348,39 @@ export async function patrolWorkspace(
214
348
  // 接管)的 stop 是 no-op,条目照删——sid 没了任务也没了,无需再管。
215
349
  for (const [key, ent] of Object.entries(bucket)) {
216
350
  if (deps.trackedAlive(ent)) {
217
- await stopEntry(ent, 'recycle',
351
+ const stopped = await stopEntry(ent, key, 'recycle',
218
352
  `回收 ${ws} 任务 ${key} pid=${ent.pid}:任务已全部清空,结束预热会话`);
353
+ if (!stopped) continue;
219
354
  }
220
355
  delete bucket[key];
221
356
  }
222
- return;
357
+ return [];
223
358
  }
224
359
 
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];
245
- }
360
+ // 与任务文件层共用一份按键分组视图。重复键对应多条任务时,state 与
361
+ // 会话都无法唯一对应到条目:跳过这些组并告警,maintainWorkspace 也只
362
+ // 处理单元素组。组的键集合同时就是后面的 currentKeys。
363
+ const taskGroups = groupTasksByKey(tasks);
364
+ for (const [key, group] of taskGroups) {
365
+ if (group.length < 2) continue;
366
+ note('warn', `任务键重复 ${ws}:${key} 对应 ${group.length} 条任务,`
367
+ + '无法区分各自会话,本轮跳过这些条目的预热与盖印');
246
368
  }
247
369
 
248
- // 第一步:认亲。spawn 只知道 script pid,登记里是 claude 孙进程;沿轮
249
- // 初快照的祖先链找到我们这代的登记详情,回填 sessionId/claudePid/
250
- // claudeProcStart。认到 = 最强健康证据,当场清零失败计数。认不到下轮
251
- // 再认,不阻断任何决策。
370
+ // 第一步:认亲。认到 = 最强健康证据,当场回填身份、清零失败计数。本
371
+ // 轮新 spawn 的不在快照里,认亲由 cmdRun 的轮内等待补做;其余条目认
372
+ // 不到下轮再认,不阻断任何决策。
252
373
  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;
261
- }
374
+ if (deps.trackedAlive(ent)) claimEntry(ent, details, ws);
262
375
  }
263
376
 
264
- // 第二步:逐任务决策。stamps 收本轮要盖的归属印(sig → 预热身份),
265
- // 第三步合并进同一次原子写。stampConflicts 记「同内容签名被多个预热
266
- // 声称」的病态任务(同毫秒连建的重复任务):一律不盖,交回调度锁原
267
- // 生规则,避免错盖后没拿到任务的预热永不满足回收条件而空转泄漏。
377
+ // 第二步:逐任务决策。stamps 收本轮要盖的归属印(taskKey → 预热身份),
378
+ // 第三步合并进同一次原子写。内容相同但 id 不同的任务各有自己的键;重复
379
+ // 键组直接跳过,不在循环内另建一套重复键索引。
268
380
  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);
381
+ for (const [key, group] of taskGroups) {
382
+ if (group.length !== 1) continue;
383
+ const t = group[0];
273
384
  let ent = bucket[key] ?? null;
274
385
  const logPath = sessionLogPath(ws, key);
275
386
  const ownerIsOurs = entryClaimed(ent)
@@ -284,14 +395,16 @@ export async function patrolWorkspace(
284
395
  if (ent && deps.trackedAlive(ent)
285
396
  && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS
286
397
  && !entryClaimed(ent)) {
287
- await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
398
+ const stopped = await stopEntry(ent, key, 'stuck',
399
+ `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
288
400
  + '进程已存活仍未登记会话,已终止;任务的创建者会话仍在场可承接,'
289
401
  + '不另拉预热');
290
- delete bucket[key];
402
+ if (stopped) delete bucket[key];
291
403
  } else if (ent && deps.trackedAlive(ent) && entryClaimed(ent)) {
292
- await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
404
+ const stopped = await stopEntry(ent, key, 'recycle',
405
+ `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
293
406
  + '任务创建者会话已在场,结束多余的预热会话');
294
- delete bucket[key];
407
+ if (stopped) delete bucket[key];
295
408
  }
296
409
  continue;
297
410
  }
@@ -314,9 +427,9 @@ export async function patrolWorkspace(
314
427
  // (c) window 回收:不要求任务此刻在窗口内——恰恰是执行完、已离开窗
315
428
  // 口的会话要被收掉。三条件缺一不可:本任务在本代执行过(lastFiredAt
316
429
  // 晚于 startedAt——区分「执行完的空闲」与「等触发的空闲」,后者是预
317
- // 热缓存在干活)、本任务日志静默超时、距本任务下次需要还有两步余量
318
- // (下轮重拉、再下轮盖印;needMs 在窗口内返回 now,天然挡住「回收后
319
- // 立刻又需要」)。
430
+ // 热缓存在干活)、本任务日志静默超时、距本任务下次需要还有一个巡检
431
+ // 间隔加余量(下轮发现并 spawn、当轮等待认亲盖印;needMs 在窗口内
432
+ // 返回 now,天然挡住「回收后立刻又需要」)。
320
433
  if (ent && deps.trackedAlive(ent) && entryClaimed(ent)
321
434
  && typeof t.lastFiredAt === 'number'
322
435
  && t.lastFiredAt > (ent.startedAt ?? 0) * 1000
@@ -324,10 +437,10 @@ export async function patrolWorkspace(
324
437
  > SESSION_RECYCLE_IDLE_SECONDS
325
438
  && taskNeedMs(t, now) - now.getTime()
326
439
  > SESSION_RECYCLE_MARGIN_SECONDS * 1000) {
327
- await stopEntry(ent, 'recycle',
440
+ const stopped = await stopEntry(ent, key, 'recycle',
328
441
  `回收 ${ws} 任务 ${key} pid=${ent.pid}:本任务已执行完毕且界面静默,`
329
442
  + '结束会话,下次窗口重新预热');
330
- delete bucket[key];
443
+ if (stopped) delete bucket[key];
331
444
  continue;
332
445
  }
333
446
 
@@ -338,9 +451,11 @@ export async function patrolWorkspace(
338
451
  // 问界面的垃圾进程。杀掉后继续走失败计数/冷却/重拉。
339
452
  if (ent && deps.trackedAlive(ent) && !entryClaimed(ent)
340
453
  && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS) {
341
- await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
454
+ const stopped = await stopEntry(ent, key, 'stuck',
455
+ `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
342
456
  + '进程已存活仍未登记会话,已终止'
343
457
  + `(排查:cron-up logs ${path.basename(ws)}--${key})`);
458
+ if (!stopped) continue;
344
459
  }
345
460
 
346
461
  // (e) resume 接管结束:sid 已从登记消失。整代身份作废(script 已
@@ -358,23 +473,12 @@ export async function patrolWorkspace(
358
473
  ent.fails = 0;
359
474
  }
360
475
 
361
- // 盖印:已认亲且任务归属还没盖到本代预热(活创建者已在 (a) 排
362
- // 除)。当轮新 spawn 的会话没认亲,下轮才盖——lead 覆盖两轮巡检。
476
+ // 盖印(跨轮老条目):已认亲且任务归属还没盖到本代预热(活创建者
477
+ // 已在 (a) 排除)。当轮新 spawn 的会话在第三步之后的轮内等待里认
478
+ // 亲,由 cmdRun 的 stampClaimed 补盖,不走这里。
363
479
  if (entryClaimed(ent)
364
480
  && (!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
- }
481
+ stamps.set(key, reownTarget(ent));
378
482
  }
379
483
 
380
484
  // warm 未到回收 / spawning 宽限内:本轮无事。
@@ -396,14 +500,11 @@ export async function patrolWorkspace(
396
500
  ...ent,
397
501
  ws,
398
502
  taskId: key,
399
- sig,
400
503
  pid: null,
401
504
  fails,
402
- cooldownUntil: cur + COOLDOWN_SECONDS,
403
505
  log: ent?.log ?? logPath,
404
506
  };
405
- note('cool', `冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
406
- + `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
507
+ enterCooldown(bucket[key], ws, key, fails, cur, stats);
407
508
  continue;
408
509
  }
409
510
 
@@ -422,7 +523,6 @@ export async function patrolWorkspace(
422
523
  bucket[key] = {
423
524
  ws,
424
525
  taskId: key,
425
- sig,
426
526
  pid: procPid,
427
527
  startedAt: cur,
428
528
  procStart: deps.procStartedAt(procPid),
@@ -431,6 +531,7 @@ export async function patrolWorkspace(
431
531
  };
432
532
  note('adopt', `接管 ${ws} 任务 ${key} pid=${procPid}:state 记录缺失,`
433
533
  + '但进程表里仍有本该任务的预热会话,重新纳入跟踪');
534
+ pending.push({ ws, key, pid: procPid });
434
535
  continue;
435
536
  }
436
537
  }
@@ -442,17 +543,14 @@ export async function patrolWorkspace(
442
543
  const newFails = fails + 1;
443
544
  if (newFails >= FAIL_LIMIT) {
444
545
  bucket[key] = {
445
- ws, taskId: key, sig, pid: null, fails: newFails,
446
- cooldownUntil: cur + COOLDOWN_SECONDS, log: logPath,
546
+ ws, taskId: key, pid: null, fails: newFails, log: logPath,
447
547
  };
448
- note('cool', `冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
449
- + `${newFails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
548
+ enterCooldown(bucket[key], ws, key, newFails, cur, stats);
450
549
  continue;
451
550
  }
452
551
  bucket[key] = {
453
552
  ws,
454
553
  taskId: key,
455
- sig,
456
554
  pid: null,
457
555
  startedAt: cur,
458
556
  fails: newFails,
@@ -465,7 +563,6 @@ export async function patrolWorkspace(
465
563
  bucket[key] = {
466
564
  ws,
467
565
  taskId: key,
468
- sig,
469
566
  pid: spawned.pid,
470
567
  startedAt: cur,
471
568
  procStart: spawned.procStart,
@@ -474,74 +571,124 @@ export async function patrolWorkspace(
474
571
  };
475
572
  note('spawn', `已启动 ${ws} 任务 ${key} pid=${spawned.pid}`
476
573
  + `(连续失败计数 ${fails})`);
574
+ pending.push({ ws, key, pid: spawned.pid });
477
575
  }
478
576
 
479
- // 第三步:任务文件维护(续期 + 改 id + 盖印)合并为一次带乐观锁的原子
480
- // 写。撞车(Claude Code 刚写了 lastFiredAt/删了任务)整轮放弃写与
481
- // reap,下轮基于新文件重算——已 spawn 的条目留下轮处理,不受影响。
577
+ // 第三步:任务文件维护(续期 + 盖印)合并为一次带 mtime 乐观检测的原子写。
578
+ // 撞车(Claude Code 刚写了 lastFiredAt/删了任务)放弃本轮老条目维护,但已
579
+ // spawn 的会话仍返回给 cmdRun 等待认亲,盖印收尾会再写一次。
482
580
  let result;
483
581
  try {
484
582
  result = maintainWorkspace(taskfile, { reown: stamps });
485
583
  } catch (e) {
486
584
  if (e instanceof TaskFileChanged) {
487
585
  note('warn', `维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
488
- + '下轮再来');
489
- return;
586
+ + '稍后重试');
587
+ return pending;
490
588
  }
491
589
  throw e;
492
590
  }
493
- if (result === null) return; // 与 readTasks 不一致的极端竞态:保守不动
591
+ // 与 readTasks 不一致的极端竞态:保守不动,新 spawn 也全留跨轮路径。
592
+ if (result === null) return [];
494
593
  if (result.renewed) {
495
594
  deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
496
595
  + '不再受 7 天过期限制');
497
596
  }
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];
597
+ if (result.reowned) noteReowned(ws, result.reowned, stats);
598
+
599
+ // 第四步:任务消失的条目回收。续期与盖印都不改任务键,分组视图的键集合
600
+ // 仍是准确的 currentKeys。冷却中的条目也删——任务没了,冷却没有意义;
601
+ // 停止失败则保留可跟踪条目,下轮继续尝试,绝不错删后遗留活会话。
602
+ for (const [key, ent] of Object.entries({ ...bucket })) {
603
+ if (taskGroups.has(key)) continue;
604
+ if (deps.trackedAlive(ent)) {
605
+ const stopped = await stopEntry(ent, key, 'recycle',
606
+ `回收 ${ws} 任务 ${key} pid=${ent.pid}:对应任务已删除,结束预热会话`);
607
+ if (!stopped) continue;
513
608
  }
609
+ delete bucket[key];
514
610
  }
611
+ return pending;
612
+ }
515
613
 
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);
614
+ // 等待拍数是墙钟帽的派生:假时钟(测试)下墙钟不走,拍数帽单独兜底。
615
+ const MAX_WARMUP_POLLS = WARMUP_WAIT_MS / WARMUP_POLL_MS;
616
+
617
+ // 轮内等待本轮新 spawn/认领的会话完成登记,认亲走与轮初同一个
618
+ // claimEntry。这是盖印加速,不是判死:等不到的活会话原样留下,判死只
619
+ // 走跨轮 WARMUP_GRACE 路径。先扫后睡——进入时各工作区决策已串行花掉数
620
+ // 秒,健康会话可能已登记;任一 pending 认亲或死亡都移出集合,全部移出
621
+ // 即提前结束。返回最新登记快照、按 ws 分组的认亲条目(供补盖印)与等
622
+ // 待期内死亡的 pending(供退出日志;其 bucket 条目已计失败)。
623
+ async function awaitWarmups(pending, state, cur, stats) {
624
+ const claimedByWs = new Map();
625
+ const died = [];
626
+ let details;
627
+ const deadline = Date.now() + WARMUP_WAIT_MS;
628
+ const recognize = () => {
629
+ const rest = [];
630
+ for (const p of pending) {
631
+ const ent = state[p.ws]?.[p.key];
632
+ if (!ent || ent.pid !== p.pid) continue;
633
+ if (!deps.trackedAlive(ent)) {
634
+ // 启动即崩的典型窗口:真拉起过却死掉,计一次失败并置空 pid,条
635
+ // 目靠 fails 走 48 小时 TTL 保留、下轮重拉;满三次当轮进冷却。
636
+ // 只打日志丢弃会让 fails 永远从零起,坏掉的环境每轮重拉却永不
637
+ // 冷却。
638
+ ent.fails = (ent.fails ?? 0) + 1;
639
+ ent.pid = null;
640
+ delete ent.procStart;
641
+ if (ent.fails >= FAIL_LIMIT) {
642
+ enterCooldown(ent, p.ws, p.key, ent.fails, cur, stats);
643
+ }
644
+ died.push(p);
645
+ continue;
646
+ }
647
+ if (claimEntry(ent, details, p.ws)) {
648
+ if (!claimedByWs.has(p.ws)) claimedByWs.set(p.ws, []);
649
+ claimedByWs.get(p.ws).push(ent);
650
+ } else {
651
+ rest.push(p);
652
+ }
653
+ }
654
+ pending = rest;
655
+ };
656
+ // 轮初快照拍于决策之前、spawn 尚未发生,认亲必然落空;进入先重扫一
657
+ // 拍再开始睡。之后睡一拍、扫一拍。
658
+ [, , details] = await deps.scanSessions();
659
+ recognize();
660
+ for (let polls = 0;
661
+ pending.length > 0 && polls < MAX_WARMUP_POLLS && Date.now() < deadline;
662
+ polls += 1) {
663
+ await deps.sleep(WARMUP_POLL_MS);
664
+ [, , details] = await deps.scanSessions();
665
+ recognize();
530
666
  }
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;
667
+ return { details, claimedByWs, died };
668
+ }
669
+
670
+ // 等待期认到亲的会话补盖归属印:claimedByWs 的每个 ent 已是 bucket 条
671
+ // 目,taskId 与认亲身份都在上面。无认亲的 ws 不调用、不写盘;撞 mtime 乐
672
+ // 观锁或极端竞态(result=null)跳过,下轮再来。
673
+ function stampClaimed(claimedByWs, taskfiles, stats) {
674
+ for (const [ws, ents] of claimedByWs) {
675
+ const stamps = new Map();
676
+ for (const ent of ents) {
677
+ if (entryClaimed(ent)) stamps.set(ent.taskId, reownTarget(ent));
539
678
  }
540
- if (deps.trackedAlive(ent)) {
541
- await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
542
- + '对应任务已删除,结束预热会话');
679
+ if (stamps.size === 0) continue;
680
+ try {
681
+ const result = maintainWorkspace(taskfiles.get(ws), { reown: stamps });
682
+ if (result?.reowned) noteReowned(ws, result.reowned, stats);
683
+ } catch (e) {
684
+ if (e instanceof TaskFileChanged) {
685
+ bump(stats, 'warn');
686
+ deps.log(`盖印暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
687
+ + '下轮再来');
688
+ continue;
689
+ }
690
+ throw e;
543
691
  }
544
- delete bucket[key];
545
692
  }
546
693
  }
547
694
 
@@ -551,10 +698,16 @@ export async function patrolWorkspace(
551
698
  async function retireLegacy(legacy, stats) {
552
699
  for (const ent of legacy) {
553
700
  if (deps.trackedAlive(ent)) {
554
- await deps.stopSession(ent);
555
- bump(stats, 'recycle');
556
- deps.log(`旧版迁移 ${ent.ws} pid=${ent.pid}:结束目录级预热会话,`
557
- + '升级后按任务重新预热');
701
+ const stopped = await deps.stopSession(ent);
702
+ if (!stopped) {
703
+ bump(stats, 'warn');
704
+ deps.log(`旧版迁移暂缓 ${ent.ws} pid=${ent.pid}:记录的进程仍存活,`
705
+ + '本次无法安全停止目录级预热会话;本次不记为已回收');
706
+ } else {
707
+ bump(stats, 'recycle');
708
+ deps.log(`旧版迁移 ${ent.ws} pid=${ent.pid}:结束目录级预热会话,`
709
+ + '升级后按任务重新预热');
710
+ }
558
711
  }
559
712
  }
560
713
  const handled = new Set(legacy.filter((e) => e.pid).map((e) => e.pid));
@@ -566,10 +719,16 @@ async function retireLegacy(legacy, stats) {
566
719
  if (isTaskSessionLogName(path.basename(file))) continue; // 新一代命名
567
720
  const ent = { pid, procStart: deps.procStartedAt(pid) };
568
721
  if (!deps.trackedAlive(ent)) continue;
569
- await deps.stopSession(ent);
570
- bump(stats, 'recycle');
571
- deps.log(`旧版迁移 pid=${pid}:结束目录级预热孤儿会话(日志 ${file}),`
572
- + '升级后按任务重新预热');
722
+ const stopped = await deps.stopSession(ent);
723
+ if (!stopped) {
724
+ bump(stats, 'warn');
725
+ deps.log(`旧版迁移暂缓 pid=${pid}:记录的进程仍存活,本次无法安全停止`
726
+ + `目录级预热孤儿会话(日志 ${file});本次不记为已回收`);
727
+ } else {
728
+ bump(stats, 'recycle');
729
+ deps.log(`旧版迁移 pid=${pid}:结束目录级预热孤儿会话(日志 ${file}),`
730
+ + '升级后按任务重新预热');
731
+ }
573
732
  }
574
733
  }
575
734
 
@@ -587,8 +746,9 @@ export async function cmdRun(args) {
587
746
  const prevStopSession = deps.stopSession;
588
747
  const stoppedPids = new Set();
589
748
  deps.stopSession = async (ent) => {
590
- if (ent?.pid) stoppedPids.add(ent.pid);
591
- return prevStopSession(ent);
749
+ const stopped = await prevStopSession(ent);
750
+ if (stopped && ent?.pid) stoppedPids.add(ent.pid);
751
+ return stopped;
592
752
  };
593
753
  const prevProcStartedAt = deps.procStartedAt;
594
754
  const lstartCache = new Map();
@@ -624,8 +784,10 @@ export async function cmdRun(args) {
624
784
  + '请检查配置文件或用 cron-up install --roots 指定');
625
785
  }
626
786
 
627
- // 登记每轮只读一次(含祖先链快照):认亲与活创建者判定都靠它。
628
- const [consumers, registryAlert, details] = await deps.scanSessions();
787
+ // 登记轮初读一次(含祖先链快照):认亲与活创建者判定都靠它。有新
788
+ // spawn 时等待期会重扫,details 随之刷新到最新快照(轮末 kept 判定
789
+ // 与消费者计数都用刷新后的版本)。
790
+ let [consumers, registryAlert, details] = await deps.scanSessions();
629
791
  if (registryAlert) {
630
792
  bump(stats, 'warn');
631
793
  deps.log(`告警:${registryAlert}`);
@@ -668,15 +830,18 @@ export async function cmdRun(args) {
668
830
 
669
831
  let found = 0;
670
832
  const scriptProcsRef = { value: null };
833
+ const pendingAll = [];
834
+ const taskfiles = new Map();
671
835
  for (const [ws, taskfile] of deps.discover(cfg.roots)) {
672
836
  found += 1;
673
837
  if (!state[ws]) state[ws] = {};
838
+ taskfiles.set(ws, taskfile);
674
839
  // 一个工作区读不动/写不动不能中断整轮:discover 是生成器,抛了会
675
840
  // 跳过后面所有工作区。
676
841
  try {
677
- await patrolWorkspace(
842
+ pendingAll.push(...await patrolWorkspace(
678
843
  ws, state[ws], taskfile, now, leadMs, cur, details,
679
- scriptProcsRef, stats);
844
+ scriptProcsRef, stats));
680
845
  } catch (e) {
681
846
  bump(stats, 'error');
682
847
  deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
@@ -684,6 +849,33 @@ export async function cmdRun(args) {
684
849
  if (Object.keys(state[ws]).length === 0) delete state[ws];
685
850
  }
686
851
 
852
+ // 本轮有新会话拉起才等待:绝大多数轮次 pending 为空,零额外 ps、零睡
853
+ // 眠。认亲后同一轮补盖归属印(否则要等下一轮,提前量就得兜两轮)。
854
+ // 等待只认亲、不判死;等不到的下轮按 WARMUP_GRACE 跨轮处理。
855
+ if (pendingAll.length > 0) {
856
+ try {
857
+ const { details: latest, claimedByWs, died } = await awaitWarmups(
858
+ pendingAll, state, cur, stats);
859
+ details = latest;
860
+ consumers = new Set(details.keys());
861
+ // 等待期内自然死亡的是本轮新 spawn 的会话,不在轮初 prevAlive 分
862
+ // 组里,单独补一条退出日志,否则启动即崩将完全静默。死亡条目
863
+ // fails≥1,轮末 kept 必保留,此刻仍在 bucket 里(pid 已置空,
864
+ // 用 pending 记的 pid 补回给日志)。
865
+ for (const d of died) {
866
+ bump(stats, 'exit');
867
+ deps.log(exitLine(d.ws, d.key,
868
+ { ...state[d.ws]?.[d.key], pid: d.pid }));
869
+ }
870
+ stampClaimed(claimedByWs, taskfiles, stats);
871
+ } catch (e) {
872
+ // 等待或补盖失败不能丢轮末落盘:最坏只是盖印晚一轮,会话进程已
873
+ // 在场,目录调度锁仍兜底。
874
+ bump(stats, 'error');
875
+ deps.log(`等待预热登记出错:${e.constructor.name}:${e.message}`);
876
+ }
877
+ }
878
+
687
879
  // 轮中死亡复核:轮初活、轮末没气、又不是本轮主动 stop 的。轮中刚被
688
880
  // --resume 接管(sid 换 pid 存活)的不报。
689
881
  for (const [ws, taskId, old] of prevAlive) {
@@ -745,7 +937,7 @@ export async function cmdRun(args) {
745
937
  } finally {
746
938
  deps.stopSession = prevStopSession;
747
939
  deps.procStartedAt = prevProcStartedAt;
748
- releaseLock(lock);
940
+ deps.releaseRunLock(lock);
749
941
  }
750
942
  }
751
943
 
@@ -754,4 +946,5 @@ Object.assign(deps, {
754
946
  runPatrol: cmdRun,
755
947
  rotatePatrolLogs,
756
948
  acquireRunLock,
949
+ releaseRunLock,
757
950
  });