cron-up 26.10.32 → 26.10.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +61 -0
- package/README.md +228 -112
- package/docs/internals.md +190 -0
- package/docs/migration.md +47 -0
- package/docs/release.md +75 -0
- package/package.json +5 -3
- package/src/args.mjs +1 -2
- package/src/cli.mjs +18 -20
- package/src/config.mjs +22 -19
- package/src/constants.mjs +7 -17
- package/src/cron.mjs +18 -9
- package/src/display.mjs +2 -2
- package/src/list.mjs +1 -1
- package/src/logs.mjs +2 -1
- package/src/overview.mjs +7 -7
- package/src/patrol.mjs +199 -162
- package/src/registry.mjs +10 -13
- package/src/service.mjs +189 -49
- package/src/sessions.mjs +61 -20
- package/src/tasks.mjs +45 -84
package/src/patrol.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 一轮巡检:发现工作区 →
|
|
1
|
+
// 一轮巡检:发现工作区 → 自动维护任务文件(续期/盖印)→ 保证每个
|
|
2
2
|
// 「创建者会话已死且处于预热窗口」的任务都有一个只属于它的健康会话。
|
|
3
3
|
// 这是 launchd 的入口(每 N 秒被拉起一次,跑完即退)。本轮新拉起的会话
|
|
4
4
|
// 在所有工作区决策完后轮内等待登记(至多 WARMUP_WAIT_MS),认亲同一轮
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
11
12
|
|
|
12
|
-
import { deps, packageVersion } from './internals.mjs';
|
|
13
|
+
import { deps, packageVersion, isPlainObject } from './internals.mjs';
|
|
13
14
|
import {
|
|
14
15
|
FAIL_LIMIT,
|
|
15
16
|
COOLDOWN_SECONDS,
|
|
@@ -33,13 +34,13 @@ import {
|
|
|
33
34
|
saveHeartbeat,
|
|
34
35
|
heartbeatStale,
|
|
35
36
|
isCooling,
|
|
37
|
+
parsePid,
|
|
36
38
|
} from './config.mjs';
|
|
37
39
|
import {
|
|
38
40
|
maintainWorkspace,
|
|
39
41
|
TaskFileChanged,
|
|
40
42
|
taskFileExists,
|
|
41
|
-
|
|
42
|
-
taskSig,
|
|
43
|
+
groupTasksByKey,
|
|
43
44
|
} from './tasks.mjs';
|
|
44
45
|
import { taskWanted, parseCronOrNone, wantedNow } from './cron.mjs';
|
|
45
46
|
import {
|
|
@@ -79,10 +80,11 @@ export function log(msg) {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
// ---- 巡检互斥锁 ----
|
|
82
|
-
// flock 的替代:O_EXCL 创建 +
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
83
|
+
// flock 的替代:O_EXCL 创建 + 完整 JSON 锁记录。token 防止释放/清理时误删
|
|
84
|
+
// 已被别人替换的文件,pid + 进程启动指纹区分 pid 复用。创建后写记录有极短
|
|
85
|
+
// 空窗,所以无效/空文件要等初始化宽限过后才能按 stale 清理。
|
|
86
|
+
|
|
87
|
+
const RUN_LOCK_INIT_GRACE_MS = 10_000;
|
|
86
88
|
|
|
87
89
|
function tryCreateLock(lockPath) {
|
|
88
90
|
try {
|
|
@@ -93,59 +95,137 @@ function tryCreateLock(lockPath) {
|
|
|
93
95
|
}
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
// 文本与 stat 一起作为删除前的比对快照:除了内容,inode/mtime 也能识别
|
|
99
|
+
// 「删掉旧锁后又恰好建了同样内容的新锁」。
|
|
100
|
+
function readLockSnapshot(lockPath) {
|
|
101
|
+
try {
|
|
102
|
+
return {
|
|
103
|
+
text: fs.readFileSync(lockPath, 'utf-8'),
|
|
104
|
+
stat: fs.statSync(lockPath, { bigint: true }),
|
|
105
|
+
};
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseLockRecord(snapshot) {
|
|
112
|
+
let record;
|
|
98
113
|
try {
|
|
99
|
-
|
|
114
|
+
record = JSON.parse(snapshot.text);
|
|
100
115
|
} catch {
|
|
101
116
|
return null;
|
|
102
117
|
}
|
|
103
|
-
|
|
118
|
+
if (!isPlainObject(record)
|
|
119
|
+
|| !Number.isSafeInteger(record.pid) || record.pid <= 0
|
|
120
|
+
|| typeof record.token !== 'string' || record.token === '') {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return record;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sameLockSnapshot(a, b) {
|
|
127
|
+
return a.text === b.text
|
|
128
|
+
&& a.stat.ino === b.stat.ino
|
|
129
|
+
&& a.stat.mtimeNs === b.stat.mtimeNs
|
|
130
|
+
&& a.stat.size === b.stat.size;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 只能删除读取时看到的那个文件。比对失败说明别人释放/重建过,宁可本轮不
|
|
134
|
+
// 抢锁,也绝不能把新持有者的锁 unlink 掉。
|
|
135
|
+
function removeLockSnapshot(lockPath, snapshot) {
|
|
136
|
+
const latest = readLockSnapshot(lockPath);
|
|
137
|
+
if (latest === null || !sameLockSnapshot(snapshot, latest)) return false;
|
|
138
|
+
try {
|
|
139
|
+
fs.unlinkSync(lockPath);
|
|
140
|
+
return true;
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function staleLock(snapshot) {
|
|
147
|
+
const record = parseLockRecord(snapshot);
|
|
148
|
+
if (record === null) {
|
|
149
|
+
// 旧版锁只有十进制 pid。升级交错时它可能仍属于活巡检;没有指纹可核,
|
|
150
|
+
// 因此活着就保守保留,明确死亡才允许清理。
|
|
151
|
+
const legacyPid = parsePid(snapshot.text);
|
|
152
|
+
if (legacyPid !== null) {
|
|
153
|
+
try {
|
|
154
|
+
return !deps.alive(legacyPid);
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// 包含 O_EXCL 成功后、JSON 写完前的空文件。mtime 新于宽限的一律保留。
|
|
160
|
+
return Date.now() - Number(snapshot.stat.mtimeMs) >= RUN_LOCK_INIT_GRACE_MS;
|
|
161
|
+
}
|
|
162
|
+
let living;
|
|
163
|
+
try {
|
|
164
|
+
living = deps.alive(record.pid);
|
|
165
|
+
} catch {
|
|
166
|
+
return false; // 活判读不出时保守保留。
|
|
167
|
+
}
|
|
168
|
+
if (!living) return true;
|
|
169
|
+
// 活 pid 但缺记录指纹,或当前指纹读不出:都无法排除锁主仍在,不能删。
|
|
170
|
+
if (typeof record.procStart !== 'string' || record.procStart === '') return false;
|
|
171
|
+
let currentStart;
|
|
172
|
+
try {
|
|
173
|
+
currentStart = deps.procStartedAt(record.pid);
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (!currentStart) return false;
|
|
178
|
+
return currentStart !== record.procStart;
|
|
104
179
|
}
|
|
105
180
|
|
|
106
181
|
export function acquireRunLock() {
|
|
107
182
|
fs.mkdirSync(deps.paths.dataDir, { recursive: true });
|
|
108
183
|
const lockPath = path.join(deps.paths.dataDir, 'run.lock');
|
|
184
|
+
let procStart;
|
|
185
|
+
try {
|
|
186
|
+
procStart = deps.procStartedAt(process.pid);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
throw new Error('读取当前进程启动指纹失败,不能安全创建巡检锁:'
|
|
189
|
+
+ `${e?.message ?? e}`);
|
|
190
|
+
}
|
|
191
|
+
// 无指纹锁一旦被崩溃留下,pid 复用时永远无法安全清理;宁可本轮响亮
|
|
192
|
+
// 失败,也不制造这种永久自锁。
|
|
193
|
+
if (!procStart) {
|
|
194
|
+
throw new Error('未读到当前进程启动指纹,不能安全创建巡检锁;'
|
|
195
|
+
+ '请检查 /bin/ps 后重试');
|
|
196
|
+
}
|
|
197
|
+
const token = randomUUID();
|
|
198
|
+
const text = `${JSON.stringify({
|
|
199
|
+
pid: process.pid,
|
|
200
|
+
procStart,
|
|
201
|
+
token,
|
|
202
|
+
})}\n`;
|
|
109
203
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
110
204
|
const fd = tryCreateLock(lockPath);
|
|
111
205
|
if (fd !== null) {
|
|
112
206
|
try {
|
|
113
|
-
fs.writeFileSync(fd,
|
|
207
|
+
fs.writeFileSync(fd, text);
|
|
114
208
|
fs.fsyncSync(fd);
|
|
115
209
|
} finally {
|
|
116
210
|
fs.closeSync(fd);
|
|
117
211
|
}
|
|
118
|
-
return { lockPath };
|
|
119
|
-
}
|
|
120
|
-
const holder = readLockPid(lockPath);
|
|
121
|
-
if (holder !== null) {
|
|
122
|
-
let live = false;
|
|
123
|
-
try {
|
|
124
|
-
process.kill(holder, 0);
|
|
125
|
-
live = true;
|
|
126
|
-
} catch (e) {
|
|
127
|
-
// EPERM:进程存在但不是我们的——当作活锁,不删别人的锁。
|
|
128
|
-
live = e.code === 'EPERM';
|
|
129
|
-
}
|
|
130
|
-
if (live) return null;
|
|
131
|
-
}
|
|
132
|
-
// 持有者已死 / 空文件 / pid 损坏:当作 stale 清掉重试。
|
|
133
|
-
try {
|
|
134
|
-
fs.unlinkSync(lockPath);
|
|
135
|
-
} catch {
|
|
136
|
-
return null; // 别人抢先重建了锁
|
|
212
|
+
return { lockPath, token };
|
|
137
213
|
}
|
|
214
|
+
const snapshot = readLockSnapshot(lockPath);
|
|
215
|
+
if (snapshot === null) continue; // 持有者恰好释放,直接重试创建。
|
|
216
|
+
if (!staleLock(snapshot)) return null;
|
|
217
|
+
if (!removeLockSnapshot(lockPath, snapshot)) return null;
|
|
138
218
|
}
|
|
139
219
|
return null;
|
|
140
220
|
}
|
|
141
221
|
|
|
142
|
-
function
|
|
143
|
-
if (!handle?.lockPath) return;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
222
|
+
export function releaseRunLock(handle) {
|
|
223
|
+
if (!handle?.lockPath || typeof handle.token !== 'string') return;
|
|
224
|
+
const snapshot = readLockSnapshot(handle.lockPath);
|
|
225
|
+
if (snapshot === null) return;
|
|
226
|
+
const record = parseLockRecord(snapshot);
|
|
227
|
+
if (record?.token !== handle.token) return;
|
|
228
|
+
removeLockSnapshot(handle.lockPath, snapshot);
|
|
149
229
|
}
|
|
150
230
|
|
|
151
231
|
// ---- 日志轮转 ----
|
|
@@ -206,20 +286,6 @@ const reownTarget = (ent) => ({
|
|
|
206
286
|
procStart: ent.claudeProcStart,
|
|
207
287
|
});
|
|
208
288
|
|
|
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
289
|
// 过户后的统一计数与日志(巡逻第三步与等待期补盖共用)。
|
|
224
290
|
function noteReowned(ws, count, stats) {
|
|
225
291
|
bump(stats, 'reown');
|
|
@@ -236,9 +302,9 @@ function enterCooldown(ent, ws, key, fails, cur, stats) {
|
|
|
236
302
|
+ `${fails} 次,暂停重试 ${COOLDOWN_SECONDS / 60} 分钟`);
|
|
237
303
|
}
|
|
238
304
|
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
305
|
+
// 保证一个工作区里每个死主窗口任务都有专属预热,并完成本文件的续期/盖印
|
|
306
|
+
// 维护。bucket 是 state[ws](原地修改)。scriptProcsRef 是全轮共享的进程表
|
|
307
|
+
// 快照容器({value: 数组|null}),只在孤儿认领时拍一次。
|
|
242
308
|
// 返回 pending:本轮新纳入跟踪、尚未认亲的活 script(新 spawn 与孤儿认
|
|
243
309
|
// 领)的 {ws,key,pid} 列表,cmdRun 据此轮内等待认亲并补盖印。
|
|
244
310
|
export async function patrolWorkspace(
|
|
@@ -263,9 +329,17 @@ export async function patrolWorkspace(
|
|
|
263
329
|
return [];
|
|
264
330
|
}
|
|
265
331
|
|
|
266
|
-
|
|
267
|
-
|
|
332
|
+
// false 是 stopSession 的「活进程未能安全停止」:不能记回收、删 state
|
|
333
|
+
// 或继续重拉。生产与测试桩都返回严格布尔值。
|
|
334
|
+
const stopEntry = async (ent, key, kind, msg) => {
|
|
335
|
+
const stopped = await deps.stopSession(ent);
|
|
336
|
+
if (!stopped) {
|
|
337
|
+
note('warn', `无法安全停止 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
338
|
+
+ '记录的进程仍存活;保留跟踪,本轮不删除条目也不另拉会话');
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
268
341
|
note(kind, msg);
|
|
342
|
+
return true;
|
|
269
343
|
};
|
|
270
344
|
|
|
271
345
|
if (tasks.length === 0) {
|
|
@@ -274,35 +348,23 @@ export async function patrolWorkspace(
|
|
|
274
348
|
// 接管)的 stop 是 no-op,条目照删——sid 没了任务也没了,无需再管。
|
|
275
349
|
for (const [key, ent] of Object.entries(bucket)) {
|
|
276
350
|
if (deps.trackedAlive(ent)) {
|
|
277
|
-
await stopEntry(ent, 'recycle',
|
|
351
|
+
const stopped = await stopEntry(ent, key, 'recycle',
|
|
278
352
|
`回收 ${ws} 任务 ${key} pid=${ent.pid}:任务已全部清空,结束预热会话`);
|
|
353
|
+
if (!stopped) continue;
|
|
279
354
|
}
|
|
280
355
|
delete bucket[key];
|
|
281
356
|
}
|
|
282
357
|
return [];
|
|
283
358
|
}
|
|
284
359
|
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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];
|
|
305
|
-
}
|
|
360
|
+
// 与任务文件层共用一份按键分组视图。重复键对应多条任务时,state 与
|
|
361
|
+
// 会话都无法唯一对应到条目:跳过这些组并告警,maintainWorkspace 也只
|
|
362
|
+
// 处理单元素组。组的键集合同时就是后面的 currentKeys。
|
|
363
|
+
const taskGroups = groupTasksByKey(tasks);
|
|
364
|
+
for (const [key, group] of taskGroups) {
|
|
365
|
+
if (group.length < 2) continue;
|
|
366
|
+
note('warn', `任务键重复 ${ws}:${key} 对应 ${group.length} 条任务,`
|
|
367
|
+
+ '无法区分各自会话,本轮跳过这些条目的预热与盖印');
|
|
306
368
|
}
|
|
307
369
|
|
|
308
370
|
// 第一步:认亲。认到 = 最强健康证据,当场回填身份、清零失败计数。本
|
|
@@ -312,15 +374,13 @@ export async function patrolWorkspace(
|
|
|
312
374
|
if (deps.trackedAlive(ent)) claimEntry(ent, details, ws);
|
|
313
375
|
}
|
|
314
376
|
|
|
315
|
-
// 第二步:逐任务决策。stamps 收本轮要盖的归属印(
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
// 生规则,避免错盖后没拿到任务的预热永不满足回收条件而空转泄漏。
|
|
377
|
+
// 第二步:逐任务决策。stamps 收本轮要盖的归属印(taskKey → 预热身份),
|
|
378
|
+
// 第三步合并进同一次原子写。内容相同但 id 不同的任务各有自己的键;重复
|
|
379
|
+
// 键组直接跳过,不在循环内另建一套重复键索引。
|
|
319
380
|
const stamps = new Map();
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
const
|
|
323
|
-
const sig = taskSig(t);
|
|
381
|
+
for (const [key, group] of taskGroups) {
|
|
382
|
+
if (group.length !== 1) continue;
|
|
383
|
+
const t = group[0];
|
|
324
384
|
let ent = bucket[key] ?? null;
|
|
325
385
|
const logPath = sessionLogPath(ws, key);
|
|
326
386
|
const ownerIsOurs = entryClaimed(ent)
|
|
@@ -335,14 +395,16 @@ export async function patrolWorkspace(
|
|
|
335
395
|
if (ent && deps.trackedAlive(ent)
|
|
336
396
|
&& cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS
|
|
337
397
|
&& !entryClaimed(ent)) {
|
|
338
|
-
await stopEntry(ent, 'stuck',
|
|
398
|
+
const stopped = await stopEntry(ent, key, 'stuck',
|
|
399
|
+
`卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
339
400
|
+ '进程已存活仍未登记会话,已终止;任务的创建者会话仍在场可承接,'
|
|
340
401
|
+ '不另拉预热');
|
|
341
|
-
delete bucket[key];
|
|
402
|
+
if (stopped) delete bucket[key];
|
|
342
403
|
} else if (ent && deps.trackedAlive(ent) && entryClaimed(ent)) {
|
|
343
|
-
await stopEntry(ent, 'recycle',
|
|
404
|
+
const stopped = await stopEntry(ent, key, 'recycle',
|
|
405
|
+
`回收 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
344
406
|
+ '任务创建者会话已在场,结束多余的预热会话');
|
|
345
|
-
delete bucket[key];
|
|
407
|
+
if (stopped) delete bucket[key];
|
|
346
408
|
}
|
|
347
409
|
continue;
|
|
348
410
|
}
|
|
@@ -375,10 +437,10 @@ export async function patrolWorkspace(
|
|
|
375
437
|
> SESSION_RECYCLE_IDLE_SECONDS
|
|
376
438
|
&& taskNeedMs(t, now) - now.getTime()
|
|
377
439
|
> SESSION_RECYCLE_MARGIN_SECONDS * 1000) {
|
|
378
|
-
await stopEntry(ent, 'recycle',
|
|
440
|
+
const stopped = await stopEntry(ent, key, 'recycle',
|
|
379
441
|
`回收 ${ws} 任务 ${key} pid=${ent.pid}:本任务已执行完毕且界面静默,`
|
|
380
442
|
+ '结束会话,下次窗口重新预热');
|
|
381
|
-
delete bucket[key];
|
|
443
|
+
if (stopped) delete bucket[key];
|
|
382
444
|
continue;
|
|
383
445
|
}
|
|
384
446
|
|
|
@@ -389,9 +451,11 @@ export async function patrolWorkspace(
|
|
|
389
451
|
// 问界面的垃圾进程。杀掉后继续走失败计数/冷却/重拉。
|
|
390
452
|
if (ent && deps.trackedAlive(ent) && !entryClaimed(ent)
|
|
391
453
|
&& cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS) {
|
|
392
|
-
await stopEntry(ent, 'stuck',
|
|
454
|
+
const stopped = await stopEntry(ent, key, 'stuck',
|
|
455
|
+
`卡死 ${ws} 任务 ${key} pid=${ent.pid}:`
|
|
393
456
|
+ '进程已存活仍未登记会话,已终止'
|
|
394
457
|
+ `(排查:cron-up logs ${path.basename(ws)}--${key})`);
|
|
458
|
+
if (!stopped) continue;
|
|
395
459
|
}
|
|
396
460
|
|
|
397
461
|
// (e) resume 接管结束:sid 已从登记消失。整代身份作废(script 已
|
|
@@ -414,7 +478,7 @@ export async function patrolWorkspace(
|
|
|
414
478
|
// 亲,由 cmdRun 的 stampClaimed 补盖,不走这里。
|
|
415
479
|
if (entryClaimed(ent)
|
|
416
480
|
&& (!t.createdBySessionId || t.createdBySessionId !== ent.sessionId)) {
|
|
417
|
-
|
|
481
|
+
stamps.set(key, reownTarget(ent));
|
|
418
482
|
}
|
|
419
483
|
|
|
420
484
|
// warm 未到回收 / spawning 宽限内:本轮无事。
|
|
@@ -436,7 +500,6 @@ export async function patrolWorkspace(
|
|
|
436
500
|
...ent,
|
|
437
501
|
ws,
|
|
438
502
|
taskId: key,
|
|
439
|
-
sig,
|
|
440
503
|
pid: null,
|
|
441
504
|
fails,
|
|
442
505
|
log: ent?.log ?? logPath,
|
|
@@ -460,7 +523,6 @@ export async function patrolWorkspace(
|
|
|
460
523
|
bucket[key] = {
|
|
461
524
|
ws,
|
|
462
525
|
taskId: key,
|
|
463
|
-
sig,
|
|
464
526
|
pid: procPid,
|
|
465
527
|
startedAt: cur,
|
|
466
528
|
procStart: deps.procStartedAt(procPid),
|
|
@@ -481,7 +543,7 @@ export async function patrolWorkspace(
|
|
|
481
543
|
const newFails = fails + 1;
|
|
482
544
|
if (newFails >= FAIL_LIMIT) {
|
|
483
545
|
bucket[key] = {
|
|
484
|
-
ws, taskId: key,
|
|
546
|
+
ws, taskId: key, pid: null, fails: newFails, log: logPath,
|
|
485
547
|
};
|
|
486
548
|
enterCooldown(bucket[key], ws, key, newFails, cur, stats);
|
|
487
549
|
continue;
|
|
@@ -489,7 +551,6 @@ export async function patrolWorkspace(
|
|
|
489
551
|
bucket[key] = {
|
|
490
552
|
ws,
|
|
491
553
|
taskId: key,
|
|
492
|
-
sig,
|
|
493
554
|
pid: null,
|
|
494
555
|
startedAt: cur,
|
|
495
556
|
fails: newFails,
|
|
@@ -502,7 +563,6 @@ export async function patrolWorkspace(
|
|
|
502
563
|
bucket[key] = {
|
|
503
564
|
ws,
|
|
504
565
|
taskId: key,
|
|
505
|
-
sig,
|
|
506
566
|
pid: spawned.pid,
|
|
507
567
|
startedAt: cur,
|
|
508
568
|
procStart: spawned.procStart,
|
|
@@ -514,9 +574,9 @@ export async function patrolWorkspace(
|
|
|
514
574
|
pending.push({ ws, key, pid: spawned.pid });
|
|
515
575
|
}
|
|
516
576
|
|
|
517
|
-
// 第三步:任务文件维护(续期 +
|
|
518
|
-
//
|
|
519
|
-
//
|
|
577
|
+
// 第三步:任务文件维护(续期 + 盖印)合并为一次带 mtime 乐观检测的原子写。
|
|
578
|
+
// 撞车(Claude Code 刚写了 lastFiredAt/删了任务)放弃本轮老条目维护,但已
|
|
579
|
+
// spawn 的会话仍返回给 cmdRun 等待认亲,盖印收尾会再写一次。
|
|
520
580
|
let result;
|
|
521
581
|
try {
|
|
522
582
|
result = maintainWorkspace(taskfile, { reown: stamps });
|
|
@@ -534,53 +594,17 @@ export async function patrolWorkspace(
|
|
|
534
594
|
deps.log(`已续期 ${ws}:${result.renewed} 个周期任务标记为 permanent,`
|
|
535
595
|
+ '不再受 7 天过期限制');
|
|
536
596
|
}
|
|
537
|
-
if (result.minified) {
|
|
538
|
-
deps.log(`已改小 id ${ws}:${result.minified} 个周期任务的 id 改为小哈希`
|
|
539
|
-
+ '值,投递延迟降到约 1 分钟内');
|
|
540
|
-
}
|
|
541
597
|
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
598
|
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
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
|
-
}
|
|
599
|
+
// 第四步:任务消失的条目回收。续期与盖印都不改任务键,分组视图的键集合
|
|
600
|
+
// 仍是准确的 currentKeys。冷却中的条目也删——任务没了,冷却没有意义;
|
|
601
|
+
// 停止失败则保留可跟踪条目,下轮继续尝试,绝不错删后遗留活会话。
|
|
572
602
|
for (const [key, ent] of Object.entries({ ...bucket })) {
|
|
573
|
-
if (
|
|
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
|
-
}
|
|
603
|
+
if (taskGroups.has(key)) continue;
|
|
581
604
|
if (deps.trackedAlive(ent)) {
|
|
582
|
-
await stopEntry(ent, 'recycle',
|
|
583
|
-
|
|
605
|
+
const stopped = await stopEntry(ent, key, 'recycle',
|
|
606
|
+
`回收 ${ws} 任务 ${key} pid=${ent.pid}:对应任务已删除,结束预热会话`);
|
|
607
|
+
if (!stopped) continue;
|
|
584
608
|
}
|
|
585
609
|
delete bucket[key];
|
|
586
610
|
}
|
|
@@ -644,14 +668,13 @@ async function awaitWarmups(pending, state, cur, stats) {
|
|
|
644
668
|
}
|
|
645
669
|
|
|
646
670
|
// 等待期认到亲的会话补盖归属印:claimedByWs 的每个 ent 已是 bucket 条
|
|
647
|
-
// 目,
|
|
648
|
-
//
|
|
671
|
+
// 目,taskId 与认亲身份都在上面。无认亲的 ws 不调用、不写盘;撞 mtime 乐
|
|
672
|
+
// 观锁或极端竞态(result=null)跳过,下轮再来。
|
|
649
673
|
function stampClaimed(claimedByWs, taskfiles, stats) {
|
|
650
674
|
for (const [ws, ents] of claimedByWs) {
|
|
651
675
|
const stamps = new Map();
|
|
652
|
-
const conflicts = new Set();
|
|
653
676
|
for (const ent of ents) {
|
|
654
|
-
if (entryClaimed(ent))
|
|
677
|
+
if (entryClaimed(ent)) stamps.set(ent.taskId, reownTarget(ent));
|
|
655
678
|
}
|
|
656
679
|
if (stamps.size === 0) continue;
|
|
657
680
|
try {
|
|
@@ -675,10 +698,16 @@ function stampClaimed(claimedByWs, taskfiles, stats) {
|
|
|
675
698
|
async function retireLegacy(legacy, stats) {
|
|
676
699
|
for (const ent of legacy) {
|
|
677
700
|
if (deps.trackedAlive(ent)) {
|
|
678
|
-
await deps.stopSession(ent);
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
701
|
+
const stopped = await deps.stopSession(ent);
|
|
702
|
+
if (!stopped) {
|
|
703
|
+
bump(stats, 'warn');
|
|
704
|
+
deps.log(`旧版迁移暂缓 ${ent.ws} pid=${ent.pid}:记录的进程仍存活,`
|
|
705
|
+
+ '本次无法安全停止目录级预热会话;本次不记为已回收');
|
|
706
|
+
} else {
|
|
707
|
+
bump(stats, 'recycle');
|
|
708
|
+
deps.log(`旧版迁移 ${ent.ws} pid=${ent.pid}:结束目录级预热会话,`
|
|
709
|
+
+ '升级后按任务重新预热');
|
|
710
|
+
}
|
|
682
711
|
}
|
|
683
712
|
}
|
|
684
713
|
const handled = new Set(legacy.filter((e) => e.pid).map((e) => e.pid));
|
|
@@ -690,10 +719,16 @@ async function retireLegacy(legacy, stats) {
|
|
|
690
719
|
if (isTaskSessionLogName(path.basename(file))) continue; // 新一代命名
|
|
691
720
|
const ent = { pid, procStart: deps.procStartedAt(pid) };
|
|
692
721
|
if (!deps.trackedAlive(ent)) continue;
|
|
693
|
-
await deps.stopSession(ent);
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
722
|
+
const stopped = await deps.stopSession(ent);
|
|
723
|
+
if (!stopped) {
|
|
724
|
+
bump(stats, 'warn');
|
|
725
|
+
deps.log(`旧版迁移暂缓 pid=${pid}:记录的进程仍存活,本次无法安全停止`
|
|
726
|
+
+ `目录级预热孤儿会话(日志 ${file});本次不记为已回收`);
|
|
727
|
+
} else {
|
|
728
|
+
bump(stats, 'recycle');
|
|
729
|
+
deps.log(`旧版迁移 pid=${pid}:结束目录级预热孤儿会话(日志 ${file}),`
|
|
730
|
+
+ '升级后按任务重新预热');
|
|
731
|
+
}
|
|
697
732
|
}
|
|
698
733
|
}
|
|
699
734
|
|
|
@@ -711,8 +746,9 @@ export async function cmdRun(args) {
|
|
|
711
746
|
const prevStopSession = deps.stopSession;
|
|
712
747
|
const stoppedPids = new Set();
|
|
713
748
|
deps.stopSession = async (ent) => {
|
|
714
|
-
|
|
715
|
-
|
|
749
|
+
const stopped = await prevStopSession(ent);
|
|
750
|
+
if (stopped && ent?.pid) stoppedPids.add(ent.pid);
|
|
751
|
+
return stopped;
|
|
716
752
|
};
|
|
717
753
|
const prevProcStartedAt = deps.procStartedAt;
|
|
718
754
|
const lstartCache = new Map();
|
|
@@ -901,7 +937,7 @@ export async function cmdRun(args) {
|
|
|
901
937
|
} finally {
|
|
902
938
|
deps.stopSession = prevStopSession;
|
|
903
939
|
deps.procStartedAt = prevProcStartedAt;
|
|
904
|
-
|
|
940
|
+
deps.releaseRunLock(lock);
|
|
905
941
|
}
|
|
906
942
|
}
|
|
907
943
|
|
|
@@ -910,4 +946,5 @@ Object.assign(deps, {
|
|
|
910
946
|
runPatrol: cmdRun,
|
|
911
947
|
rotatePatrolLogs,
|
|
912
948
|
acquireRunLock,
|
|
949
|
+
releaseRunLock,
|
|
913
950
|
});
|
package/src/registry.mjs
CHANGED
|
@@ -62,18 +62,18 @@ export function processParentMap() {
|
|
|
62
62
|
return map.size > 0 ? map : null;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// 沿父子表上溯(不含 pid 自身),直到 launchd(1;其父记为 0)
|
|
66
|
-
//
|
|
65
|
+
// 沿父子表上溯(不含 pid 自身),直到 launchd(1;其父记为 0)、父 pid 在
|
|
66
|
+
// 快照里缺失或成环。调用方只消费祖先列表;断链时保留已读到的部分即可。
|
|
67
67
|
export function ancestorChain(pid, parentMap) {
|
|
68
68
|
const ancestors = [];
|
|
69
69
|
const seen = new Set([pid]);
|
|
70
70
|
let cur = pid;
|
|
71
71
|
for (;;) {
|
|
72
72
|
const parent = parentMap?.get(cur);
|
|
73
|
-
if (parent === undefined) return
|
|
73
|
+
if (parent === undefined) return ancestors;
|
|
74
74
|
ancestors.push(parent);
|
|
75
|
-
if (parent <= 1) return
|
|
76
|
-
if (seen.has(parent)) return
|
|
75
|
+
if (parent <= 1) return ancestors; // 0/1 即根
|
|
76
|
+
if (seen.has(parent)) return ancestors;
|
|
77
77
|
seen.add(parent);
|
|
78
78
|
cur = parent;
|
|
79
79
|
}
|
|
@@ -159,9 +159,9 @@ async function readSessionFile(file) {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
// 返回 [拥有活消费者会话的工作区集合, 告警字符串|null, 会话详情 Map]。
|
|
162
|
-
// 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors
|
|
163
|
-
//
|
|
164
|
-
//
|
|
162
|
+
// 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors}>>。
|
|
163
|
+
// 祖先链恒拍:快照失败给空链,相关预热本轮认不到亲、下轮自动重试;
|
|
164
|
+
// consumers 判定不受影响。
|
|
165
165
|
export async function scanSessions() {
|
|
166
166
|
let files = [];
|
|
167
167
|
try {
|
|
@@ -217,9 +217,7 @@ export async function scanSessions() {
|
|
|
217
217
|
if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
|
|
218
218
|
const ws = lexReal(cwd);
|
|
219
219
|
consumers.add(ws);
|
|
220
|
-
const
|
|
221
|
-
? ancestorChain(pid, parentMap)
|
|
222
|
-
: { ancestors: [], chainComplete: false };
|
|
220
|
+
const ancestors = parentMap ? ancestorChain(pid, parentMap) : [];
|
|
223
221
|
const list = details.get(ws) ?? [];
|
|
224
222
|
list.push({
|
|
225
223
|
pid,
|
|
@@ -227,8 +225,7 @@ export async function scanSessions() {
|
|
|
227
225
|
// procStart 是 ps lstart 原文(单位数日期含双空格),只许原样
|
|
228
226
|
// 传递给任务文件,绝不能自己拼。
|
|
229
227
|
procStart: typeof s.procStart === 'string' ? s.procStart : null,
|
|
230
|
-
ancestors
|
|
231
|
-
chainComplete: chain.chainComplete,
|
|
228
|
+
ancestors,
|
|
232
229
|
});
|
|
233
230
|
details.set(ws, list);
|
|
234
231
|
continue;
|