cron-up 26.10.31 → 26.10.33

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/src/tasks.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // Claude Code 任务文件层:发现工作区、读任务、续期(permanent 标记)、id
2
- // 改小、归属盖印与原子写。任务文件是另一个程序写的外部输入,所有入口都
1
+ // Claude Code 任务文件层:发现工作区、读任务、续期(permanent 标记)、
2
+ // 归属盖印与原子写。任务文件是另一个程序写的外部输入,所有入口都
3
3
  // 校验形状,坏数据读作「没有任务」而不是抛进巡检循环。
4
4
 
5
5
  import fs from 'node:fs';
@@ -10,8 +10,6 @@ import { deps, isPlainObject } from './internals.mjs';
10
10
  import { TASK_REL } from './paths.mjs';
11
11
  import {
12
12
  PRUNE_DIRS,
13
- CC_JITTER,
14
- AUTO_MIN_ID_TARGET_DELAY_MS,
15
13
  MAX_SCAN_DEPTH,
16
14
  } from './constants.mjs';
17
15
 
@@ -140,9 +138,9 @@ export function taskKey(t) {
140
138
  return `anon:${createHash('sha1').update(taskSig(t)).digest('hex').slice(0, 16)}`;
141
139
  }
142
140
 
143
- // 任务的稳定内容签名:只含不会被我们与调度器改动的字段。minify 改 id、盖
144
- // 印改 createdBy*、执行改 lastFiredAt、续期加 permanent 都不改变签名——
145
- // 它是「id 改写前后 state 条目改键」与「一批多 target 盖印」的串联键。
141
+ // 任务的稳定内容签名:只含不会被我们与调度器改动的字段。盖印改
142
+ // createdBy*、执行改 lastFiredAt、续期加 permanent 都不改变签名;无 id
143
+ // 任务的 taskKey 用它生成内容摘要键。
146
144
  export function taskSig(t) {
147
145
  return JSON.stringify([
148
146
  t?.cron ?? null,
@@ -152,9 +150,25 @@ export function taskSig(t) {
152
150
  ]);
153
151
  }
154
152
 
153
+ // 按任务身份键分组的共享视图:重复键天然落在同一组,调用方不必各自
154
+ // 维护重复计数与键集合。非对象条目不参与盖印或巡检,和 readTasks/
155
+ // maintain 的既有口径一致。
156
+ export function groupTasksByKey(tasks) {
157
+ const groups = new Map();
158
+ for (const t of tasks) {
159
+ if (!isPlainObject(t)) continue;
160
+ const key = taskKey(t);
161
+ const group = groups.get(key) ?? [];
162
+ group.push(t);
163
+ groups.set(key, group);
164
+ }
165
+ return groups;
166
+ }
167
+
155
168
  // 任务文件在「读到」与「写回」之间被别的写者改了(Claude Code 更新
156
- // lastFiredAt 或删除已触发任务):绝不覆盖,抛 TaskFileChanged 让下轮
157
- // 巡检基于更新的文档重试。
169
+ // lastFiredAt 或删除已触发任务):最终检查时能发现就抛 TaskFileChanged,
170
+ // 让下轮巡检基于更新的文档重试。检查与 rename 之间仍有极小竞态,不能把
171
+ // 这层检测当成与上游共用的互斥锁。
158
172
  export class TaskFileChanged extends Error {
159
173
  constructor(file) {
160
174
  super(`任务文件已被其他写者改动:${file}`);
@@ -184,8 +198,8 @@ function tmpSibling(file) {
184
198
 
185
199
  // 旁边写再 rename 的原子替换,保留原文件权限位与中文字面量。
186
200
  // tmp 以 0600 创建(绝不更宽),写完再放宽到原 mode。expectedMtimeNs 是
187
- // 乐观锁:路径 mtime 自调用方读后变了说明别的写者抢先,抛
188
- // TaskFileChanged 而不是覆盖它的更新。
201
+ // 乐观检测:最终检查时路径 mtime 已变说明别的写者抢先,抛 TaskFileChanged
202
+ // 而不是覆盖这次检查之前发生的更新。
189
203
  export function atomicWriteJson(file, doc, expectedMtimeNs) {
190
204
  let mode = 0o600;
191
205
  try {
@@ -221,72 +235,17 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
221
235
  fs.renameSync(tmp, file);
222
236
  }
223
237
 
224
- // 严格 8 位十六进制的任务 id(Claude Code 自己生成的形状)转数值;其他
225
- // 形状(短串、UUID、缺失、非字符串)返回 null——它们算「未达标」,由
226
- // minifyIds 改写成合规小号。
227
- export function parseTaskIdN(id) {
228
- if (typeof id !== 'string' || !/^[0-9a-fA-F]{8}$/.test(id)) return null;
229
- return parseInt(id, 16);
230
- }
231
-
232
- // autoMinId 的 id 数值上界:每日任务延迟 ≤ AUTO_MIN_ID_TARGET_DELAY_MS。
233
- export function minTaskIdMaxN() {
234
- return Math.floor(
235
- AUTO_MIN_ID_TARGET_DELAY_MS
236
- / (CC_JITTER.recurringFrac * 86400_000) * 2 ** 32);
237
- }
238
-
239
- // 把显式周期任务里 id 数值超过上界(或形状不合法)的,改写成文件内未占用
240
- // 的小号(00000001 起的 8 位十六进制),使其投递抖动降到目标延迟内。原地
241
- // 修改 tasks 元素,返回 {changed, idChanges};idChanges 用 taskKey 口径
242
- // 记录改前/改后键,巡检同轮把 state 条目跟着改键(state 以任务 id 为
243
- // 键,不改键会把刚换 id 的任务误判为「旧任务删除 + 新任务」而错杀重
244
- // 拉)。关键纪律:
245
- // - 只碰 t.recurring truthy 的显式周期任务,一次性与无标志手写条目不碰;
246
- // - 已达标的小号(含 00000000)原样保留,永不重排——多次巡检结果稳定,
247
- // 第二轮起 0 改动、文件字节不动;
248
- // - 空号在「文件内全部任务」(含一次性)已占用的 id 之外分配,保证同文
249
- // 件唯一;跨文件重复无妨(各工作区独立加载)。
250
- export function minifyIds(tasks) {
251
- const maxN = minTaskIdMaxN();
252
- const used = new Set();
253
- for (const t of tasks) {
254
- if (!isPlainObject(t)) continue;
255
- const n = parseTaskIdN(t.id);
256
- if (n !== null) used.add(n);
257
- }
258
- let next = 1;
259
- const takeFree = () => {
260
- while (used.has(next)) next += 1;
261
- used.add(next);
262
- return next;
263
- };
264
- let changed = 0;
265
- const idChanges = [];
266
- for (const t of tasks) {
267
- if (!isPlainObject(t) || !t.recurring) continue;
268
- const n = parseTaskIdN(t.id);
269
- if (n !== null && n <= maxN) continue;
270
- const from = taskKey(t);
271
- const to = takeFree().toString(16).padStart(8, '0');
272
- t.id = to;
273
- changed += 1;
274
- idChanges.push({ from, to });
275
- }
276
- return { changed, idChanges };
277
- }
278
-
279
- // 一轮巡检对一个任务文件做的维护:renew(补 permanent)、minify(id 改
280
- // 小)、reown(把任务归属盖给各自的预热会话)合并为同一次受 mtime 乐观
281
- // 锁保护的原子写。renew/minify 无条件执行,没有开关。先取 mtimeNs、清残
282
- // 留 .cron-up-tmp、loadTaskDoc round-trip 整文档;三项计数都为 0 时字节
283
- // 级不动。返回 {renewed,minified,reowned,idChanges};null 表示文件缺失/
284
- // 损坏;读到写间被改抛 TaskFileChanged。
238
+ // 一轮巡检对一个任务文件做的维护:renew(补 permanent)与 reown(把任务
239
+ // 归属盖给各自的预热会话)合并为同一次带 mtime 乐观检测的原子写。先取
240
+ // mtimeNs、清残留 .cron-up-tmp、loadTaskDoc round-trip 整文档;两项计数
241
+ // 都为 0 时字节级不动。返回 {renewed,reowned};null 表示文件缺失/损坏;
242
+ // 读到写间被改抛 TaskFileChanged。
285
243
  //
286
- // reown 形状:Map<taskSig, {sessionId, pid, procStart}>。一轮内同一文件的
287
- // 不同任务可盖向不同预热(一任务一会话);Map 以签名为键,minify 先改 id
288
- // 不影响随后的盖印查找。只盖 createdBySessionId 不等于目标的任务,第二轮
289
- // 起字节级不动;pid/procStart 是会话登记里 claude 进程的 pid 与 lstart。
244
+ // reown 形状:Map<taskKey, {sessionId, pid, procStart}>。一轮内同一文件的
245
+ // 不同任务可盖向不同预热(一任务一会话);内容相同但 id 不同的任务互不
246
+ // 影响。同一个 taskKey 对应多条任务是病态输入,无法把 state/会话唯一对应
247
+ // 到条目,这些键全部跳过盖印。只盖 createdBySessionId 不等于目标的任务,
248
+ // 第二轮起字节级不动;pid/procStart 是登记里 claude 进程的 pid 与 lstart。
290
249
  export function maintainWorkspace(taskfile, { reown = null } = {}) {
291
250
  let mtimeNs;
292
251
  try {
@@ -306,23 +265,25 @@ export function maintainWorkspace(taskfile, { reown = null } = {}) {
306
265
  if (doc === null) return null;
307
266
  let renewed = 0;
308
267
  for (const t of doc.tasks) {
268
+ if (!isPlainObject(t)) continue;
309
269
  // truthy 而非 === true:与 Claude Code 自己的加载器一致
310
270
  // (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
311
271
  // 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
312
272
  // 一次性任务不碰。
313
- if (isPlainObject(t) && t.recurring && !t.permanent) {
273
+ if (t.recurring && !t.permanent) {
314
274
  t.permanent = true;
315
275
  renewed += 1;
316
276
  }
317
277
  }
318
- const { changed: minified, idChanges } = minifyIds(doc.tasks);
319
278
  let reowned = 0;
320
279
  if (reown instanceof Map && reown.size > 0) {
321
- for (const t of doc.tasks) {
280
+ // 只有确实需要盖印时才计算任务键;纯续期与常见空操作不付摘要成本。
281
+ for (const [key, group] of groupTasksByKey(doc.tasks)) {
282
+ if (group.length !== 1) continue;
283
+ const t = group[0];
284
+ const target = reown.get(key);
322
285
  // 没有 owner 字段的外部条目同样盖——窗口内任务统一归各自预热,语义
323
286
  // 不因条目来源留例外。
324
- if (!isPlainObject(t)) continue;
325
- const target = reown.get(taskSig(t));
326
287
  if (target && t.createdBySessionId !== target.sessionId) {
327
288
  t.createdBySessionId = target.sessionId;
328
289
  t.createdByPid = target.pid;
@@ -332,11 +293,11 @@ export function maintainWorkspace(taskfile, { reown = null } = {}) {
332
293
  }
333
294
  }
334
295
  }
335
- if (renewed === 0 && minified === 0 && reowned === 0) {
336
- return { renewed: 0, minified: 0, reowned: 0, idChanges: [] };
296
+ if (renewed === 0 && reowned === 0) {
297
+ return { renewed: 0, reowned: 0 };
337
298
  }
338
299
  atomicWriteJson(taskfile, doc, mtimeNs);
339
- return { renewed, minified, reowned, idChanges };
300
+ return { renewed, reowned };
340
301
  }
341
302
 
342
303
  Object.assign(deps, { discover, readTasks, loadTaskDoc });