cron-up 26.10.31 → 26.10.32
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/README.md +5 -5
- package/package.json +1 -1
- package/src/constants.mjs +17 -5
- package/src/cron.mjs +25 -7
- package/src/display.mjs +6 -5
- package/src/patrol.mjs +219 -63
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Claude Code 的任务归属是**任务级**的:任务文件里每条任务各
|
|
|
18
18
|
3. 本进程是任务记录的创建者进程(resume 形态)→ 投;
|
|
19
19
|
4. 其余情况 → 必须持锁、任务属于本项目、且创建者进程已死,才接管。
|
|
20
20
|
|
|
21
|
-
cron-up 利用这套规则:为每个「归属会话已死、
|
|
21
|
+
cron-up 利用这套规则:为每个「归属会话已死、6 分钟内需要执行」(一次性任务按最早可能投递时刻计,最多再提前 90 秒)的任务单独拉起一个后台会话,并把该任务的归属字段盖成这个新会话。于是同目录多个任务即使同一时刻触发,也各自投进各自的会话、互不插队;任一个预热中途死了,它的任务还会由另一个持锁活会话按规则 4 兜底接管。你本人的交互会话优先级最高:**只要任务的创建者会话还开着,cron-up 完全不碰它**。
|
|
22
22
|
|
|
23
23
|
| 环节 | 由谁负责 |
|
|
24
24
|
|---|---|
|
|
@@ -62,7 +62,7 @@ cron-up logs team-space--00000002 -f
|
|
|
62
62
|
cron-up uninstall # 停止全部预热会话并卸载(配置日志保留;--purge 连产物一起删)
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
巡检固定每 5 分钟一轮、提前
|
|
65
|
+
巡检固定每 5 分钟一轮、提前 6 分钟为任务备好会话;这些不是可配项。任何任务最迟在执行前一轮巡检被发现;拉起会话后轮内每 3 秒重扫一次会话登记(先扫后睡、至多等 24 秒),健康会话约 10 秒即可完成登记,归属在**同一轮**盖印完成,不再跨一轮。24 秒只是盖印加速的等待上限,不是判死时限:等不到的会话原样留到下轮,仍按跨轮 3 分钟未登记判死,失败计数不因等待而增加。一次性任务在 :00/:30 落点最多提前 90 秒投递,其窗口前沿按最早投递时刻再前推 90 秒,保证任意巡检相位都有一轮在投递前把它发现。
|
|
66
66
|
|
|
67
67
|
### 自动维护(每轮无条件进行,没有开关)
|
|
68
68
|
|
|
@@ -84,7 +84,7 @@ cron-up uninstall # 停止全部预热会话并卸载(配置日
|
|
|
84
84
|
|
|
85
85
|
## 窗口轮转与生命周期
|
|
86
86
|
|
|
87
|
-
预热会话是缓存不是宠物:每个会话只活在「落点前
|
|
87
|
+
预热会话是缓存不是宠物:每个会话只活在「落点前 6 分钟(一次性网格任务至多 7.5 分钟)→ 本任务执行完并静默 3 分钟 → 回收」这一段,任务之间上下文互不累积,内存也只在窗口内占用(每个会话数百 MB,窗口外为零)。回收还要求距该任务下次需要至少 7 分钟——回收后下轮巡检(≤5 分钟)即重新拉起并在当轮完成认亲盖印,余量罩住一次性任务的 90 秒提前投递与唤醒抖动,任务不可能在空档到点。更近的触发直接复用现有会话。
|
|
88
88
|
|
|
89
89
|
其它生命周期规则:
|
|
90
90
|
|
|
@@ -122,7 +122,7 @@ Claude Code 任务的实际投递不在 cron 落点,而是落点之后加一
|
|
|
122
122
|
## 已知限制
|
|
123
123
|
|
|
124
124
|
- 仅支持 macOS。电脑休眠期间错过的周期任务不会补执行(launchd 醒来只补跑一轮巡检);错过的**一次性**任务会在后台会话启动时补执行。
|
|
125
|
-
- 每个窗口内的死主任务对应一个后台会话:同一分钟内有大量任务同时到期时,会同时存在相应数量的会话(每个数百 MB 内存)。时间错开的日常任务只在各自的
|
|
125
|
+
- 每个窗口内的死主任务对应一个后台会话:同一分钟内有大量任务同时到期时,会同时存在相应数量的会话(每个数百 MB 内存)。时间错开的日常任务只在各自的 6 分钟窗口内占用(一次性任务至多 7.5 分钟)。
|
|
126
126
|
- 手工创建的任务若缺少 `createdAt`,错过补执行判定不生效。`cron-up list` 对缺字段的手写任务仍显示「已错过/设定时间」,但预热与补执行依赖 Claude Code 与 cron-up 的字段纪律。
|
|
127
127
|
- 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除或移出 roots)后,仍在运行的预热不会被回收,总览会列出这类会话,不需要时手动结束。
|
|
128
128
|
- 预热依据的逐任务谓词、调度锁与三代进程结构是未公开内部实现:新版若调整归属/锁语义,任务级隔离的前提需要重新核对。cron-up 保留了会话登记格式的烟雾告警,格式漂移时在总览页明示。
|
|
@@ -155,7 +155,7 @@ Claude Code 任务的实际投递不在 cron 落点,而是落点之后加一
|
|
|
155
155
|
{ "roots": ["/Users/you/code", "/Users/you/work"] }
|
|
156
156
|
```
|
|
157
157
|
|
|
158
|
-
间隔(300 秒)、提前量(
|
|
158
|
+
间隔(300 秒)、提前量(360 秒,一次性任务另加至多 90 秒)、扫描深度(3)、轮内等待认亲上限(24 秒)都是固定常量;直接编辑文件改 roots 也可以,最多 5 分钟后的下轮巡检生效。字段损坏时巡检响亮失败(退出码 2),不静默回退。
|
|
159
159
|
|
|
160
160
|
| 产物 | 位置 |
|
|
161
161
|
|---|---|
|
package/package.json
CHANGED
package/src/constants.mjs
CHANGED
|
@@ -11,9 +11,12 @@ export const DEFAULT_CONFIG = {
|
|
|
11
11
|
|
|
12
12
|
// launchd 两轮巡检之间的秒数(plist 的 StartInterval)。
|
|
13
13
|
export const INTERVAL_SECONDS = 300;
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
14
|
+
// 提前多少秒为窗口内任务备好会话。任务相对巡检网格的相位任意:最迟在落
|
|
15
|
+
// 点前一轮(≤INTERVAL)被发现并 spawn,spawn 后当轮等待认亲并盖印(见
|
|
16
|
+
// WARMUP_WAIT_MS),故提前量只需「一轮间隔 + 等待与重扫开销」。一次性
|
|
17
|
+
// 任务在 :00/:30 网格最多提前 90 秒投递,其窗口前沿在 cron.mjs 的 wanted
|
|
18
|
+
// 里再前推 CC_JITTER.oneShotMaxMs,不占这里的周期任务余量。
|
|
19
|
+
export const LEAD_SECONDS = 360;
|
|
17
20
|
// 工作区发现的最大递归深度(相对 roots):深度 3 覆盖
|
|
18
21
|
// <容器>/<仓>/<子目录>/<孙目录> 里的任务文件。
|
|
19
22
|
export const MAX_SCAN_DEPTH = 3;
|
|
@@ -33,14 +36,23 @@ export const FAIL_TTL_SECONDS = 48 * 3600;
|
|
|
33
36
|
export const PID_T_MAX = 2 ** 31 - 1;
|
|
34
37
|
// 健康会话约 10 秒内完成登记。超时仍存活却没登记,是卡在无人应答的提问
|
|
35
38
|
// 界面(目录信任、工具授权);只看存活会把它当健康,只有这个时限兜得住。
|
|
39
|
+
// 这是唯一的判死阈值,且只在跨轮判定(轮龄为整数个巡检间隔)时生效。
|
|
36
40
|
export const WARMUP_GRACE_SECONDS = 180;
|
|
41
|
+
// spawn 当轮在轮内等待会话登记的上限:它是「盖印加速」预算,不是判死阈
|
|
42
|
+
// 值——等不到的会话原样留到下轮,按 WARMUP_GRACE_SECONDS 跨轮判死,失
|
|
43
|
+
// 败计数不因等待而累积(慢启动环境两轮就被冷却的回退就是这么来的)。
|
|
44
|
+
// 健康会话约 10 秒登记,24 秒是 8 拍重扫(另加进入时的先扫一拍);拍数
|
|
45
|
+
// 帽与墙钟帽双出口,高负载拖慢 setTimeout 也不会把一轮撑过间隔。
|
|
46
|
+
export const WARMUP_WAIT_MS = 24_000;
|
|
47
|
+
export const WARMUP_POLL_MS = 3_000;
|
|
37
48
|
// window 回收判据一:界面静默超过此时长视为「本任务已执行完」。TUI 工作
|
|
38
49
|
// 时持续渲染(毫秒级刷新 typescript),静默 3 分钟不可能是在执行;取
|
|
39
50
|
// WARMUP_GRACE 同值,结构上对齐「3 分钟定生死」的既有心智。
|
|
40
51
|
export const SESSION_RECYCLE_IDLE_SECONDS = WARMUP_GRACE_SECONDS;
|
|
41
52
|
// window 回收判据二:距该任务下次「需要会话在场」至少还有这么多秒。回收
|
|
42
|
-
//
|
|
43
|
-
//
|
|
53
|
+
// 后下轮(≤INTERVAL)发现并 spawn,进程即在场,盖印在该轮等待期内完成;
|
|
54
|
+
// 一个巡检间隔之外的 120 秒余量罩一次性任务最多 90 秒的提前投递与唤醒
|
|
55
|
+
// 抖动。更近的触发不回收,复用现有会话。
|
|
44
56
|
export const SESSION_FIRE_MARGIN_SECONDS = 120;
|
|
45
57
|
export const SESSION_RECYCLE_MARGIN_SECONDS =
|
|
46
58
|
SESSION_FIRE_MARGIN_SECONDS + INTERVAL_SECONDS;
|
package/src/cron.mjs
CHANGED
|
@@ -196,10 +196,10 @@ function createdAtDate(task) {
|
|
|
196
196
|
return Number.isFinite(d.getTime()) ? d : null;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
// 此刻是否已需要会话:一次性任务错过待补,或周期任务处在落点之后、抖动
|
|
200
|
+
// 投递之前的尾窗。不带任何前瞻——display 的「已错过」判定与回收余量都
|
|
201
|
+
// 按这个口径,未来落点子句不属于这里。
|
|
202
|
+
function wantedNow(cron, task, now) {
|
|
203
203
|
// 首次触发已过却仍在文件里:一次性任务意味着没有 REPL 运行时被错过
|
|
204
204
|
// (重启/睡眠)。原生机制会在会话启动时补执行一次再删除它。
|
|
205
205
|
if (taskIsOneshot(task)) {
|
|
@@ -223,15 +223,33 @@ function wanted(cron, task, now, leadMs) {
|
|
|
223
223
|
return recurringTailWindow(cron, task.id, now) !== null;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// leadMs 前瞻窗口内(含此刻)是否需要会话。leadMs 必须为正的前瞻量;
|
|
227
|
+
// 传 0 时等价 wantedNow(「此刻」没有窗口,网格提前量也不成立),问此
|
|
228
|
+
// 刻口径请直接用 wantedNow。一次性任务在 :00/:30 网格落点最多提前
|
|
229
|
+
// oneShotMaxMs 投递(见 nextDelivery 的 Wsn 复刻):前瞻窗口的前沿罩到
|
|
230
|
+
// 最早投递时刻,否则约一成相位带在提前量内没有任何巡检点;非网格落点
|
|
231
|
+
// 本不提前,保守同口径只是多等 90 秒,无损。周期任务的抖动在落点之
|
|
232
|
+
// 后,未来子句不前推,尾窗交给 wantedNow。
|
|
233
|
+
function wantedWithin(cron, task, now, leadMs) {
|
|
234
|
+
if (leadMs > 0) {
|
|
235
|
+
const nxt = cron.nextAfter(now);
|
|
236
|
+
const lookahead = taskIsOneshot(task)
|
|
237
|
+
? leadMs + CC_JITTER.oneShotMaxMs
|
|
238
|
+
: leadMs;
|
|
239
|
+
if (nxt && nxt.getTime() <= now.getTime() + lookahead) return true;
|
|
240
|
+
}
|
|
241
|
+
return wantedNow(cron, task, now);
|
|
242
|
+
}
|
|
243
|
+
|
|
226
244
|
// 任务是否需要已预热的会话。任务字段全是别的程序写的,畸形条目读作「不
|
|
227
245
|
// 需要」,绝不能让一个坏条目中断整轮巡检。
|
|
228
246
|
export function taskWanted(task, now, leadMs) {
|
|
229
247
|
const cron = parseCronOrNone(task?.cron);
|
|
230
|
-
return cron !== null &&
|
|
248
|
+
return cron !== null && wantedWithin(cron, task, now, leadMs);
|
|
231
249
|
}
|
|
232
250
|
|
|
233
|
-
//
|
|
234
|
-
export {
|
|
251
|
+
// 两个口径供展示视图复用(同一个已解析 cron 顺带回答巡检问题)。
|
|
252
|
+
export { wantedNow, wantedWithin };
|
|
235
253
|
|
|
236
254
|
// ---- Claude Code 投递抖动(预计实际触发时间)----
|
|
237
255
|
// 复刻 v2.1.285 内部的 M():任务 id 前 8 位十六进制除以 2^32,得 [0,1) 的
|
package/src/display.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import {
|
|
4
4
|
parseCronOrNone,
|
|
5
5
|
taskIsOneshot,
|
|
6
|
-
|
|
6
|
+
wantedNow,
|
|
7
|
+
wantedWithin,
|
|
7
8
|
nextDelivery,
|
|
8
9
|
} from './cron.mjs';
|
|
9
10
|
|
|
@@ -71,7 +72,7 @@ function taskSummary(task) {
|
|
|
71
72
|
|
|
72
73
|
// 把一个任务整理成展示数据。字段来自别的程序写的文件,畸形也不抛。
|
|
73
74
|
// 传 leadMs 时顺带在同一个已解析 cron 上回答「现在是否需要预热会话」
|
|
74
|
-
// (
|
|
75
|
+
// (wantedWithin),总览页就不必为同一任务再解析一遍 cron。
|
|
75
76
|
export function taskView(task, now, leadMs) {
|
|
76
77
|
const expr = task?.cron;
|
|
77
78
|
const cron = parseCronOrNone(expr);
|
|
@@ -90,8 +91,8 @@ export function taskView(task, now, leadMs) {
|
|
|
90
91
|
}
|
|
91
92
|
const oneshot = taskIsOneshot(task);
|
|
92
93
|
// nxt 按定义不晚于展示语境里的下一次投递,「已错过」完全由补执行判定
|
|
93
|
-
//
|
|
94
|
-
const missed = oneshot &&
|
|
94
|
+
// 回答:只问此刻、不带前瞻,与巡检的错过/尾窗判定同一口径。
|
|
95
|
+
const missed = oneshot && wantedNow(cron, task, now);
|
|
95
96
|
// 落点与投递由 nextDelivery 同源算出:尾窗内的周期任务即将到来的是上
|
|
96
97
|
// 一落点的这次延迟投递,箭头才画得出「09:00 → 09:30」,而不是从明天
|
|
97
98
|
// 的落点拐回来(2 月 29 日任务尾窗内 nextAfter 甚至找不到下一落点)。
|
|
@@ -108,7 +109,7 @@ export function taskView(task, now, leadMs) {
|
|
|
108
109
|
summary: taskSummary(task),
|
|
109
110
|
// wanted 恒为 null 或布尔:没传 leadMs(不问)与 cron 无效(问不了)
|
|
110
111
|
// 都是 null,键的形状不随参数变化。
|
|
111
|
-
wanted: leadMs !== undefined ?
|
|
112
|
+
wanted: leadMs !== undefined ? wantedWithin(cron, task, now, leadMs) : null,
|
|
112
113
|
// 计入 Claude Code 投递抖动后的预计实际触发时刻;已错过的一次性任务
|
|
113
114
|
// 没有未来触发,为 null;尾窗内的周期任务是即将到来的这次延迟投递。
|
|
114
115
|
// 与 nxt 同为 Date 或 null。
|
package/src/patrol.mjs
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// 一轮巡检:发现工作区 → 自动维护任务文件(续期/改 id/盖印)→ 保证每个
|
|
2
2
|
// 「创建者会话已死且处于预热窗口」的任务都有一个只属于它的健康会话。
|
|
3
|
-
// 这是 launchd 的入口(每 N
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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';
|
|
@@ -13,6 +15,8 @@ import {
|
|
|
13
15
|
COOLDOWN_SECONDS,
|
|
14
16
|
FAIL_TTL_SECONDS,
|
|
15
17
|
WARMUP_GRACE_SECONDS,
|
|
18
|
+
WARMUP_WAIT_MS,
|
|
19
|
+
WARMUP_POLL_MS,
|
|
16
20
|
SESSION_RECYCLE_IDLE_SECONDS,
|
|
17
21
|
SESSION_RECYCLE_MARGIN_SECONDS,
|
|
18
22
|
PATROL_LOG_ROTATE_BYTES,
|
|
@@ -37,7 +41,7 @@ import {
|
|
|
37
41
|
taskKey,
|
|
38
42
|
taskSig,
|
|
39
43
|
} from './tasks.mjs';
|
|
40
|
-
import { taskWanted, parseCronOrNone,
|
|
44
|
+
import { taskWanted, parseCronOrNone, wantedNow } from './cron.mjs';
|
|
41
45
|
import {
|
|
42
46
|
sessionLogPath,
|
|
43
47
|
sessionLogIdleSeconds,
|
|
@@ -164,13 +168,14 @@ export function rotatePatrolLogs() {
|
|
|
164
168
|
}
|
|
165
169
|
|
|
166
170
|
// 该任务下次「需要会话在场」的时刻(ms):现在已在窗口(落点尾窗、一次
|
|
167
|
-
// 性错过待补)视为立刻需要;窗口外取 cron
|
|
168
|
-
//
|
|
169
|
-
//
|
|
171
|
+
// 性错过待补)视为立刻需要;窗口外取 cron 裸落点(周期任务投递晚于落
|
|
172
|
+
// 点,按落点算余量更小、回收更保守;一次性网格落点反而提前至多 90 秒投
|
|
173
|
+
// 递,这段提前量由回收余量 SESSION_FIRE_MARGIN_SECONDS 罩住)。cron
|
|
174
|
+
// 无效返回 Infinity,永不回收。
|
|
170
175
|
function taskNeedMs(task, now) {
|
|
171
176
|
const cron = parseCronOrNone(task?.cron);
|
|
172
177
|
if (cron === null) return Infinity;
|
|
173
|
-
if (
|
|
178
|
+
if (wantedNow(cron, task, now)) return now.getTime();
|
|
174
179
|
return cron.nextAfter(now)?.getTime() ?? Infinity;
|
|
175
180
|
}
|
|
176
181
|
|
|
@@ -179,12 +184,67 @@ function entryClaimed(ent) {
|
|
|
179
184
|
return Boolean(ent?.sessionId && ent.claudeProcStart);
|
|
180
185
|
}
|
|
181
186
|
|
|
187
|
+
// 沿登记快照把一个活 script pid 认成登记里的 claude 孙进程:认到则回填
|
|
188
|
+
// 身份、清零失败计数并返回登记详情,否则返回 null。轮初认亲与轮内等待
|
|
189
|
+
// 认亲共用这唯一一份回填。
|
|
190
|
+
function claimEntry(ent, details, ws) {
|
|
191
|
+
const d = deps.claimWarm(details, ws, ent.pid);
|
|
192
|
+
if (!d) return null;
|
|
193
|
+
ent.fails = 0;
|
|
194
|
+
if (d.sessionId && d.procStart) {
|
|
195
|
+
ent.claudePid = d.pid;
|
|
196
|
+
ent.sessionId = d.sessionId;
|
|
197
|
+
ent.claudeProcStart = d.procStart;
|
|
198
|
+
}
|
|
199
|
+
return d;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 盖印目标三字段(tasks.mjs maintainWorkspace reown 映射的值形状)。
|
|
203
|
+
const reownTarget = (ent) => ({
|
|
204
|
+
sessionId: ent.sessionId,
|
|
205
|
+
pid: ent.claudePid,
|
|
206
|
+
procStart: ent.claudeProcStart,
|
|
207
|
+
});
|
|
208
|
+
|
|
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
|
+
// 过户后的统一计数与日志(巡逻第三步与等待期补盖共用)。
|
|
224
|
+
function noteReowned(ws, count, stats) {
|
|
225
|
+
bump(stats, 'reown');
|
|
226
|
+
deps.log(`已过户 ${ws}:${count} 个窗口内任务的归属改到各自的`
|
|
227
|
+
+ '预热会话,这些任务将只在对应会话触发');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 失败顶限时的统一冷却迁移:置冷却截止、计数、日志。cooldownUntil 之外
|
|
231
|
+
// 的条目形状由各调用点按自身语境构造。
|
|
232
|
+
function enterCooldown(ent, ws, key, fails, cur, stats) {
|
|
233
|
+
ent.cooldownUntil = cur + COOLDOWN_SECONDS;
|
|
234
|
+
bump(stats, 'cool');
|
|
235
|
+
deps.log(`冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
|
|
236
|
+
+ `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
|
|
237
|
+
}
|
|
238
|
+
|
|
182
239
|
// 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/改
|
|
183
240
|
// id/盖印维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共
|
|
184
241
|
// 享的进程表快照容器({value: 数组|null}),只在孤儿认领时拍一次。
|
|
242
|
+
// 返回 pending:本轮新纳入跟踪、尚未认亲的活 script(新 spawn 与孤儿认
|
|
243
|
+
// 领)的 {ws,key,pid} 列表,cmdRun 据此轮内等待认亲并补盖印。
|
|
185
244
|
export async function patrolWorkspace(
|
|
186
245
|
ws, bucket, taskfile, now, leadMs, cur, details, scriptProcsRef, stats,
|
|
187
246
|
) {
|
|
247
|
+
const pending = [];
|
|
188
248
|
const tasks = deps.readTasks(ws);
|
|
189
249
|
const note = (kind, msg) => {
|
|
190
250
|
bump(stats, kind);
|
|
@@ -200,7 +260,7 @@ export async function patrolWorkspace(
|
|
|
200
260
|
note('warn', `任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
|
|
201
261
|
+ `其中 ${TASK_REL} 里的定时任务都不会执行`);
|
|
202
262
|
}
|
|
203
|
-
return;
|
|
263
|
+
return [];
|
|
204
264
|
}
|
|
205
265
|
|
|
206
266
|
const stopEntry = async (ent, kind, msg) => {
|
|
@@ -219,7 +279,7 @@ export async function patrolWorkspace(
|
|
|
219
279
|
}
|
|
220
280
|
delete bucket[key];
|
|
221
281
|
}
|
|
222
|
-
return;
|
|
282
|
+
return [];
|
|
223
283
|
}
|
|
224
284
|
|
|
225
285
|
// 第零步:id 改键的崩溃窗口治愈。任务文件里旧键已不存在(上轮 minify
|
|
@@ -245,20 +305,11 @@ export async function patrolWorkspace(
|
|
|
245
305
|
}
|
|
246
306
|
}
|
|
247
307
|
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
// 再认,不阻断任何决策。
|
|
308
|
+
// 第一步:认亲。认到 = 最强健康证据,当场回填身份、清零失败计数。本
|
|
309
|
+
// 轮新 spawn 的不在快照里,认亲由 cmdRun 的轮内等待补做;其余条目认
|
|
310
|
+
// 不到下轮再认,不阻断任何决策。
|
|
252
311
|
for (const ent of Object.values(bucket)) {
|
|
253
|
-
if (
|
|
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
|
-
}
|
|
312
|
+
if (deps.trackedAlive(ent)) claimEntry(ent, details, ws);
|
|
262
313
|
}
|
|
263
314
|
|
|
264
315
|
// 第二步:逐任务决策。stamps 收本轮要盖的归属印(sig → 预热身份),
|
|
@@ -314,9 +365,9 @@ export async function patrolWorkspace(
|
|
|
314
365
|
// (c) window 回收:不要求任务此刻在窗口内——恰恰是执行完、已离开窗
|
|
315
366
|
// 口的会话要被收掉。三条件缺一不可:本任务在本代执行过(lastFiredAt
|
|
316
367
|
// 晚于 startedAt——区分「执行完的空闲」与「等触发的空闲」,后者是预
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
368
|
+
// 热缓存在干活)、本任务日志静默超时、距本任务下次需要还有一个巡检
|
|
369
|
+
// 间隔加余量(下轮发现并 spawn、当轮等待认亲盖印;needMs 在窗口内
|
|
370
|
+
// 返回 now,天然挡住「回收后立刻又需要」)。
|
|
320
371
|
if (ent && deps.trackedAlive(ent) && entryClaimed(ent)
|
|
321
372
|
&& typeof t.lastFiredAt === 'number'
|
|
322
373
|
&& t.lastFiredAt > (ent.startedAt ?? 0) * 1000
|
|
@@ -358,23 +409,12 @@ export async function patrolWorkspace(
|
|
|
358
409
|
ent.fails = 0;
|
|
359
410
|
}
|
|
360
411
|
|
|
361
|
-
//
|
|
362
|
-
//
|
|
412
|
+
// 盖印(跨轮老条目):已认亲且任务归属还没盖到本代预热(活创建者
|
|
413
|
+
// 已在 (a) 排除)。当轮新 spawn 的会话在第三步之后的轮内等待里认
|
|
414
|
+
// 亲,由 cmdRun 的 stampClaimed 补盖,不走这里。
|
|
363
415
|
if (entryClaimed(ent)
|
|
364
416
|
&& (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
|
|
365
|
-
|
|
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
|
-
}
|
|
417
|
+
mergeStamp(stamps, stampConflicts, sig, reownTarget(ent));
|
|
378
418
|
}
|
|
379
419
|
|
|
380
420
|
// warm 未到回收 / spawning 宽限内:本轮无事。
|
|
@@ -399,11 +439,9 @@ export async function patrolWorkspace(
|
|
|
399
439
|
sig,
|
|
400
440
|
pid: null,
|
|
401
441
|
fails,
|
|
402
|
-
cooldownUntil: cur + COOLDOWN_SECONDS,
|
|
403
442
|
log: ent?.log ?? logPath,
|
|
404
443
|
};
|
|
405
|
-
|
|
406
|
-
+ `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
|
|
444
|
+
enterCooldown(bucket[key], ws, key, fails, cur, stats);
|
|
407
445
|
continue;
|
|
408
446
|
}
|
|
409
447
|
|
|
@@ -431,6 +469,7 @@ export async function patrolWorkspace(
|
|
|
431
469
|
};
|
|
432
470
|
note('adopt', `接管 ${ws} 任务 ${key} pid=${procPid}:state 记录缺失,`
|
|
433
471
|
+ '但进程表里仍有本该任务的预热会话,重新纳入跟踪');
|
|
472
|
+
pending.push({ ws, key, pid: procPid });
|
|
434
473
|
continue;
|
|
435
474
|
}
|
|
436
475
|
}
|
|
@@ -442,11 +481,9 @@ export async function patrolWorkspace(
|
|
|
442
481
|
const newFails = fails + 1;
|
|
443
482
|
if (newFails >= FAIL_LIMIT) {
|
|
444
483
|
bucket[key] = {
|
|
445
|
-
ws, taskId: key, sig, pid: null, fails: newFails,
|
|
446
|
-
cooldownUntil: cur + COOLDOWN_SECONDS, log: logPath,
|
|
484
|
+
ws, taskId: key, sig, pid: null, fails: newFails, log: logPath,
|
|
447
485
|
};
|
|
448
|
-
|
|
449
|
-
+ `${newFails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
|
|
486
|
+
enterCooldown(bucket[key], ws, key, newFails, cur, stats);
|
|
450
487
|
continue;
|
|
451
488
|
}
|
|
452
489
|
bucket[key] = {
|
|
@@ -474,23 +511,25 @@ export async function patrolWorkspace(
|
|
|
474
511
|
};
|
|
475
512
|
note('spawn', `已启动 ${ws} 任务 ${key} pid=${spawned.pid}`
|
|
476
513
|
+ `(连续失败计数 ${fails})`);
|
|
514
|
+
pending.push({ ws, key, pid: spawned.pid });
|
|
477
515
|
}
|
|
478
516
|
|
|
479
517
|
// 第三步:任务文件维护(续期 + 改 id + 盖印)合并为一次带乐观锁的原子
|
|
480
|
-
// 写。撞车(Claude Code 刚写了 lastFiredAt
|
|
481
|
-
//
|
|
518
|
+
// 写。撞车(Claude Code 刚写了 lastFiredAt/删了任务)放弃本轮老条目维
|
|
519
|
+
// 护,但已 spawn 的会话仍返回给 cmdRun 等待认亲,盖印收尾会再写一次。
|
|
482
520
|
let result;
|
|
483
521
|
try {
|
|
484
522
|
result = maintainWorkspace(taskfile, { reown: stamps });
|
|
485
523
|
} catch (e) {
|
|
486
524
|
if (e instanceof TaskFileChanged) {
|
|
487
525
|
note('warn', `维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
|
|
488
|
-
+ '
|
|
489
|
-
return;
|
|
526
|
+
+ '稍后重试');
|
|
527
|
+
return pending;
|
|
490
528
|
}
|
|
491
529
|
throw e;
|
|
492
530
|
}
|
|
493
|
-
|
|
531
|
+
// 与 readTasks 不一致的极端竞态:保守不动,新 spawn 也全留跨轮路径。
|
|
532
|
+
if (result === null) return [];
|
|
494
533
|
if (result.renewed) {
|
|
495
534
|
deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
|
|
496
535
|
+ '不再受 7 天过期限制');
|
|
@@ -499,17 +538,19 @@ export async function patrolWorkspace(
|
|
|
499
538
|
deps.log(`已改小 id ${ws}:${result.minified} 个周期任务的 id 改为小哈希`
|
|
500
539
|
+ '值,投递延迟降到约 1 分钟内');
|
|
501
540
|
}
|
|
502
|
-
if (result.reowned)
|
|
503
|
-
bump(stats, 'reown');
|
|
504
|
-
deps.log(`已过户 ${ws}:${result.reowned} 个窗口内任务的归属改到各自的`
|
|
505
|
-
+ '预热会话,这些任务将只在对应会话触发');
|
|
506
|
-
}
|
|
541
|
+
if (result.reowned) noteReowned(ws, result.reowned, stats);
|
|
507
542
|
// id 改小同轮把 state 条目跟着改键:不改的话下轮会把换 id 的任务误判
|
|
508
|
-
// 成「旧任务删除 + 新任务」,错杀刚拉起的会话再重拉一个。
|
|
543
|
+
// 成「旧任务删除 + 新任务」,错杀刚拉起的会话再重拉一个。pending 是本
|
|
544
|
+
// 轮 spawn 的返回快照,键也要跟着改,否则轮内等待按旧键找不到条目,新
|
|
545
|
+
// 任务的盖印被白白推到下轮。
|
|
509
546
|
for (const { from, to } of result.idChanges) {
|
|
510
547
|
if (bucket[from] && !bucket[to]) {
|
|
548
|
+
const movedPid = bucket[from].pid;
|
|
511
549
|
bucket[to] = { ...bucket[from], taskId: to };
|
|
512
550
|
delete bucket[from];
|
|
551
|
+
for (const p of pending) {
|
|
552
|
+
if (p.key === from && p.pid === movedPid) p.key = to;
|
|
553
|
+
}
|
|
513
554
|
}
|
|
514
555
|
}
|
|
515
556
|
|
|
@@ -543,6 +584,89 @@ export async function patrolWorkspace(
|
|
|
543
584
|
}
|
|
544
585
|
delete bucket[key];
|
|
545
586
|
}
|
|
587
|
+
return pending;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 等待拍数是墙钟帽的派生:假时钟(测试)下墙钟不走,拍数帽单独兜底。
|
|
591
|
+
const MAX_WARMUP_POLLS = WARMUP_WAIT_MS / WARMUP_POLL_MS;
|
|
592
|
+
|
|
593
|
+
// 轮内等待本轮新 spawn/认领的会话完成登记,认亲走与轮初同一个
|
|
594
|
+
// claimEntry。这是盖印加速,不是判死:等不到的活会话原样留下,判死只
|
|
595
|
+
// 走跨轮 WARMUP_GRACE 路径。先扫后睡——进入时各工作区决策已串行花掉数
|
|
596
|
+
// 秒,健康会话可能已登记;任一 pending 认亲或死亡都移出集合,全部移出
|
|
597
|
+
// 即提前结束。返回最新登记快照、按 ws 分组的认亲条目(供补盖印)与等
|
|
598
|
+
// 待期内死亡的 pending(供退出日志;其 bucket 条目已计失败)。
|
|
599
|
+
async function awaitWarmups(pending, state, cur, stats) {
|
|
600
|
+
const claimedByWs = new Map();
|
|
601
|
+
const died = [];
|
|
602
|
+
let details;
|
|
603
|
+
const deadline = Date.now() + WARMUP_WAIT_MS;
|
|
604
|
+
const recognize = () => {
|
|
605
|
+
const rest = [];
|
|
606
|
+
for (const p of pending) {
|
|
607
|
+
const ent = state[p.ws]?.[p.key];
|
|
608
|
+
if (!ent || ent.pid !== p.pid) continue;
|
|
609
|
+
if (!deps.trackedAlive(ent)) {
|
|
610
|
+
// 启动即崩的典型窗口:真拉起过却死掉,计一次失败并置空 pid,条
|
|
611
|
+
// 目靠 fails 走 48 小时 TTL 保留、下轮重拉;满三次当轮进冷却。
|
|
612
|
+
// 只打日志丢弃会让 fails 永远从零起,坏掉的环境每轮重拉却永不
|
|
613
|
+
// 冷却。
|
|
614
|
+
ent.fails = (ent.fails ?? 0) + 1;
|
|
615
|
+
ent.pid = null;
|
|
616
|
+
delete ent.procStart;
|
|
617
|
+
if (ent.fails >= FAIL_LIMIT) {
|
|
618
|
+
enterCooldown(ent, p.ws, p.key, ent.fails, cur, stats);
|
|
619
|
+
}
|
|
620
|
+
died.push(p);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
if (claimEntry(ent, details, p.ws)) {
|
|
624
|
+
if (!claimedByWs.has(p.ws)) claimedByWs.set(p.ws, []);
|
|
625
|
+
claimedByWs.get(p.ws).push(ent);
|
|
626
|
+
} else {
|
|
627
|
+
rest.push(p);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
pending = rest;
|
|
631
|
+
};
|
|
632
|
+
// 轮初快照拍于决策之前、spawn 尚未发生,认亲必然落空;进入先重扫一
|
|
633
|
+
// 拍再开始睡。之后睡一拍、扫一拍。
|
|
634
|
+
[, , details] = await deps.scanSessions();
|
|
635
|
+
recognize();
|
|
636
|
+
for (let polls = 0;
|
|
637
|
+
pending.length > 0 && polls < MAX_WARMUP_POLLS && Date.now() < deadline;
|
|
638
|
+
polls += 1) {
|
|
639
|
+
await deps.sleep(WARMUP_POLL_MS);
|
|
640
|
+
[, , details] = await deps.scanSessions();
|
|
641
|
+
recognize();
|
|
642
|
+
}
|
|
643
|
+
return { details, claimedByWs, died };
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// 等待期认到亲的会话补盖归属印:claimedByWs 的每个 ent 已是 bucket 条
|
|
647
|
+
// 目,sig/认亲身份都在上面。无认亲的 ws 不调用、不写盘;撞 mtime 乐观
|
|
648
|
+
// 锁或极端竞态(result=null)跳过,下轮再来。
|
|
649
|
+
function stampClaimed(claimedByWs, taskfiles, stats) {
|
|
650
|
+
for (const [ws, ents] of claimedByWs) {
|
|
651
|
+
const stamps = new Map();
|
|
652
|
+
const conflicts = new Set();
|
|
653
|
+
for (const ent of ents) {
|
|
654
|
+
if (entryClaimed(ent)) mergeStamp(stamps, conflicts, ent.sig, reownTarget(ent));
|
|
655
|
+
}
|
|
656
|
+
if (stamps.size === 0) continue;
|
|
657
|
+
try {
|
|
658
|
+
const result = maintainWorkspace(taskfiles.get(ws), { reown: stamps });
|
|
659
|
+
if (result?.reowned) noteReowned(ws, result.reowned, stats);
|
|
660
|
+
} catch (e) {
|
|
661
|
+
if (e instanceof TaskFileChanged) {
|
|
662
|
+
bump(stats, 'warn');
|
|
663
|
+
deps.log(`盖印暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
|
|
664
|
+
+ '下轮再来');
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
throw e;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
546
670
|
}
|
|
547
671
|
|
|
548
672
|
// 升级轮:旧版目录级 state 的预热逐个结束(不迁移归属,新流程按任务重
|
|
@@ -624,8 +748,10 @@ export async function cmdRun(args) {
|
|
|
624
748
|
+ '请检查配置文件或用 cron-up install --roots 指定');
|
|
625
749
|
}
|
|
626
750
|
|
|
627
|
-
//
|
|
628
|
-
|
|
751
|
+
// 登记轮初读一次(含祖先链快照):认亲与活创建者判定都靠它。有新
|
|
752
|
+
// spawn 时等待期会重扫,details 随之刷新到最新快照(轮末 kept 判定
|
|
753
|
+
// 与消费者计数都用刷新后的版本)。
|
|
754
|
+
let [consumers, registryAlert, details] = await deps.scanSessions();
|
|
629
755
|
if (registryAlert) {
|
|
630
756
|
bump(stats, 'warn');
|
|
631
757
|
deps.log(`告警:${registryAlert}`);
|
|
@@ -668,15 +794,18 @@ export async function cmdRun(args) {
|
|
|
668
794
|
|
|
669
795
|
let found = 0;
|
|
670
796
|
const scriptProcsRef = { value: null };
|
|
797
|
+
const pendingAll = [];
|
|
798
|
+
const taskfiles = new Map();
|
|
671
799
|
for (const [ws, taskfile] of deps.discover(cfg.roots)) {
|
|
672
800
|
found += 1;
|
|
673
801
|
if (!state[ws]) state[ws] = {};
|
|
802
|
+
taskfiles.set(ws, taskfile);
|
|
674
803
|
// 一个工作区读不动/写不动不能中断整轮:discover 是生成器,抛了会
|
|
675
804
|
// 跳过后面所有工作区。
|
|
676
805
|
try {
|
|
677
|
-
await patrolWorkspace(
|
|
806
|
+
pendingAll.push(...await patrolWorkspace(
|
|
678
807
|
ws, state[ws], taskfile, now, leadMs, cur, details,
|
|
679
|
-
scriptProcsRef, stats);
|
|
808
|
+
scriptProcsRef, stats));
|
|
680
809
|
} catch (e) {
|
|
681
810
|
bump(stats, 'error');
|
|
682
811
|
deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
|
|
@@ -684,6 +813,33 @@ export async function cmdRun(args) {
|
|
|
684
813
|
if (Object.keys(state[ws]).length === 0) delete state[ws];
|
|
685
814
|
}
|
|
686
815
|
|
|
816
|
+
// 本轮有新会话拉起才等待:绝大多数轮次 pending 为空,零额外 ps、零睡
|
|
817
|
+
// 眠。认亲后同一轮补盖归属印(否则要等下一轮,提前量就得兜两轮)。
|
|
818
|
+
// 等待只认亲、不判死;等不到的下轮按 WARMUP_GRACE 跨轮处理。
|
|
819
|
+
if (pendingAll.length > 0) {
|
|
820
|
+
try {
|
|
821
|
+
const { details: latest, claimedByWs, died } = await awaitWarmups(
|
|
822
|
+
pendingAll, state, cur, stats);
|
|
823
|
+
details = latest;
|
|
824
|
+
consumers = new Set(details.keys());
|
|
825
|
+
// 等待期内自然死亡的是本轮新 spawn 的会话,不在轮初 prevAlive 分
|
|
826
|
+
// 组里,单独补一条退出日志,否则启动即崩将完全静默。死亡条目
|
|
827
|
+
// fails≥1,轮末 kept 必保留,此刻仍在 bucket 里(pid 已置空,
|
|
828
|
+
// 用 pending 记的 pid 补回给日志)。
|
|
829
|
+
for (const d of died) {
|
|
830
|
+
bump(stats, 'exit');
|
|
831
|
+
deps.log(exitLine(d.ws, d.key,
|
|
832
|
+
{ ...state[d.ws]?.[d.key], pid: d.pid }));
|
|
833
|
+
}
|
|
834
|
+
stampClaimed(claimedByWs, taskfiles, stats);
|
|
835
|
+
} catch (e) {
|
|
836
|
+
// 等待或补盖失败不能丢轮末落盘:最坏只是盖印晚一轮,会话进程已
|
|
837
|
+
// 在场,目录调度锁仍兜底。
|
|
838
|
+
bump(stats, 'error');
|
|
839
|
+
deps.log(`等待预热登记出错:${e.constructor.name}:${e.message}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
|
|
687
843
|
// 轮中死亡复核:轮初活、轮末没气、又不是本轮主动 stop 的。轮中刚被
|
|
688
844
|
// --resume 接管(sid 换 pid 存活)的不报。
|
|
689
845
|
for (const [ws, taskId, old] of prevAlive) {
|