cron-up 26.10.27 → 26.10.29
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 +28 -7
- package/package.json +1 -1
- package/src/args.mjs +1 -0
- package/src/cli.mjs +1 -1
- package/src/config.mjs +4 -0
- package/src/constants.mjs +7 -0
- package/src/overview.mjs +7 -3
- package/src/patrol.mjs +138 -24
- package/src/registry.mjs +144 -19
- package/src/service.mjs +1 -0
- package/src/tasks.mjs +35 -7
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
为命令行 AI agent 的 cron 定时任务**提前启动并保持一个可用的交互会话**。任务本身仍由 agent 自己的定时机制执行,cron-up 只负责保证:任务到点时,对应目录里已经有一个开着的会话。
|
|
4
4
|
|
|
5
5
|
> [!WARNING]
|
|
6
|
-
> cron-up 建立在 Claude Code **未公开文档的内部机制**之上(`.claude/scheduled_tasks.json` 文件格式、`~/.claude/sessions/*.json` 会话登记、定时任务只在交互界面里执行、周期任务创建满 7 天后失效、任务的 `permanent` 字段可豁免该失效、登记的会话进程其进程命令行可识别为 claude——原生安装是可执行名含 claude 的二进制,npm/bun 安装是解释器执行 claude
|
|
6
|
+
> cron-up 建立在 Claude Code **未公开文档的内部机制**之上(`.claude/scheduled_tasks.json` 文件格式、`~/.claude/sessions/*.json` 会话登记、定时任务只在交互界面里执行、周期任务创建满 7 天后失效、任务的 `permanent` 字段可豁免该失效、登记的会话进程其进程命令行可识别为 claude——原生安装是可执行名含 claude 的二进制,npm/bun 安装是解释器执行 claude 入口脚本、任务按 `createdBySessionId` 等归属字段决定在哪个会话执行)。这些行为可能随版本变化。会话保活已在 **Claude Code 2.1.278** 实测;7 天判定与 `permanent` 豁免的代码逻辑核对自 **2.1.280**;定时任务的投递抖动与任务 id 哈希算法(autoMinId 依赖它)逆向核对自 **2.1.285**;任务归属路由规则、归属三字段与预热会话的三代进程结构(stampOwner 依赖它们)核对自 **2.1.300**(均为 macOS)。升级 Claude Code 后建议重新核对。
|
|
7
7
|
|
|
8
8
|
## 平台要求
|
|
9
9
|
|
|
@@ -74,6 +74,24 @@ cron-up install --force --no-auto-renew
|
|
|
74
74
|
|
|
75
75
|
> 续期(autoRenew)会**改写各工作目录的 `.claude/scheduled_tasks.json`**:原子写入、保留原权限与中文,只新增 `permanent` 一个字段,不改动 prompt、cron 或 id(id 改小是上一节 autoMinId 的独立动作)。被续期的任务不再被自动删除,为它保活的后台会话也会因此**长期常驻**——这等于关闭了 Claude Code 对自主会话的寿命护栏。相关风险见「安全说明」。
|
|
76
76
|
|
|
77
|
+
### 任务归属预热(stampOwner,默认关闭)
|
|
78
|
+
|
|
79
|
+
默认行为下,只要工作区里已经有任何交互会话(包括你本人正在用的那个),巡检就不会再拉预热会话;而 Claude Code 按任务文件里记录的「创建者会话」投递——用 CronCreate 建任务时你在哪个会话,到点任务就弹进哪个会话。于是你建完任务、那个窗口一直没关又恰好正在里面干别的活时,到点任务会挤进当前会话。
|
|
80
|
+
|
|
81
|
+
开启归属改写:配置 `stampOwner: true`(最多 5 分钟后的下一轮巡检生效),或安装时 `cron-up install --force --stamp-owner`(关闭用 `--no-stamp-owner`)。开启后巡检每轮先回答一个问题:**投递窗口内的任务,它们的创建者会话还活着吗?**
|
|
82
|
+
|
|
83
|
+
- **创建者会话还开着(哪怕你正在里面干别的活)——完全尊重,不另拉、不改归属**,任务到点照旧进那个会话。cron-up 不判断你是在「特意等结果」还是「被打扰」,创建者会话活着本身就是唯一信号;
|
|
84
|
+
- **创建者会话已关闭或崩溃**——同目录即使还有别的会话在开(它不是创建者),巡检也**另拉一个独立的预热会话**承接,并把窗口内任务的 `createdBySessionId`、`createdByPid`、`createdByProcStart` 三个归属字段盖成它:Claude Code 见到自己是创建者就铁定投到预热,别的会话见到 owner 是另一个活会话则主动回避,不抢锁、不双发;
|
|
85
|
+
- 只盖「窗口内且创建者已不活」的任务、幂等(第二轮起字节级不动)、与续期/改小 id 合并为同一次带乐观锁的原子写;预热会话若中途死亡,任务自动退回 Claude Code 原生的「创建者已死、持锁会话接管」规则,不会漏跑,新一代预热拉起后重新盖印;
|
|
86
|
+
- 一个边界:同一目录的窗口任务里只要**还有一个**创建者会话活着,巡检就不会在该目录另拉预热(避免任何双会话);其中创建者已死的任务会按 Claude Code 原生规则由持锁的活会话接管这一次。
|
|
87
|
+
|
|
88
|
+
两个附带的行为变化需要知道:
|
|
89
|
+
|
|
90
|
+
1. window 模式的回收余量从约 2 分钟收紧为「一个巡检间隔 + 2 分钟」(默认约 7 分钟)——回收后要留足「下轮重拉、再下轮盖印」的时间,任务才不可能在盖印前到点;
|
|
91
|
+
2. 卡在提问界面、超过 3 分钟没登记的预热会话会被终止(默认行为下同目录有别的会话时会跳过这次回收):活创建者在场时只终止不重拉(创建者自己承接任务),创建者已死时终止并重拉。
|
|
92
|
+
|
|
93
|
+
> 这同样依赖逆向出来的内部机制:任务文件的三个归属字段、以及「进程是 `script → zsh → claude` 三层、靠祖先 pid 认亲」都不是官方承诺的接口。识别退化(如 `ps` 输出异常)时巡检会发告警并**保守退回默认行为**(不另拉会话、暂停盖印),绝不会在同目录堆会话。残余的回退窗口(电脑休眠漏轮、会话恰在盖印前后换代)见「已知限制」。
|
|
94
|
+
|
|
77
95
|
安装产物:
|
|
78
96
|
|
|
79
97
|
| 产物 | 位置 |
|
|
@@ -162,15 +180,16 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
162
180
|
- **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
|
|
163
181
|
- **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,在写入配置之前就被响亮拒绝,非法值不会落盘。
|
|
164
182
|
- **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。「需要会话」按计入投递抖动(见上节)的**预计实际触发时刻**计算:某任务的 cron 落点已过、但还没到它的延迟投递点的这段尾窗(落点后最多 30 分钟)里会话照样保留,尾窗内若会话意外死亡也会立即重拉——否则同目录两个落点相近的任务,较早的执行完后会话被回收,较晚的还没到投递点就会漏跑(周期任务没有补执行)。效果:**错开约 30 分钟以上(已含投递抖动、静默判定与余量)的任务才必然各自拿到全新会话**,更近的共享同一会话(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
|
|
183
|
+
- **开关任务归属预热**:配置项 `stampOwner`,命令行 `cron-up install --force --stamp-owner` / `--no-stamp-owner`。开启后以「创建者会话是否还活着」为最高优先级:建任务的会话还开着就完全尊重、任务照旧进它;创建者已关闭/崩溃时才另拉独立预热承接,不让任务漏给同目录无关的会话(完整语义见安装一节的「任务归属预热」)。两个附带变化:window 回收余量从约 2 分钟收紧为约 7 分钟(间隔 + 2 分钟);卡在提问界面的预热会话会被 3 分钟卡死退休——活创建者在场时只终止不重拉,创建者已死时终止并重拉(默认行为下同目录有别的会话时跳过这次回收)。
|
|
165
184
|
- **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
|
|
166
|
-
- **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up
|
|
185
|
+
- **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;默认配置下同目录当时还有你本人打开的会话时除外,见「已知限制」;`stampOwner` 开启时不除外——活创建者在场则只终止不重拉,创建者已死则终止并重拉)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
|
|
167
186
|
- **日志大小**:后台会话的界面输出是持续的全屏渲染流。`script(1)` 每次启动会清空旧日志,因此每次拉起该目录的会话时,上一代的界面记录先轮转为 `.log.1`(只保留一代,总量上界两代)——否则 window 模式下上一次任务执行的记录会随下一代拉起被抹掉。活着的一代日志总量由会话生命周期约束(window 模式一代即一次执行;`always` 模式由 7 天换代约束),唯一无法覆盖的是 `always` 模式下连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)每轮固定两行心跳(`巡检开始…`/`巡检结束…`,后者带「发现 N 个工作区、活动交互会话 N 个、动作汇总与耗时」),事件行只在有事发生时才打:启动、主动回收、换代、卡死、冷却、接管孤儿、启动失败(含 spawn 异步错误原因)、会话非巡检主动结束(带存活时长)、登记格式漂移告警。每轮都有心跳,所以 launchd 配置没坏但就是没在跑时,从日志断档和总览页都能发现;距上轮超过两个间隔还会提示可能漏轮(常见于电脑休眠后 launchd 合并错过的间隔)。超过 1MB 时同样轮转为 `.1`,只保留上一份。
|
|
168
|
-
- **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up
|
|
187
|
+
- **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有四重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、interactive 登记的 pid/工作目录字段集体无法解读、或存活会话的进程父链读不完整(仅影响 stampOwner 的认亲与盖印,不影响基础保活)时,「需要留意」会明确提示登记格式或进程信息疑似变更;看到这类告警后,在重新核对之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
|
|
169
188
|
- **巡检心跳与沉默告警**:每轮巡检成功跑完都会刷新 `~/.local/share/cron-up/heartbeat`。总览页的巡检区显示「上次巡检:N 分钟前」;launchd 显示已加载、但心跳超过两个轮次间隔没刷新时,「需要留意」会报「巡检已沉默」——启动脚本静态检查全绿但运行期拉不起来(或轮次持续崩在半道)只有靠它发现。轮首发现距上轮过久也会在巡检日志里记一条漏轮提示(电脑休眠后 launchd 会合并非错过的间隔,醒来只补一轮,属正常现象)。
|
|
170
189
|
|
|
171
190
|
## 安全说明
|
|
172
191
|
|
|
173
|
-
- 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions
|
|
192
|
+
- 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions/`(判断目录里是否已有会话、读取进程 pid 与启动时间用于认亲)与 roots 下的任务文件,并写入自己的配置、状态与日志目录。**开启 autoRenew 或 autoMinId(两者默认都开)时,它还会改写 roots 下各工作目录的 `.claude/scheduled_tasks.json`**:在同目录写临时文件(先按 0600 创建)再原子替换、保留原文件权限(写一半被杀留下的临时文件,下一轮巡检会顺手清掉)。前两种改写只针对显式周期任务、只动两个字段——补 `permanent: true`(autoRenew)、把哈希过大的 `id` 换成文件内未占用的小号(autoMinId),**绝不改动 prompt 与 cron,也不碰一次性任务**;两项都幂等,每个任务只在首次需要时写一次,之后字节级不再重写。**开启 stampOwner(默认关)时存在第三种改写**:仅当任务的创建者会话已不存活时,才把投递窗口内任务(含一次性任务)的 `createdBySessionId`、`createdByPid`、`createdByProcStart` 三个归属字段盖成本轮预热会话的值,创建者会话还开着的任务一律不碰;不碰 `createdInProject`、prompt 与 cron;同样幂等,任务的归属只在「窗口打开且预热换了一代」时才再写一次。三种写入合并在同一次原子替换里。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再来(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
|
|
174
193
|
- **续期会改变三条生命周期/边界**:
|
|
175
194
|
1. 被打 `permanent` 的周期任务不再被 Claude Code 在创建满 7 天后删除,cron-up 为它保活的后台会话随之**长期常驻**,不再随任务过期被回收——这等于主动关闭了 Claude Code 对自主会话的寿命护栏。
|
|
176
195
|
2. 7 天上限本也是一道**自动淘汰**防线:一个你没留意、随 `git pull` 或他人提交进入工作目录的周期任务,原本最多存活 7 天;autoRenew 会无差别把它也永久化。因此**不要把他人可写、或你不完全掌控的仓库纳入 roots**(与下一条任务文件即执行输入的警告同理);拉取外部代码后先检查 `.claude/scheduled_tasks.json` 里有没有意料之外的周期任务,必要时先关闭 autoRenew。
|
|
@@ -185,11 +204,13 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
185
204
|
- Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
|
|
186
205
|
- autoMinId 改小 id 依赖逆向出来的投递哈希算法(见「投递抖动」),不是官方承诺的接口:新版 Claude Code 若更换算法,小 id 不再能保证准点——届时关掉 autoMinId、改用「cron 往前提 30 分钟」即可,`list` 的预计时间与 findings 的验算脚本可用来发现这种漂移。此外在交互会话里用 `CronCreate` 重建任务会生成新的随机大 id(需等下轮巡检再改回),改时间请直接编辑 cron 字段而不是重建。
|
|
187
206
|
- autoMinId 改写 id 的当轮起,巡检按新 id(投递延迟不足 1 分钟)计算投递尾窗,而**已在运行的会话仍按旧 id 投递**(顶格约 30 分钟后):若保活会话恰在「新投递时刻 ~ 旧投递时刻」这最长约 29 分钟的空隙里死亡,该次投递漏跑(周期任务无补执行),下个周期恢复。window 模式下会话执行完即回收、重拉的新会话读到新 id,风险窗口至多覆盖一次执行;always 模式则持续到 7 天换代。在意的话,等一轮执行完(会话已重拉)再跑 `cron-up renew` 可完全避开。
|
|
188
|
-
- 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code
|
|
207
|
+
- 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。`stampOwner` 开启后,创建者会话已关闭/崩溃的任务会走进程独立的预热会话、不漏给同目录无关会话;但创建者会话还开着的任务照旧进创建者(这是刻意的最高优先级),同目录重叠任务的归属仍由 Claude Code 裁决,真正的任务级并发隔离还是需要官方支持任务路由。
|
|
189
208
|
- 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
|
|
190
209
|
- 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
|
|
191
|
-
- 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up
|
|
192
|
-
-
|
|
210
|
+
- 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up 默认的策略是「只在没有任何会话时才启动」,正常流程不会制造第二个会话;`stampOwner` 开启后也只在「窗口任务的创建者会话都已不活」时才制造「用户会话 + 预热会话」并存的状态(创建者会话还开着则永不双开),并存时靠把死主任务的归属盖给预热会话来保证只有它执行(你的会话按 Claude Code 自身规则主动回避,预热死亡才允许接管),不依赖「谁先动手」,因此不会各执行一次。
|
|
211
|
+
- 「已有会话在消费」按目录判断:默认配置下分不清消费者是我们的预热会话还是你本人打开的会话,卡在提问界面(始终不会登记)的预热会话,若同目录恰有你的交互会话在场,3 分钟的卡死回收会被跳过——最晚由 7 天自动换代收掉,或在你关掉自己的会话后、下次有任务需要会话的那轮巡检被回收。`stampOwner` 开启时改为两件事:按会话 id 判定窗口任务的创建者是否还活着(活着就完全尊重),按进程祖先链认出自己的预热(state 记的 `script` 是登记中 claude 进程的祖父);只有父链读不完整(`ps` 异常、进程正在退出)时才退回分不清的保守状态——该轮不另拉会话、不盖印并发出告警,下轮自动恢复。
|
|
212
|
+
- `stampOwner` 开启后仍有一个盖印跟不上的回退窗口:创建者会话在窗口内刚关闭、或预热会话换代/被回收后,要经历「下轮巡检重拉(≤ 一个间隔)→ 新会话登记 → 再下轮盖印」两步。若电脑休眠漏轮、或关闭/换代恰好发生在落点前不久,任务可能由同目录的其他活会话按 Claude Code 原生规则(持有调度锁且创建者已死)接管这一次——不会漏跑、不会重复,下轮自动盖回新预热,等同功能关闭时的行为。window 模式的回收余量已因此从约 2 分钟收紧为一个间隔 + 2 分钟(默认约 7 分钟),把这个窗口压缩到极端情况。
|
|
213
|
+
- `stampOwner` 依赖任务文件的三个 `createdBy*` 归属字段、活会话的 session id 与三代进程结构(核对自 Claude Code 2.1.300):新版若移除或改名这些字段、改变启动进程结构,盖印会失效或认不到自己的预热——退化时一律按保守方向处理:创建者存活性判断不到时视为「可能还活着」(不另拉),认亲失败按歧义处理(不双开),不会出现任务被抢走或同目录堆会话。核对方法同投递抖动:发现任务又弹进错误的会话时,检查任务文件字段与会话登记格式,必要时先关闭开关。
|
|
193
214
|
- 扫描不深入子目录里的符号链接(`roots` 本身的符号链接会先解析成真实路径):容器里用 symlink 指向别处的仓库不会被发现,其中的任务不会被保活。
|
|
194
215
|
- 系统设置「登录项与扩展 → 允许在后台」里,巡检条目显示为 **cron-up-service**:macOS 按 launchd 拉起的第一个可执行文件(启动脚本)给条目起名。条目小字可能标注「来自未识别的开发者」——启动脚本没有 Apple 签发证书的签名,这行小字没有本地手段改;主名称与开关行为不受影响。
|
|
195
216
|
- 若升级 Node 时删掉了启动脚本 PATH 指向的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「PATH 目录里找不到 node」,重跑一次 `cron-up install` 即可修复。
|
package/package.json
CHANGED
package/src/args.mjs
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -128,7 +128,7 @@ const USAGE = `cron-up —— 为定时任务提前备好交互会话
|
|
|
128
128
|
|
|
129
129
|
用法:
|
|
130
130
|
cron-up 查看总览(服务、配置、任务、会话)
|
|
131
|
-
cron-up install 安装配置与 LaunchAgent(--roots --interval --lead --session-retain --force --no-auto-renew --no-auto-min-id)
|
|
131
|
+
cron-up install 安装配置与 LaunchAgent(--roots --interval --lead --session-retain --stamp-owner --force --no-auto-renew --no-auto-min-id --no-stamp-owner)
|
|
132
132
|
cron-up uninstall 卸载(--purge 连配置日志一起删,--keep-sessions 保留后台会话)
|
|
133
133
|
cron-up list 逐个列出定时任务的设定/预计实际触发时间
|
|
134
134
|
cron-up run 执行一轮巡检(launchd 入口)
|
package/src/config.mjs
CHANGED
|
@@ -93,6 +93,10 @@ export function configFieldErrors(cfg) {
|
|
|
93
93
|
errs.push(['sessionRetain',
|
|
94
94
|
`sessionRetain 应是 "window" 或 "always",当前为 ${JSON.stringify(cfg.sessionRetain)}`]);
|
|
95
95
|
}
|
|
96
|
+
if (typeof cfg.stampOwner !== 'boolean') {
|
|
97
|
+
errs.push(['stampOwner',
|
|
98
|
+
`stampOwner 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.stampOwner)}`]);
|
|
99
|
+
}
|
|
96
100
|
return errs;
|
|
97
101
|
}
|
|
98
102
|
|
package/src/constants.mjs
CHANGED
|
@@ -25,6 +25,13 @@ export const DEFAULT_CONFIG = {
|
|
|
25
25
|
// 上下文不互相累积;'always'——旧常驻行为,执行间隔里也保留会话,仅受
|
|
26
26
|
// 7 天超龄换代约束。
|
|
27
27
|
sessionRetain: 'window',
|
|
28
|
+
// 任务归属改写:默认关,维持「同目录有会话就不双开」的旧行为。开启后,
|
|
29
|
+
// 即使用户本人的交互会话在场,也另拉独立预热会话,并把投递窗口内任务的
|
|
30
|
+
// createdBySessionId/createdByPid/createdByProcStart 三字段改写成预热会话
|
|
31
|
+
// ——任务按 Claude Code 自身的「创建者会话铁拿」规则投到预热会话,用户
|
|
32
|
+
// 会话主动不碰;预热死亡则自动滑回原生的死者接管规则。依赖 Claude Code
|
|
33
|
+
// 未公开的任务文件字段与进程父子关系,见 README「任务归属预热」。
|
|
34
|
+
stampOwner: false,
|
|
28
35
|
};
|
|
29
36
|
|
|
30
37
|
// 预热会话连续三次启动后死亡,大概率是环境坏了(缺信任、env 不对),退避
|
package/src/overview.mjs
CHANGED
|
@@ -200,7 +200,10 @@ export async function cmdOverview(args) {
|
|
|
200
200
|
// 都静默拉不起来,这是总览唯一能发现它的地方。
|
|
201
201
|
const health = launcherHealth();
|
|
202
202
|
if (health) alerts.push(health);
|
|
203
|
-
|
|
203
|
+
// 父链快照是 stampOwner 的按需成本:只有开关确实开启时才拍,总览页也
|
|
204
|
+
// 才能顺带显示「认亲退化」这第四枚告警。
|
|
205
|
+
const [consumers, registryAlert] =
|
|
206
|
+
await deps.scanSessions({ lineage: cfg?.stampOwner === true });
|
|
204
207
|
if (registryAlert) alerts.push(registryAlert);
|
|
205
208
|
const state = loadState();
|
|
206
209
|
const curOv = Math.floor(Date.now() / 1000);
|
|
@@ -221,12 +224,13 @@ export async function cmdOverview(args) {
|
|
|
221
224
|
const minIdTag = cfg.autoMinId ? ',id 自动改小' : ',id 保持原样';
|
|
222
225
|
const retainTag = cfg.sessionRetain === 'always'
|
|
223
226
|
? ',会话常驻' : ',会话按执行回收';
|
|
227
|
+
const stampTag = cfg.stampOwner ? ',任务归属预热' : '';
|
|
224
228
|
if (actualIv == null) {
|
|
225
229
|
deps.print(`巡检:配置为每 ${cfg.intervalSeconds} 秒一轮(服务未加载),`
|
|
226
|
-
+ `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${minIdTag}${retainTag}`);
|
|
230
|
+
+ `提前 ${cfg.leadSeconds} 秒启动会话${renewTag}${minIdTag}${retainTag}${stampTag}`);
|
|
227
231
|
} else {
|
|
228
232
|
deps.print(`巡检:每 ${actualIv} 秒一轮,提前 ${cfg.leadSeconds} 秒启动`
|
|
229
|
-
+ `会话${renewTag}${minIdTag}${retainTag}`);
|
|
233
|
+
+ `会话${renewTag}${minIdTag}${retainTag}${stampTag}`);
|
|
230
234
|
if (actualIv !== cfg.intervalSeconds) {
|
|
231
235
|
alerts.push(`launchd 实际间隔 ${actualIv} 秒与配置 ${cfg.intervalSeconds} `
|
|
232
236
|
+ '秒不一致,重新运行 cron-up install 后生效');
|
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 } from './internals.mjs';
|
|
8
|
+
import { deps, packageVersion, isPlainObject } from './internals.mjs';
|
|
9
9
|
import {
|
|
10
10
|
FAIL_LIMIT,
|
|
11
11
|
COOLDOWN_SECONDS,
|
|
@@ -51,6 +51,7 @@ const EVENT_LABELS = [
|
|
|
51
51
|
['exit', '自行退出'],
|
|
52
52
|
['stuck', '卡死退休'],
|
|
53
53
|
['adopt', '接管'],
|
|
54
|
+
['reown', '过户'],
|
|
54
55
|
['fail', '启动失败'],
|
|
55
56
|
['cool', '进入冷却'],
|
|
56
57
|
['warn', '告警'],
|
|
@@ -180,9 +181,15 @@ function needHorizonMs(tasks, now) {
|
|
|
180
181
|
// cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。scriptProcs
|
|
181
182
|
// 是进程表快照或 null:null 时仅在需要认领孤儿(state 丢失的兜底分支)当
|
|
182
183
|
// 下才拍快照,平静轮不付全量 ps 的代价。
|
|
184
|
+
// stamp=null 表示 stampOwner 关(一切旧行为);开启时为
|
|
185
|
+
// {verdict, creatorAlive, recycleMarginSec}:verdict 是 classifyWorkspace
|
|
186
|
+
// 对轮初快照的认亲结论(own/creatorAlive/ambiguous/user,换代或回收导致
|
|
187
|
+
// 快照变化时在 retire 内重算),creatorAlive 是「窗口任务有活创建者」
|
|
188
|
+
// (几分钟内不会变,retire 重算时复用),recycleMarginSec 是 window 回收
|
|
189
|
+
// 余量。
|
|
183
190
|
export async function patrolWorkspace(
|
|
184
191
|
ws, state, now, leadMs, cur, consumers, scriptProcs = null,
|
|
185
|
-
retain = 'always', stats = null,
|
|
192
|
+
retain = 'always', stats = null, stamp = null,
|
|
186
193
|
) {
|
|
187
194
|
const tasks = deps.readTasks(ws);
|
|
188
195
|
let ent = state[ws] ?? null;
|
|
@@ -221,13 +228,26 @@ export async function patrolWorkspace(
|
|
|
221
228
|
// 回收是同一个动作,差别只在触发条件与文案;消费者快照拍于轮初,刚结束
|
|
222
229
|
// 的这代可能正是快照里的消费者,不重扫会让陈旧登记挡住下面的重拉。
|
|
223
230
|
let mineAlive = deps.trackedAlive(ent);
|
|
231
|
+
// verdict 是轮初快照的认亲结论;换代/回收杀掉本代后消费者集合变了,
|
|
232
|
+
// 必须按重扫结果重算而不是手改布尔——幸存下来的断链会话(进程组杀不
|
|
233
|
+
// 掉的用户会话)仍应是歧义态,否则会被误当普通用户会话、在它旁边双开。
|
|
234
|
+
let verdict = stamp?.verdict ?? null;
|
|
224
235
|
const retire = async (kind, msg) => {
|
|
225
236
|
await deps.stopSession(ent);
|
|
226
237
|
note(kind, msg);
|
|
227
238
|
delete state[ws];
|
|
228
239
|
ent = null;
|
|
229
240
|
mineAlive = false;
|
|
230
|
-
|
|
241
|
+
if (stamp) {
|
|
242
|
+
const [nextConsumers, , nextDetails] = await deps.scanSessions({ lineage: true });
|
|
243
|
+
consumers = nextConsumers;
|
|
244
|
+
// ent 已没,entAlive=false;creatorAlive 沿用轮初判定(活创建者在
|
|
245
|
+
// 换代这几分钟内不会变成别的状态)。
|
|
246
|
+
verdict = deps.classifyWorkspace(nextDetails, ws, null, false,
|
|
247
|
+
stamp.creatorAlive);
|
|
248
|
+
} else {
|
|
249
|
+
consumers = (await deps.scanSessions())[0];
|
|
250
|
+
}
|
|
231
251
|
};
|
|
232
252
|
|
|
233
253
|
// 换代:超龄且日志静默(无任务在执行的证据)的会话主动结束,需要时下
|
|
@@ -246,36 +266,76 @@ export async function patrolWorkspace(
|
|
|
246
266
|
// 缺一不可:本代会话期间真的有任务触发过(lastFiredAt 晚于 startedAt
|
|
247
267
|
// ——区分「执行完的空闲」与「等待触发的空闲」,后者是预热缓存在干活,
|
|
248
268
|
// 回收它只会每轮空转重拉);界面静默超时(TUI 执行期间持续渲染,静默
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
269
|
+
// 即不在干活);距下次需要会话还有余量(回收后下轮巡检重拉的新会话来
|
|
270
|
+
// 得及登记,不会漏掉到点任务)。时间上重叠或紧邻的任务仍共享会话:任
|
|
271
|
+
// 务路由在 Claude Code 手里,同目录并发多会话无法指定归属,还有重复
|
|
272
|
+
// 执行的风险。回收不计失败——任务成功执行过是最强的健康证据,陈年计
|
|
273
|
+
// 数一并清零。
|
|
274
|
+
// 余量取值:默认只保登记抖动(约 2 分钟);stampOwner 开启时必须再留
|
|
275
|
+
// 出一个巡检间隔——回收后要靠「下轮重拉→再下轮过户」两步把 owner 盖
|
|
276
|
+
// 到新会话,余量小于间隔就可能在过户完成前到点(cmdRun 传入
|
|
277
|
+
// SESSION_FIRE_MARGIN_SECONDS + intervalSeconds)。
|
|
278
|
+
const fireMarginSec = stamp
|
|
279
|
+
? stamp.recycleMarginSec
|
|
280
|
+
: SESSION_FIRE_MARGIN_SECONDS;
|
|
254
281
|
if (ent !== null && mineAlive && retain === 'window'
|
|
255
282
|
&& tasks.some((t) => typeof t?.lastFiredAt === 'number'
|
|
256
283
|
&& t.lastFiredAt > (ent.startedAt ?? 0) * 1000)
|
|
257
284
|
&& sessionLogIdleSeconds(ent.log || sessionLogPath(ws), cur)
|
|
258
285
|
> SESSION_RECYCLE_IDLE_SECONDS
|
|
259
286
|
&& needHorizonMs(tasks, now) - now.getTime()
|
|
260
|
-
>
|
|
261
|
-
//
|
|
287
|
+
> fireMarginSec * 1000) {
|
|
288
|
+
// stampOwner 关时:用户自己的会话若在场,retire 的重扫依旧看见,不
|
|
289
|
+
// 会在同目录双开;开启时:旧代整组结束后重扫只可能剩用户会话,下面
|
|
290
|
+
// 的逻辑会按需另拉新预热。
|
|
262
291
|
await retire('recycle', `回收 ${ws} pid=${ent.pid}:本代任务已执行完毕且`
|
|
263
292
|
+ '界面静默,结束会话,下次需要时重拉');
|
|
264
293
|
}
|
|
265
294
|
|
|
295
|
+
// 在场的消费者足以让我们放手吗?功能关时任何消费者都算;开启时三种
|
|
296
|
+
// 情况算——认到自己的预热(own)、窗口任务的创建者会话还活着
|
|
297
|
+
// (creatorAlive:用户显式要求尊重,绝不另拉)、父链有歧义无法排除是
|
|
298
|
+
// 自己(ambiguous)。仅无关用户会话(user)不算:创建者已关闭/崩溃,
|
|
299
|
+
// 下面继续走到 wanted→卡死退休/冷却→spawn,在同目录另起独立预热承接
|
|
300
|
+
// (全程只 stop 自己的 ent,不碰用户会话)。
|
|
301
|
+
const consumerHolds = consumers.has(ws)
|
|
302
|
+
&& (!stamp
|
|
303
|
+
|| verdict?.kind === 'own'
|
|
304
|
+
|| verdict?.kind === 'creatorAlive'
|
|
305
|
+
|| verdict?.kind === 'ambiguous');
|
|
306
|
+
|
|
266
307
|
// 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
|
|
267
308
|
// 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
|
|
268
|
-
//
|
|
269
|
-
|
|
309
|
+
// 却,连一次新尝试都不给。只有真的认到自己(own)才清零:creatorAlive
|
|
310
|
+
// 时在场的是活创建者、我们自己的预热可能正卡着没登记,不能替它清零;
|
|
311
|
+
// 歧义态保守当真,与「不双开」方向一致。
|
|
312
|
+
if (ent !== null && mineAlive && consumers.has(ws)
|
|
313
|
+
&& (!stamp || verdict?.kind === 'own' || verdict?.kind === 'ambiguous')) {
|
|
270
314
|
ent.fails = 0;
|
|
271
315
|
}
|
|
272
316
|
|
|
273
317
|
// 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
318
|
+
// 盖),也可能是用户的交互会话——后者默认放手:不在这里删死条目,失败
|
|
319
|
+
// 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
|
|
320
|
+
// 由轮末 kept 过滤统一处理。先于 wanted 判定返回:有消费者在场的平稳
|
|
321
|
+
// 态不必为整组任务跑一遍 cron 求值。
|
|
322
|
+
// creatorAlive/ambiguous 返回前还有一件事:我们自己跟踪的预热若已卡死
|
|
323
|
+
// (超登记宽限仍未认到——verdict 不是 own),它是没用的垃圾进程,任务
|
|
324
|
+
// 由活创建者承接(或歧义保守),杀掉它即可、本轮不重拉。
|
|
325
|
+
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
|
+
}
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
279
339
|
|
|
280
340
|
if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
|
|
281
341
|
|
|
@@ -362,7 +422,12 @@ export async function patrolWorkspace(
|
|
|
362
422
|
fails,
|
|
363
423
|
log: sessionLogPath(ws),
|
|
364
424
|
};
|
|
365
|
-
|
|
425
|
+
if (stamp && consumers.has(ws) && verdict?.kind === 'user') {
|
|
426
|
+
note('spawn', `已另启动预热会话 ${ws} pid=${spawned.pid}:你的交互会话仍在`
|
|
427
|
+
+ `,窗口内任务将改由预热会话承接(连续失败计数 ${fails})`);
|
|
428
|
+
} else {
|
|
429
|
+
note('spawn', `已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
|
|
430
|
+
}
|
|
366
431
|
}
|
|
367
432
|
|
|
368
433
|
// 跑一轮巡检;抢不到锁返回 false。
|
|
@@ -436,12 +501,21 @@ export async function cmdRun(args) {
|
|
|
436
501
|
// 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
|
|
437
502
|
// 属这里。进程表快照不在这里拍:只有孤儿认领分支需要,按需现拍(见
|
|
438
503
|
// patrolWorkspace 的 scriptProcs 形参)。
|
|
439
|
-
|
|
504
|
+
// lineage 只在 stampOwner 开启时拍:祖先链要一次全机 ps,关闭时
|
|
505
|
+
// scanSessions 不付这个代价(见 registry 的 scanSessions 注释)。
|
|
506
|
+
const [consumers, registryAlert, details] =
|
|
507
|
+
await deps.scanSessions({ lineage: cfg.stampOwner });
|
|
440
508
|
if (registryAlert) {
|
|
441
509
|
bump(stats, 'warn');
|
|
442
510
|
deps.log(`告警:${registryAlert}`);
|
|
443
511
|
}
|
|
444
512
|
|
|
513
|
+
// stampOwner 的结论依赖任务内容(活创建者判定要看窗口任务的
|
|
514
|
+
// createdBySessionId),所以在下面 discover 循环里、读到任务后逐工
|
|
515
|
+
// 作区现算;这里只留全轮常量。
|
|
516
|
+
const recycleMarginSec = SESSION_FIRE_MARGIN_SECONDS
|
|
517
|
+
+ cfg.intervalSeconds;
|
|
518
|
+
|
|
445
519
|
// 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
|
|
446
520
|
// 日志时间线就是「退出 → 重拉」,而不是反过来。
|
|
447
521
|
for (const [ws, old] of prevDead) {
|
|
@@ -453,13 +527,48 @@ export async function cmdRun(args) {
|
|
|
453
527
|
for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
|
|
454
528
|
found += 1;
|
|
455
529
|
// 一个工作区读不动不能中断整轮:discover 是生成器,这里抛了会跳过
|
|
456
|
-
// 后面所有工作区。续期/改 id
|
|
457
|
-
//
|
|
458
|
-
|
|
530
|
+
// 后面所有工作区。续期/改 id/过户与保活各自独立守卫——维护失败绝不
|
|
531
|
+
// 压制该工作区的会话保活。三者合并为同一次原子写(见
|
|
532
|
+
// maintainWorkspace)。
|
|
533
|
+
//
|
|
534
|
+
// stampOwner 的判定在这里现算(需要任务内容):窗口内是否还有任务
|
|
535
|
+
// 的创建者会话活着——活创建者拥有否决权,不另拉、不盖印。
|
|
536
|
+
let target = null;
|
|
537
|
+
let stampArg = null;
|
|
538
|
+
let liveSids = null;
|
|
539
|
+
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));
|
|
547
|
+
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 };
|
|
554
|
+
}
|
|
555
|
+
// 盖印谓词只盖「窗口内且创建者已不活」的任务:活创建者的任务永远
|
|
556
|
+
// 不碰(即便本目录另有我们的预热在承接别的死主任务);已盖给本代
|
|
557
|
+
// 预热的任务因 owner 就在活会话集合里而自然跳过,幂等不靠另写比较。
|
|
558
|
+
if (cfg.autoRenew || cfg.autoMinId || target) {
|
|
459
559
|
try {
|
|
460
|
-
const { renewed, minified } = maintainWorkspace(taskfile, {
|
|
560
|
+
const { renewed, minified, reowned } = maintainWorkspace(taskfile, {
|
|
461
561
|
renew: cfg.autoRenew,
|
|
462
562
|
minify: cfg.autoMinId,
|
|
563
|
+
// target 多出的祖先链字段 tasks 层不读,展开即可,不必手抄
|
|
564
|
+
// 三个盖印字段。
|
|
565
|
+
reown: target
|
|
566
|
+
? {
|
|
567
|
+
...target,
|
|
568
|
+
isWanted: (t) => taskWanted(t, now, leadMs)
|
|
569
|
+
&& !liveSids.has(t.createdBySessionId),
|
|
570
|
+
}
|
|
571
|
+
: null,
|
|
463
572
|
}) ?? {};
|
|
464
573
|
if (renewed) {
|
|
465
574
|
deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
|
|
@@ -471,6 +580,11 @@ export async function cmdRun(args) {
|
|
|
471
580
|
+ '已在运行的会话要等其结束重拉(window 模式执行完即回收、'
|
|
472
581
|
+ 'always 模式换代)后才读到新 id');
|
|
473
582
|
}
|
|
583
|
+
if (reowned) {
|
|
584
|
+
bump(stats, 'reown');
|
|
585
|
+
deps.log(`已过户 ${ws}:${reowned} 个窗口内任务的归属改到预热会话`
|
|
586
|
+
+ ` pid=${target.pid},这些任务将只在该会话触发`);
|
|
587
|
+
}
|
|
474
588
|
} catch (e) {
|
|
475
589
|
bump(stats, e instanceof TaskFileChanged ? 'warn' : 'error');
|
|
476
590
|
if (e instanceof TaskFileChanged) {
|
|
@@ -484,7 +598,7 @@ export async function cmdRun(args) {
|
|
|
484
598
|
try {
|
|
485
599
|
await patrolWorkspace(
|
|
486
600
|
ws, state, now, leadMs, cur, consumers, null,
|
|
487
|
-
cfg.sessionRetain, stats);
|
|
601
|
+
cfg.sessionRetain, stats, stampArg);
|
|
488
602
|
} catch (e) {
|
|
489
603
|
bump(stats, 'error');
|
|
490
604
|
deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
|
package/src/registry.mjs
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
// Claude Code 会话登记(~/.claude/sessions/*.json)的读取与消费者判定。
|
|
2
2
|
// 登记是另一个程序未文档化的输出:一旦形状变化,所有会话都会被读成「不
|
|
3
|
-
// 存在」,巡检就会在用户会话旁边反复重拉。scanSessions
|
|
4
|
-
//
|
|
3
|
+
// 存在」,巡检就会在用户会话旁边反复重拉。scanSessions 因此内置四枚烟
|
|
4
|
+
// 雾告警,分别盯住四种漂移形态。
|
|
5
|
+
//
|
|
6
|
+
// 除「某目录有没有消费者」外,它还回报每个消费者的进程祖先链:cron-up
|
|
7
|
+
// 拉起的预热会话是三层结构 script(S) → zsh(Z) → claude(C)(claude 在交
|
|
8
|
+
// 互 shell 里是函数,函数帧不做 tail-exec),state 只记得到 S,登记里
|
|
9
|
+
// 却是 C,pgid 又被交互 shell 的作业控制改过——唯一稳定的认亲方式是沿
|
|
10
|
+
// ppid 上溯。
|
|
5
11
|
|
|
6
12
|
import fs from 'node:fs';
|
|
7
13
|
import path from 'node:path';
|
|
@@ -27,15 +33,86 @@ function ps(pid, fmt) {
|
|
|
27
33
|
}
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
// 一次 ps 同时拿进程名与父 pid:{isClaude, ppid}。ppid 按「末尾空白+
|
|
37
|
+
// 数字」取——comm 可能是含空格的路径,但不会以「空白+数字」结尾;列缺
|
|
38
|
+
// 失(ps 失败/输出漂移)时 ppid 为 null,调用方按歧义保守处理。
|
|
39
|
+
export function processShape(pid) {
|
|
40
|
+
const line = ps(pid, 'comm=,ppid=');
|
|
41
|
+
const m = line.match(/^(.*\S)\s+(\d+)\s*$/);
|
|
42
|
+
const comm = path.basename((m ? m[1] : line).trim()).toLowerCase();
|
|
43
|
+
const ppid = m ? parseInt(m[2], 10) : null;
|
|
44
|
+
let isClaude = comm.includes('claude');
|
|
45
|
+
if (!isClaude && INTERPRETER_COMMS.has(comm)) {
|
|
46
|
+
isClaude = CLAUDE_ENTRY_RE.test(ps(pid, 'args=').toLowerCase());
|
|
37
47
|
}
|
|
38
|
-
return
|
|
48
|
+
return { isClaude, ppid };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 全机进程父子表:一次 ps -axo pid=,ppid= 拍快照,Map<pid, ppid>。任何
|
|
52
|
+
// 异常都返回 null(调用方退化为「全部歧义」的保守行为,绝不抛出)。
|
|
53
|
+
export function processParentMap() {
|
|
54
|
+
const r = deps.execFile('/bin/ps', ['-axo', 'pid=,ppid='], { timeout: 5000 });
|
|
55
|
+
if (r.status !== 0) return null;
|
|
56
|
+
const map = new Map();
|
|
57
|
+
for (const line of (r.stdout ?? '').split('\n')) {
|
|
58
|
+
const m = line.match(/^\s*(\d+)\s+(\d+)\s*$/);
|
|
59
|
+
if (m) map.set(parseInt(m[1], 10), parseInt(m[2], 10));
|
|
60
|
+
}
|
|
61
|
+
return map.size > 0 ? map : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 沿父子表上溯(不含 pid 自身),直到 launchd(1;其父记为 0)。中途父 pid
|
|
65
|
+
// 在快照里缺失(ps 退化/进程刚好退出)或成环时 chainComplete=false——
|
|
66
|
+
// 「没找到目标」与「证据链断了」必须由调用方区分:后者按歧义不双开。
|
|
67
|
+
export function ancestorChain(pid, parentMap) {
|
|
68
|
+
const ancestors = [];
|
|
69
|
+
const seen = new Set([pid]);
|
|
70
|
+
let cur = pid;
|
|
71
|
+
for (;;) {
|
|
72
|
+
const parent = parentMap?.get(cur);
|
|
73
|
+
if (parent === undefined) return { ancestors, chainComplete: false };
|
|
74
|
+
ancestors.push(parent);
|
|
75
|
+
if (parent <= 1) return { ancestors, chainComplete: true }; // 0/1 即根
|
|
76
|
+
if (seen.has(parent)) return { ancestors, chainComplete: false };
|
|
77
|
+
seen.add(parent);
|
|
78
|
+
cur = parent;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 本目录活着的消费者会话 id 集合。窗口任务的 createdBySessionId 命中其中
|
|
83
|
+
// 任一个,就说明创建它的会话还开着——stampOwner 的最高优先级是尊重活创
|
|
84
|
+
// 建者,既不另拉也不过户。
|
|
85
|
+
export function liveSessionIds(details, ws) {
|
|
86
|
+
return new Set((details?.get(ws) ?? [])
|
|
87
|
+
.map((d) => d.sessionId)
|
|
88
|
+
.filter((x) => typeof x === 'string'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// stampOwner 的认亲结论。details/ws 是 scanSessions 的第三元素;ent 是
|
|
92
|
+
// state 条目,entAlive 由调用方按指纹判好(registry 不认识 state 语义);
|
|
93
|
+
// creatorAlive 表示「至少一个窗口内任务的创建者会话还在本目录活着」:
|
|
94
|
+
// own 祖先链里有本代 script pid——在场消费者就是我们的预热;
|
|
95
|
+
// target 给出可盖印详情,身份字段缺失时为 null;
|
|
96
|
+
// creatorAlive 窗口任务的创建者会话还活着——任务按 Claude Code 规则必
|
|
97
|
+
// 然投给它,不另拉、不盖印(用户显式要求的最高优先级);
|
|
98
|
+
// ambiguous 没有认到自己的预热,但在场会话父链不完整——无法证明不
|
|
99
|
+
// 是自己(如 state 丢失的孤儿预热),保守不双开;
|
|
100
|
+
// user 以上皆否——创建者已关闭/崩溃,在场的只是无关用户会话
|
|
101
|
+
// (或根本没有消费者),可以另拉独立预热承接。
|
|
102
|
+
export function classifyWorkspace(details, ws, ent, entAlive, creatorAlive) {
|
|
103
|
+
const list = details?.get(ws) ?? [];
|
|
104
|
+
if (ent?.pid && entAlive) {
|
|
105
|
+
const own = list.find((d) => d.ancestors.includes(ent.pid));
|
|
106
|
+
if (own) {
|
|
107
|
+
return {
|
|
108
|
+
kind: 'own',
|
|
109
|
+
target: own.sessionId && own.procStart ? own : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (creatorAlive) return { kind: 'creatorAlive', target: null };
|
|
114
|
+
if (list.some((d) => !d.chainComplete)) return { kind: 'ambiguous', target: null };
|
|
115
|
+
return { kind: 'user', target: null };
|
|
39
116
|
}
|
|
40
117
|
|
|
41
118
|
// 读一个登记文件;读不出返回 null。Claude Code 会就地(非原子)重写登
|
|
@@ -60,8 +137,12 @@ async function readSessionFile(file) {
|
|
|
60
137
|
return null;
|
|
61
138
|
}
|
|
62
139
|
|
|
63
|
-
// 返回 [拥有活消费者会话的工作区集合, 告警字符串|null]。
|
|
64
|
-
|
|
140
|
+
// 返回 [拥有活消费者会话的工作区集合, 告警字符串|null, 会话详情 Map]。
|
|
141
|
+
// 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors,
|
|
142
|
+
// chainComplete}>>;祖先链要付一次全机 ps,只有 stampOwner 需要,所以
|
|
143
|
+
// lineage 默认关:关时恒为空 Map,总览/list/平静巡检不增加任何进程开销
|
|
144
|
+
// (与本仓「全量 ps 按需现拍」的既有纪律一致)。
|
|
145
|
+
export async function scanSessions({ lineage = false } = {}) {
|
|
65
146
|
let files = [];
|
|
66
147
|
try {
|
|
67
148
|
files = fs.readdirSync(deps.paths.sessionDir)
|
|
@@ -78,12 +159,29 @@ export async function scanSessions() {
|
|
|
78
159
|
}
|
|
79
160
|
}
|
|
80
161
|
|
|
162
|
+
// 全轮一张父子表快照:所有会话的祖先链取自同一时刻,且只付一次全量
|
|
163
|
+
// ps。仅在 lineage 显式开启时拍——快照失败给 null,祖先链一律按断链
|
|
164
|
+
// 处理(stampOwner 退化为「全部歧义、绝不双开」),consumers 判定不受
|
|
165
|
+
// 影响。
|
|
166
|
+
let parentMap = null;
|
|
167
|
+
if (lineage) {
|
|
168
|
+
try {
|
|
169
|
+
parentMap = deps.processParentMap();
|
|
170
|
+
} catch {
|
|
171
|
+
parentMap = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
81
175
|
const consumers = new Set();
|
|
82
|
-
const
|
|
176
|
+
const details = new Map();
|
|
177
|
+
const shapeCache = new Map();
|
|
83
178
|
let liveOther = 0;
|
|
84
179
|
// interactive 认得出来,却读不出有效 pid 或工作目录的条目数:pid/cwd
|
|
85
180
|
// 字段改名就是这种形态,而下面两条自检只看 kind 与进程形态,兜不住。
|
|
86
181
|
let suspect = 0;
|
|
182
|
+
// 已是消费者、但进程父 pid 或祖先链读不完整的条目数:stampOwner 会因
|
|
183
|
+
// 此无法认亲,只能保守不双开(过户暂停),需要一行告警让人看见退化。
|
|
184
|
+
let lineageMissing = 0;
|
|
87
185
|
for (const s of interactive) {
|
|
88
186
|
// pid 也是外部程序写的字段:null、非数值、非正数、超 pid_t 的巨大整
|
|
89
187
|
// 数都读不出活进程。这段代码在 per-workspace 保护圈之外,一个畸形登
|
|
@@ -95,16 +193,35 @@ export async function scanSessions() {
|
|
|
95
193
|
continue;
|
|
96
194
|
}
|
|
97
195
|
if (!deps.alive(pid)) continue; // 陈旧登记(崩溃留下):正常,不计
|
|
98
|
-
if (!
|
|
99
|
-
|
|
196
|
+
if (!shapeCache.has(pid)) {
|
|
197
|
+
shapeCache.set(pid, deps.processShape(pid)); // ps 调用按 pid 缓存
|
|
100
198
|
}
|
|
101
|
-
|
|
199
|
+
const shape = shapeCache.get(pid);
|
|
200
|
+
if (shape.isClaude) {
|
|
102
201
|
// cwd 同样不可信:非字符串当定位不到工作区;空串与相对路径会被
|
|
103
202
|
// resolve 拼到巡检进程自己的 cwd 下,凭空造出消费者目录,可能令预
|
|
104
203
|
// 热被静默跳过。
|
|
105
204
|
const cwd = s.cwd;
|
|
106
205
|
if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
|
|
107
|
-
|
|
206
|
+
const ws = lexReal(cwd);
|
|
207
|
+
consumers.add(ws);
|
|
208
|
+
if (lineage) {
|
|
209
|
+
const chain = parentMap
|
|
210
|
+
? ancestorChain(pid, parentMap)
|
|
211
|
+
: { ancestors: [], chainComplete: false };
|
|
212
|
+
if (shape.ppid === null || !chain.chainComplete) lineageMissing += 1;
|
|
213
|
+
const list = details.get(ws) ?? [];
|
|
214
|
+
list.push({
|
|
215
|
+
pid,
|
|
216
|
+
sessionId: typeof s.sessionId === 'string' ? s.sessionId : null,
|
|
217
|
+
// procStart 是 ps lstart 原文(单位数日期含双空格),只许原样
|
|
218
|
+
// 传递给任务文件,绝不能自己拼。
|
|
219
|
+
procStart: typeof s.procStart === 'string' ? s.procStart : null,
|
|
220
|
+
ancestors: chain.ancestors,
|
|
221
|
+
chainComplete: chain.chainComplete,
|
|
222
|
+
});
|
|
223
|
+
details.set(ws, list);
|
|
224
|
+
}
|
|
108
225
|
continue;
|
|
109
226
|
}
|
|
110
227
|
suspect += 1; // 活着的 claude 却没有可用 cwd:cwd 字段漂移
|
|
@@ -125,11 +242,19 @@ export async function scanSessions() {
|
|
|
125
242
|
alert = '会话登记中的 interactive 会话都读不出有效的 pid 或工作目录;'
|
|
126
243
|
+ 'Claude Code 的会话登记字段可能已变更,cron-up 会把已有会话误判为'
|
|
127
244
|
+ '不存在,在同目录重复拉起会话';
|
|
245
|
+
} else if (lineage && lineageMissing > 0) {
|
|
246
|
+
alert = `${lineageMissing} 个存活会话的进程父子关系读不完整(ps 输出异常或`
|
|
247
|
+
+ '会话进程正在退出);stampOwner 本轮按保守策略不另拉预热会话、暂停'
|
|
248
|
+
+ '任务过户,下轮自动恢复';
|
|
128
249
|
}
|
|
129
|
-
return [consumers, alert];
|
|
250
|
+
return [consumers, alert, details];
|
|
130
251
|
}
|
|
131
252
|
|
|
132
253
|
Object.assign(deps, {
|
|
133
|
-
|
|
254
|
+
processShape,
|
|
255
|
+
processParentMap,
|
|
256
|
+
ancestorChain,
|
|
257
|
+
liveSessionIds,
|
|
258
|
+
classifyWorkspace,
|
|
134
259
|
scanSessions,
|
|
135
260
|
});
|
package/src/service.mjs
CHANGED
|
@@ -346,6 +346,7 @@ export async function cmdInstall(args) {
|
|
|
346
346
|
}
|
|
347
347
|
if (args.autoRenew !== undefined) overrides.autoRenew = args.autoRenew;
|
|
348
348
|
if (args.autoMinId !== undefined) overrides.autoMinId = args.autoMinId;
|
|
349
|
+
if (args.stampOwner !== undefined) overrides.stampOwner = args.stampOwner;
|
|
349
350
|
if (args.sessionRetain !== undefined) {
|
|
350
351
|
if (args.sessionRetain !== 'window' && args.sessionRetain !== 'always') {
|
|
351
352
|
deps.printErr(`--session-retain 只接受 window 或 always,当前为 ${JSON.stringify(args.sessionRetain)}`);
|
package/src/tasks.mjs
CHANGED
|
@@ -248,12 +248,21 @@ export function minifyIds(tasks) {
|
|
|
248
248
|
return changed;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
-
// 一轮巡检对一个任务文件做的维护:renew(补 permanent
|
|
252
|
-
//
|
|
253
|
-
// .cron-up-tmp、
|
|
254
|
-
//
|
|
251
|
+
// 一轮巡检对一个任务文件做的维护:renew(补 permanent)、minify(id 改
|
|
252
|
+
// 小)、reown(把窗口内任务的 owner 三字段盖给指定预热会话)合并为同一
|
|
253
|
+
// 次受 mtime 乐观锁保护的原子写。先取 mtimeNs、清残留 .cron-up-tmp、
|
|
254
|
+
// loadTaskDoc round-trip 整文档;三项计数都为 0 时字节级不动。返回
|
|
255
|
+
// {renewed,minified,reowned};null 表示文件缺失/损坏;读到写间被改抛
|
|
255
256
|
// TaskFileChanged。
|
|
256
|
-
|
|
257
|
+
//
|
|
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
|
+
}) {
|
|
257
266
|
let mtimeNs;
|
|
258
267
|
try {
|
|
259
268
|
mtimeNs = fs.statSync(taskfile, { bigint: true }).mtimeNs;
|
|
@@ -284,9 +293,28 @@ export function maintainWorkspace(taskfile, { renew = false, minify = false }) {
|
|
|
284
293
|
}
|
|
285
294
|
}
|
|
286
295
|
const minified = minify ? minifyIds(doc.tasks) : 0;
|
|
287
|
-
|
|
296
|
+
let reowned = 0;
|
|
297
|
+
if (reown) {
|
|
298
|
+
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;
|
|
307
|
+
// ps lstart 原文(含「Oct 8」双空格),原样取自会话登记。
|
|
308
|
+
t.createdByProcStart = reown.procStart;
|
|
309
|
+
reowned += 1;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (renewed === 0 && minified === 0 && reowned === 0) {
|
|
314
|
+
return { renewed: 0, minified: 0, reowned: 0 };
|
|
315
|
+
}
|
|
288
316
|
atomicWriteJson(taskfile, doc, mtimeNs);
|
|
289
|
-
return { renewed, minified };
|
|
317
|
+
return { renewed, minified, reowned };
|
|
290
318
|
}
|
|
291
319
|
|
|
292
320
|
Object.assign(deps, { discover, readTasks, loadTaskDoc });
|