cron-up 26.10.29 → 26.10.30

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.10.29",
3
+ "version": "26.10.30",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/patrol.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
 
8
- import { deps, packageVersion, isPlainObject } from './internals.mjs';
8
+ import { deps, packageVersion } from './internals.mjs';
9
9
  import {
10
10
  FAIL_LIMIT,
11
11
  COOLDOWN_SECONDS,
@@ -177,16 +177,25 @@ function needHorizonMs(tasks, now) {
177
177
  return horizon;
178
178
  }
179
179
 
180
+ // stampOwner 的活创建者判定,一处备齐 classifyWorkspace 需要的输入:
181
+ // 返回本目录活会话 id 集合与「窗口内是否还有任务的创建者会话活着」。
182
+ // 逐任务的 creatorSessionAlive 是本原(盖印逐个任务判定时复用同一谓词),
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 };
189
+ }
190
+
180
191
  // 保证一个工作区里有一个健康的消费者会话。原地修改 state。retain 来自
181
192
  // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。scriptProcs
182
193
  // 是进程表快照或 null:null 时仅在需要认领孤儿(state 丢失的兜底分支)当
183
194
  // 下才拍快照,平静轮不付全量 ps 的代价。
184
195
  // stamp=null 表示 stampOwner 关(一切旧行为);开启时为
