cron-up 26.10.28 → 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/README.md +15 -14
- package/package.json +1 -1
- package/src/patrol.mjs +92 -44
- package/src/registry.mjs +34 -11
package/README.md
CHANGED
|
@@ -76,18 +76,19 @@ cron-up install --force --no-auto-renew
|
|
|
76
76
|
|
|
77
77
|
### 任务归属预热(stampOwner,默认关闭)
|
|
78
78
|
|
|
79
|
-
默认行为下,只要工作区里已经有任何交互会话(包括你本人正在用的那个),巡检就不会再拉预热会话;而 Claude Code 按任务文件里记录的「创建者会话」投递——用 CronCreate
|
|
79
|
+
默认行为下,只要工作区里已经有任何交互会话(包括你本人正在用的那个),巡检就不会再拉预热会话;而 Claude Code 按任务文件里记录的「创建者会话」投递——用 CronCreate 建任务时你在哪个会话,到点任务就弹进哪个会话。于是你建完任务、那个窗口一直没关又恰好正在里面干别的活时,到点任务会挤进当前会话。
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
开启归属改写:配置 `stampOwner: true`(最多 5 分钟后的下一轮巡检生效),或安装时 `cron-up install --force --stamp-owner`(关闭用 `--no-stamp-owner`)。开启后巡检每轮先回答一个问题:**投递窗口内的任务,它们的创建者会话还活着吗?**
|
|
82
82
|
|
|
83
|
-
-
|
|
84
|
-
-
|
|
85
|
-
-
|
|
83
|
+
- **创建者会话还开着(哪怕你正在里面干别的活)——完全尊重,不另拉、不改归属**,任务到点照旧进那个会话。cron-up 不判断你是在「特意等结果」还是「被打扰」,创建者会话活着本身就是唯一信号;
|
|
84
|
+
- **创建者会话已关闭或崩溃**——同目录即使还有别的会话在开(它不是创建者),巡检也**另拉一个独立的预热会话**承接,并把窗口内任务的 `createdBySessionId`、`createdByPid`、`createdByProcStart` 三个归属字段盖成它:Claude Code 见到自己是创建者就铁定投到预热,别的会话见到 owner 是另一个活会话则主动回避,不抢锁、不双发;
|
|
85
|
+
- 只盖「窗口内且创建者已不活」的任务、幂等(第二轮起字节级不动)、与续期/改小 id 合并为同一次带乐观锁的原子写;预热会话若中途死亡,任务自动退回 Claude Code 原生的「创建者已死、持锁会话接管」规则,不会漏跑,新一代预热拉起后重新盖印;
|
|
86
|
+
- 一个边界:同一目录的窗口任务里只要**还有一个**创建者会话活着,巡检就不会在该目录另拉预热(避免任何双会话);其中创建者已死的任务会按 Claude Code 原生规则由持锁的活会话接管这一次。
|
|
86
87
|
|
|
87
88
|
两个附带的行为变化需要知道:
|
|
88
89
|
|
|
89
90
|
1. window 模式的回收余量从约 2 分钟收紧为「一个巡检间隔 + 2 分钟」(默认约 7 分钟)——回收后要留足「下轮重拉、再下轮盖印」的时间,任务才不可能在盖印前到点;
|
|
90
|
-
2. 卡在提问界面、超过 3
|
|
91
|
+
2. 卡在提问界面、超过 3 分钟没登记的预热会话会被终止(默认行为下同目录有别的会话时会跳过这次回收):活创建者在场时只终止不重拉(创建者自己承接任务),创建者已死时终止并重拉。
|
|
91
92
|
|
|
92
93
|
> 这同样依赖逆向出来的内部机制:任务文件的三个归属字段、以及「进程是 `script → zsh → claude` 三层、靠祖先 pid 认亲」都不是官方承诺的接口。识别退化(如 `ps` 输出异常)时巡检会发告警并**保守退回默认行为**(不另拉会话、暂停盖印),绝不会在同目录堆会话。残余的回退窗口(电脑休眠漏轮、会话恰在盖印前后换代)见「已知限制」。
|
|
93
94
|
|
|
@@ -179,16 +180,16 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
179
180
|
- **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
|
|
180
181
|
- **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,在写入配置之前就被响亮拒绝,非法值不会落盘。
|
|
181
182
|
- **会话保留策略(sessionRetain,默认 `window`)**:会话的生命周期对齐「一次执行」——任务触发过、执行完毕(界面静默约 3 分钟)且距下次需要会话还有余量(约 2 分钟)时,结束本代会话,下次任务窗口照常重拉。「需要会话」按计入投递抖动(见上节)的**预计实际触发时刻**计算:某任务的 cron 落点已过、但还没到它的延迟投递点的这段尾窗(落点后最多 30 分钟)里会话照样保留,尾窗内若会话意外死亡也会立即重拉——否则同目录两个落点相近的任务,较早的执行完后会话被回收,较晚的还没到投递点就会漏跑(周期任务没有补执行)。效果:**错开约 30 分钟以上(已含投递抖动、静默判定与余量)的任务才必然各自拿到全新会话**,更近的共享同一会话(取决于巡检落点);上下文不互相累积,内存也只在执行窗口内占用(实测每个会话数百 MB);时间上重叠或紧邻(前后几分钟内)的任务仍共享同一会话——任务在哪个会话执行由 Claude Code 决定,同目录并发多会话既无法指定归属、还有重复执行的风险,cron-up 不制造这种状态。需要旧的常驻行为(执行间隔里也保留会话、上下文跨触发累积)时,把配置文件中的 `sessionRetain` 改为 `"always"`(下一轮巡检生效),或安装时 `--session-retain always`。
|
|
182
|
-
- **开关任务归属预热**:配置项 `stampOwner`,命令行 `cron-up install --force --stamp-owner` / `--no-stamp-owner
|
|
183
|
+
- **开关任务归属预热**:配置项 `stampOwner`,命令行 `cron-up install --force --stamp-owner` / `--no-stamp-owner`。开启后以「创建者会话是否还活着」为最高优先级:建任务的会话还开着就完全尊重、任务照旧进它;创建者已关闭/崩溃时才另拉独立预热承接,不让任务漏给同目录无关的会话(完整语义见安装一节的「任务归属预热」)。两个附带变化:window 回收余量从约 2 分钟收紧为约 7 分钟(间隔 + 2 分钟);卡在提问界面的预热会话会被 3 分钟卡死退休——活创建者在场时只终止不重拉,创建者已死时终止并重拉(默认行为下同目录有别的会话时跳过这次回收)。
|
|
183
184
|
- **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
|
|
184
|
-
- **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;默认配置下同目录当时还有你本人打开的会话时除外,见「已知限制」;`stampOwner`
|
|
185
|
+
- **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;默认配置下同目录当时还有你本人打开的会话时除外,见「已知限制」;`stampOwner` 开启时不除外——活创建者在场则只终止不重拉,创建者已死则终止并重拉)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
|
|
185
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`,只保留上一份。
|
|
186
187
|
- **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有四重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、interactive 登记的 pid/工作目录字段集体无法解读、或存活会话的进程父链读不完整(仅影响 stampOwner 的认亲与盖印,不影响基础保活)时,「需要留意」会明确提示登记格式或进程信息疑似变更;看到这类告警后,在重新核对之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
|
|
187
188
|
- **巡检心跳与沉默告警**:每轮巡检成功跑完都会刷新 `~/.local/share/cron-up/heartbeat`。总览页的巡检区显示「上次巡检:N 分钟前」;launchd 显示已加载、但心跳超过两个轮次间隔没刷新时,「需要留意」会报「巡检已沉默」——启动脚本静态检查全绿但运行期拉不起来(或轮次持续崩在半道)只有靠它发现。轮首发现距上轮过久也会在巡检日志里记一条漏轮提示(电脑休眠后 launchd 会合并非错过的间隔,醒来只补一轮,属正常现象)。
|
|
188
189
|
|
|
189
190
|
## 安全说明
|
|
190
191
|
|
|
191
|
-
- 后台会话拥有你在 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
|
|
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,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
|
|
192
193
|
- **续期会改变三条生命周期/边界**:
|
|
193
194
|
1. 被打 `permanent` 的周期任务不再被 Claude Code 在创建满 7 天后删除,cron-up 为它保活的后台会话随之**长期常驻**,不再随任务过期被回收——这等于主动关闭了 Claude Code 对自主会话的寿命护栏。
|
|
194
195
|
2. 7 天上限本也是一道**自动淘汰**防线:一个你没留意、随 `git pull` 或他人提交进入工作目录的周期任务,原本最多存活 7 天;autoRenew 会无差别把它也永久化。因此**不要把他人可写、或你不完全掌控的仓库纳入 roots**(与下一条任务文件即执行输入的警告同理);拉取外部代码后先检查 `.claude/scheduled_tasks.json` 里有没有意料之外的周期任务,必要时先关闭 autoRenew。
|
|
@@ -203,13 +204,13 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
203
204
|
- Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
|
|
204
205
|
- autoMinId 改小 id 依赖逆向出来的投递哈希算法(见「投递抖动」),不是官方承诺的接口:新版 Claude Code 若更换算法,小 id 不再能保证准点——届时关掉 autoMinId、改用「cron 往前提 30 分钟」即可,`list` 的预计时间与 findings 的验算脚本可用来发现这种漂移。此外在交互会话里用 `CronCreate` 重建任务会生成新的随机大 id(需等下轮巡检再改回),改时间请直接编辑 cron 字段而不是重建。
|
|
205
206
|
- autoMinId 改写 id 的当轮起,巡检按新 id(投递延迟不足 1 分钟)计算投递尾窗,而**已在运行的会话仍按旧 id 投递**(顶格约 30 分钟后):若保活会话恰在「新投递时刻 ~ 旧投递时刻」这最长约 29 分钟的空隙里死亡,该次投递漏跑(周期任务无补执行),下个周期恢复。window 模式下会话执行完即回收、重拉的新会话读到新 id,风险窗口至多覆盖一次执行;always 模式则持续到 7 天换代。在意的话,等一轮执行完(会话已重拉)再跑 `cron-up renew` 可完全避开。
|
|
206
|
-
- 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。`stampOwner`
|
|
207
|
+
- 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。`stampOwner` 开启后,创建者会话已关闭/崩溃的任务会走进程独立的预热会话、不漏给同目录无关会话;但创建者会话还开着的任务照旧进创建者(这是刻意的最高优先级),同目录重叠任务的归属仍由 Claude Code 裁决,真正的任务级并发隔离还是需要官方支持任务路由。
|
|
207
208
|
- 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
|
|
208
209
|
- 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
|
|
209
|
-
- 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up 默认的策略是「只在没有任何会话时才启动」,正常流程不会制造第二个会话;`stampOwner`
|
|
210
|
-
- 「已有会话在消费」按目录判断:默认配置下分不清消费者是我们的预热会话还是你本人打开的会话,卡在提问界面(始终不会登记)的预热会话,若同目录恰有你的交互会话在场,3 分钟的卡死回收会被跳过——最晚由 7 天自动换代收掉,或在你关掉自己的会话后、下次有任务需要会话的那轮巡检被回收。`stampOwner`
|
|
211
|
-
- `stampOwner`
|
|
212
|
-
- `stampOwner` 依赖任务文件的三个 `createdBy*`
|
|
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):新版若移除或改名这些字段、改变启动进程结构,盖印会失效或认不到自己的预热——退化时一律按保守方向处理:创建者存活性判断不到时视为「可能还活着」(不另拉),认亲失败按歧义处理(不双开),不会出现任务被抢走或同目录堆会话。核对方法同投递抖动:发现任务又弹进错误的会话时,检查任务文件字段与会话登记格式,必要时先关闭开关。
|
|
213
214
|
- 扫描不深入子目录里的符号链接(`roots` 本身的符号链接会先解析成真实路径):容器里用 symlink 指向别处的仓库不会被发现,其中的任务不会被保活。
|
|
214
215
|
- 系统设置「登录项与扩展 → 允许在后台」里,巡检条目显示为 **cron-up-service**:macOS 按 launchd 拉起的第一个可执行文件(启动脚本)给条目起名。条目小字可能标注「来自未识别的开发者」——启动脚本没有 Apple 签发证书的签名,这行小字没有本地手段改;主名称与开关行为不受影响。
|
|
215
216
|
- 若升级 Node 时删掉了启动脚本 PATH 指向的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「PATH 目录里找不到 node」,重跑一次 `cron-up install` 即可修复。
|
package/package.json
CHANGED
package/src/patrol.mjs
CHANGED
|
@@ -177,14 +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
196
|
// {verdict, recycleMarginSec}:verdict 是 classifyWorkspace 对轮初消费者
|
|
186
|
-
// 快照的认亲结论(own/ambiguous/user
|
|
187
|
-
//
|
|
197
|
+
// 快照的认亲结论(own/creatorAlive/ambiguous/user,换代或回收导致快照变
|
|
198
|
+
// 化时在 retire 内用新快照重算),recycleMarginSec 是 window 回收余量。
|
|
188
199
|
export async function patrolWorkspace(
|
|
189
200
|
ws, state, now, leadMs, cur, consumers, scriptProcs = null,
|
|
190
201
|
retain = 'always', stats = null, stamp = null,
|
|
@@ -239,7 +250,10 @@ export async function patrolWorkspace(
|
|
|
239
250
|
if (stamp) {
|
|
240
251
|
const [nextConsumers, , nextDetails] = await deps.scanSessions({ lineage: true });
|
|
241
252
|
consumers = nextConsumers;
|
|
242
|
-
|
|
253
|
+
// ent 已没(ownPid 给 null);creatorAlive 也必须按新快照重算——
|
|
254
|
+
// 重扫本身就承认换代这几秒现场会变,活创建者可能恰好开关会话。
|
|
255
|
+
verdict = deps.classifyWorkspace(nextDetails, ws, null,
|
|
256
|
+
liveCreatorWanted(tasks, nextDetails, ws, now, leadMs).creatorAlive);
|
|
243
257
|
} else {
|
|
244
258
|
consumers = (await deps.scanSessions())[0];
|
|
245
259
|
}
|
|
@@ -287,41 +301,65 @@ export async function patrolWorkspace(
|
|
|
287
301
|
+ '界面静默,结束会话,下次需要时重拉');
|
|
288
302
|
}
|
|
289
303
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
//
|
|
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)、窗口任务的创建者会话还活着
|
|
324
|
+
// (creatorAlive:用户显式要求尊重,绝不另拉)、父链有歧义无法排除是
|
|
325
|
+
// 自己(ambiguous)。仅 user 不算:创建者已关闭/崩溃,下面继续走到
|
|
326
|
+
// wanted→卡死退休/冷却→spawn,在同目录另起独立预热承接(全程只 stop
|
|
327
|
+
// 自己的 ent,不碰用户会话)。
|
|
294
328
|
const consumerHolds = consumers.has(ws)
|
|
295
|
-
&& (!stamp || verdict
|
|
329
|
+
&& (!stamp || verdict.kind !== 'user');
|
|
296
330
|
|
|
297
331
|
// 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
|
|
298
332
|
// 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
333
|
+
// 却,连一次新尝试都不给。只有真的认到自己(own)或歧义态才清零:
|
|
334
|
+
// creatorAlive 时在场的是活创建者、我们的预热可能正卡着没登记,不能替
|
|
335
|
+
// 它清零。
|
|
336
|
+
if (ent !== null && mineAlive && consumers.has(ws)
|
|
337
|
+
&& (!stamp || verdict.kind === 'own' || verdict.kind === 'ambiguous')) {
|
|
302
338
|
ent.fails = 0;
|
|
303
339
|
}
|
|
304
340
|
|
|
305
341
|
// 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
|
|
306
342
|
// 盖),也可能是用户的交互会话——后者默认放手:不在这里删死条目,失败
|
|
307
343
|
// 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
|
|
308
|
-
// 由轮末 kept
|
|
309
|
-
//
|
|
310
|
-
if (consumerHolds)
|
|
344
|
+
// 由轮末 kept 过滤统一处理。creatorAlive/ambiguous 返回前顺手清掉卡死
|
|
345
|
+
// 的垃圾预热;平稳态在此提前返回,不必继续后面的流程。
|
|
346
|
+
if (consumerHolds) {
|
|
347
|
+
// 默认配置(stamp 关)沿用旧行为:用户会话在场时「掩护」卡死预热,
|
|
348
|
+
// 不在这里杀,交给 7 天换代——所以 holds 路径只在 stamp 开启时清垃圾。
|
|
349
|
+
if (stamp && warmStuck) await killStuckWarm(true);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
311
352
|
|
|
312
|
-
if (!
|
|
353
|
+
if (!wantedNow) return;
|
|
313
354
|
|
|
314
355
|
const cooling = isCooling(ent, cur);
|
|
315
356
|
|
|
316
357
|
// 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
|
|
317
|
-
//
|
|
358
|
+
// 应答的提问界面,永不登记——退休它并推进失败计数。宽限内的年轻会话
|
|
359
|
+
// 直接返回;warmStuck 已含超宽限判定。
|
|
318
360
|
if (ent !== null && mineAlive) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
await deps.stopSession(ent);
|
|
322
|
-
note('stuck', `卡死 ${ws} pid=${ent.pid}:进程已存活 ${age} 秒仍未登记会话,`
|
|
323
|
-
+ '已终止(排查:cron-up logs '
|
|
324
|
-
+ `${path.basename(ws)})`);
|
|
361
|
+
if (!warmStuck) return;
|
|
362
|
+
await killStuckWarm(false);
|
|
325
363
|
}
|
|
326
364
|
|
|
327
365
|
if (cooling) return;
|
|
@@ -482,22 +520,11 @@ export async function cmdRun(args) {
|
|
|
482
520
|
deps.log(`告警:${registryAlert}`);
|
|
483
521
|
}
|
|
484
522
|
|
|
485
|
-
// stampOwner
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (cfg.stampOwner) {
|
|
491
|
-
for (const ws0 of consumers) {
|
|
492
|
-
const ent0 = state[ws0] ?? null;
|
|
493
|
-
verdicts.set(ws0,
|
|
494
|
-
deps.classifyWorkspace(details, ws0, ent0, Boolean(ent0)
|
|
495
|
-
&& deps.trackedAlive(ent0)));
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
const stampBase = cfg.stampOwner
|
|
499
|
-
? { recycleMarginSec: SESSION_FIRE_MARGIN_SECONDS + cfg.intervalSeconds }
|
|
500
|
-
: null;
|
|
523
|
+
// stampOwner 的结论依赖任务内容(活创建者判定要看窗口任务的
|
|
524
|
+
// createdBySessionId),所以在下面 discover 循环里、读到任务后逐工
|
|
525
|
+
// 作区现算;这里只留全轮常量。
|
|
526
|
+
const recycleMarginSec = SESSION_FIRE_MARGIN_SECONDS
|
|
527
|
+
+ cfg.intervalSeconds;
|
|
501
528
|
|
|
502
529
|
// 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
|
|
503
530
|
// 日志时间线就是「退出 → 重拉」,而不是反过来。
|
|
@@ -513,7 +540,27 @@ export async function cmdRun(args) {
|
|
|
513
540
|
// 后面所有工作区。续期/改 id/过户与保活各自独立守卫——维护失败绝不
|
|
514
541
|
// 压制该工作区的会话保活。三者合并为同一次原子写(见
|
|
515
542
|
// maintainWorkspace)。
|
|
516
|
-
|
|
543
|
+
//
|
|
544
|
+
// stampOwner 的判定在这里现算(依赖任务内容):窗口内是否还有任务
|
|
545
|
+
// 的创建者会话活着——活创建者拥有否决权,不另拉、不盖印。
|
|
546
|
+
let stampArg = null;
|
|
547
|
+
let liveSids = null;
|
|
548
|
+
if (cfg.stampOwner) {
|
|
549
|
+
const wsTasks = deps.readTasks(ws) ?? []; // readTasks 已滤掉非对象
|
|
550
|
+
const creator = liveCreatorWanted(wsTasks, details, ws, now, leadMs);
|
|
551
|
+
liveSids = creator.liveSids;
|
|
552
|
+
const entNow = state[ws] ?? null;
|
|
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
|
+
};
|
|
559
|
+
}
|
|
560
|
+
const target = stampArg?.verdict?.target ?? null;
|
|
561
|
+
// 盖印谓词只盖「窗口内且创建者已不活」的任务:活创建者的任务永远
|
|
562
|
+
// 不碰(即便本目录另有我们的预热在承接别的死主任务);已盖给本代
|
|
563
|
+
// 预热的任务因 owner 就在活会话集合里而自然跳过,幂等不靠另写比较。
|
|
517
564
|
if (cfg.autoRenew || cfg.autoMinId || target) {
|
|
518
565
|
try {
|
|
519
566
|
const { renewed, minified, reowned } = maintainWorkspace(taskfile, {
|
|
@@ -522,7 +569,11 @@ export async function cmdRun(args) {
|
|
|
522
569
|
// target 多出的祖先链字段 tasks 层不读,展开即可,不必手抄
|
|
523
570
|
// 三个盖印字段。
|
|
524
571
|
reown: target
|
|
525
|
-
? {
|
|
572
|
+
? {
|
|
573
|
+
...target,
|
|
574
|
+
isWanted: (t) => taskWanted(t, now, leadMs)
|
|
575
|
+
&& !deps.creatorSessionAlive(t, liveSids),
|
|
576
|
+
}
|
|
526
577
|
: null,
|
|
527
578
|
}) ?? {};
|
|
528
579
|
if (renewed) {
|
|
@@ -553,10 +604,7 @@ export async function cmdRun(args) {
|
|
|
553
604
|
try {
|
|
554
605
|
await patrolWorkspace(
|
|
555
606
|
ws, state, now, leadMs, cur, consumers, null,
|
|
556
|
-
cfg.sessionRetain, stats,
|
|
557
|
-
stampBase
|
|
558
|
-
? { ...stampBase, verdict: verdicts.get(ws) ?? null }
|
|
559
|
-
: null);
|
|
607
|
+
cfg.sessionRetain, stats, stampArg);
|
|
560
608
|
} catch (e) {
|
|
561
609
|
bump(stats, 'error');
|
|
562
610
|
deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
|
package/src/registry.mjs
CHANGED
|
@@ -79,18 +79,38 @@ export function ancestorChain(pid, parentMap) {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
// 逐任务本原谓词:这条任务的创建者会话还活着吗?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——在场消费者就是我们的预热;
|
|
103
|
+
// target 给出可盖印详情,身份字段缺失时为 null;
|
|
104
|
+
// creatorAlive 窗口任务的创建者会话还活着——任务按 Claude Code 规则必
|
|
105
|
+
// 然投给它,不另拉、不盖印(用户显式要求的最高优先级);
|
|
106
|
+
// ambiguous 没有认到自己的预热,但在场会话父链不完整——无法证明不
|
|
107
|
+
// 是自己(如 state 丢失的孤儿预热),保守不双开;
|
|
108
|
+
// user 以上皆否——创建者已关闭/崩溃,在场的只是无关用户会话
|
|
109
|
+
// (或根本没有消费者),可以另拉独立预热承接。
|
|
110
|
+
export function classifyWorkspace(details, ws, ownPid, creatorAlive) {
|
|
91
111
|
const list = details?.get(ws) ?? [];
|
|
92
|
-
if (
|
|
93
|
-
const own = list.find((d) => d.ancestors.includes(
|
|
112
|
+
if (ownPid !== null) {
|
|
113
|
+
const own = list.find((d) => d.ancestors.includes(ownPid));
|
|
94
114
|
if (own) {
|
|
95
115
|
return {
|
|
96
116
|
kind: 'own',
|
|
@@ -98,6 +118,7 @@ export function classifyWorkspace(details, ws, ent, entAlive) {
|
|
|
98
118
|
};
|
|
99
119
|
}
|
|
100
120
|
}
|
|
121
|
+
if (creatorAlive) return { kind: 'creatorAlive', target: null };
|
|
101
122
|
if (list.some((d) => !d.chainComplete)) return { kind: 'ambiguous', target: null };
|
|
102
123
|
return { kind: 'user', target: null };
|
|
103
124
|
}
|
|
@@ -241,6 +262,8 @@ Object.assign(deps, {
|
|
|
241
262
|
processShape,
|
|
242
263
|
processParentMap,
|
|
243
264
|
ancestorChain,
|
|
265
|
+
liveSessionIds,
|
|
266
|
+
creatorSessionAlive,
|
|
244
267
|
classifyWorkspace,
|
|
245
268
|
scanSessions,
|
|
246
269
|
});
|