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/CONTRIBUTING.md +62 -0
- package/README.md +228 -112
- package/docs/internals.md +190 -0
- package/docs/migration.md +47 -0
- package/docs/release.md +75 -0
- package/package.json +5 -3
- package/src/cli.mjs +8 -4
- package/src/config.mjs +3 -2
- package/src/constants.mjs +5 -7
- package/src/cron.mjs +7 -5
- package/src/display.mjs +2 -2
- package/src/internals.mjs +17 -0
- package/src/list.mjs +6 -3
- package/src/logs.mjs +4 -1
- package/src/overview.mjs +9 -9
- package/src/patrol.mjs +46 -37
- package/src/service.mjs +9 -5
- package/src/sessions.mjs +4 -4
- package/src/tasks.mjs +11 -3
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 {
|
|
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
|
|
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
|
-
|
|
481
|
-
stamps.set(key,
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
552
|
+
const next = setOwn(bucket, key, {
|
|
546
553
|
ws, taskId: key, pid: null, fails: newFails, log: logPath,
|
|
547
|
-
};
|
|
548
|
-
enterCooldown(
|
|
554
|
+
});
|
|
555
|
+
enterCooldown(next, ws, key, newFails, cur, stats);
|
|
549
556
|
continue;
|
|
550
557
|
}
|
|
551
|
-
bucket
|
|
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
|
|
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
|
|
592
|
-
|
|
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]
|
|
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
|
|
797
|
-
|
|
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]
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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)
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
125
|
-
//
|
|
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
|
-
//
|
|
248
|
-
//
|
|
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
|
|
295
|
+
if (target && !taskOwnedBy(t, target)) {
|
|
288
296
|
t.createdBySessionId = target.sessionId;
|
|
289
297
|
t.createdByPid = target.pid;
|
|
290
298
|
// ps lstart 原文(含「Oct 8」双空格),原样取自会话登记。
|