cron-up 26.10.32 → 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,4 +1,4 @@
1
- // 一轮巡检:发现工作区 → 自动维护任务文件(续期/改 id/盖印)→ 保证每个
1
+ // 一轮巡检:发现工作区 → 自动维护任务文件(续期/盖印)→ 保证每个
2
2
  // 「创建者会话已死且处于预热窗口」的任务都有一个只属于它的健康会话。
3
3
  // 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。本轮新拉起的会话
4
4
  // 在所有工作区决策完后轮内等待登记(至多 WARMUP_WAIT_MS),认亲同一轮
@@ -8,8 +8,9 @@
8
8
 
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { randomUUID } from 'node:crypto';
11
12
 
12
- import { deps, packageVersion } from './internals.mjs';
13
+ import { deps, packageVersion, isPlainObject } from './internals.mjs';
13
14
  import {
14
15
  FAIL_LIMIT,
15
16
  COOLDOWN_SECONDS,
@@ -33,13 +34,13 @@ import {
33
34
  saveHeartbeat,
34
35
  heartbeatStale,
35
36
  isCooling,
37
+ parsePid,
36
38
  } from './config.mjs';
37
39
  import {
38
40
  maintainWorkspace,
39
41
  TaskFileChanged,
40
42
  taskFileExists,
41
- taskKey,
42
- taskSig,
43
+ groupTasksByKey,
43
44
  } from './tasks.mjs';
44
45
  import { taskWanted, parseCronOrNone, wantedNow } from './cron.mjs';
45
46
  import {
@@ -79,10 +80,11 @@ export function log(msg) {
79
80
  }
80
81
 
81
82
  // ---- 巡检互斥锁 ----
82
- // flock 的替代:O_EXCL 创建 + 持锁 pid。内核不会在持有者死亡时自动释放
83
- // 这种锁,所以抢锁失败要读 pid 判活,死锁/半成品文件按 stale 清理后有
84
- // 限重试。对 300 秒一轮的单机 launchd,残余的 pid 复用窗口最坏只跳过一
85
- // 轮,可接受。
83
+ // flock 的替代:O_EXCL 创建 + 完整 JSON 锁记录。token 防止释放/清理时误删
84
+ // 已被别人替换的文件,pid + 进程启动指纹区分 pid 复用。创建后写记录有极短
85
+ // 空窗,所以无效/空文件要等初始化宽限过后才能按 stale 清理。
86
+
87
+ const RUN_LOCK_INIT_GRACE_MS = 10_000;
86
88
 
87
89
  function tryCreateLock(lockPath) {
88
90
  try {
@@ -93,59 +95,137 @@ function tryCreateLock(lockPath) {
93
95
  }
94
96
  }
95
97
 
96
- function readLockPid(lockPath) {
97
- 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;
98
113
  try {
99
- text = fs.readFileSync(lockPath, 'utf-8').trim();
114
+ record = JSON.parse(snapshot.text);
100
115
  } catch {
101
116
  return null;
102
117
  }
103
- 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;
104
179
  }
105
180
 
106
181
  export function acquireRunLock() {
107
182
  fs.mkdirSync(deps.paths.dataDir, { recursive: true });
108
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`;
109
203
  for (let attempt = 0; attempt < 3; attempt++) {
110
204
  const fd = tryCreateLock(lockPath);
111
205
  if (fd !== null) {
112
206
  try {
113
- fs.writeFileSync(fd, `${process.pid}\n`);
207
+ fs.writeFileSync(fd, text);
114
208
  fs.fsyncSync(fd);
115
209
  } finally {
116
210
  fs.closeSync(fd);
117
211
  }
118
- return { lockPath };
119
- }
120
- const holder = readLockPid(lockPath);
121
- if (holder !== null) {
122
- let live = false;
123
- try {
124
- process.kill(holder, 0);
125
- live = true;
126
- } catch (e) {
127
- // EPERM:进程存在但不是我们的——当作活锁,不删别人的锁。
128
- live = e.code === 'EPERM';
129
- }
130
- if (live) return null;
131
- }
132
- // 持有者已死 / 空文件 / pid 损坏:当作 stale 清掉重试。
133
- try {
134
- fs.unlinkSync(lockPath);
135
- } catch {
136
- return null; // 别人抢先重建了锁
212
+ return { lockPath, token };
137
213
  }
214
+ const snapshot = readLockSnapshot(lockPath);
215
+ if (snapshot === null) continue; // 持有者恰好释放,直接重试创建。
216
+ if (!staleLock(snapshot)) return null;
217
+ if (!removeLockSnapshot(lockPath, snapshot)) return null;
138
218
  }
139
219
  return null;
140
220
  }
141
221
 
142
- function releaseLock(handle) {
143
- if (!handle?.lockPath) return;
144
- try {
145
- fs.unlinkSync(handle.lockPath);
146
- } catch {
147
- // 锁文件已不在即达成目的。
148
- }
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);
149
229
  }
150
230
 
151
231
  // ---- 日志轮转 ----
@@ -206,20 +286,6 @@ const reownTarget = (ent) => ({
206
286
  procStart: ent.claudeProcStart,
207
287
  });
208
288
 
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
- }
221
- }
222
-
223
289
  // 过户后的统一计数与日志(巡逻第三步与等待期补盖共用)。
224
290
  function noteReowned(ws, count, stats) {
225
291
  bump(stats, 'reown');
@@ -236,9 +302,9 @@ function enterCooldown(ent, ws, key, fails, cur, stats) {
236
302
  + `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
237
303
  }
238
304
 
239
- // 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/改
240
- // id/盖印维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共
241
- // 享的进程表快照容器({value: 数组|null}),只在孤儿认领时拍一次。
305
+ // 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/盖印
306
+ // 维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共享的进程表
307
+ // 快照容器({value: 数组|null}),只在孤儿认领时拍一次。
242
308
  // 返回 pending:本轮新纳入跟踪、尚未认亲的活 script(新 spawn 与孤儿认
243
309
  // 领)的 {ws,key,pid} 列表,cmdRun 据此轮内等待认亲并补盖印。
244
310
  export async function patrolWorkspace(
@@ -263,9 +329,17 @@ export async function patrolWorkspace(
263
329
  return [];
264
330
  }
265
331
 
266
- const stopEntry = async (ent, kind, msg) => {
267
- 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
+ }
268
341
  note(kind, msg);
342
+ return true;
269
343
  };
270
344
 
271
345
  if (tasks.length === 0) {
@@ -274,35 +348,23 @@ export async function patrolWorkspace(
274
348
  // 接管)的 stop 是 no-op,条目照删——sid 没了任务也没了,无需再管。
275
349
  for (const [key, ent] of Object.entries(bucket)) {
276
350
  if (deps.trackedAlive(ent)) {
277
- await stopEntry(ent, 'recycle',
351
+ const stopped = await stopEntry(ent, key, 'recycle',
278
352
  `回收 ${ws} 任务 ${key} pid=${ent.pid}:任务已全部清空,结束预热会话`);
353
+ if (!stopped) continue;
279
354
  }
280
355
  delete bucket[key];
281
356
  }
282
357
  return [];
283
358
  }
284
359
 
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];
305
- }
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
+ + '无法区分各自会话,本轮跳过这些条目的预热与盖印');
306
368
  }
307
369
 
308
370
  // 第一步:认亲。认到 = 最强健康证据,当场回填身份、清零失败计数。本
@@ -312,15 +374,13 @@ export async function patrolWorkspace(
312
374
  if (deps.trackedAlive(ent)) claimEntry(ent, details, ws);
313
375
  }
314
376
 
315
- // 第二步:逐任务决策。stamps 收本轮要盖的归属印(sig → 预热身份),
316
- // 第三步合并进同一次原子写。stampConflicts 记「同内容签名被多个预热
317
- // 声称」的病态任务(同毫秒连建的重复任务):一律不盖,交回调度锁原
318
- // 生规则,避免错盖后没拿到任务的预热永不满足回收条件而空转泄漏。
377
+ // 第二步:逐任务决策。stamps 收本轮要盖的归属印(taskKey → 预热身份),
378
+ // 第三步合并进同一次原子写。内容相同但 id 不同的任务各有自己的键;重复
379
+ // 键组直接跳过,不在循环内另建一套重复键索引。
319
380
  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);
