cron-up 26.10.30 → 26.10.31
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 +24 -44
- package/src/list.mjs +49 -22
- package/src/logs.mjs +36 -24
- package/src/overview.mjs +108 -109
- package/src/patrol.mjs +445 -429
- 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,8 @@
|
|
|
1
|
-
// 一轮巡检:发现工作区 →
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// 一轮巡检:发现工作区 → 自动维护任务文件(续期/改 id/盖印)→ 保证每个
|
|
2
|
+
// 「创建者会话已死且处于预热窗口」的任务都有一个只属于它的健康会话。
|
|
3
|
+
// 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。状态机覆盖:认亲、
|
|
4
|
+
// 卡死退休、失败冷却、执行完回收、resume 接管脱离、孤儿认领、任务消失回
|
|
5
|
+
// 收、旧版目录级 state 迁移。
|
|
4
6
|
|
|
5
7
|
import fs from 'node:fs';
|
|
6
8
|
import path from 'node:path';
|
|
@@ -11,16 +13,17 @@ import {
|
|
|
11
13
|
COOLDOWN_SECONDS,
|
|
12
14
|
FAIL_TTL_SECONDS,
|
|
13
15
|
WARMUP_GRACE_SECONDS,
|
|
14
|
-
SESSION_MAX_AGE_SECONDS,
|
|
15
|
-
SESSION_IDLE_SECONDS,
|
|
16
16
|
SESSION_RECYCLE_IDLE_SECONDS,
|
|
17
|
-
|
|
17
|
+
SESSION_RECYCLE_MARGIN_SECONDS,
|
|
18
18
|
PATROL_LOG_ROTATE_BYTES,
|
|
19
19
|
HEARTBEAT_STALE_FACTOR,
|
|
20
|
+
INTERVAL_SECONDS,
|
|
21
|
+
LEAD_SECONDS,
|
|
20
22
|
} from './constants.mjs';
|
|
21
23
|
import {
|
|
22
24
|
loadConfig,
|
|
23
25
|
loadState,
|
|
26
|
+
loadLegacyEntries,
|
|
24
27
|
saveState,
|
|
25
28
|
loadHeartbeat,
|
|
26
29
|
saveHeartbeat,
|
|
@@ -31,23 +34,24 @@ import {
|
|
|
31
34
|
maintainWorkspace,
|
|
32
35
|
TaskFileChanged,
|
|
33
36
|
taskFileExists,
|
|
37
|
+
taskKey,
|
|
38
|
+
taskSig,
|
|
34
39
|
} from './tasks.mjs';
|
|
35
40
|
import { taskWanted, parseCronOrNone, wanted } from './cron.mjs';
|
|
36
41
|
import {
|
|
37
42
|
sessionLogPath,
|
|
38
43
|
sessionLogIdleSeconds,
|
|
39
44
|
scriptProcLogPath,
|
|
45
|
+
isTaskSessionLogName,
|
|
40
46
|
rotateOnce,
|
|
41
47
|
} from './sessions.mjs';
|
|
42
48
|
import { TASK_REL } from './paths.mjs';
|
|
43
49
|
import { elapsedZh, pad2 } from './display.mjs';
|
|
44
50
|
|
|
45
|
-
// 事件类型 →
|
|
46
|
-
// stats 计数器里记账,平静轮也会打出「本轮无动作」。
|
|
51
|
+
// 事件类型 → 轮末汇总里的中文标签。平静轮也会打出「本轮无动作」。
|
|
47
52
|
const EVENT_LABELS = [
|
|
48
53
|
['spawn', '启动'],
|
|
49
54
|
['recycle', '回收'],
|
|
50
|
-
['ageout', '换代'],
|
|
51
55
|
['exit', '自行退出'],
|
|
52
56
|
['stuck', '卡死退休'],
|
|
53
57
|
['adopt', '接管'],
|
|
@@ -159,55 +163,34 @@ export function rotatePatrolLogs() {
|
|
|
159
163
|
}
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
// cron
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const cron = parseCronOrNone(t?.cron);
|
|
172
|
-
if (cron === null) continue;
|
|
173
|
-
if (wanted(cron, t, now, 0)) return now.getTime();
|
|
174
|
-
const nxt = cron.nextAfter(now)?.getTime();
|
|
175
|
-
if (nxt !== undefined && nxt < horizon) horizon = nxt;
|
|
176
|
-
}
|
|
177
|
-
return horizon;
|
|
166
|
+
// 该任务下次「需要会话在场」的时刻(ms):现在已在窗口(落点尾窗、一次
|
|
167
|
+
// 性错过待补)视为立刻需要;窗口外取 cron 裸落点(刻意不取抖动后投递时
|
|
168
|
+
// 刻:落点更早,按落点算余量更小、回收更保守,是安全方向)。cron 无效
|
|
169
|
+
// 返回 Infinity,永不回收。
|
|
170
|
+
function taskNeedMs(task, now) {
|
|
171
|
+
const cron = parseCronOrNone(task?.cron);
|
|
172
|
+
if (cron === null) return Infinity;
|
|
173
|
+
if (wanted(cron, task, now, 0)) return now.getTime();
|
|
174
|
+
return cron.nextAfter(now)?.getTime() ?? Infinity;
|
|
178
175
|
}
|
|
179
176
|
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// 聚合布尔只是它的派生。
|
|
184
|
-
function liveCreatorWanted(tasks, details, ws, now, leadMs) {
|
|
185
|
-
const liveSids = deps.liveSessionIds(details, ws);
|
|
186
|
-
const creatorAlive = tasks.some((t) =>
|
|
187
|
-
taskWanted(t, now, leadMs) && deps.creatorSessionAlive(t, liveSids));
|
|
188
|
-
return { liveSids, creatorAlive };
|
|
177
|
+
// 一个预热 entry 是否已认到登记里的活 claude 会话。
|
|
178
|
+
function entryClaimed(ent) {
|
|
179
|
+
return Boolean(ent?.sessionId && ent.claudeProcStart);
|
|
189
180
|
}
|
|
190
181
|
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
// 下才拍快照,平静轮不付全量 ps 的代价。
|
|
195
|
-
// stamp=null 表示 stampOwner 关(一切旧行为);开启时为
|
|
196
|
-
// {verdict, recycleMarginSec}:verdict 是 classifyWorkspace 对轮初消费者
|
|
197
|
-
// 快照的认亲结论(own/creatorAlive/ambiguous/user,换代或回收导致快照变
|
|
198
|
-
// 化时在 retire 内用新快照重算),recycleMarginSec 是 window 回收余量。
|
|
182
|
+
// 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/改
|
|
183
|
+
// id/盖印维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共
|
|
184
|
+
// 享的进程表快照容器({value: 数组|null}),只在孤儿认领时拍一次。
|
|
199
185
|
export async function patrolWorkspace(
|
|
200
|
-
ws,
|
|
201
|
-
retain = 'always', stats = null, stamp = null,
|
|
186
|
+
ws, bucket, taskfile, now, leadMs, cur, details, scriptProcsRef, stats,
|
|
202
187
|
) {
|
|
203
188
|
const tasks = deps.readTasks(ws);
|
|
204
|
-
let ent = state[ws] ?? null;
|
|
205
|
-
// 边记日志边给轮末汇总记账:日志给人翻,stats 给轮首/轮末心跳行一个
|
|
206
|
-
// 紧凑全貌。stats 为 null(直接单测)时退化为纯日志。
|
|
207
189
|
const note = (kind, msg) => {
|
|
208
190
|
bump(stats, kind);
|
|
209
191
|
deps.log(msg);
|
|
210
192
|
};
|
|
193
|
+
const liveSids = deps.liveSessionIds(details, ws);
|
|
211
194
|
|
|
212
195
|
if (tasks === null) {
|
|
213
196
|
// 任务文件读不出:证据太弱,绝不动会话;但不能不吭声——文件还在却读
|
|
@@ -219,224 +202,374 @@ export async function patrolWorkspace(
|
|
|
219
202
|
}
|
|
220
203
|
return;
|
|
221
204
|
}
|
|
205
|
+
|
|
206
|
+
const stopEntry = async (ent, kind, msg) => {
|
|
207
|
+
await deps.stopSession(ent);
|
|
208
|
+
note(kind, msg);
|
|
209
|
+
};
|
|
210
|
+
|
|
222
211
|
if (tasks.length === 0) {
|
|
223
212
|
// 文件完好但任务清空:一次性任务触发后被删、周期任务过期、或用户手
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
213
|
+
// 删。我们为这些任务拉起的会话没理由继续留着;detached(用户 resume
|
|
214
|
+
// 接管)的 stop 是 no-op,条目照删——sid 没了任务也没了,无需再管。
|
|
215
|
+
for (const [key, ent] of Object.entries(bucket)) {
|
|
227
216
|
if (deps.trackedAlive(ent)) {
|
|
228
|
-
await
|
|
229
|
-
|
|
217
|
+
await stopEntry(ent, 'recycle',
|
|
218
|
+
`回收 ${ws} 任务 ${key} pid=${ent.pid}:任务已全部清空,结束预热会话`);
|
|
230
219
|
}
|
|
231
|
-
delete
|
|
220
|
+
delete bucket[key];
|
|
232
221
|
}
|
|
233
222
|
return;
|
|
234
223
|
}
|
|
235
224
|
|
|
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];
|
|
225
|
+
// 第零步:id 改键的崩溃窗口治愈。任务文件里旧键已不存在(上轮 minify
|
|
226
|
+
// 改小了 id、state 却没来得及存盘),而内容签名唯一匹配某个当前任务、
|
|
227
|
+
// 目标键又无主:先把条目挪到新键,让下面的决策把它当作连续的一代——
|
|
228
|
+
// 否则会按「旧任务删除 + 新任务」错杀活会话再重拉一个。必须在逐任务
|
|
229
|
+
// 决策之前做,spawn 才不会抢先占掉目标键。
|
|
230
|
+
let sigToKeys = new Map();
|
|
231
|
+
let currentKeys = new Set();
|
|
232
|
+
for (const t of tasks) {
|
|
233
|
+
const k = taskKey(t);
|
|
234
|
+
currentKeys.add(k);
|
|
235
|
+
const list0 = sigToKeys.get(taskSig(t)) ?? [];
|
|
236
|
+
list0.push(k);
|
|
237
|
+
sigToKeys.set(taskSig(t), list0);
|
|
238
|
+
}
|
|
239
|
+
for (const [key, ent] of Object.entries({ ...bucket })) {
|
|
240
|
+
if (currentKeys.has(key) || !ent.sig) continue;
|
|
241
|
+
const candidates = sigToKeys.get(ent.sig) ?? [];
|
|
242
|
+
if (candidates.length === 1 && !bucket[candidates[0]]) {
|
|
243
|
+
bucket[candidates[0]] = { ...ent, taskId: candidates[0] };
|
|
244
|
+
delete bucket[key];
|
|
259
245
|
}
|
|
260
|
-
}
|
|
246
|
+
}
|
|
261
247
|
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
248
|
+
// 第一步:认亲。spawn 只知道 script pid,登记里是 claude 孙进程;沿轮
|
|
249
|
+
// 初快照的祖先链找到我们这代的登记详情,回填 sessionId/claudePid/
|
|
250
|
+
// claudeProcStart。认到 = 最强健康证据,当场清零失败计数。认不到下轮
|
|
251
|
+
// 再认,不阻断任何决策。
|
|
252
|
+
for (const ent of Object.values(bucket)) {
|
|
253
|
+
if (!deps.trackedAlive(ent)) continue;
|
|
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;
|
|
271
261
|
}
|
|
272
262
|
}
|
|
273
263
|
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
264
|
+
// 第二步:逐任务决策。stamps 收本轮要盖的归属印(sig → 预热身份),
|
|
265
|
+
// 第三步合并进同一次原子写。stampConflicts 记「同内容签名被多个预热
|
|
266
|
+
// 声称」的病态任务(同毫秒连建的重复任务):一律不盖,交回调度锁原
|
|
267
|
+
// 生规则,避免错盖后没拿到任务的预热永不满足回收条件而空转泄漏。
|
|
268
|
+
const stamps = new Map();
|
|
269
|
+
const stampConflicts = new Set();
|
|
270
|
+
for (const t of tasks) {
|
|
271
|
+
const key = taskKey(t);
|
|
272
|
+
const sig = taskSig(t);
|
|
273
|
+
let ent = bucket[key] ?? null;
|
|
274
|
+
const logPath = sessionLogPath(ws, key);
|
|
275
|
+
const ownerIsOurs = entryClaimed(ent)
|
|
276
|
+
&& typeof t.createdBySessionId === 'string'
|
|
277
|
+
&& t.createdBySessionId === ent.sessionId;
|
|
278
|
+
|
|
279
|
+
// (a) 任务的创建者会话还开着:最高优先级,任务铁定投进它,我们不预
|
|
280
|
+
// 热、不盖印。ownerIsOurs 除外(归属本就是我们这代预热,sid 在活集
|
|
281
|
+
// 合里是理所当然)。若此前已为它拉过预热,结束多余的那条:未认亲超
|
|
282
|
+
// 宽限是卡垃圾,已认亲则是归属被外部改回活创建者,两者都不计失败。
|
|
283
|
+
if (!ownerIsOurs && deps.creatorSessionAlive(t, liveSids)) {
|
|
284
|
+
if (ent && deps.trackedAlive(ent)
|
|
285
|
+
&& cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS
|
|
286
|
+
&& !entryClaimed(ent)) {
|
|
287
|
+
await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
288
|
+
+ '进程已存活仍未登记会话,已终止;任务的创建者会话仍在场可承接,'
|
|
289
|
+
+ '不另拉预热');
|
|
290
|
+
delete bucket[key];
|
|
291
|
+
} else if (ent && deps.trackedAlive(ent) && entryClaimed(ent)) {
|
|
292
|
+
await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
293
|
+
+ '任务创建者会话已在场,结束多余的预热会话');
|
|
294
|
+
delete bucket[key];
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
303
298
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
+ (held ? ';同目录已有会话可承接任务,不另拉预热' : '')
|
|
319
|
-
+ `(排查:cron-up logs ${path.basename(ws)})`);
|
|
320
|
-
};
|
|
299
|
+
// (b) 用户在终端 --resume 接管:script 层已死,但同一 sessionId 的
|
|
300
|
+
// 裸 claude 还活着。冻结条目:不重拉(双开)、不计数、不碰用户进
|
|
301
|
+
// 程;sid 消失后再按死主任务正常重拉。
|
|
302
|
+
if (ent && !deps.trackedAlive(ent)
|
|
303
|
+
&& typeof ent.sessionId === 'string'
|
|
304
|
+
&& liveSids.has(ent.sessionId)) {
|
|
305
|
+
if (!ent.notified) {
|
|
306
|
+
ent.notified = true;
|
|
307
|
+
deps.log(`接管脱离 ${ws} 任务 ${key}:预热会话已在终端被 --resume `
|
|
308
|
+
+ '打开(sessionId 仍存活),停止管理它,不再另拉预热;该会话关闭'
|
|
309
|
+
+ '后巡检会重新拉起');
|
|
310
|
+
}
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
321
313
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
314
|
+
// (c) window 回收:不要求任务此刻在窗口内——恰恰是执行完、已离开窗
|
|
315
|
+
// 口的会话要被收掉。三条件缺一不可:本任务在本代执行过(lastFiredAt
|
|
316
|
+
// 晚于 startedAt——区分「执行完的空闲」与「等触发的空闲」,后者是预
|
|
317
|
+
// 热缓存在干活)、本任务日志静默超时、距本任务下次需要还有两步余量
|
|
318
|
+
// (下轮重拉、再下轮盖印;needMs 在窗口内返回 now,天然挡住「回收后
|
|
319
|
+
// 立刻又需要」)。
|
|
320
|
+
if (ent && deps.trackedAlive(ent) && entryClaimed(ent)
|
|
321
|
+
&& typeof t.lastFiredAt === 'number'
|
|
322
|
+
&& t.lastFiredAt > (ent.startedAt ?? 0) * 1000
|
|
323
|
+
&& sessionLogIdleSeconds(ent.log || logPath, cur)
|
|
324
|
+
> SESSION_RECYCLE_IDLE_SECONDS
|
|
325
|
+
&& taskNeedMs(t, now) - now.getTime()
|
|
326
|
+
> SESSION_RECYCLE_MARGIN_SECONDS * 1000) {
|
|
327
|
+
await stopEntry(ent, 'recycle',
|
|
328
|
+
`回收 ${ws} 任务 ${key} pid=${ent.pid}:本任务已执行完毕且界面静默,`
|
|
329
|
+
+ '结束会话,下次窗口重新预热');
|
|
330
|
+
delete bucket[key];
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
340
333
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
// 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
|
|
344
|
-
// 由轮末 kept 过滤统一处理。creatorAlive/ambiguous 返回前顺手清掉卡死
|
|
345
|
-
// 的垃圾预热;平稳态在此提前返回,不必继续后面的流程。
|
|
346
|
-
if (consumerHolds) {
|
|
347
|
-
// 默认配置(stamp 关)沿用旧行为:用户会话在场时「掩护」卡死预热,
|
|
348
|
-
// 不在这里杀,交给 7 天换代——所以 holds 路径只在 stamp 开启时清垃圾。
|
|
349
|
-
if (stamp && warmStuck) await killStuckWarm(true);
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
334
|
+
// 此后只服务「死主 + 在预热窗口内」的任务。
|
|
335
|
+
if (!taskWanted(t, now, leadMs)) continue;
|
|
352
336
|
|
|
353
|
-
|
|
337
|
+
// (d) 卡死退休:script 活着、超登记宽限仍未认亲——停在信任/授权提
|
|
338
|
+
// 问界面的垃圾进程。杀掉后继续走失败计数/冷却/重拉。
|
|
339
|
+
if (ent && deps.trackedAlive(ent) && !entryClaimed(ent)
|
|
340
|
+
&& cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS) {
|
|
341
|
+
await stopEntry(ent, 'stuck', `卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
342
|
+
+ '进程已存活仍未登记会话,已终止'
|
|
343
|
+
+ `(排查:cron-up logs ${path.basename(ws)}--${key})`);
|
|
344
|
+
}
|
|
354
345
|
|
|
355
|
-
|
|
346
|
+
// (e) resume 接管结束:sid 已从登记消失。整代身份作废(script 已
|
|
347
|
+
// 死、认亲信息失效),fails 清零(用户使用期间的退出不算我们的启动
|
|
348
|
+
// 失败),作为全新一条落入下面的重拉。
|
|
349
|
+
if (ent && !deps.trackedAlive(ent)
|
|
350
|
+
&& typeof ent.sessionId === 'string'
|
|
351
|
+
&& !liveSids.has(ent.sessionId)) {
|
|
352
|
+
ent.pid = null;
|
|
353
|
+
delete ent.procStart;
|
|
354
|
+
delete ent.claudePid;
|
|
355
|
+
delete ent.sessionId;
|
|
356
|
+
delete ent.claudeProcStart;
|
|
357
|
+
delete ent.notified;
|
|
358
|
+
ent.fails = 0;
|
|
359
|
+
}
|
|
356
360
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
361
|
+
// 盖印:已认亲且任务归属还没盖到本代预热(活创建者已在 (a) 排
|
|
362
|
+
// 除)。当轮新 spawn 的会话没认亲,下轮才盖——lead 覆盖两轮巡检。
|
|
363
|
+
if (entryClaimed(ent)
|
|
364
|
+
&& (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
|
|
365
|
+
const target = {
|
|
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
|
+
}
|
|
378
|
+
}
|
|
364
379
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
// 没有消费者,我们跟踪的会话(若有)已死或刚作为卡死退休。
|
|
368
|
-
let fails;
|
|
369
|
-
if (ent && ent.cooldownUntil) {
|
|
370
|
-
// 冷却刚结束:给一次干净的缓刑拉起,而不是立刻再次顶满限制。
|
|
371
|
-
fails = 0;
|
|
372
|
-
} else {
|
|
373
|
-
// 只有真的拉起过(pid 非空)的条目死亡才算一次新失败;pid 为空的条目
|
|
374
|
-
// 是当场就没拉起的尝试,那次失败已在写入时计过,不再重复累计。
|
|
375
|
-
fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
|
|
376
|
-
}
|
|
380
|
+
// warm 未到回收 / spawning 宽限内:本轮无事。
|
|
381
|
+
if (ent && deps.trackedAlive(ent)) continue;
|
|
377
382
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
383
|
+
if (isCooling(ent, cur)) continue;
|
|
384
|
+
|
|
385
|
+
// 失败计数:冷却刚结束给一次干净缓刑;只有真拉起过(pid 非空)的条
|
|
386
|
+
// 目死亡才算一次新失败,当场没拉起的尝试已在写入时计过。
|
|
387
|
+
let fails;
|
|
388
|
+
if (ent?.cooldownUntil) {
|
|
389
|
+
fails = 0;
|
|
390
|
+
} else {
|
|
391
|
+
fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (fails >= FAIL_LIMIT) {
|
|
395
|
+
bucket[key] = {
|
|
396
|
+
...ent,
|
|
397
|
+
ws,
|
|
398
|
+
taskId: key,
|
|
399
|
+
sig,
|
|
400
|
+
pid: null,
|
|
401
|
+
fails,
|
|
402
|
+
cooldownUntil: cur + COOLDOWN_SECONDS,
|
|
403
|
+
log: ent?.log ?? logPath,
|
|
404
|
+
};
|
|
405
|
+
note('cool', `冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
|
|
406
|
+
+ `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
388
409
|
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
410
|
+
// 孤儿认领:state 丢了但进程表里还有为本任务拉起的 script(卡在提问
|
|
411
|
+
// 界面就不会出现在登记里)。按任务专属日志路径精确相认,alive 复核
|
|
412
|
+
// 防把快照里刚被杀的 pid 认回来。
|
|
413
|
+
if (ent === null) {
|
|
414
|
+
if (scriptProcsRef.value === null) {
|
|
415
|
+
scriptProcsRef.value = deps.listScriptProcesses();
|
|
416
|
+
}
|
|
417
|
+
const adopted = scriptProcsRef.value.find(
|
|
418
|
+
([procPid, procArgs]) =>
|
|
419
|
+
scriptProcLogPath(procArgs) === logPath && deps.alive(procPid));
|
|
420
|
+
if (adopted) {
|
|
421
|
+
const [procPid] = adopted;
|
|
422
|
+
bucket[key] = {
|
|
423
|
+
ws,
|
|
424
|
+
taskId: key,
|
|
425
|
+
sig,
|
|
400
426
|
pid: procPid,
|
|
401
427
|
startedAt: cur,
|
|
402
428
|
procStart: deps.procStartedAt(procPid),
|
|
403
429
|
fails: 0,
|
|
404
430
|
log: logPath,
|
|
405
431
|
};
|
|
406
|
-
note('adopt', `接管 ${ws} pid=${procPid}:state
|
|
407
|
-
+ '
|
|
408
|
-
|
|
432
|
+
note('adopt', `接管 ${ws} 任务 ${key} pid=${procPid}:state 记录缺失,`
|
|
433
|
+
+ '但进程表里仍有本该任务的预热会话,重新纳入跟踪');
|
|
434
|
+
continue;
|
|
409
435
|
}
|
|
410
436
|
}
|
|
411
|
-
}
|
|
412
437
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
438
|
+
// spawn:成败只认有没有拿到 pid。当场失败立即计入;本轮就顶满失败上
|
|
439
|
+
// 限的,直接进冷却(不必再空跑一轮)。
|
|
440
|
+
const spawned = deps.spawnSession(ws, key);
|
|
441
|
+
if (spawned?.pid == null) {
|
|
442
|
+
const newFails = fails + 1;
|
|
443
|
+
if (newFails >= FAIL_LIMIT) {
|
|
444
|
+
bucket[key] = {
|
|
445
|
+
ws, taskId: key, sig, pid: null, fails: newFails,
|
|
446
|
+
cooldownUntil: cur + COOLDOWN_SECONDS, log: logPath,
|
|
447
|
+
};
|
|
448
|
+
note('cool', `冷却 ${ws} 任务 ${key}:仍有任务需要会话,但预热连续失败 `
|
|
449
|
+
+ `${newFails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
bucket[key] = {
|
|
453
|
+
ws,
|
|
454
|
+
taskId: key,
|
|
455
|
+
sig,
|
|
456
|
+
pid: null,
|
|
457
|
+
startedAt: cur,
|
|
458
|
+
fails: newFails,
|
|
459
|
+
log: logPath,
|
|
460
|
+
};
|
|
461
|
+
note('fail', `启动失败 ${ws} 任务 ${key}:会话进程没有拉起`
|
|
462
|
+
+ `(连续失败计数 ${newFails})`);
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
bucket[key] = {
|
|
466
|
+
ws,
|
|
467
|
+
taskId: key,
|
|
468
|
+
sig,
|
|
469
|
+
pid: spawned.pid,
|
|
421
470
|
startedAt: cur,
|
|
422
|
-
|
|
423
|
-
|
|
471
|
+
procStart: spawned.procStart,
|
|
472
|
+
fails,
|
|
473
|
+
log: logPath,
|
|
424
474
|
};
|
|
425
|
-
note('
|
|
426
|
-
|
|
475
|
+
note('spawn', `已启动 ${ws} 任务 ${key} pid=${spawned.pid}`
|
|
476
|
+
+ `(连续失败计数 ${fails})`);
|
|
427
477
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
478
|
+
|
|
479
|
+
// 第三步:任务文件维护(续期 + 改 id + 盖印)合并为一次带乐观锁的原子
|
|
480
|
+
// 写。撞车(Claude Code 刚写了 lastFiredAt/删了任务)整轮放弃写与
|
|
481
|
+
// reap,下轮基于新文件重算——已 spawn 的条目留下轮处理,不受影响。
|
|
482
|
+
let result;
|
|
483
|
+
try {
|
|
484
|
+
result = maintainWorkspace(taskfile, { reown: stamps });
|
|
485
|
+
} catch (e) {
|
|
486
|
+
if (e instanceof TaskFileChanged) {
|
|
487
|
+
note('warn', `维护暂缓 ${ws}:任务文件刚被 Claude Code 改动,本轮不覆盖,`
|
|
488
|
+
+ '下轮再来');
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
throw e;
|
|
492
|
+
}
|
|
493
|
+
if (result === null) return; // 与 readTasks 不一致的极端竞态:保守不动
|
|
494
|
+
if (result.renewed) {
|
|
495
|
+
deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
|
|
496
|
+
+ '不再受 7 天过期限制');
|
|
497
|
+
}
|
|
498
|
+
if (result.minified) {
|
|
499
|
+
deps.log(`已改小 id ${ws}:${result.minified} 个周期任务的 id 改为小哈希`
|
|
500
|
+
+ '值,投递延迟降到约 1 分钟内');
|
|
501
|
+
}
|
|
502
|
+
if (result.reowned) {
|
|
503
|
+
bump(stats, 'reown');
|
|
504
|
+
deps.log(`已过户 ${ws}:${result.reowned} 个窗口内任务的归属改到各自的`
|
|
505
|
+
+ '预热会话,这些任务将只在对应会话触发');
|
|
506
|
+
}
|
|
507
|
+
// id 改小同轮把 state 条目跟着改键:不改的话下轮会把换 id 的任务误判
|
|
508
|
+
// 成「旧任务删除 + 新任务」,错杀刚拉起的会话再重拉一个。
|
|
509
|
+
for (const { from, to } of result.idChanges) {
|
|
510
|
+
if (bucket[from] && !bucket[to]) {
|
|
511
|
+
bucket[to] = { ...bucket[from], taskId: to };
|
|
512
|
+
delete bucket[from];
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 第四步:任务消失的条目回收。第零步已做过签名治愈;这里重建当前键索
|
|
517
|
+
// 引——注意内存里的 tasks 是维护前的快照,minify 改的是重新读盘的
|
|
518
|
+
// doc,所以键必须经 idChanges 修正,否则刚改到新键的条目会被旧 id 视
|
|
519
|
+
// 图误当成「签名可治愈的旧键」再改回去。剩下对不上的键就是真的被删
|
|
520
|
+
// 了。冷却中的条目也删——任务没了,冷却没有意义。
|
|
521
|
+
const renamed = new Map(result.idChanges.map((c) => [c.from, c.to]));
|
|
522
|
+
sigToKeys = new Map();
|
|
523
|
+
currentKeys = new Set();
|
|
524
|
+
for (const t of tasks) {
|
|
525
|
+
const k = renamed.get(taskKey(t)) ?? taskKey(t);
|
|
526
|
+
currentKeys.add(k);
|
|
527
|
+
const list = sigToKeys.get(taskSig(t)) ?? [];
|
|
528
|
+
list.push(k);
|
|
529
|
+
sigToKeys.set(taskSig(t), list);
|
|
530
|
+
}
|
|
531
|
+
for (const [key, ent] of Object.entries({ ...bucket })) {
|
|
532
|
+
if (currentKeys.has(key)) continue;
|
|
533
|
+
const candidates = ent.sig ? (sigToKeys.get(ent.sig) ?? []) : [];
|
|
534
|
+
if (candidates.length === 1 && !bucket[candidates[0]]) {
|
|
535
|
+
const to = candidates[0];
|
|
536
|
+
bucket[to] = { ...ent, taskId: to };
|
|
537
|
+
delete bucket[key];
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (deps.trackedAlive(ent)) {
|
|
541
|
+
await stopEntry(ent, 'recycle', `回收 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
542
|
+
+ '对应任务已删除,结束预热会话');
|
|
543
|
+
}
|
|
544
|
+
delete bucket[key];
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// 升级轮:旧版目录级 state 的预热逐个结束(不迁移归属,新流程按任务重
|
|
549
|
+
// 拉);state.json 已丢的旧孤儿 script(日志是旧命名,不含任务键段)从
|
|
550
|
+
// 进程表兜底清扫。只在 loadLegacyEntries 非空(确在跨版本)时执行。
|
|
551
|
+
async function retireLegacy(legacy, stats) {
|
|
552
|
+
for (const ent of legacy) {
|
|
553
|
+
if (deps.trackedAlive(ent)) {
|
|
554
|
+
await deps.stopSession(ent);
|
|
555
|
+
bump(stats, 'recycle');
|
|
556
|
+
deps.log(`旧版迁移 ${ent.ws} pid=${ent.pid}:结束目录级预热会话,`
|
|
557
|
+
+ '升级后按任务重新预热');
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
const handled = new Set(legacy.filter((e) => e.pid).map((e) => e.pid));
|
|
561
|
+
for (const [pid, procArgs] of deps.listScriptProcesses()) {
|
|
562
|
+
if (handled.has(pid)) continue;
|
|
563
|
+
const file = scriptProcLogPath(procArgs);
|
|
564
|
+
if (file === null) continue;
|
|
565
|
+
if (path.dirname(file) !== deps.paths.sessionLogDir) continue;
|
|
566
|
+
if (isTaskSessionLogName(path.basename(file))) continue; // 新一代命名
|
|
567
|
+
const ent = { pid, procStart: deps.procStartedAt(pid) };
|
|
568
|
+
if (!deps.trackedAlive(ent)) continue;
|
|
569
|
+
await deps.stopSession(ent);
|
|
570
|
+
bump(stats, 'recycle');
|
|
571
|
+
deps.log(`旧版迁移 pid=${pid}:结束目录级预热孤儿会话(日志 ${file}),`
|
|
572
|
+
+ '升级后按任务重新预热');
|
|
440
573
|
}
|
|
441
574
|
}
|
|
442
575
|
|
|
@@ -448,10 +581,9 @@ export async function cmdRun(args) {
|
|
|
448
581
|
return false;
|
|
449
582
|
}
|
|
450
583
|
// 包一层 stopSession 记下本轮主动结束的 pid:轮末凭它区分「我们回收的」
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
// spawn 一次 ps。finally 一并还原。
|
|
584
|
+
// 与「会话自行退出/被外部杀掉的」。同样包一层 procStartedAt:同一 pid
|
|
585
|
+
// 的 lstart 一轮内不会变,缓存后每个 pid 只 spawn 一次 ps。finally 还
|
|
586
|
+
// 原。
|
|
455
587
|
const prevStopSession = deps.stopSession;
|
|
456
588
|
const stoppedPids = new Set();
|
|
457
589
|
deps.stopSession = async (ent) => {
|
|
@@ -470,174 +602,126 @@ export async function cmdRun(args) {
|
|
|
470
602
|
deps.rotatePatrolLogs();
|
|
471
603
|
const cfg = loadConfig(args?.config ?? deps.paths.configPath);
|
|
472
604
|
const now = new Date();
|
|
473
|
-
const leadMs =
|
|
605
|
+
const leadMs = LEAD_SECONDS * 1000;
|
|
474
606
|
const state = loadState();
|
|
607
|
+
const legacy = loadLegacyEntries();
|
|
475
608
|
const cur = Math.floor(Date.now() / 1000);
|
|
476
609
|
|
|
477
610
|
// 轮首心跳:没有这两行,launchd 拉不起巡检(启动脚本失效、node 缺失)
|
|
478
611
|
// 时日志会一片寂静,无法和「平安无事」区分。上轮心跳过老说明中间漏
|
|
479
612
|
// 轮——休眠期间错过的 StartInterval,launchd 醒来只补跑一轮。
|
|
480
613
|
const hb = loadHeartbeat();
|
|
481
|
-
if (heartbeatStale(hb,
|
|
614
|
+
if (heartbeatStale(hb, INTERVAL_SECONDS, cur)) {
|
|
482
615
|
bump(stats, 'warn');
|
|
483
616
|
deps.log(`距上次巡检已 ${elapsedZh(cur - hb.ranAt)},超过 `
|
|
484
|
-
+ `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${
|
|
617
|
+
+ `${HEARTBEAT_STALE_FACTOR} 个轮次间隔(${INTERVAL_SECONDS} 秒),`
|
|
485
618
|
+ '期间 launchd 可能漏轮(常见于电脑休眠)');
|
|
486
619
|
}
|
|
487
|
-
deps.log(
|
|
488
|
-
+ `${cfg.roots.length} 个`);
|
|
620
|
+
deps.log(`巡检开始:扫描根目录 ${cfg.roots.length} 个`);
|
|
489
621
|
if (cfg.roots.length === 0) {
|
|
490
622
|
bump(stats, 'warn');
|
|
491
623
|
deps.log('警告:扫描目录 roots 为空,巡检不会发现任何工作区;'
|
|
492
|
-
+ '请检查配置文件或用 install --roots 指定');
|
|
624
|
+
+ '请检查配置文件或用 cron-up install --roots 指定');
|
|
493
625
|
}
|
|
494
626
|
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
// kill -0 成功的——两轮之间崩溃正是最典型的被动死亡,本轮开始时它早
|
|
498
|
-
// 已没气;带 deadSince 的是上轮已报过死亡的旧条目,跳过以免每轮重复报。
|
|
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 });
|
|
627
|
+
// 登记每轮只读一次(含祖先链快照):认亲与活创建者判定都靠它。
|
|
628
|
+
const [consumers, registryAlert, details] = await deps.scanSessions();
|
|
518
629
|
if (registryAlert) {
|
|
519
630
|
bump(stats, 'warn');
|
|
520
631
|
deps.log(`告警:${registryAlert}`);
|
|
521
632
|
}
|
|
522
633
|
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
// 作区现算;这里只留全轮常量。
|
|
526
|
-
const recycleMarginSec = SESSION_FIRE_MARGIN_SECONDS
|
|
527
|
-
+ cfg.intervalSeconds;
|
|
634
|
+
// 旧版收尾必须先于一切 spawn 决策(state 轮末被新形状整体覆盖)。
|
|
635
|
+
if (legacy.length > 0) await retireLegacy(legacy, stats);
|
|
528
636
|
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
|
|
637
|
+
// 轮初把上轮末仍相信活着的条目分成三组:活、resume 脱离(script 已
|
|
638
|
+
// 死但 sid 还在,不报退出)、真死(立即报)。带 deadSince 的旧报条
|
|
639
|
+
// 目跳过。不能只收此刻 kill -0 成功的——两轮之间崩溃正是最典型的被
|
|
640
|
+
// 动死亡。
|
|
641
|
+
const prevDead = [];
|
|
642
|
+
const prevAlive = [];
|
|
643
|
+
const sidInDetails = (ws, sid) =>
|
|
644
|
+
(details.get(ws) ?? []).some((d) => d.sessionId === sid);
|
|
645
|
+
for (const [ws, bucket] of Object.entries(state)) {
|
|
646
|
+
for (const [taskId, ent] of Object.entries(bucket)) {
|
|
647
|
+
if (!ent.pid || ent.deadSince) continue;
|
|
648
|
+
const copy = { ...ent };
|
|
649
|
+
if (deps.trackedAlive(copy)) {
|
|
650
|
+
prevAlive.push([ws, taskId, copy]);
|
|
651
|
+
} else if (typeof ent.sessionId === 'string'
|
|
652
|
+
&& sidInDetails(ws, ent.sessionId)) {
|
|
653
|
+
// detached:静默。
|
|
654
|
+
} else {
|
|
655
|
+
prevDead.push([ws, taskId, copy]);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
const exitLine = (ws, taskId, old) => `会话退出 ${ws} 任务 ${taskId} `
|
|
660
|
+
+ `pid=${old.pid}:非巡检主动结束(已存活 `
|
|
661
|
+
+ `${elapsedZh(cur - (old.startedAt ?? cur))}),可能自行退出或被外部`
|
|
662
|
+
+ '终止;若反复出现请用 cron-up logs '
|
|
663
|
+
+ `${path.basename(ws)}--${taskId} 排查启动过程`;
|
|
664
|
+
for (const [ws, taskId, old] of prevDead) {
|
|
532
665
|
bump(stats, 'exit');
|
|
533
|
-
deps.log(exitLine(ws, old));
|
|
666
|
+
deps.log(exitLine(ws, taskId, old));
|
|
534
667
|
}
|
|
535
668
|
|
|
536
669
|
let found = 0;
|
|
537
|
-
|
|
670
|
+
const scriptProcsRef = { value: null };
|
|
671
|
+
for (const [ws, taskfile] of deps.discover(cfg.roots)) {
|
|
538
672
|
found += 1;
|
|
539
|
-
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
// maintainWorkspace)。
|
|
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
|
-
}
|
|
673
|
+
if (!state[ws]) state[ws] = {};
|
|
674
|
+
// 一个工作区读不动/写不动不能中断整轮:discover 是生成器,抛了会
|
|
675
|
+
// 跳过后面所有工作区。
|
|
604
676
|
try {
|
|
605
677
|
await patrolWorkspace(
|
|
606
|
-
ws, state, now, leadMs, cur,
|
|
607
|
-
|
|
678
|
+
ws, state[ws], taskfile, now, leadMs, cur, details,
|
|
679
|
+
scriptProcsRef, stats);
|
|
608
680
|
} catch (e) {
|
|
609
681
|
bump(stats, 'error');
|
|
610
682
|
deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
|
|
611
683
|
}
|
|
684
|
+
if (Object.keys(state[ws]).length === 0) delete state[ws];
|
|
612
685
|
}
|
|
613
686
|
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
// 才已在循环前报过。报过的死条目随后由 kept 过滤打上 deadSince,后续
|
|
618
|
-
// 轮次不会重复报。
|
|
619
|
-
for (const [ws, old] of prevAlive) {
|
|
687
|
+
// 轮中死亡复核:轮初活、轮末没气、又不是本轮主动 stop 的。轮中刚被
|
|
688
|
+
// --resume 接管(sid 换 pid 存活)的不报。
|
|
689
|
+
for (const [ws, taskId, old] of prevAlive) {
|
|
620
690
|
if (stoppedPids.has(old.pid)) continue;
|
|
621
691
|
if (deps.trackedAlive(old)) continue;
|
|
692
|
+
if (typeof old.sessionId === 'string'
|
|
693
|
+
&& deps.sessionAliveNow(old.sessionId, ws)) continue;
|
|
622
694
|
bump(stats, 'exit');
|
|
623
|
-
deps.log(exitLine(ws, old));
|
|
695
|
+
deps.log(exitLine(ws, taskId, old));
|
|
624
696
|
}
|
|
625
697
|
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
if (
|
|
698
|
+
// kept 过滤(嵌套):活会话、resume 脱离(sid 仍在登记)、冷却中、
|
|
699
|
+
// 带失败计数的死条目(计数跨任务窗口累积,48 小时 TTL 老化)保留;
|
|
700
|
+
// 其余丢掉。首次发现死亡打 deadSince。
|
|
701
|
+
const kept = {};
|
|
702
|
+
for (const [ws, bucket] of Object.entries(state)) {
|
|
703
|
+
const out = {};
|
|
704
|
+
for (const [taskId, ent] of Object.entries(bucket)) {
|
|
705
|
+
if (deps.trackedAlive(ent)) {
|
|
706
|
+
out[taskId] = ent;
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (typeof ent.sessionId === 'string'
|
|
710
|
+
&& sidInDetails(ws, ent.sessionId)) {
|
|
711
|
+
out[taskId] = ent;
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
if (isCooling(ent, cur)) {
|
|
715
|
+
out[taskId] = ent;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
634
718
|
if ((ent.fails ?? 0) > 0) {
|
|
635
719
|
ent.deadSince ??= cur;
|
|
636
|
-
|
|
720
|
+
if (cur - ent.deadSince < FAIL_TTL_SECONDS) out[taskId] = ent;
|
|
637
721
|
}
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
722
|
+
}
|
|
723
|
+
if (Object.keys(out).length > 0) kept[ws] = out;
|
|
724
|
+
}
|
|
641
725
|
saveState(kept);
|
|
642
726
|
|
|
643
727
|
// 轮末汇总:一行交代这轮看见了什么、动了什么、花了多久,随后更新心跳。
|
|
@@ -653,8 +737,7 @@ export async function cmdRun(args) {
|
|
|
653
737
|
saveHeartbeat({
|
|
654
738
|
ranAt: cur,
|
|
655
739
|
version: packageVersion(),
|
|
656
|
-
intervalSeconds:
|
|
657
|
-
sessionRetain: cfg.sessionRetain,
|
|
740
|
+
intervalSeconds: INTERVAL_SECONDS,
|
|
658
741
|
durationMs,
|
|
659
742
|
events: stats,
|
|
660
743
|
});
|
|
@@ -666,73 +749,6 @@ export async function cmdRun(args) {
|
|
|
666
749
|
}
|
|
667
750
|
}
|
|
668
751
|
|
|
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
752
|
Object.assign(deps, {
|
|
737
753
|
log,
|
|
738
754
|
runPatrol: cmdRun,
|