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/src/sessions.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // 预热会话的整个生命周期:存活探测、pid 身份指纹、拉起、停止、日志文件
2
2
  // 名、进程表兜底。被拉起的是 /usr/bin/script 包裹的 TUI,detached 独立进
3
- // 程组,与短命的巡检进程在进程层早就分开。
3
+ // 程组,与短命的巡检进程在进程层早就分开。一个预热会话只服务一个任务
4
+ // (归属靠任务文件盖印,不靠命令行),日志名因此带任务 id。
4
5
 
5
6
  import fs from 'node:fs';
6
7
  import path from 'node:path';
@@ -43,12 +44,26 @@ export function trackedAlive(ent) {
43
44
 
44
45
  // 日志文件名折法:下划线转义必须在斜杠折叠之前——/a/b → a_b,
45
46
  // /a_b → a__b,两类路径才不会共用同一个日志文件(script(1) 启动即截断,
46
- // 撞名意味着两个工作区互相覆盖记录)。
47
- export function sessionLogPath(ws) {
48
- const slug = ws.replace(/^\/+|\/+$/g, '')
47
+ // 撞名意味着两个工作区互相覆盖记录)。logs 命令按工作区前缀找日志时也
48
+ // 用同一个折法。
49
+ export function slugSegment(s) {
50
+ return String(s).replace(/^\/+|\/+$/g, '')
49
51
  .replace(/_/g, '__')
50
52
  .replace(/\//g, '_');
51
- return path.join(deps.paths.sessionLogDir, `${slug}.log`);
53
+ }
54
+
55
+ // 每个任务的预热会话有独立日志:<工作区slug>--<任务键>.log。-- 分隔,任
56
+ // 务键是 8 位十六进制或 anon:<16 位十六进制>。
57
+ export function sessionLogPath(ws, taskId) {
58
+ return path.join(deps.paths.sessionLogDir,
59
+ `${slugSegment(ws)}--${slugSegment(taskId)}.log`);
60
+ }
61
+
62
+ // 文件名是否为新命名(任务级)的预热日志。升级轮清扫旧版目录级命名的孤
63
+ // 儿 script 进程时用它区分两代:任务键段的严格形状让旧命名(纯工作区
64
+ // slug)几乎不可能误判为新命名。
65
+ export function isTaskSessionLogName(name) {
66
+ return /--(?:[0-9a-f]{8}|anon:[0-9a-f]{16})\.log$/.test(name);
52
67
  }
53
68
 
54
69
  // POSIX shell 引用:安全字符白名单内原样返回,否则单引号包裹,内部单引
@@ -94,14 +109,14 @@ export function rotateOnce(file) {
94
109
  fs.renameSync(file, old);
95
110
  }
96
111
 
97
- // 拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
98
- export function spawnSession(ws) {
99
- const logPath = sessionLogPath(ws);
112
+ // 为一个任务拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
113
+ export function spawnSession(ws, taskId) {
114
+ const logPath = sessionLogPath(ws, taskId);
115
+ // 会话日志目录 700(README 承诺):每任务独立文件,目录只在此处创建。
100
116
  ensurePrivateDir(path.dirname(logPath));
101
- // window 模式下「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的
102
- // 话上一代(上一次任务执行)的界面记录会随本次拉起被抹掉。无条件保留
103
- // 一代(.1)——总量上界两代,活着的一代增长再快也只影响自身,不会经
104
- // 轮转放大。
117
+ // 「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的话上一代(上
118
+ // 一次执行)的界面记录会随本次拉起被抹掉。无条件保留一代(.1)——总
119
+ // 量上界两代,活着的一代增长再快也只影响自身,不会经轮转放大。
105
120
  try {
106
121
  if (fs.existsSync(logPath)) rotateOnce(logPath);
107
122
  } catch {
@@ -127,7 +142,7 @@ export function spawnSession(ws) {
127
142
  // 但错误原因要留进巡检日志——spawn 同步返回成功、子进程 exec 阶段才失败
128
143
  // (ENOENT/EAGAIN 等)时,上层只能看到会话随后死亡,没有任何原因可查。
129
144
  child.on('error', (e) => {
130
- deps.log(`会话进程拉起异常 ${ws}:${e?.code ? `${e.code}:` : ''}`
145
+ deps.log(`会话进程拉起异常 ${ws} 任务 ${taskId}:${e?.code ? `${e.code}:` : ''}`
131
146
  + `${e?.message ?? e}`);
132
147
  });
133
148
  child.unref();
@@ -138,7 +153,9 @@ export function spawnSession(ws) {
138
153
  }
139
154
 
140
155
  // 按进程组结束我们拉起的会话。发信号前先核身份:state 条目可能比它命名
141
- // 的进程活得久,给回收 pid 发组信号会杀掉无关进程组。
156
+ // 的进程活得久,给回收 pid 发组信号会杀掉无关进程组。detached(用户
157
+ // resume 接管)时 trackedAlive 为 false:本函数直接返回,绝不碰用户的裸
158
+ // claude。
142
159
  export async function stopSession(ent) {
143
160
  if (!deps.trackedAlive(ent)) return;
144
161
  const pid = ent.pid;
@@ -163,8 +180,8 @@ export async function stopSession(ent) {
163
180
  }
164
181
 
165
182
  // typescript 多少秒没被写。文件缺失读作「没有任何活动证据」:Infinity 大
166
- // 于任何静默阈值,天然不挡换代与回收(哨兵值曾绑定 SESSION_IDLE_SECONDS
167
- // + 1,阈值调低后会悄悄翻转语义)。
183
+ // 于任何静默阈值,天然不挡回收(哨兵值曾绑定固定阈值 + 1,阈值调低后会
184
+ // 悄悄翻转语义)。
168
185
  export function sessionLogIdleSeconds(logPath, cur) {
169
186
  try {
170
187
  return Math.max(0, cur - Math.floor(fs.statSync(logPath).mtimeMs / 1000));
@@ -174,7 +191,7 @@ export function sessionLogIdleSeconds(logPath, cur) {
174
191
  }
175
192
 
176
193
  // 进程表是 state.json 丢失时的兜底:被遗忘的预热会话卡在提问界面时不会出
177
- // 现在会话登记里,没这层交叉检查就会在同目录堆第二个 TUI。
194
+ // 现在会话登记里,没这层交叉检查就会为同一任务堆第二个 TUI。
178
195
  export function listScriptProcesses() {
179
196
  const r = deps.execFile('/bin/ps', ['-axo', 'pid=,args='], { timeout: 5000 });
180
197
  const out = [];
package/src/tasks.mjs CHANGED
@@ -1,9 +1,10 @@
1
- // Claude Code 任务文件层:发现工作区、读任务、续期(permanent 标记)与
2
- // 原子写。任务文件是另一个程序写的外部输入,所有入口都校验形状,坏数据
3
- // 读作「没有任务」而不是抛进巡检循环。
1
+ // Claude Code 任务文件层:发现工作区、读任务、续期(permanent 标记)、id
2
+ // 改小、归属盖印与原子写。任务文件是另一个程序写的外部输入,所有入口都
3
+ // 校验形状,坏数据读作「没有任务」而不是抛进巡检循环。
4
4
 
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import { createHash } from 'node:crypto';
7
8
 
8
9
  import { deps, isPlainObject } from './internals.mjs';
9
10
  import { TASK_REL } from './paths.mjs';
@@ -11,6 +12,7 @@ import {
11
12
  PRUNE_DIRS,
12
13
  CC_JITTER,
13
14
  AUTO_MIN_ID_TARGET_DELAY_MS,
15
+ MAX_SCAN_DEPTH,
14
16
  } from './constants.mjs';
15
17
 
16
18
  export function expandHome(p) {
@@ -59,7 +61,6 @@ export function taskFileExists(ws) {
59
61
  // 写的路径照样通过 isDirectory,却与会话登记里真实大小写的 cwd 永远匹配
60
62
  // 不上。在扫描边界认回一次真实写法,下游就不必各自提防。
61
63
  export function onDiskCase(p) {
62
- // resolve 的输出不含双斜杠与尾部斜杠(根除外),按段拆开自顶向下认回。
63
64
  const parts = path.resolve(p).split('/').filter(Boolean);
64
65
  let cur = '/';
65
66
  for (const name of parts) {
@@ -77,8 +78,9 @@ export function onDiskCase(p) {
77
78
  }
78
79
 
79
80
  // 枚举所有含任务文件的工作区,产出 [workspace, taskfile]。生成器:一个工
80
- // 作区出问题不该跳过其后所有工作区(调用方另有 per-workspace 保护)。
81
- export function* discover(roots, maxDepth) {
81
+ // 作区出问题不该跳过其后所有工作区(调用方另有 per-workspace 保护)。扫
82
+ // 描深度固定为 MAX_SCAN_DEPTH,不可配置。
83
+ export function* discover(roots) {
82
84
  const seen = new Set();
83
85
  for (const raw of roots) {
84
86
  let root = realpathOrNull(path.resolve(expandHome(String(raw))));
@@ -96,9 +98,9 @@ export function* discover(roots, maxDepth) {
96
98
  return;
97
99
  }
98
100
  const depth = dirpath.split('/').length - 1 - baseDepth;
99
- // 先剪枝:depth==maxDepth 这一层本身仍要检查候选文件,只是不再下
100
- // 降;再做候选检查。
101
- const subdirs = depth >= maxDepth
101
+ // 先剪枝:depth==MAX_SCAN_DEPTH 这一层本身仍要检查候选文件,只是不
102
+ // 再下降;再做候选检查。
103
+ const subdirs = depth >= MAX_SCAN_DEPTH
102
104
  ? []
103
105
  : entries.filter((e) => e.isDirectory() && !PRUNE_DIRS.has(e.name));
104
106
  const candidate = path.join(dirpath, TASK_REL);
@@ -125,12 +127,31 @@ export function* discover(roots, maxDepth) {
125
127
  }
126
128
 
127
129
  // 读一个工作区的任务列表,坏形状一律返回 null。读盘与顶层形状校验复用
128
- // loadTaskDoc(renew 也要同一口径 round-trip 整份文档)。
130
+ // loadTaskDoc(maintain 也要同一口径 round-trip 整份文档)。
129
131
  export function readTasks(ws) {
130
132
  const doc = deps.loadTaskDoc(path.join(ws, TASK_REL));
131
133
  return doc === null ? null : doc.tasks.filter(isPlainObject);
132
134
  }
133
135
 
136
+ // 任务在 state 与盖印映射里的身份键:CronCreate 的任务总有唯一 8 位十六
137
+ // 进制 id,直接用它;手写无 id 任务退化为内容摘要键。
138
+ export function taskKey(t) {
139
+ if (typeof t?.id === 'string' && t.id !== '') return t.id;
140
+ return `anon:${createHash('sha1').update(taskSig(t)).digest('hex').slice(0, 16)}`;
141
+ }
142
+
143
+ // 任务的稳定内容签名:只含不会被我们与调度器改动的字段。minify 改 id、盖
144
+ // 印改 createdBy*、执行改 lastFiredAt、续期加 permanent 都不改变签名——
145
+ // 它是「id 改写前后 state 条目改键」与「一批多 target 盖印」的串联键。
146
+ export function taskSig(t) {
147
+ return JSON.stringify([
148
+ t?.cron ?? null,
149
+ t?.prompt ?? null,
150
+ t?.createdAt ?? null,
151
+ Boolean(t?.recurring),
152
+ ]);
153
+ }
154
+
134
155
  // 任务文件在「读到」与「写回」之间被别的写者改了(Claude Code 更新
135
156
  // lastFiredAt 或删除已触发任务):绝不覆盖,抛 TaskFileChanged 让下轮
136
157
  // 巡检基于更新的文档重试。
@@ -141,7 +162,7 @@ export class TaskFileChanged extends Error {
141
162
  }
142
163
  }
143
164
 
144
- // 读整份文档(未知顶层键保留):renew 要就地重写文件,必须 round-trip
165
+ // 读整份文档(未知顶层键保留):maintain 要就地重写文件,必须 round-trip
145
166
  // Claude Code 或其他工具放进去的每个键,而不只是 tasks 子集。
146
167
  export function loadTaskDoc(file) {
147
168
  let doc;
@@ -173,7 +194,7 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
173
194
  // 新文件用 0600。
174
195
  }
175
196
  const tmp = tmpSibling(file);
176
- // JSON.stringify 两空格缩进:中文按字面写出(不转 \u),无尾换行。
197
+ // JSON.stringify 两空格缩进:中文按字面写出(不转 \u 转义),无尾换行。
177
198
  fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), {
178
199
  flag: 'w',
179
200
  mode: 0o600,
@@ -217,7 +238,10 @@ export function minTaskIdMaxN() {
217
238
 
218
239
  // 把显式周期任务里 id 数值超过上界(或形状不合法)的,改写成文件内未占用
219
240
  // 的小号(00000001 起的 8 位十六进制),使其投递抖动降到目标延迟内。原地
220
- // 修改 tasks 元素,返回改写条数。关键纪律:
241
+ // 修改 tasks 元素,返回 {changed, idChanges};idChanges 用 taskKey 口径
242
+ // 记录改前/改后键,巡检同轮把 state 条目跟着改键(state 以任务 id 为
243
+ // 键,不改键会把刚换 id 的任务误判为「旧任务删除 + 新任务」而错杀重
244
+ // 拉)。关键纪律:
221
245
  // - 只碰 t.recurring truthy 的显式周期任务,一次性与无标志手写条目不碰;
222
246
  // - 已达标的小号(含 00000000)原样保留,永不重排——多次巡检结果稳定,
223
247
  // 第二轮起 0 改动、文件字节不动;
@@ -238,31 +262,32 @@ export function minifyIds(tasks) {
238
262
  return next;
239
263
  };
240
264
  let changed = 0;
265
+ const idChanges = [];
241
266
  for (const t of tasks) {
242
267
  if (!isPlainObject(t) || !t.recurring) continue;
243
268
  const n = parseTaskIdN(t.id);
244
269
  if (n !== null && n <= maxN) continue;
245
- t.id = takeFree().toString(16).padStart(8, '0');
270
+ const from = taskKey(t);
271
+ const to = takeFree().toString(16).padStart(8, '0');
272
+ t.id = to;
246
273
  changed += 1;
274
+ idChanges.push({ from, to });
247
275
  }
248
- return changed;
276
+ return { changed, idChanges };
249
277
  }
250
278
 
251
279
  // 一轮巡检对一个任务文件做的维护:renew(补 permanent)、minify(id 改
252
- // 小)、reown(把窗口内任务的 owner 三字段盖给指定预热会话)合并为同一
253
- // 次受 mtime 乐观锁保护的原子写。先取 mtimeNs、清残留 .cron-up-tmp、
254
- // loadTaskDoc round-trip 整文档;三项计数都为 0 时字节级不动。返回
255
- // {renewed,minified,reowned};null 表示文件缺失/损坏;读到写间被改抛
256
- // TaskFileChanged。
280
+ // 小)、reown(把任务归属盖给各自的预热会话)合并为同一次受 mtime 乐观
281
+ // 锁保护的原子写。renew/minify 无条件执行,没有开关。先取 mtimeNs、清残
282
+ // 留 .cron-up-tmp、loadTaskDoc round-trip 整文档;三项计数都为 0 时字节
283
+ // 级不动。返回 {renewed,minified,reowned,idChanges};null 表示文件缺失/
284
+ // 损坏;读到写间被改抛 TaskFileChanged。
257
285
  //
258
- // reown 形状:{sessionId, pid, procStart, isWanted(t)} | null。isWanted
259
- // 由调用方注入(tasks 层不反向依赖 cron 层);只盖 createdBySessionId/
260
- // createdByPid/createdByProcStart 三个字段,不碰 createdInProject。
261
- export function maintainWorkspace(taskfile, {
262
- renew = false,
263
- minify = false,
264
- reown = null,
265
- }) {
286
+ // reown 形状:Map<taskSig, {sessionId, pid, procStart}>。一轮内同一文件的
287
+ // 不同任务可盖向不同预热(一任务一会话);Map 以签名为键,minify 先改 id
288
+ // 不影响随后的盖印查找。只盖 createdBySessionId 不等于目标的任务,第二轮
289
+ // 起字节级不动;pid/procStart 是会话登记里 claude 进程的 pid 与 lstart。
290
+ export function maintainWorkspace(taskfile, { reown = null } = {}) {
266
291
  let mtimeNs;
267
292
  try {
268
293
  mtimeNs = fs.statSync(taskfile, { bigint: true }).mtimeNs;
@@ -280,41 +305,38 @@ export function maintainWorkspace(taskfile, {
280
305
  const doc = deps.loadTaskDoc(taskfile);
281
306
  if (doc === null) return null;
282
307
  let renewed = 0;
283
- if (renew) {
284
- for (const t of doc.tasks) {
285
- // truthy 而非 === true:与 Claude Code 自己的加载器一致
286
- // (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
287
- // 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
288
- // 一次性任务不碰。
289
- if (isPlainObject(t) && t.recurring && !t.permanent) {
290
- t.permanent = true;
291
- renewed += 1;
292
- }
308
+ for (const t of doc.tasks) {
309
+ // truthy 而非 === true:与 Claude Code 自己的加载器一致
310
+ // (...o.permanent && {permanent:true}),它已认作永久的任何值都算,
311
+ // 不再重写。只有显式周期任务有资格——绝不从 cron 形状推断周期性,
312
+ // 一次性任务不碰。
313
+ if (isPlainObject(t) && t.recurring && !t.permanent) {
314
+ t.permanent = true;
315
+ renewed += 1;
293
316
  }
294
317
  }
295
- const minified = minify ? minifyIds(doc.tasks) : 0;
318
+ const { changed: minified, idChanges } = minifyIds(doc.tasks);
296
319
  let reowned = 0;
297
- if (reown) {
320
+ if (reown instanceof Map && reown.size > 0) {
298
321
  for (const t of doc.tasks) {
299
- // createdBySessionId 已是目标值即跳过:与 renew/minify 同一条幂等
300
- // 纪律,第二轮起字节级不动。没有 owner 字段的外部条目同样盖——窗
301
- // 口内任务统一归预热会话,语义不因条目来源留例外。
302
- if (isPlainObject(t)
303
- && t.createdBySessionId !== reown.sessionId
304
- && reown.isWanted(t)) {
305
- t.createdBySessionId = reown.sessionId;
306
- t.createdByPid = reown.pid;
322
+ // 没有 owner 字段的外部条目同样盖——窗口内任务统一归各自预热,语义
323
+ // 不因条目来源留例外。
324
+ if (!isPlainObject(t)) continue;
325
+ const target = reown.get(taskSig(t));
326
+ if (target && t.createdBySessionId !== target.sessionId) {
327
+ t.createdBySessionId = target.sessionId;
328
+ t.createdByPid = target.pid;
307
329
  // ps lstart 原文(含「Oct 8」双空格),原样取自会话登记。
308
- t.createdByProcStart = reown.procStart;
330
+ t.createdByProcStart = target.procStart;
309
331
  reowned += 1;
310
332
  }
311
333
  }
312
334
  }
313
335
  if (renewed === 0 && minified === 0 && reowned === 0) {
314
- return { renewed: 0, minified: 0, reowned: 0 };
336
+ return { renewed: 0, minified: 0, reowned: 0, idChanges: [] };
315
337
  }
316
338
  atomicWriteJson(taskfile, doc, mtimeNs);
317
- return { renewed, minified, reowned };
339
+ return { renewed, minified, reowned, idChanges };
318
340
  }
319
341
 
320
342
  Object.assign(deps, { discover, readTasks, loadTaskDoc });