cron-up 26.10.33 → 26.10.35

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
@@ -10,7 +10,13 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import { randomUUID } from 'node:crypto';
12
12
 
13
- import { deps, packageVersion, isPlainObject } from './internals.mjs';
13
+ import {
14
+ deps,
15
+ packageVersion,
16
+ isPlainObject,
17
+ getOwn,
18
+ setOwn,
19
+ } from './internals.mjs';
14
20
  import {
15
21
  FAIL_LIMIT,
16
22
  COOLDOWN_SECONDS,
@@ -41,6 +47,7 @@ import {
41
47
  TaskFileChanged,
42
48
  taskFileExists,
43
49
  groupTasksByKey,
50
+ taskOwnedBy,
44
51
  } from './tasks.mjs';
45
52
  import { taskWanted, parseCronOrNone, wantedNow } from './cron.mjs';
46
53
  import {
@@ -381,7 +388,7 @@ export async function patrolWorkspace(
381
388
  for (const [key, group] of taskGroups) {
382
389
  if (group.length !== 1) continue;
383
390
  const t = group[0];
384
- let ent = bucket[key] ?? null;
391
+ let ent = getOwn(bucket, key) ?? null;
385
392
  const logPath = sessionLogPath(ws, key);
386
393
  const ownerIsOurs = entryClaimed(ent)
387
394
  && typeof t.createdBySessionId === 'string'
@@ -476,9 +483,9 @@ export async function patrolWorkspace(
476
483
  // 盖印(跨轮老条目):已认亲且任务归属还没盖到本代预热(活创建者
477
484
  // 已在 (a) 排除)。当轮新 spawn 的会话在第三步之后的轮内等待里认
478
485
  // 亲,由 cmdRun 的 stampClaimed 补盖,不走这里。
479
- if (entryClaimed(ent)
480
- && (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
481
- stamps.set(key, reownTarget(ent));
486
+ if (entryClaimed(ent)) {
487
+ const target = reownTarget(ent);
488
+ if (!taskOwnedBy(t, target)) stamps.set(key, target);
482
489
  }
483
490
 
484
491
  // warm 未到回收 / spawning 宽限内:本轮无事。
@@ -496,15 +503,15 @@ export async function patrolWorkspace(
496
503
  }
497
504
 
498
505
  if (fails >= FAIL_LIMIT) {
499
- bucket[key] = {
506
+ const next = setOwn(bucket, key, {
500
507
  ...ent,
501
508
  ws,
502
509
  taskId: key,
503
510
  pid: null,
504
511
  fails,
505
512
  log: ent?.log ?? logPath,
506
- };
507
- enterCooldown(bucket[key], ws, key, fails, cur, stats);
513
+ });
514
+ enterCooldown(next, ws, key, fails, cur, stats);
508
515
  continue;
509
516
  }
510
517
 
@@ -520,7 +527,7 @@ export async function patrolWorkspace(
520
527
  scriptProcLogPath(procArgs) === logPath && deps.alive(procPid));
521
528
  if (adopted) {
522
529
  const [procPid] = adopted;
523
- bucket[key] = {
530
+ setOwn(bucket, key, {
524
531
  ws,
525
532
  taskId: key,
526
533
  pid: procPid,
@@ -528,7 +535,7 @@ export async function patrolWorkspace(
528
535
  procStart: deps.procStartedAt(procPid),
529
536
  fails: 0,
530
537
  log: logPath,
531
- };
538
+ });
532
539
  note('adopt', `接管 ${ws} 任务 ${key} pid=${procPid}:state 记录缺失,`
533
540
  + '但进程表里仍有本该任务的预热会话,重新纳入跟踪');
534
541
  pending.push({ ws, key, pid: procPid });
@@ -542,25 +549,25 @@ export async function patrolWorkspace(
542
549
  if (spawned?.pid == null) {
543
550
  const newFails = fails + 1;
544
551
  if (newFails >= FAIL_LIMIT) {
545
- bucket[key] = {
552
+ const next = setOwn(bucket, key, {
546
553
  ws, taskId: key, pid: null, fails: newFails, log: logPath,
547
- };
548
- enterCooldown(bucket[key], ws, key, newFails, cur, stats);
554
+ });
555
+ enterCooldown(next, ws, key, newFails, cur, stats);
549
556
  continue;
550
557
  }
551
- bucket[key] = {
558
+ setOwn(bucket, key, {
552
559
  ws,
553
560
  taskId: key,
554
561
  pid: null,
555
562
  startedAt: cur,
556
563
  fails: newFails,
557
564
  log: logPath,
558
- };
565
+ });
559
566
  note('fail', `启动失败 ${ws} 任务 ${key}:会话进程没有拉起`
560
567
  + `(连续失败计数 ${newFails})`);
561
568
  continue;
562
569
  }
563
- bucket[key] = {
570
+ setOwn(bucket, key, {
564
571
  ws,
565
572
  taskId: key,
566
573
  pid: spawned.pid,
@@ -568,7 +575,7 @@ export async function patrolWorkspace(
568
575
  procStart: spawned.procStart,
569
576
  fails,
570
577
  log: logPath,
571
- };
578
+ });
572
579
  note('spawn', `已启动 ${ws} 任务 ${key} pid=${spawned.pid}`
573
580
  + `(连续失败计数 ${fails})`);
574
581
  pending.push({ ws, key, pid: spawned.pid });
@@ -588,8 +595,9 @@ export async function patrolWorkspace(
588
595
  }
589
596
  throw e;
590
597
  }
591
- // 与 readTasks 不一致的极端竞态:保守不动,新 spawn 也全留跨轮路径。
592
- if (result === null) return [];
598
+ // 与 readTasks 不一致的极端竞态:维护保守不动,但本轮新 spawn 仍要进入
599
+ // 等待认亲与补盖流程;丢掉 pending 会把同轮收尾无故推迟到下一轮。
600
+ if (result === null) return pending;
593
601
  if (result.renewed) {
594
602
  deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
595
603
  + '不再受 7 天过期限制');
@@ -628,7 +636,7 @@ async function awaitWarmups(pending, state, cur, stats) {
628
636
  const recognize = () => {
629
637
  const rest = [];
630
638
  for (const p of pending) {
631
- const ent = state[p.ws]?.[p.key];
639
+ const ent = getOwn(state[p.ws], p.key);
632
640
  if (!ent || ent.pid !== p.pid) continue;
633
641
  if (!deps.trackedAlive(ent)) {
634
642
  // 启动即崩的典型窗口:真拉起过却死掉,计一次失败并置空 pid,条
@@ -696,10 +704,12 @@ function stampClaimed(claimedByWs, taskfiles, stats) {
696
704
  // 拉);state.json 已丢的旧孤儿 script(日志是旧命名,不含任务键段)从
697
705
  // 进程表兜底清扫。只在 loadLegacyEntries 非空(确在跨版本)时执行。
698
706
  async function retireLegacy(legacy, stats) {
707
+ let retired = true;
699
708
  for (const ent of legacy) {
700
709
  if (deps.trackedAlive(ent)) {
701
710
  const stopped = await deps.stopSession(ent);
702
711
  if (!stopped) {
712
+ retired = false;
703
713
  bump(stats, 'warn');
704
714
  deps.log(`旧版迁移暂缓 ${ent.ws} pid=${ent.pid}:记录的进程仍存活,`
705
715
  + '本次无法安全停止目录级预热会话;本次不记为已回收');
@@ -721,6 +731,7 @@ async function retireLegacy(legacy, stats) {
721
731
  if (!deps.trackedAlive(ent)) continue;
722
732
  const stopped = await deps.stopSession(ent);
723
733
  if (!stopped) {
734
+ retired = false;
724
735
  bump(stats, 'warn');
725
736
  deps.log(`旧版迁移暂缓 pid=${pid}:记录的进程仍存活,本次无法安全停止`
726
737
  + `目录级预热孤儿会话(日志 ${file});本次不记为已回收`);
@@ -730,6 +741,7 @@ async function retireLegacy(legacy, stats) {
730
741
  + '升级后按任务重新预热');
731
742
  }
732
743
  }
744
+ return retired;
733
745
  }
734
746
 
735
747
  // 跑一轮巡检;抢不到锁返回 false。
@@ -793,8 +805,9 @@ export async function cmdRun(args) {
793
805
  deps.log(`告警:${registryAlert}`);
794
806
  }
795
807
 
796
- // 旧版收尾必须先于一切 spawn 决策(state 轮末被新形状整体覆盖)。
797
- if (legacy.length > 0) await retireLegacy(legacy, stats);
808
+ // 旧版收尾必须先于一切 spawn 决策。停止失败时轮末保留原 state,下轮
809
+ // 才能继续重试;本轮新会话可由任务专属日志认领,不会因未落盘而重拉。
810
+ const legacyRetired = legacy.length === 0 || await retireLegacy(legacy, stats);
798
811
 
799
812
  // 轮初把上轮末仍相信活着的条目分成三组:活、resume 脱离(script 已
800
813
  // 死但 sid 还在,不报退出)、真死(立即报)。带 deadSince 的旧报条
@@ -865,7 +878,7 @@ export async function cmdRun(args) {
865
878
  for (const d of died) {
866
879
  bump(stats, 'exit');
867
880
  deps.log(exitLine(d.ws, d.key,
868
- { ...state[d.ws]?.[d.key], pid: d.pid }));
881
+ { ...getOwn(state[d.ws], d.key), pid: d.pid }));
869
882
  }
870
883
  stampClaimed(claimedByWs, taskfiles, stats);
871
884
  } catch (e) {
@@ -894,27 +907,23 @@ export async function cmdRun(args) {
894
907
  for (const [ws, bucket] of Object.entries(state)) {
895
908
  const out = {};
896
909
  for (const [taskId, ent] of Object.entries(bucket)) {
897
- if (deps.trackedAlive(ent)) {
898
- out[taskId] = ent;
899
- continue;
900
- }
901
- if (typeof ent.sessionId === 'string'
902
- && sidInDetails(ws, ent.sessionId)) {
903
- out[taskId] = ent;
904
- continue;
905
- }
906
- if (isCooling(ent, cur)) {
907
- out[taskId] = ent;
910
+ if (deps.trackedAlive(ent)
911
+ || (typeof ent.sessionId === 'string'
912
+ && sidInDetails(ws, ent.sessionId))
913
+ || isCooling(ent, cur)) {
914
+ setOwn(out, taskId, ent);
908
915
  continue;
909
916
  }
910
917
  if ((ent.fails ?? 0) > 0) {
911
918
  ent.deadSince ??= cur;
912
- if (cur - ent.deadSince < FAIL_TTL_SECONDS) out[taskId] = ent;
919
+ if (cur - ent.deadSince < FAIL_TTL_SECONDS) {
920
+ setOwn(out, taskId, ent);
921
+ }
913
922
  }
914
923
  }
915
- if (Object.keys(out).length > 0) kept[ws] = out;
924
+ if (Object.keys(out).length > 0) setOwn(kept, ws, out);
916
925
  }
917
- saveState(kept);
926
+ if (legacyRetired) saveState(kept);
918
927
 
919
928
  // 轮末汇总:一行交代这轮看见了什么、动了什么、花了多久,随后更新心跳。
920
929
  const actions = EVENT_LABELS
package/src/service.mjs CHANGED
@@ -292,7 +292,10 @@ function scriptPathDirs(scriptPath) {
292
292
  export function launcherHealth() {
293
293
  if (!fs.existsSync(deps.paths.plistPath)) return null;
294
294
  const args = readPlistProgramArgs();
295
- if (!args || args.length !== 1) return null;
295
+ if (!args || args.length !== 1) {
296
+ return `LaunchAgent ${deps.paths.plistPath} 的 ProgramArguments 无法解读,`
297
+ + '重跑 cron-up install 覆盖重生成';
298
+ }
296
299
  const [scriptPath] = args;
297
300
  if (!fs.existsSync(scriptPath)) {
298
301
  return `launchd 配置中的启动脚本已失效(${path.basename(scriptPath)} `
@@ -360,11 +363,12 @@ export async function cmdInstall(args) {
360
363
  validateConfig(cfg, (m) => deps.print(m));
361
364
  if (JSON.stringify(cfg.roots)
362
365
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
363
- // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。
366
+ // 与 README「安全与自动修改」同一口径,在配置落定时再提示一次。
364
367
  deps.print(`提示:未指定 --roots,默认扫描 ${DEFAULT_CONFIG.roots.join('、')}`
365
368
  + '(不存在的目录跳过);今后 clone 进这些容器的仓库也会进入巡检,'
366
- + '其中的周期任务会被自动续期永久化。目录布局不同或需收窄时用 '
367
- + '--roots 指定(见 README 安全说明)');
369
+ + '其中的周期任务会被自动续期永久化,任务、CLAUDE.md 与项目配置都会'
370
+ + '影响无人值守会话。目录布局不同或需收窄时用 --roots 指定'
371
+ + '(见 README「安全与自动修改」)');
368
372
  }
369
373
 
370
374
  const launcher = deps.resolveLauncher();
@@ -515,7 +519,7 @@ export async function cmdUninstall(args) {
515
519
  // npm 管的文件不能手删:命令行本身交给 npm 卸载。
516
520
  deps.print('命令行本身请运行 npm uninstall -g cron-up 卸载');
517
521
  } else {
518
- deps.print('配置与日志已保留(--purge 可删除全部产物)');
522
+ deps.print('配置与日志已保留(--purge 可删除 cron-up 自有配置与日志)');
519
523
  }
520
524
  } finally {
521
525
  deps.releaseRunLock(lock);
package/src/sessions.mjs CHANGED
@@ -119,11 +119,11 @@ function canSignalProcessGroup(pid) {
119
119
  // 为一个任务拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
120
120
  export function spawnSession(ws, taskId) {
121
121
  const logPath = sessionLogPath(ws, taskId);
122
- // 会话日志目录 700(README 承诺):每任务独立文件,目录只在此处创建。
122
+ // 会话日志目录 700(README 权限约定):每任务独立文件,目录只在此处创建。
123
123
  ensurePrivateDir(path.dirname(logPath));
124
- // 「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的话上一代(上
125
- // 一次执行)的界面记录会随本次拉起被抹掉。无条件保留一代(.1)——总
126
- // 量上界两代,活着的一代增长再快也只影响自身,不会经轮转放大。
124
+ // script(1) 启动即截断:每次新拉起前无条件保留上一代(.1),避免上次
125
+ // 启动以来的界面记录被抹掉。总量上界两代,活着的一代增长再快也只影响
126
+ // 自身,不会经轮转放大。
127
127
  try {
128
128
  if (fs.existsSync(logPath)) rotateOnce(logPath);
129
129
  } catch {
package/src/tasks.mjs CHANGED
@@ -235,6 +235,14 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
235
235
  fs.renameSync(tmp, file);
236
236
  }
237
237
 
238
+ // 任务归属是否已完全等于盖印目标。巡检预判与实际写入共用同一判据,
239
+ // 避免一层只看 sessionId、另一层比较三字段而逐渐漂移。
240
+ export function taskOwnedBy(t, target) {
241
+ return t.createdBySessionId === target.sessionId
242
+ && t.createdByPid === target.pid
243
+ && t.createdByProcStart === target.procStart;
244
+ }
245
+
238
246
  // 一轮巡检对一个任务文件做的维护:renew(补 permanent)与 reown(把任务
239
247
  // 归属盖给各自的预热会话)合并为同一次带 mtime 乐观检测的原子写。先取
240
248
  // mtimeNs、清残留 .cron-up-tmp、loadTaskDoc round-trip 整文档;两项计数
@@ -244,8 +252,8 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
244
252
  // reown 形状:Map<taskKey, {sessionId, pid, procStart}>。一轮内同一文件的
245
253
  // 不同任务可盖向不同预热(一任务一会话);内容相同但 id 不同的任务互不
246
254
  // 影响。同一个 taskKey 对应多条任务是病态输入,无法把 state/会话唯一对应
247
- // 到条目,这些键全部跳过盖印。只盖 createdBySessionId 不等于目标的任务,
248
- // 第二轮起字节级不动;pid/procStart 是登记里 claude 进程的 pid 与 lstart。
255
+ // 到条目,这些键全部跳过盖印。归属三字段未完全等于目标时才写,第二轮起
256
+ // 字节级不动;pid/procStart 是登记里 claude 进程的 pid 与 lstart。
249
257
  export function maintainWorkspace(taskfile, { reown = null } = {}) {
250
258
  let mtimeNs;
251
259
  try {
@@ -284,7 +292,7 @@ export function maintainWorkspace(taskfile, { reown = null } = {}) {
284
292
  const target = reown.get(key);
285
293
  // 没有 owner 字段的外部条目同样盖——窗口内任务统一归各自预热,语义
286
294
  // 不因条目来源留例外。
287
- if (target && t.createdBySessionId !== target.sessionId) {
295
+ if (target && !taskOwnedBy(t, target)) {
288
296
  t.createdBySessionId = target.sessionId;
289
297
  t.createdByPid = target.pid;
290
298
  // ps lstart 原文(含「Oct 8」双空格),原样取自会话登记。