cron-up 26.10.30 → 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 +111 -176
- package/package.json +1 -1
- package/src/args.mjs +5 -14
- package/src/cli.mjs +12 -29
- package/src/config.mjs +90 -66
- package/src/constants.mjs +36 -44
- package/src/cron.mjs +25 -7
- package/src/display.mjs +6 -5
- package/src/list.mjs +49 -22
- package/src/logs.mjs +36 -24
- package/src/overview.mjs +108 -109
- package/src/patrol.mjs +604 -432
- package/src/registry.mjs +78 -80
- package/src/service.mjs +89 -166
- package/src/sessions.mjs +34 -17
- package/src/tasks.mjs +73 -51
package/src/patrol.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// 一轮巡检:发现工作区 →
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// 一轮巡检:发现工作区 → 自动维护任务文件(续期/改 id/盖印)→ 保证每个
|
|
2
|
+
// 「创建者会话已死且处于预热窗口」的任务都有一个只属于它的健康会话。
|
|
3
|
+
// 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。本轮新拉起的会话
|
|
4
|
+
// 在所有工作区决策完后轮内等待登记(至多 WARMUP_WAIT_MS),认亲同一轮
|
|
5
|
+
// 补盖归属印;等不到的留给跨轮宽限,绝不当轮判死。状态机还覆盖:卡死
|
|
6
|
+
// 退休、失败冷却、执行完回收、resume 接管脱离、孤儿认领、任务消失回收、
|
|
7
|
+
// 旧版目录级 state 迁移。
|
|
4
8
|
|
|
5
9
|
import fs from 'node:fs';
|
|
6
10
|
import path from 'node:path';
|
|
@@ -11,16 +15,19 @@ import {
|
|
|
11
15
|
COOLDOWN_SECONDS,
|
|
12
16
|
FAIL_TTL_SECONDS,
|
|
13
17
|
WARMUP_GRACE_SECONDS,
|
|
14
|
-
|
|
15
|
-
|
|
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,
|
|
19
23
|
HEARTBEAT_STALE_FACTOR,
|
|
24
|
+
INTERVAL_SECONDS,
|
|
25
|
+
LEAD_SECONDS,
|
|
20
26
|
} from './constants.mjs';
|
|
21
27
|
import {
|
|
22
28
|
loadConfig,
|
|
23
29
|
loadState,
|
|
30
|
+
loadLegacyEntries,
|
|
24
31
|
saveState,
|
|
25
32
|
loadHeartbeat,
|
|
26
33
|
saveHeartbeat,
|
|
@@ -31,23 +38,24 @@ import {
|
|
|
31
38
|
maintainWorkspace,
|
|
32
39
|
TaskFileChanged,
|
|
33
40
|
taskFileExists,
|
|
41
|
+
taskKey,
|
|
42
|
+
taskSig,
|
|
34
43
|
} from './tasks.mjs';
|
|
35
|
-
import { taskWanted, parseCronOrNone,
|
|
44
|
+
import { taskWanted, parseCronOrNone, wantedNow } from './cron.mjs';
|
|
36
45
|
import {
|
|
37
46
|
sessionLogPath,
|
|
38
47
|
sessionLogIdleSeconds,
|
|
39
48
|
scriptProcLogPath,
|
|
49
|
+
isTaskSessionLogName,
|
|
40
50
|
rotateOnce,
|
|
41
51
|
} from './sessions.mjs';
|
|
42
52
|
import { TASK_REL } from './paths.mjs';
|
|
43
53
|
import { elapsedZh, pad2 } from './display.mjs';
|
|
44
54
|
|
|
45
|
-
// 事件类型 →
|
|
46
|
-
// stats 计数器里记账,平静轮也会打出「本轮无动作」。
|
|
55
|
+
// 事件类型 → 轮末汇总里的中文标签。平静轮也会打出「本轮无动作」。
|
|
47
56
|
const EVENT_LABELS = [
|
|
48
57
|
['spawn', '启动'],
|
|
49
58
|
['recycle', '回收'],
|
|
50
|
-
['ageout', '换代'],
|
|
51
59
|
['exit', '自行退出'],
|
|
52
60
|
['stuck', '卡死退休'],
|
|
53
61
|
['adopt', '接管'],
|
|
@@ -159,55 +167,90 @@ export function rotatePatrolLogs() {
|
|
|
159
167
|
}
|
|
160
168
|
}
|
|
161
169
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
170
|
+
// 该任务下次「需要会话在场」的时刻(ms):现在已在窗口(落点尾窗、一次
|
|
171
|
+
// 性错过待补)视为立刻需要;窗口外取 cron 裸落点(周期任务投递晚于落
|
|
172
|
+
// 点,按落点算余量更小、回收更保守;一次性网格落点反而提前至多 90 秒投
|
|
173
|
+
// 递,这段提前量由回收余量 SESSION_FIRE_MARGIN_SECONDS 罩住)。cron
|
|
174
|
+
// 无效返回 Infinity,永不回收。
|
|
175
|
+
function taskNeedMs(task, now) {
|
|
176
|
+
const cron = parseCronOrNone(task?.cron);
|
|
177
|
+
if (cron === null) return Infinity;
|
|
178
|
+
if (wantedNow(cron, task, now)) return now.getTime();
|
|
179
|
+
return cron.nextAfter(now)?.getTime() ?? Infinity;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 一个预热 entry 是否已认到登记里的活 claude 会话。
|
|
183
|
+
function entryClaimed(ent) {
|
|
184
|
+
return Boolean(ent?.sessionId && ent.claudeProcStart);
|
|
185
|
+
}
|
|
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;
|
|
176
198
|
}
|
|
177
|
-
return
|
|
199
|
+
return d;
|
|
178
200
|
}
|
|
179
201
|
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
+
}
|
|
189
221
|
}
|
|
190
222
|
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
//
|
|
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
|
+
|
|
239
|
+
// 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/改
|
|
240
|
+
// id/盖印维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共
|
|
241
|
+
// 享的进程表快照容器({value: 数组|null}),只在孤儿认领时拍一次。
|
|
242
|
+
// 返回 pending:本轮新纳入跟踪、尚未认亲的活 script(新 spawn 与孤儿认
|
|
243
|
+
// 领)的 {ws,key,pid} 列表,cmdRun 据此轮内等待认亲并补盖印。
|
|
199
244
|
export async function patrolWorkspace(
|
|
200
|
-
ws,
|
|
201
|
-
retain = 'always', stats = null, stamp = null,
|
|
245
|
+
ws, bucket, taskfile, now, leadMs, cur, details, scriptProcsRef, stats,
|
|
202
246
|
) {
|
|
247
|
+
const pending = [];
|
|
203
248
|
const tasks = deps.readTasks(ws);
|
|
204
|
-
let ent = state[ws] ?? null;
|
|
205
|
-
// 边记日志边给轮末汇总记账:日志给人翻,stats 给轮首/轮末心跳行一个
|
|
206
|
-
// 紧凑全貌。stats 为 null(直接单测)时退化为纯日志。
|
|
207
249
|
const note = (kind, msg) => {
|
|
208
250
|
bump(stats, kind);
|
|
209
251
|
deps.log(msg);
|
|
210
252
|
};
|
|
253
|
+
const liveSids = deps.liveSessionIds(details, ws);
|
|
211
254
|
|
|
212
255
|
if (tasks === null) {
|
|
213
256
|
// 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
|
|
@@ -217,226 +260,440 @@ export async function patrolWorkspace(
|
|
|
217
260
|
note('warn', `任务文件读不出 ${ws}:格式损坏或不可读,本轮跳过该目录,`
|
|
218
261
|
+ `其中 ${TASK_REL} 里的定时任务都不会执行`);
|
|
219
262
|
}
|
|
220
|
-
return;
|
|
263
|
+
return [];
|
|
221
264
|
}
|
|
265
|
+
|
|
266
|
+
const stopEntry = async (ent, kind, msg) => {
|
|
267
|
+
await deps.stopSession(ent);
|
|
268
|
+
note(kind, msg);
|
|
269
|
+
};
|
|
270
|
+
|
|
222
271
|
if (tasks.length === 0) {
|
|
223
272
|
// 文件完好但任务清空:一次性任务触发后被删、周期任务过期、或用户手
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
273
|
+
// 删。我们为这些任务拉起的会话没理由继续留着;detached(用户 resume
|
|
274
|
+
// 接管)的 stop 是 no-op,条目照删——sid 没了任务也没了,无需再管。
|
|
275
|
+
for (const [key, ent] of Object.entries(bucket)) {
|
|
227
276
|
if (deps.trackedAlive(ent)) {
|
|
228
|
-
await
|
|
229
|
-
|
|
277
|
+
await stopEntry(ent, 'recycle',
|
|
278
|
+
`回收 ${ws} 任务 ${key} pid=${ent.pid}:任务已全部清空,结束预热会话`);
|
|
230
279
|
}
|
|
231
|
-
delete
|
|
280
|
+
delete bucket[key];
|
|
232
281
|
}
|
|
233
|
-
return;
|
|
282
|
+
return [];
|
|
234
283
|
}
|
|
235
284
|
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
liveCreatorWanted(tasks, nextDetails, ws, now, leadMs).creatorAlive);
|
|
257
|
-
} else {
|
|
258
|
-
consumers = (await deps.scanSessions())[0];
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
// 换代:超龄且日志静默(无任务在执行的证据)的会话主动结束,需要时下
|
|
263
|
-
// 文自然重拉。换代是维护,不计失败。
|
|
264
|
-
if (ent !== null && mineAlive) {
|
|
265
|
-
const age = cur - (ent.startedAt ?? cur);
|
|
266
|
-
if (age > SESSION_MAX_AGE_SECONDS
|
|
267
|
-
&& sessionLogIdleSeconds(
|
|
268
|
-
ent.log || sessionLogPath(ws), cur) > SESSION_IDLE_SECONDS) {
|
|
269
|
-
await retire('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
|
|
270
|
-
+ `${Math.floor(age / 86400)} 天且近期无输出,结束后按需重拉`);
|
|
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];
|
|
271
305
|
}
|
|
272
306
|
}
|
|
273
307
|
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
// 得及登记,不会漏掉到点任务)。时间上重叠或紧邻的任务仍共享会话:任
|
|
280
|
-
// 务路由在 Claude Code 手里,同目录并发多会话无法指定归属,还有重复
|
|
281
|
-
// 执行的风险。回收不计失败——任务成功执行过是最强的健康证据,陈年计
|
|
282
|
-
// 数一并清零。
|
|
283
|
-
// 余量取值:默认只保登记抖动(约 2 分钟);stampOwner 开启时必须再留
|
|
284
|
-
// 出一个巡检间隔——回收后要靠「下轮重拉→再下轮过户」两步把 owner 盖
|
|
285
|
-
// 到新会话,余量小于间隔就可能在过户完成前到点(cmdRun 传入
|
|
286
|
-
// SESSION_FIRE_MARGIN_SECONDS + intervalSeconds)。
|
|
287
|
-
const fireMarginSec = stamp
|
|
288
|
-
? stamp.recycleMarginSec
|
|
289
|
-
: SESSION_FIRE_MARGIN_SECONDS;
|
|
290
|
-
if (ent !== null && mineAlive && retain === 'window'
|
|
291
|
-
&& tasks.some((t) => typeof t?.lastFiredAt === 'number'
|
|
292
|
-
&& t.lastFiredAt > (ent.startedAt ?? 0) * 1000)
|
|
293
|
-
&& sessionLogIdleSeconds(ent.log || sessionLogPath(ws), cur)
|
|
294
|
-
> SESSION_RECYCLE_IDLE_SECONDS
|
|
295
|
-
&& needHorizonMs(tasks, now) - now.getTime()
|
|
296
|
-
> fireMarginSec * 1000) {
|
|
297
|
-
// stampOwner 关时:用户自己的会话若在场,retire 的重扫依旧看见,不
|
|
298
|
-
// 会在同目录双开;开启时:旧代整组结束后重扫只可能剩用户会话,下面
|
|
299
|
-
// 的逻辑会按需另拉新预热。
|
|
300
|
-
await retire('recycle', `回收 ${ws} pid=${ent.pid}:本代任务已执行完毕且`
|
|
301
|
-
+ '界面静默,结束会话,下次需要时重拉');
|
|
308
|
+
// 第一步:认亲。认到 = 最强健康证据,当场回填身份、清零失败计数。本
|
|
309
|
+
// 轮新 spawn 的不在快照里,认亲由 cmdRun 的轮内等待补做;其余条目认
|
|
310
|
+
// 不到下轮再认,不阻断任何决策。
|
|
311
|
+
for (const ent of Object.values(bucket)) {
|
|
312
|
+
if (deps.trackedAlive(ent)) claimEntry(ent, details, ws);
|
|
302
313
|
}
|
|
303
314
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
315
|
+
// 第二步:逐任务决策。stamps 收本轮要盖的归属印(sig → 预热身份),
|
|
316
|
+
// 第三步合并进同一次原子写。stampConflicts 记「同内容签名被多个预热
|
|
317
|
+
// 声称」的病态任务(同毫秒连建的重复任务):一律不盖,交回调度锁原
|
|
318
|
+
// 生规则,避免错盖后没拿到任务的预热永不满足回收条件而空转泄漏。
|
|
319
|
+
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);
|
|
324
|
+
let ent = bucket[key] ?? null;
|
|
325
|
+
const logPath = sessionLogPath(ws, key);
|
|
326
|
+
const ownerIsOurs = entryClaimed(ent)
|
|
327
|
+
&& typeof t.createdBySessionId === 'string'
|
|
328
|
+
&& t.createdBySessionId === ent.sessionId;
|
|
329
|
+
|
|
330
|
+
// (a) 任务的创建者会话还开着:最高优先级,任务铁定投进它,我们不预
|
|
331
|
+
// 热、不盖印。ownerIsOurs 除外(归属本就是我们这代预热,sid 在活集
|
|
332
|
+
// 合里是理所当然)。若此前已为它拉过预热,结束多余的那条:未认亲超
|
|
333
|
+
// 宽限是卡垃圾,已认亲则是归属被外部改回活创建者,两者都不计失败。
|
|
334
|
+
if (!ownerIsOurs && deps.creatorSessionAlive(t, liveSids)) {
|
|
335
|
+
if (ent && deps.trackedAlive(ent)
|
|
336
|
+
&& cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS
|
|
337
|
+
&& !entryClaimed(ent)) {
|
|
338
|
+
await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
339
|
+
+ '进程已存活仍未登记会话,已终止;任务的创建者会话仍在场可承接,'
|
|
340
|
+
+ '不另拉预热');
|
|
341
|
+
delete bucket[key];
|
|
342
|
+
} else if (ent && deps.trackedAlive(ent) && entryClaimed(ent)) {
|
|
343
|
+
await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
344
|
+
+ '任务创建者会话已在场,结束多余的预热会话');
|
|
345
|
+
delete bucket[key];
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
321
349
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (ent !== null && mineAlive && consumers.has(ws)
|
|
337
|
-
&& (!stamp || verdict.kind === 'own' || verdict.kind === 'ambiguous')) {
|
|
338
|
-
ent.fails = 0;
|
|
339
|
-
}
|
|
350
|
+
// (b) 用户在终端 --resume 接管:script 层已死,但同一 sessionId 的
|
|
351
|
+
// 裸 claude 还活着。冻结条目:不重拉(双开)、不计数、不碰用户进
|
|
352
|
+
// 程;sid 消失后再按死主任务正常重拉。
|
|
353
|
+
if (ent && !deps.trackedAlive(ent)
|
|
354
|
+
&& typeof ent.sessionId === 'string'
|
|
355
|
+
&& liveSids.has(ent.sessionId)) {
|
|
356
|
+
if (!ent.notified) {
|
|
357
|
+
ent.notified = true;
|
|
358
|
+
deps.log(`接管脱离 ${ws} 任务 ${key}:预热会话已在终端被 --resume `
|
|
359
|
+
+ '打开(sessionId 仍存活),停止管理它,不再另拉预热;该会话关闭'
|
|
360
|
+
+ '后巡检会重新拉起');
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
340
364
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
365
|
+
// (c) window 回收:不要求任务此刻在窗口内——恰恰是执行完、已离开窗
|
|
366
|
+
// 口的会话要被收掉。三条件缺一不可:本任务在本代执行过(lastFiredAt
|
|
367
|
+
// 晚于 startedAt——区分「执行完的空闲」与「等触发的空闲」,后者是预
|
|
368
|
+
// 热缓存在干活)、本任务日志静默超时、距本任务下次需要还有一个巡检
|
|
369
|
+
// 间隔加余量(下轮发现并 spawn、当轮等待认亲盖印;needMs 在窗口内
|
|
370
|
+
// 返回 now,天然挡住「回收后立刻又需要」)。
|
|
371
|
+
if (ent && deps.trackedAlive(ent) && entryClaimed(ent)
|
|
372
|
+
&& typeof t.lastFiredAt === 'number'
|
|
373
|
+
&& t.lastFiredAt > (ent.startedAt ?? 0) * 1000
|
|
374
|
+
&& sessionLogIdleSeconds(ent.log || logPath, cur)
|
|
375
|
+
> SESSION_RECYCLE_IDLE_SECONDS
|
|
376
|
+
&& taskNeedMs(t, now) - now.getTime()
|
|
377
|
+
> SESSION_RECYCLE_MARGIN_SECONDS * 1000) {
|
|
378
|
+
await stopEntry(ent, 'recycle',
|
|
379
|
+
`回收 ${ws} 任务 ${key} pid=${ent.pid}:本任务已执行完毕且界面静默,`
|
|
380
|
+
+ '结束会话,下次窗口重新预热');
|
|
381
|
+
delete bucket[key];
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
352
384
|
|
|
353
|
-
|
|
385
|
+
// 此后只服务「死主 + 在预热窗口内」的任务。
|
|
386
|
+
if (!taskWanted(t, now, leadMs)) continue;
|
|
354
387
|
|
|
355
|
-
|
|
388
|
+
// (d) 卡死退休:script 活着、超登记宽限仍未认亲——停在信任/授权提
|
|
389
|
+
// 问界面的垃圾进程。杀掉后继续走失败计数/冷却/重拉。
|
|
390
|
+
if (ent && deps.trackedAlive(ent) && !entryClaimed(ent)
|
|
391
|
+
&& cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS) {
|
|
392
|
+
await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
393
|
+
+ '进程已存活仍未登记会话,已终止'
|
|
394
|
+
+ `(排查:cron-up logs ${path.basename(ws)}--${key})`);
|
|
395
|
+
}
|
|
356
396
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
397
|
+
// (e) resume 接管结束:sid 已从登记消失。整代身份作废(script 已
|
|
398
|
+
// 死、认亲信息失效),fails 清零(用户使用期间的退出不算我们的启动
|
|
399
|
+
// 失败),作为全新一条落入下面的重拉。
|
|
400
|
+
if (ent && !deps.trackedAlive(ent)
|
|
401
|
+
&& typeof ent.sessionId === 'string'
|
|
402
|
+
&& !liveSids.has(ent.sessionId)) {
|
|
403
|
+
ent.pid = null;
|
|
404
|
+
delete ent.procStart;
|
|
405
|
+
delete ent.claudePid;
|
|
406
|
+
delete ent.sessionId;
|
|
407
|
+
delete ent.claudeProcStart;
|
|
408
|
+
delete ent.notified;
|
|
409
|
+
ent.fails = 0;
|
|
410
|
+
}
|
|
364
411
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
} else {
|
|
373
|
-
// 只有真的拉起过(pid 非空)的条目死亡才算一次新失败;pid 为空的条目
|
|
374
|
-
// 是当场就没拉起的尝试,那次失败已在写入时计过,不再重复累计。
|
|
375
|
-
fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
|
|
376
|
-
}
|
|
412
|
+
// 盖印(跨轮老条目):已认亲且任务归属还没盖到本代预热(活创建者
|
|
413
|
+
// 已在 (a) 排除)。当轮新 spawn 的会话在第三步之后的轮内等待里认
|
|
414
|
+
// 亲,由 cmdRun 的 stampClaimed 补盖,不走这里。
|
|
415
|
+
if (entryClaimed(ent)
|
|
416
|
+
&& (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
|
|
417
|
+
mergeStamp(stamps, stampConflicts, sig, reownTarget(ent));
|
|
418
|
+
}
|
|
377
419
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
pid: null,
|
|
381
|
-
fails,
|
|
382
|
-
cooldownUntil: cur + COOLDOWN_SECONDS,
|
|
383
|
-
};
|
|
384
|
-
note('cool', `冷却 ${ws}:仍有任务需要会话,但保活会话连续失败 ${fails} 次,`
|
|
385
|
-
+ `暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
420
|
+
// warm 未到回收 / spawning 宽限内:本轮无事。
|
|
421
|
+
if (ent && deps.trackedAlive(ent)) continue;
|
|
388
422
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
423
|
+
if (isCooling(ent, cur)) continue;
|
|
424
|
+
|
|
425
|
+
// 失败计数:冷却刚结束给一次干净缓刑;只有真拉起过(pid 非空)的条
|
|
426
|
+
// 目死亡才算一次新失败,当场没拉起的尝试已在写入时计过。
|
|
427
|
+
let fails;
|
|
428
|
+
if (ent?.cooldownUntil) {
|
|
429
|
+
fails = 0;
|
|
430
|
+
} else {
|
|
431
|
+
fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (fails >= FAIL_LIMIT) {
|
|
435
|
+
bucket[key] = {
|
|
436
|
+
...ent,
|
|
437
|
+
ws,
|
|
438
|
+
taskId: key,
|
|
439
|
+
sig,
|
|
440
|
+
pid: null,
|
|
441
|
+
fails,
|
|
442
|
+
log: ent?.log ?? logPath,
|
|
443
|
+
};
|
|
444
|
+
enterCooldown(bucket[key], ws, key, fails, cur, stats);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 孤儿认领:state 丢了但进程表里还有为本任务拉起的 script(卡在提问
|
|
449
|
+
// 界面就不会出现在登记里)。按任务专属日志路径精确相认,alive 复核
|
|
450
|
+
// 防把快照里刚被杀的 pid 认回来。
|
|
451
|
+
if (ent === null) {
|
|
452
|
+
if (scriptProcsRef.value === null) {
|
|
453
|
+
scriptProcsRef.value = deps.listScriptProcesses();
|
|
454
|
+
}
|
|
455
|
+
const adopted = scriptProcsRef.value.find(
|
|
456
|
+
([procPid, procArgs]) =>
|
|
457
|
+
scriptProcLogPath(procArgs) === logPath && deps.alive(procPid));
|
|
458
|
+
if (adopted) {
|
|
459
|
+
const [procPid] = adopted;
|
|
460
|
+
bucket[key] = {
|
|
461
|
+
ws,
|
|
462
|
+
taskId: key,
|
|
463
|
+
sig,
|
|
400
464
|
pid: procPid,
|
|
401
465
|
startedAt: cur,
|
|
402
466
|
procStart: deps.procStartedAt(procPid),
|
|
403
467
|
fails: 0,
|
|
404
468
|
log: logPath,
|
|
405
469
|
};
|
|
406
|
-
note('adopt', `接管 ${ws} pid=${procPid}:state
|
|
407
|
-
+ '
|
|
408
|
-
|
|
470
|
+
note('adopt', `接管 ${ws} 任务 ${key} pid=${procPid}:state 记录缺失,`
|
|
471
|
+
+ '但进程表里仍有本该任务的预热会话,重新纳入跟踪');
|
|
472
|
+
pending.push({ ws, key, pid: procPid });
|
|
473
|
+
continue;
|
|
409
474
|
}
|
|
410
475
|
}
|
|
411
|
-
}
|
|
412
476
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
477
|
+
// spawn:成败只认有没有拿到 pid。当场失败立即计入;本轮就顶满失败上
|
|
478
|
+
// 限的,直接进冷却(不必再空跑一轮)。
|
|
479
|
+
const spawned = deps.spawnSession(ws, key);
|
|
480
|
+
if (spawned?.pid == null) {
|
|
481
|
+
const newFails = fails + 1;
|
|
482
|
+
if (newFails >= FAIL_LIMIT) {
|
|
483
|
+
bucket[key] = {
|
|
484
|
+
ws, taskId: key, sig, pid: null, fails: newFails, log: logPath,
|
|
485
|
+
};
|
|
486
|
+
enterCooldown(bucket[key], ws, key, newFails, cur, stats);
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
bucket[key] = {
|
|
490
|
+
ws,
|
|
491
|
+
taskId: key,
|
|
492
|
+
sig,
|
|
493
|
+
pid: null,
|
|
494
|
+
startedAt: cur,
|
|
495
|
+
fails: newFails,
|
|
496
|
+
log: logPath,
|
|
497
|
+
};
|
|
498
|
+
note('fail', `启动失败 ${ws} 任务 ${key}:会话进程没有拉起`
|
|
499
|
+
+ `(连续失败计数 ${newFails})`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
bucket[key] = {
|
|
503
|
+
ws,
|
|
504
|
+
taskId: key,
|
|
505
|
+
sig,
|
|
506
|
+
pid: spawned.pid,
|
|
421
507
|
startedAt: cur,
|
|
422
|
-
|
|
423
|
-
|
|
508
|
+
procStart: spawned.procStart,
|
|
509
|
+
fails,
|
|
510
|
+
log: logPath,
|
|
424
511
|
};
|
|
425
|
-
note('
|
|
426
|
-
|
|
512
|
+
note('spawn', `已启动 ${ws} 任务 ${key} pid=${spawned.pid}`
|
|
513
|
+
+ `(连续失败计数 ${fails})`);
|
|
514
|
+
pending.push({ ws, key, pid: spawned.pid });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// 第三步:任务文件维护(续期 + 改 id + 盖印)合并为一次带乐观锁的原子
|
|
518
|
+
// 写。撞车(Claude Code 刚写了 lastFiredAt/删了任务)放弃本轮老条目维
|
|
519
|
+
// 护,但已 spawn 的会话仍返回给 cmdRun 等待认亲,盖印收尾会再写一次。
|
|
520
|
+
let result;
|
|
521
|
+
try {
|
|
522
|
+
result = maintainWorkspace(taskfile, { reown: stamps });
|
|
523
|
+
} catch (e) {
|
|
524
|
+
if (e instanceof TaskFileChanged) {
|
|
525
|
+
note('warn', `维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
|
|
526
|
+
+ '稍后重试');
|
|
527
|
+
return pending;
|
|
528
|
+
}
|
|
529
|
+
throw e;
|
|
427
530
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
531
|
+
// 与 readTasks 不一致的极端竞态:保守不动,新 spawn 也全留跨轮路径。
|
|
532
|
+
if (result === null) return [];
|
|
533
|
+
if (result.renewed) {
|
|
534
|
+
deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
|
|
535
|
+
+ '不再受 7 天过期限制');
|
|
536
|
+
}
|
|
537
|
+
if (result.minified) {
|
|
538
|
+
deps.log(`已改小 id ${ws}:${result.minified} 个周期任务的 id 改为小哈希`
|
|
539
|
+
+ '值,投递延迟降到约 1 分钟内');
|
|
540
|
+
}
|
|
541
|
+
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
|
+
|
|
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
|
+
}
|
|
572
|
+
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
|
+
}
|
|
581
|
+
if (deps.trackedAlive(ent)) {
|
|
582
|
+
await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
583
|
+
+ '对应任务已删除,结束预热会话');
|
|
584
|
+
}
|
|
585
|
+
delete bucket[key];
|
|
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;
|
|
434
631
|
};
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 升级轮:旧版目录级 state 的预热逐个结束(不迁移归属,新流程按任务重
|
|
673
|
+
// 拉);state.json 已丢的旧孤儿 script(日志是旧命名,不含任务键段)从
|
|
674
|
+
// 进程表兜底清扫。只在 loadLegacyEntries 非空(确在跨版本)时执行。
|
|
675
|
+
async function retireLegacy(legacy, stats) {
|
|
676
|
+
for (const ent of legacy) {
|
|
677
|
+
if (deps.trackedAlive(ent)) {
|
|
678
|
+
await deps.stopSession(ent);
|
|
679
|
+
bump(stats, 'recycle');
|
|
680
|
+
deps.log(`旧版迁移 ${ent.ws} pid=${ent.pid}:结束目录级预热会话,`
|
|
681
|
+
+ '升级后按任务重新预热');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const handled = new Set(legacy.filter((e) => e.pid).map((e) => e.pid));
|
|
685
|
+
for (const [pid, procArgs] of deps.listScriptProcesses()) {
|
|
686
|
+
if (handled.has(pid)) continue;
|
|
687
|
+
const file = scriptProcLogPath(procArgs);
|
|
688
|
+
if (file === null) continue;
|
|
689
|
+
if (path.dirname(file) !== deps.paths.sessionLogDir) continue;
|
|
690
|
+
if (isTaskSessionLogName(path.basename(file))) continue; // 新一代命名
|
|
691
|
+
const ent = { pid, procStart: deps.procStartedAt(pid) };
|
|
692
|
+
if (!deps.trackedAlive(ent)) continue;
|
|
693
|
+
await deps.stopSession(ent);
|
|
694
|
+
bump(stats, 'recycle');
|
|
695
|
+
deps.log(`旧版迁移 pid=${pid}:结束目录级预热孤儿会话(日志 ${file}),`
|
|
696
|
+
+ '升级后按任务重新预热');
|
|
440
697
|
}
|
|
441
698
|
}
|
|
442
699
|
|
|
@@ -448,10 +705,9 @@ export async function cmdRun(args) {
|
|
|
448
705
|
return false;
|
|
449
706
|
}
|
|
450
707
|
// 包一层 stopSession 记下本轮主动结束的 pid:轮末凭它区分「我们回收的」
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// spawn 一次 ps。finally 一并还原。
|
|
708
|
+
// 与「会话自行退出/被外部杀掉的」。同样包一层 procStartedAt:同一 pid
|
|
709
|
+
// 的 lstart 一轮内不会变,缓存后每个 pid 只 spawn 一次 ps。finally 还
|
|
710
|
+
// 原。
|
|
455
711
|
const prevStopSession = deps.stopSession;
|
|
456
712
|
const stoppedPids = new Set();
|
|
457
713
|
deps.stopSession = async (ent) => {
|
|
@@ -470,174 +726,158 @@ export async function cmdRun(args) {
|
|
|
470
726
|
deps.rotatePatrolLogs();
|
|
471
727
|
const cfg = loadConfig(args?.config ?? deps.paths.configPath);
|
|
472
728
|
const now = new Date();
|
|
473
|
-
const leadMs =
|
|
729
|
+
const leadMs = LEAD_SECONDS * 1000;
|
|
474
730
|
const state = loadState();
|
|
731
|
+
const legacy = loadLegacyEntries();
|
|
475
732
|
const cur = Math.floor(Date.now() / 1000);
|
|
476
733
|
|
|
477
734
|
// 轮首心跳:没有这两行,launchd 拉不起巡检(启动脚本失效、node 缺失)
|
|
478
735
|
// 时日志会一片寂静,无法和「平安无事」区分。上轮心跳过老说明中间漏
|
|
479
736
|
// 轮——休眠期间错过的 StartInterval,launchd 醒来只补跑一轮。
|
|
480
737
|
const hb = loadHeartbeat();
|
|
481
|
-
if (heartbeatStale(hb,
|
|
738
|
+
if (heartbeatStale(hb, INTERVAL_SECONDS, cur)) {
|
|
482
739
|
bump(stats, 'warn');
|
|
483
740
|
deps.log(`距上次巡检已 ${elapsedZh(cur - hb.ranAt)},超过 `
|
|
484
|
-
+ `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${
|
|
741
|
+
+ `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${INTERVAL_SECONDS} 秒),`
|
|
485
742
|
+ '期间 launchd 可能漏轮(常见于电脑休眠)');
|
|
486
743
|
}
|
|
487
|
-
deps.log(
|
|
488
|
-
+ `${cfg.roots.length} 个`);
|
|
744
|
+
deps.log(`巡检开始:扫描根目录 ${cfg.roots.length} 个`);
|
|
489
745
|
if (cfg.roots.length === 0) {
|
|
490
746
|
bump(stats, 'warn');
|
|
491
747
|
deps.log('警告:扫描目录 roots 为空,巡检不会发现任何工作区;'
|
|
492
|
-
+ '请检查配置文件或用 install --roots 指定');
|
|
748
|
+
+ '请检查配置文件或用 cron-up install --roots 指定');
|
|
493
749
|
}
|
|
494
750
|
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
const prevDead = [];
|
|
500
|
-
const prevAlive = [];
|
|
501
|
-
for (const [ws, ent] of Object.entries(state)) {
|
|
502
|
-
if (!ent.pid || ent.deadSince) continue;
|
|
503
|
-
const copy = [ws, { ...ent }];
|
|
504
|
-
(deps.trackedAlive(copy[1]) ? prevAlive : prevDead).push(copy);
|
|
505
|
-
}
|
|
506
|
-
const exitLine = (ws, old) => `会话退出 ${ws} pid=${old.pid}:非巡检主动结束`
|
|
507
|
-
+ `(已存活 ${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或`
|
|
508
|
-
+ '被外部终止;若反复出现请用 cron-up logs '
|
|
509
|
-
+ `${path.basename(ws)} 排查启动过程`;
|
|
510
|
-
|
|
511
|
-
// 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
|
|
512
|
-
// 属这里。进程表快照不在这里拍:只有孤儿认领分支需要,按需现拍(见
|
|
513
|
-
// patrolWorkspace 的 scriptProcs 形参)。
|
|
514
|
-
// lineage 只在 stampOwner 开启时拍:祖先链要一次全机 ps,关闭时
|
|
515
|
-
// scanSessions 不付这个代价(见 registry 的 scanSessions 注释)。
|
|
516
|
-
const [consumers, registryAlert, details] =
|
|
517
|
-
await deps.scanSessions({ lineage: cfg.stampOwner });
|
|
751
|
+
// 登记轮初读一次(含祖先链快照):认亲与活创建者判定都靠它。有新
|
|
752
|
+
// spawn 时等待期会重扫,details 随之刷新到最新快照(轮末 kept 判定
|
|
753
|
+
// 与消费者计数都用刷新后的版本)。
|
|
754
|
+
let [consumers, registryAlert, details] = await deps.scanSessions();
|
|
518
755
|
if (registryAlert) {
|
|
519
756
|
bump(stats, 'warn');
|
|
520
757
|
deps.log(`告警:${registryAlert}`);
|
|
521
758
|
}
|
|
522
759
|
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
// 作区现算;这里只留全轮常量。
|
|
526
|
-
const recycleMarginSec = SESSION_FIRE_MARGIN_SECONDS
|
|
527
|
-
+ cfg.intervalSeconds;
|
|
760
|
+
// 旧版收尾必须先于一切 spawn 决策(state 轮末被新形状整体覆盖)。
|
|
761
|
+
if (legacy.length > 0) await retireLegacy(legacy, stats);
|
|
528
762
|
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
763
|
+
// 轮初把上轮末仍相信活着的条目分成三组:活、resume 脱离(script 已
|
|
764
|
+
// 死但 sid 还在,不报退出)、真死(立即报)。带 deadSince 的旧报条
|
|
765
|
+
// 目跳过。不能只收此刻 kill -0 成功的——两轮之间崩溃正是最典型的被
|
|
766
|
+
// 动死亡。
|
|
767
|
+
const prevDead = [];
|
|
768
|
+
const prevAlive = [];
|
|
769
|
+
const sidInDetails = (ws, sid) =>
|
|
770
|
+
(details.get(ws) ?? []).some((d) => d.sessionId === sid);
|
|
771
|
+
for (const [ws, bucket] of Object.entries(state)) {
|
|
772
|
+
for (const [taskId, ent] of Object.entries(bucket)) {
|
|
773
|
+
if (!ent.pid || ent.deadSince) continue;
|
|
774
|
+
const copy = { ...ent };
|
|
775
|
+
if (deps.trackedAlive(copy)) {
|
|
776
|
+
prevAlive.push([ws, taskId, copy]);
|
|
777
|
+
} else if (typeof ent.sessionId === 'string'
|
|
778
|
+
&& sidInDetails(ws, ent.sessionId)) {
|
|
779
|
+
// detached:静默。
|
|
780
|
+
} else {
|
|
781
|
+
prevDead.push([ws, taskId, copy]);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const exitLine = (ws, taskId, old) => `会话退出 ${ws} 任务 ${taskId} `
|
|
786
|
+
+ `pid=${old.pid}:非巡检主动结束(已存活 `
|
|
787
|
+
+ `${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或被外部`
|
|
788
|
+
+ '终止;若反复出现请用 cron-up logs '
|
|
789
|
+
+ `${path.basename(ws)}--${taskId} 排查启动过程`;
|
|
790
|
+
for (const [ws, taskId, old] of prevDead) {
|
|
532
791
|
bump(stats, 'exit');
|
|
533
|
-
deps.log(exitLine(ws, old));
|
|
792
|
+
deps.log(exitLine(ws, taskId, old));
|
|
534
793
|
}
|
|
535
794
|
|
|
536
795
|
let found = 0;
|
|
537
|
-
|
|
796
|
+
const scriptProcsRef = { value: null };
|
|
797
|
+
const pendingAll = [];
|
|
798
|
+
const taskfiles = new Map();
|
|
799
|
+
for (const [ws, taskfile] of deps.discover(cfg.roots)) {
|
|
538
800
|
found += 1;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
// stampOwner 的判定在这里现算(依赖任务内容):窗口内是否还有任务
|
|
545
|
-
// 的创建者会话活着——活创建者拥有否决权,不另拉、不盖印。
|
|
546
|
-
let stampArg = null;
|
|
547
|
-
let liveSids = null;
|
|
548
|
-
if (cfg.stampOwner) {
|
|
549
|
-
const wsTasks = deps.readTasks(ws) ?? []; // readTasks 已滤掉非对象
|
|
550
|
-
const creator = liveCreatorWanted(wsTasks, details, ws, now, leadMs);
|
|
551
|
-
liveSids = creator.liveSids;
|
|
552
|
-
const entNow = state[ws] ?? null;
|
|
553
|
-
const ownPid = entNow && deps.trackedAlive(entNow) ? entNow.pid : null;
|
|
554
|
-
stampArg = {
|
|
555
|
-
verdict: deps.classifyWorkspace(details, ws, ownPid,
|
|
556
|
-
creator.creatorAlive),
|
|
557
|
-
recycleMarginSec,
|
|
558
|
-
};
|
|
559
|
-
}
|
|
560
|
-
const target = stampArg?.verdict?.target ?? null;
|
|
561
|
-
// 盖印谓词只盖「窗口内且创建者已不活」的任务:活创建者的任务永远
|
|
562
|
-
// 不碰(即便本目录另有我们的预热在承接别的死主任务);已盖给本代
|
|
563
|
-
// 预热的任务因 owner 就在活会话集合里而自然跳过,幂等不靠另写比较。
|
|
564
|
-
if (cfg.autoRenew || cfg.autoMinId || target) {
|
|
565
|
-
try {
|
|
566
|
-
const { renewed, minified, reowned } = maintainWorkspace(taskfile, {
|
|
567
|
-
renew: cfg.autoRenew,
|
|
568
|
-
minify: cfg.autoMinId,
|
|
569
|
-
// target 多出的祖先链字段 tasks 层不读,展开即可,不必手抄
|
|
570
|
-
// 三个盖印字段。
|
|
571
|
-
reown: target
|
|
572
|
-
? {
|
|
573
|
-
...target,
|
|
574
|
-
isWanted: (t) => taskWanted(t, now, leadMs)
|
|
575
|
-
&& !deps.creatorSessionAlive(t, liveSids),
|
|
576
|
-
}
|
|
577
|
-
: null,
|
|
578
|
-
}) ?? {};
|
|
579
|
-
if (renewed) {
|
|
580
|
-
deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
|
|
581
|
-
+ '不再受 7 天过期限制');
|
|
582
|
-
}
|
|
583
|
-
if (minified) {
|
|
584
|
-
deps.log(`已改小 id ${ws}:${minified} 个周期任务的 id 改为小哈希`
|
|
585
|
-
+ '值,投递延迟降到约 1 分钟内;下次预热拉起的新会话起生效,'
|
|
586
|
-
+ '已在运行的会话要等其结束重拉(window 模式执行完即回收、'
|
|
587
|
-
+ 'always 模式换代)后才读到新 id');
|
|
588
|
-
}
|
|
589
|
-
if (reowned) {
|
|
590
|
-
bump(stats, 'reown');
|
|
591
|
-
deps.log(`已过户 ${ws}:${reowned} 个窗口内任务的归属改到预热会话`
|
|
592
|
-
+ ` pid=${target.pid},这些任务将只在该会话触发`);
|
|
593
|
-
}
|
|
594
|
-
} catch (e) {
|
|
595
|
-
bump(stats, e instanceof TaskFileChanged ? 'warn' : 'error');
|
|
596
|
-
if (e instanceof TaskFileChanged) {
|
|
597
|
-
deps.log(`维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,`
|
|
598
|
-
+ '本轮不覆盖,下轮再来');
|
|
599
|
-
} else {
|
|
600
|
-
deps.log(`维护错误 ${ws}:${e.constructor.name}:${e.message}`);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
}
|
|
801
|
+
if (!state[ws]) state[ws] = {};
|
|
802
|
+
taskfiles.set(ws, taskfile);
|
|
803
|
+
// 一个工作区读不动/写不动不能中断整轮:discover 是生成器,抛了会
|
|
804
|
+
// 跳过后面所有工作区。
|
|
604
805
|
try {
|
|
605
|
-
await patrolWorkspace(
|
|
606
|
-
ws, state, now, leadMs, cur,
|
|
607
|
-
|
|
806
|
+
pendingAll.push(...await patrolWorkspace(
|
|
807
|
+
ws, state[ws], taskfile, now, leadMs, cur, details,
|
|
808
|
+
scriptProcsRef, stats));
|
|
608
809
|
} catch (e) {
|
|
609
810
|
bump(stats, 'error');
|
|
610
811
|
deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
|
|
611
812
|
}
|
|
813
|
+
if (Object.keys(state[ws]).length === 0) delete state[ws];
|
|
814
|
+
}
|
|
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
|
+
}
|
|
612
841
|
}
|
|
613
842
|
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
// 才已在循环前报过。报过的死条目随后由 kept 过滤打上 deadSince,后续
|
|
618
|
-
// 轮次不会重复报。
|
|
619
|
-
for (const [ws, old] of prevAlive) {
|
|
843
|
+
// 轮中死亡复核:轮初活、轮末没气、又不是本轮主动 stop 的。轮中刚被
|
|
844
|
+
// --resume 接管(sid 换 pid 存活)的不报。
|
|
845
|
+
for (const [ws, taskId, old] of prevAlive) {
|
|
620
846
|
if (stoppedPids.has(old.pid)) continue;
|
|
621
847
|
if (deps.trackedAlive(old)) continue;
|
|
848
|
+
if (typeof old.sessionId === 'string'
|
|
849
|
+
&& deps.sessionAliveNow(old.sessionId, ws)) continue;
|
|
622
850
|
bump(stats, 'exit');
|
|
623
|
-
deps.log(exitLine(ws, old));
|
|
851
|
+
deps.log(exitLine(ws, taskId, old));
|
|
624
852
|
}
|
|
625
853
|
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (
|
|
854
|
+
// kept 过滤(嵌套):活会话、resume 脱离(sid 仍在登记)、冷却中、
|
|
855
|
+
// 带失败计数的死条目(计数跨任务窗口累积,48 小时 TTL 老化)保留;
|
|
856
|
+
// 其余丢掉。首次发现死亡打 deadSince。
|
|
857
|
+
const kept = {};
|
|
858
|
+
for (const [ws, bucket] of Object.entries(state)) {
|
|
859
|
+
const out = {};
|
|
860
|
+
for (const [taskId, ent] of Object.entries(bucket)) {
|
|
861
|
+
if (deps.trackedAlive(ent)) {
|
|
862
|
+
out[taskId] = ent;
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
if (typeof ent.sessionId === 'string'
|
|
866
|
+
&& sidInDetails(ws, ent.sessionId)) {
|
|
867
|
+
out[taskId] = ent;
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (isCooling(ent, cur)) {
|
|
871
|
+
out[taskId] = ent;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
634
874
|
if ((ent.fails ?? 0) > 0) {
|
|
635
875
|
ent.deadSince ??= cur;
|
|
636
|
-
|
|
876
|
+
if (cur - ent.deadSince < FAIL_TTL_SECONDS) out[taskId] = ent;
|
|
637
877
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
878
|
+
}
|
|
879
|
+
if (Object.keys(out).length > 0) kept[ws] = out;
|
|
880
|
+
}
|
|
641
881
|
saveState(kept);
|
|
642
882
|
|
|
643
883
|
// 轮末汇总:一行交代这轮看见了什么、动了什么、花了多久,随后更新心跳。
|
|
@@ -653,8 +893,7 @@ export async function cmdRun(args) {
|
|
|
653
893
|
saveHeartbeat({
|
|
654
894
|
ranAt: cur,
|
|
655
895
|
version: packageVersion(),
|
|
656
|
-
intervalSeconds:
|
|
657
|
-
sessionRetain: cfg.sessionRetain,
|
|
896
|
+
intervalSeconds: INTERVAL_SECONDS,
|
|
658
897
|
durationMs,
|
|
659
898
|
events: stats,
|
|
660
899
|
});
|
|
@@ -666,73 +905,6 @@ export async function cmdRun(args) {
|
|
|
666
905
|
}
|
|
667
906
|
}
|
|
668
907
|
|
|
669
|
-
// 立即给所有周期任务补 permanent 并把 id 改小。忽略 cfg.autoRenew /
|
|
670
|
-
// cfg.autoMinId——手动跑命令本身就是显式指令(想只续期不改 id 的场景极
|
|
671
|
-
// 少,真需要可临时关掉配置后跑巡检)。与巡检共用同一把锁。
|
|
672
|
-
export async function cmdRenew(args) {
|
|
673
|
-
const lock = deps.acquireRunLock();
|
|
674
|
-
if (lock === null) {
|
|
675
|
-
deps.print('已有一轮巡检(或另一次 renew)在进行,请稍后重试');
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
try {
|
|
679
|
-
const cfg = loadConfig(args?.config ?? deps.paths.configPath);
|
|
680
|
-
let files = 0;
|
|
681
|
-
let tagged = 0;
|
|
682
|
-
let minified = 0;
|
|
683
|
-
let unchanged = 0;
|
|
684
|
-
const skipped = [];
|
|
685
|
-
for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
|
|
686
|
-
files += 1;
|
|
687
|
-
let r;
|
|
688
|
-
try {
|
|
689
|
-
r = maintainWorkspace(taskfile, { renew: true, minify: true });
|
|
690
|
-
} catch (e) {
|
|
691
|
-
if (e instanceof TaskFileChanged) {
|
|
692
|
-
deps.print(`暂缓 ${ws}:任务文件刚被 Claude Code 改动,本次未写入,`
|
|
693
|
-
+ '可再跑一次');
|
|
694
|
-
skipped.push(ws);
|
|
695
|
-
continue;
|
|
696
|
-
}
|
|
697
|
-
deps.print(`跳过 ${ws}:(${e.constructor.name}:${e.message})`);
|
|
698
|
-
skipped.push(ws);
|
|
699
|
-
continue;
|
|
700
|
-
}
|
|
701
|
-
if (r === null) {
|
|
702
|
-
deps.print(`跳过 ${ws}:任务文件读不出或格式损坏`);
|
|
703
|
-
skipped.push(ws);
|
|
704
|
-
continue;
|
|
705
|
-
}
|
|
706
|
-
if (r.renewed || r.minified) {
|
|
707
|
-
if (r.renewed) {
|
|
708
|
-
tagged += r.renewed;
|
|
709
|
-
deps.print(`已续期 ${ws}:${r.renewed} 个周期任务标记为 permanent`);
|
|
710
|
-
}
|
|
711
|
-
if (r.minified) {
|
|
712
|
-
minified += r.minified;
|
|
713
|
-
deps.print(`已改小 id ${ws}:${r.minified} 个周期任务投递延迟降到约`
|
|
714
|
-
+ ' 1 分钟内(下次预热的新会话起生效)');
|
|
715
|
-
}
|
|
716
|
-
} else {
|
|
717
|
-
unchanged += 1;
|
|
718
|
-
deps.print(`已是最新 ${ws}:周期任务均已 permanent 且 id 已足够小,`
|
|
719
|
-
+ '无需改动');
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
deps.print('');
|
|
723
|
-
let line = `扫描 ${files} 个任务文件:本次续期 ${tagged} 个、改小 id `
|
|
724
|
-
+ `${minified} 个任务,${unchanged} 个文件无需改动`;
|
|
725
|
-
if (skipped.length) line += `,${skipped.length} 个跳过`;
|
|
726
|
-
deps.print(line);
|
|
727
|
-
if (tagged) {
|
|
728
|
-
deps.print('这些周期任务此后不再受 7 天过期限制;为它们保活的后台会话'
|
|
729
|
-
+ '会长期常驻。');
|
|
730
|
-
}
|
|
731
|
-
} finally {
|
|
732
|
-
releaseLock(lock);
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
908
|
Object.assign(deps, {
|
|
737
909
|
log,
|
|
738
910
|
runPatrol: cmdRun,
|