185
- // {verdict, creatorAlive, recycleMarginSec}:verdict 是 classifyWorkspace
186
- // 对轮初快照的认亲结论(own/creatorAlive/ambiguous/user,换代或回收导致
187
- // 快照变化时在 retire 内重算),creatorAlive 是「窗口任务有活创建者」
188
- // (几分钟内不会变,retire 重算时复用),recycleMarginSec 是 window 回收
189
- // 余量。
196
+ // {verdict, recycleMarginSec}:verdict 是 classifyWorkspace 对轮初消费者
197
+ // 快照的认亲结论(own/creatorAlive/ambiguous/user,换代或回收导致快照变
198
+ // 化时在 retire 内用新快照重算),recycleMarginSec 是 window 回收余量。
190
199
  export async function patrolWorkspace(
191
200
  ws, state, now, leadMs, cur, consumers, scriptProcs = null,
192
201
  retain = 'always', stats = null, stamp = null,
@@ -241,10 +250,10 @@ export async function patrolWorkspace(
241
250
  if (stamp) {
242
251
  const [nextConsumers, , nextDetails] = await deps.scanSessions({ lineage: true });
243
252
  consumers = nextConsumers;
244
- // ent 已没,entAlive=false;creatorAlive 沿用轮初判定(活创建者在
245
- // 换代这几分钟内不会变成别的状态)。
246
- verdict = deps.classifyWorkspace(nextDetails, ws, null, false,
247
- stamp.creatorAlive);
253
+ // ent 已没(ownPid 给 null);creatorAlive 也必须按新快照重算——
254
+ // 重扫本身就承认换代这几秒现场会变,活创建者可能恰好开关会话。
255
+ verdict = deps.classifyWorkspace(nextDetails, ws, null,
256
+ liveCreatorWanted(tasks, nextDetails, ws, now, leadMs).creatorAlive);
248
257
  } else {
249
258
  consumers = (await deps.scanSessions())[0];
250
259
  }
@@ -292,64 +301,65 @@ export async function patrolWorkspace(
292
301
  + '界面静默,结束会话,下次需要时重拉');
293
302
  }
294
303
 
295
- // 在场的消费者足以让我们放手吗?功能关时任何消费者都算;开启时三种
296
- // 情况算——认到自己的预热(own)、窗口任务的创建者会话还活着
304
+ const wantedNow = tasks.some((t) => taskWanted(t, now, leadMs));
305
+ const warmAge = ent !== null ? cur - (ent.startedAt ?? cur) : 0;
306
+ // 我们跟踪的预热活着、超过登记宽限、且没被认成自己(own 不可能卡死):
307
+ // 卡在提问界面的垃圾进程。两条善后路径共用这一个判定:
308
+ // - 有别的会话可承接(consumerHolds):只杀掉,不摘 state、不计数、不
309
+ // 重拉——是垃圾回收,不是一次启动失败;
310
+ // - 没人承接:杀掉后继续走失败计数/冷却/重拉。
311
+ const warmStuck = ent !== null && mineAlive && wantedNow
312
+ && warmAge > WARMUP_GRACE_SECONDS
313
+ && (!stamp || verdict.kind !== 'own');
314
+ const killStuckWarm = async (held) => {
315
+ await deps.stopSession(ent);
316
+ note('stuck', `卡死 ${ws} pid=${ent.pid}:进程已存活 ${warmAge} 秒仍未登记`
317
+ + '会话,已终止'
318
+ + (held ? ';同目录已有会话可承接任务,不另拉预热' : '')
319
+ + `(排查:cron-up logs ${path.basename(ws)})`);
320
+ };
321
+
322
+ // 在场的消费者足以让我们放手吗?功能关时任何消费者都算;开启时除 user
323
+ // 外的三态都算——认到自己的预热(own)、窗口任务的创建者会话还活着
297
324
  // (creatorAlive:用户显式要求尊重,绝不另拉)、父链有歧义无法排除是
298
- // 自己(ambiguous)。仅无关用户会话(user)不算:创建者已关闭/崩溃,
299
- // 下面继续走到 wanted→卡死退休/冷却→spawn,在同目录另起独立预热承接
300
- // (全程只 stop 自己的 ent,不碰用户会话)。
325
+ // 自己(ambiguous)。仅 user 不算:创建者已关闭/崩溃,下面继续走到
326
+ // wanted→卡死退休/冷却→spawn,在同目录另起独立预热承接(全程只 stop
327
+ // 自己的 ent,不碰用户会话)。
301
328
  const consumerHolds = consumers.has(ws)
302
- && (!stamp
303
- || verdict?.kind === 'own'
304
- || verdict?.kind === 'creatorAlive'
305
- || verdict?.kind === 'ambiguous');
329
+ && (!stamp || verdict.kind !== 'user');
306
330
 
307
331
  // 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
308
332
  // 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
309
- // 却,连一次新尝试都不给。只有真的认到自己(own)才清零:creatorAlive
310
- // 时在场的是活创建者、我们自己的预热可能正卡着没登记,不能替它清零;
311
- // 歧义态保守当真,与「不双开」方向一致。
333
+ // 却,连一次新尝试都不给。只有真的认到自己(own)或歧义态才清零:
334
+ // creatorAlive 时在场的是活创建者、我们的预热可能正卡着没登记,不能替
335
+ // 它清零。
312
336
  if (ent !== null && mineAlive && consumers.has(ws)
313
- && (!stamp || verdict?.kind === 'own' || verdict?.kind === 'ambiguous')) {
337
+ && (!stamp || verdict.kind === 'own' || verdict.kind === 'ambiguous')) {
314
338
  ent.fails = 0;
315
339
  }
316
340
 
317
341
  // 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
318
342
  // 盖),也可能是用户的交互会话——后者默认放手:不在这里删死条目,失败
319
343
  // 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
320
- // 由轮末 kept 过滤统一处理。先于 wanted 判定返回:有消费者在场的平稳
321
- // 态不必为整组任务跑一遍 cron 求值。
322
- // creatorAlive/ambiguous 返回前还有一件事:我们自己跟踪的预热若已卡死
323
- // (超登记宽限仍未认到——verdict 不是 own),它是没用的垃圾进程,任务
324
- // 由活创建者承接(或歧义保守),杀掉它即可、本轮不重拉。
344
+ // 由轮末 kept 过滤统一处理。creatorAlive/ambiguous 返回前顺手清掉卡死
345
+ // 的垃圾预热;平稳态在此提前返回,不必继续后面的流程。
325
346
  if (consumerHolds) {
326
- if (stamp && verdict?.kind !== 'own'
327
- && ent !== null && mineAlive
328
- && tasks.some((t) => taskWanted(t, now, leadMs))
329
- && cur - (ent.startedAt ?? cur) > WARMUP_GRACE_SECONDS) {
330
- const age = cur - (ent.startedAt ?? cur);
331
- await deps.stopSession(ent);
332
- note('stuck', `卡死 ${ws} pid=${ent.pid}:进程已存活 ${age} 秒仍未登记`
333
- + '会话,已终止;同目录已有会话可承接任务,不另拉预热'
334
- + '(排查:cron-up logs '
335
- + `${path.basename(ws)})`);
336
- }
347
+ // 默认配置(stamp 关)沿用旧行为:用户会话在场时「掩护」卡死预热,
348
+ // 不在这里杀,交给 7 天换代——所以 holds 路径只在 stamp 开启时清垃圾。
349
+ if (stamp && warmStuck) await killStuckWarm(true);
337
350
  return;
338
351
  }
339
352
 
340
- if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
353
+ if (!wantedNow) return;
341
354
 
342
355
  const cooling = isCooling(ent, cur);
343
356
 
344
357
  // 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
345
- // 应答的提问界面,永不登记——退休它并推进失败计数。
358
+ // 应答的提问界面,永不登记——退休它并推进失败计数。宽限内的年轻会话
359
+ // 直接返回;warmStuck 已含超宽限判定。
346
360
  if (ent !== null && mineAlive) {
347
- const age = cur - (ent.startedAt ?? cur);
348
- if (age <= WARMUP_GRACE_SECONDS) return;
349
- await deps.stopSession(ent);
350
- note('stuck', `卡死 ${ws} pid=${ent.pid}:进程已存活 ${age} 秒仍未登记会话,`
351
- + '已终止(排查:cron-up logs '
352
- + `${path.basename(ws)})`);
361
+ if (!warmStuck) return;
362
+ await killStuckWarm(false);
353
363
  }
354
364
 
355
365
  if (cooling) return;
@@ -531,27 +541,23 @@ export async function cmdRun(args) {
531
541
  // 压制该工作区的会话保活。三者合并为同一次原子写(见
532
542
  // maintainWorkspace)。
533
543
  //
534
- // stampOwner 的判定在这里现算(需要任务内容):窗口内是否还有任务
544
+ // stampOwner 的判定在这里现算(依赖任务内容):窗口内是否还有任务
535
545
  // 的创建者会话活着——活创建者拥有否决权,不另拉、不盖印。
536
- let target = null;
537
546
  let stampArg = null;
538
547
  let liveSids = null;
539
548
  if (cfg.stampOwner) {
540
- const wsTasks = deps.readTasks(ws) ?? [];
541
- liveSids = deps.liveSessionIds(details, ws);
542
- const creatorAlive = wsTasks.some((t) =>
543
- isPlainObject(t)
544
- && typeof t.createdBySessionId === 'string'
545
- && liveSids.has(t.createdBySessionId)
546
- && taskWanted(t, now, leadMs));
549
+ const wsTasks = deps.readTasks(ws) ?? []; // readTasks 已滤掉非对象
550
+ const creator = liveCreatorWanted(wsTasks, details, ws, now, leadMs);
551
+ liveSids = creator.liveSids;
547
552
  const entNow = state[ws] ?? null;
548
- const verdict = deps.classifyWorkspace(
549
- details, ws, entNow,
550
- Boolean(entNow) && deps.trackedAlive(entNow),
551
- creatorAlive);
552
- target = verdict.target; // 仅 own 且身份完整时非空
553
- stampArg = { verdict, creatorAlive, recycleMarginSec };
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
+ };
554
559
  }
560
+ const target = stampArg?.verdict?.target ?? null;
555
561
  // 盖印谓词只盖「窗口内且创建者已不活」的任务:活创建者的任务永远
556
562
  // 不碰(即便本目录另有我们的预热在承接别的死主任务);已盖给本代
557
563
  // 预热的任务因 owner 就在活会话集合里而自然跳过,幂等不靠另写比较。
@@ -566,7 +572,7 @@ export async function cmdRun(args) {
566
572
  ? {
567
573
  ...target,
568
574
  isWanted: (t) => taskWanted(t, now, leadMs)
569
- && !liveSids.has(t.createdBySessionId),
575
+ && !deps.creatorSessionAlive(t, liveSids),
570
576
  }
571
577
  : null,
572
578
  }) ?? {};