381
+ for (const [key, group] of taskGroups) {
382
+ if (group.length !== 1) continue;
383
+ const t = group[0];
324
384
  let ent = bucket[key] ?? null;
325
385
  const logPath = sessionLogPath(ws, key);
326
386
  const ownerIsOurs = entryClaimed(ent)
@@ -335,14 +395,16 @@ export async function patrolWorkspace(
335
395
  if (ent && deps.trackedAlive(ent)
336
396
  && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS
337
397
  && !entryClaimed(ent)) {
338
- await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
398
+ const stopped = await stopEntry(ent, key, 'stuck',
399
+ `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
339
400
  + '进程已存活仍未登记会话,已终止;任务的创建者会话仍在场可承接,'
340
401
  + '不另拉预热');
341
- delete bucket[key];
402
+ if (stopped) delete bucket[key];
342
403
  } else if (ent && deps.trackedAlive(ent) && entryClaimed(ent)) {
343
- await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
404
+ const stopped = await stopEntry(ent, key, 'recycle',
405
+ `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
344
406
  + '任务创建者会话已在场,结束多余的预热会话');
345
- delete bucket[key];
407
+ if (stopped) delete bucket[key];
346
408
  }
347
409
  continue;
348
410
  }
@@ -375,10 +437,10 @@ export async function patrolWorkspace(
375
437
  > SESSION_RECYCLE_IDLE_SECONDS
376
438
  && taskNeedMs(t, now) - now.getTime()
377
439
  > SESSION_RECYCLE_MARGIN_SECONDS * 1000) {
378
- await stopEntry(ent, 'recycle',
440
+ const stopped = await stopEntry(ent, key, 'recycle',
379
441
  `回收 ${ws} 任务 ${key} pid=${ent.pid}:本任务已执行完毕且界面静默,`
380
442
  + '结束会话,下次窗口重新预热');
381
- delete bucket[key];
443
+ if (stopped) delete bucket[key];
382
444
  continue;
383
445
  }
384
446
 
@@ -389,9 +451,11 @@ export async function patrolWorkspace(
389
451
  // 问界面的垃圾进程。杀掉后继续走失败计数/冷却/重拉。
390
452
  if (ent && deps.trackedAlive(ent) && !entryClaimed(ent)
391
453
  && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS) {
392
- await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
454
+ const stopped = await stopEntry(ent, key, 'stuck',
455
+ `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
393
456
  + '进程已存活仍未登记会话,已终止'
394
457
  + `(排查:cron-up logs ${path.basename(ws)}--${key})`);
458
+ if (!stopped) continue;
395
459
  }
396
460
 
397
461
  // (e) resume 接管结束:sid 已从登记消失。整代身份作废(script 已
@@ -414,7 +478,7 @@ export async function patrolWorkspace(
414
478
  // 亲,由 cmdRun 的 stampClaimed 补盖,不走这里。
415
479
  if (entryClaimed(ent)
416
480
  && (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
417
- mergeStamp(stamps, stampConflicts, sig, reownTarget(ent));
481
+ stamps.set(key, reownTarget(ent));
418
482
  }
419
483
 
420
484
  // warm 未到回收 / spawning 宽限内:本轮无事。
@@ -436,7 +500,6 @@ export async function patrolWorkspace(
436
500
  ...ent,
437
501
  ws,
438
502
  taskId: key,
439
- sig,
440
503
  pid: null,
441
504
  fails,
442
505
  log: ent?.log ?? logPath,
@@ -460,7 +523,6 @@ export async function patrolWorkspace(
460
523
  bucket[key] = {
461
524
  ws,
462
525
  taskId: key,
463
- sig,
464
526
  pid: procPid,
465
527
  startedAt: cur,
466
528
  procStart: deps.procStartedAt(procPid),
@@ -481,7 +543,7 @@ export async function patrolWorkspace(
481
543
  const newFails = fails + 1;
482
544
  if (newFails >= FAIL_LIMIT) {
483
545
  bucket[key] = {
484
- ws, taskId: key, sig, pid: null, fails: newFails, log: logPath,
546
+ ws, taskId: key, pid: null, fails: newFails, log: logPath,
485
547
  };
486
548
  enterCooldown(bucket[key], ws, key, newFails, cur, stats);
487
549
  continue;
@@ -489,7 +551,6 @@ export async function patrolWorkspace(
489
551
  bucket[key] = {
490
552
  ws,
491
553
  taskId: key,
492
- sig,
493
554
  pid: null,
494
555
  startedAt: cur,
495
556
  fails: newFails,
@@ -502,7 +563,6 @@ export async function patrolWorkspace(
502
563
  bucket[key] = {
503
564
  ws,
504
565
  taskId: key,
505
- sig,
506
566
  pid: spawned.pid,
507
567
  startedAt: cur,
508
568
  procStart: spawned.procStart,
@@ -514,9 +574,9 @@ export async function patrolWorkspace(
514
574
  pending.push({ ws, key, pid: spawned.pid });
515
575
  }
516
576
 
517
- // 第三步:任务文件维护(续期 + 改 id + 盖印)合并为一次带乐观锁的原子
518
- // 写。撞车(Claude Code 刚写了 lastFiredAt/删了任务)放弃本轮老条目维
519
- // 护,但已 spawn 的会话仍返回给 cmdRun 等待认亲,盖印收尾会再写一次。
577
+ // 第三步:任务文件维护(续期 + 盖印)合并为一次带 mtime 乐观检测的原子写。
578
+ // 撞车(Claude Code 刚写了 lastFiredAt/删了任务)放弃本轮老条目维护,但已
579
+ // spawn 的会话仍返回给 cmdRun 等待认亲,盖印收尾会再写一次。
520
580
  let result;
521
581
  try {
522
582
  result = maintainWorkspace(taskfile, { reown: stamps });
@@ -534,53 +594,17 @@ export async function patrolWorkspace(
534
594
  deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
535
595
  + '不再受 7 天过期限制');
536
596
  }
537
- if (result.minified) {
538
- deps.log(`已改小 id ${ws}:${result.minified} 个周期任务的 id 改为小哈希`
539
- + '值,投递延迟降到约 1 分钟内');
540
- }
541
597
  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
598
 
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
- }
599
+ // 第四步:任务消失的条目回收。续期与盖印都不改任务键,分组视图的键集合
600
+ // 仍是准确的 currentKeys。冷却中的条目也删——任务没了,冷却没有意义;
601
+ // 停止失败则保留可跟踪条目,下轮继续尝试,绝不错删后遗留活会话。
572
602
  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
- }
603
+ if (taskGroups.has(key)) continue;
581
604
  if (deps.trackedAlive(ent)) {
582
- await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
583
- + '对应任务已删除,结束预热会话');
605
+ const stopped = await stopEntry(ent, key, 'recycle',
606
+ `回收 ${ws} 任务 ${key} pid=${ent.pid}:对应任务已删除,结束预热会话`);
607
+ if (!stopped) continue;
584
608
  }
585
609
  delete bucket[key];
586
610
  }
@@ -644,14 +668,13 @@ async function awaitWarmups(pending, state, cur, stats) {
644
668
  }
645
669
 
646
670
  // 等待期认到亲的会话补盖归属印:claimedByWs 的每个 ent 已是 bucket 条
647
- // 目,sig/认亲身份都在上面。无认亲的 ws 不调用、不写盘;撞 mtime 乐观
648
- // 锁或极端竞态(result=null)跳过,下轮再来。
671
+ // 目,taskId 与认亲身份都在上面。无认亲的 ws 不调用、不写盘;撞 mtime 乐
672
+ // 观锁或极端竞态(result=null)跳过,下轮再来。
649
673
  function stampClaimed(claimedByWs, taskfiles, stats) {
650
674
  for (const [ws, ents] of claimedByWs) {
651
675
  const stamps = new Map();
652
- const conflicts = new Set();
653
676
  for (const ent of ents) {
654
- if (entryClaimed(ent)) mergeStamp(stamps, conflicts, ent.sig, reownTarget(ent));
677
+ if (entryClaimed(ent)) stamps.set(ent.taskId, reownTarget(ent));
655
678
  }
656
679
  if (stamps.size === 0) continue;
657
680
  try {
@@ -675,10 +698,16 @@ function stampClaimed(claimedByWs, taskfiles, stats) {
675
698
  async function retireLegacy(legacy, stats) {
676
699
  for (const ent of legacy) {
677
700
  if (deps.trackedAlive(ent)) {
678
- await deps.stopSession(ent);
679
- bump(stats, 'recycle');
680
- deps.log(`旧版迁移 ${ent.ws} pid=${ent.pid}:结束目录级预热会话,`
681
- + '升级后按任务重新预热');
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
+ }
682
711
  }
683
712
  }
684
713
  const handled = new Set(legacy.filter((e) => e.pid).map((e) => e.pid));
@@ -690,10 +719,16 @@ async function retireLegacy(legacy, stats) {
690
719
  if (isTaskSessionLogName(path.basename(file))) continue; // 新一代命名
691
720
  const ent = { pid, procStart: deps.procStartedAt(pid) };
692
721
  if (!deps.trackedAlive(ent)) continue;
693
- await deps.stopSession(ent);
694
- bump(stats, 'recycle');
695
- deps.log(`旧版迁移 pid=${pid}:结束目录级预热孤儿会话(日志 ${file}),`
696
- + '升级后按任务重新预热');
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
+ }
697
732
  }
698
733
  }
699
734
 
@@ -711,8 +746,9 @@ export async function cmdRun(args) {
711
746
  const prevStopSession = deps.stopSession;
712
747
  const stoppedPids = new Set();
713
748
  deps.stopSession = async (ent) => {
714
- if (ent?.pid) stoppedPids.add(ent.pid);
715
- return prevStopSession(ent);
749
+ const stopped = await prevStopSession(ent);
750
+ if (stopped && ent?.pid) stoppedPids.add(ent.pid);
751
+ return stopped;
716
752
  };
717
753
  const prevProcStartedAt = deps.procStartedAt;
718
754
  const lstartCache = new Map();
@@ -901,7 +937,7 @@ export async function cmdRun(args) {
901
937
  } finally {
902
938
  deps.stopSession = prevStopSession;
903
939
  deps.procStartedAt = prevProcStartedAt;
904
- releaseLock(lock);
940
+ deps.releaseRunLock(lock);
905
941
  }
906
942
  }
907
943
 
@@ -910,4 +946,5 @@ Object.assign(deps, {
910
946
  runPatrol: cmdRun,
911
947
  rotatePatrolLogs,
912
948
  acquireRunLock,
949
+ releaseRunLock,
913
950
  });
package/src/registry.mjs CHANGED
@@ -62,18 +62,18 @@ export function processParentMap() {
62
62
  return map.size > 0 ? map : null;
63
63
  }
64
64
 
65
- // 沿父子表上溯(不含 pid 自身),直到 launchd(1;其父记为 0)。中途父 pid
66
- // 在快照里缺失(ps 退化/进程刚好退出)或成环时 chainComplete=false。
65
+ // 沿父子表上溯(不含 pid 自身),直到 launchd(1;其父记为 0)、父 pid 在
66
+ // 快照里缺失或成环。调用方只消费祖先列表;断链时保留已读到的部分即可。
67
67
  export function ancestorChain(pid, parentMap) {
68
68
  const ancestors = [];
69
69
  const seen = new Set([pid]);
70
70
  let cur = pid;
71
71
  for (;;) {
72
72
  const parent = parentMap?.get(cur);
73
- if (parent === undefined) return { ancestors, chainComplete: false };
73
+ if (parent === undefined) return ancestors;
74
74
  ancestors.push(parent);
75
- if (parent <= 1) return { ancestors, chainComplete: true }; // 0/1 即根
76
- if (seen.has(parent)) return { ancestors, chainComplete: false };
75
+ if (parent <= 1) return ancestors; // 0/1 即根
76
+ if (seen.has(parent)) return ancestors;
77
77
  seen.add(parent);
78
78
  cur = parent;
79
79
  }
@@ -159,9 +159,9 @@ async function readSessionFile(file) {
159
159
  }
160
160
 
161
161
  // 返回 [拥有活消费者会话的工作区集合, 告警字符串|null, 会话详情 Map]。
162
- // 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors,
163
- // chainComplete}>>。祖先链恒拍:快照失败给空链,相关预热本轮认不到亲、
164
- // 下轮自动重试;consumers 判定不受影响。
162
+ // 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors}>>。
163
+ // 祖先链恒拍:快照失败给空链,相关预热本轮认不到亲、下轮自动重试;
164
+ // consumers 判定不受影响。
165
165
  export async function scanSessions() {
166
166
  let files = [];
167
167
  try {
@@ -217,9 +217,7 @@ export async function scanSessions() {
217
217
  if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
218
218
  const ws = lexReal(cwd);
219
219
  consumers.add(ws);
220
- const chain = parentMap
221
- ? ancestorChain(pid, parentMap)
222
- : { ancestors: [], chainComplete: false };
220
+ const ancestors = parentMap ? ancestorChain(pid, parentMap) : [];
223
221
  const list = details.get(ws) ?? [];
224
222
  list.push({
225
223
  pid,
@@ -227,8 +225,7 @@ export async function scanSessions() {
227
225
  // procStart 是 ps lstart 原文(单位数日期含双空格),只许原样
228
226
  // 传递给任务文件,绝不能自己拼。
229
227
  procStart: typeof s.procStart === 'string' ? s.procStart : null,
230
- ancestors: chain.ancestors,
231
- chainComplete: chain.chainComplete,
228
+ ancestors,
232
229
  });
233
230
  details.set(ws, list);
234
231
  continue;