package/src/registry.mjs CHANGED
@@ -88,10 +88,18 @@ export function liveSessionIds(details, ws) {
88
88
  .filter((x) => typeof x === 'string'));
89
89
  }
90
90
 
91
- // stampOwner 的认亲结论。details/ws 是 scanSessions 的第三元素;ent 是
92
- // state 条目,entAlive 由调用方按指纹判好(registry 不认识 state 语义);
93
- // creatorAlive 表示「至少一个窗口内任务的创建者会话还在本目录活着」:
94
- // own 祖先链里有本代 script pid——在场消费者就是我们的预热;
91
+ // 逐任务本原谓词:这条任务的创建者会话还活着吗?liveSids 是同工作区的
92
+ // liveSessionIds 快照。认亲分类(聚合布尔)与盖印(逐任务)都从它派生。
93
+ export function creatorSessionAlive(task, liveSids) {
94
+ return typeof task?.createdBySessionId === 'string'
95
+ && liveSids.has(task.createdBySessionId);
96
+ }
97
+
98
+ // stampOwner 的认亲结论。details/ws 是 scanSessions 的第三元素;ownPid
99
+ // 是调用方按指纹判活的本代 script pid(没有活条目时给 null,registry 不
100
+ // 认识 state 条目的语义);creatorAlive 表示「至少一个窗口内任务的创建
101
+ // 者会话还在本目录活着」:
102
+ // own 祖先链里有 ownPid——在场消费者就是我们的预热;
95
103
  // target 给出可盖印详情,身份字段缺失时为 null;
96
104
  // creatorAlive 窗口任务的创建者会话还活着——任务按 Claude Code 规则必
97
105
  // 然投给它,不另拉、不盖印(用户显式要求的最高优先级);
@@ -99,10 +107,10 @@ export function liveSessionIds(details, ws) {
99
107
  // 是自己(如 state 丢失的孤儿预热),保守不双开;
100
108
  // user 以上皆否——创建者已关闭/崩溃,在场的只是无关用户会话
101
109
  // (或根本没有消费者),可以另拉独立预热承接。
102
- export function classifyWorkspace(details, ws, ent, entAlive, creatorAlive) {
110
+ export function classifyWorkspace(details, ws, ownPid, creatorAlive) {
103
111
  const list = details?.get(ws) ?? [];
104
- if (ent?.pid && entAlive) {
105
- const own = list.find((d) => d.ancestors.includes(ent.pid));
112
+ if (ownPid !== null) {
113
+ const own = list.find((d) => d.ancestors.includes(ownPid));
106
114
  if (own) {
107
115
  return {
108
116
  kind: 'own',
@@ -255,6 +263,7 @@ Object.assign(deps, {
255
263
  processParentMap,
256
264
  ancestorChain,
257
265
  liveSessionIds,
266
+ creatorSessionAlive,
258
267
  classifyWorkspace,
259
268
  scanSessions,
260
269
  });