cron-up 26.10.26 → 26.10.28
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 +27 -7
- package/package.json +1 -1
- package/src/args.mjs +37 -0
- package/src/cli.mjs +2 -35
- package/src/config.mjs +27 -13
- package/src/constants.mjs +8 -1
- package/src/cron.mjs +3 -13
- package/src/display.mjs +5 -4
- package/src/internals.mjs +25 -0
- package/src/logs.mjs +3 -2
- package/src/overview.mjs +21 -16
- package/src/paths.mjs +0 -13
- package/src/patrol.mjs +121 -46
- package/src/registry.mjs +132 -21
- package/src/service.mjs +18 -17
- package/src/sessions.mjs +2 -7
- package/src/tasks.mjs +39 -17
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,23 @@ 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
|
+
- 即便你的交互会话正在该目录,巡检也**另拉一个独立的预热会话**(同目录会同时存在两个会话,这是本开关的显式代价);
|
|
84
|
+
- 预热会话完成登记后(通常几秒),下一轮巡检把**投递窗口内**(提前量覆盖的时段)任务的 `createdBySessionId`、`createdByPid`、`createdByProcStart` 三个归属字段盖成预热会话——Claude Code 见到自己是创建者就铁定投到它,你的会话见到 owner 是另一个还活着的会话则主动不碰,不需要抢锁、不存在双发;
|
|
85
|
+
- 只盖窗口内的任务、幂等(第二轮起字节级不动)、与续期/改小 id 合并为同一次带乐观锁的原子写;预热会话若中途死亡,任务自动退回 Claude Code 原生的「创建者已死、持锁会话接管」规则,不会漏跑,新一代预热拉起后重新盖印。
|
|
86
|
+
|
|
87
|
+
两个附带的行为变化需要知道:
|
|
88
|
+
|
|
89
|
+
1. window 模式的回收余量从约 2 分钟收紧为「一个巡检间隔 + 2 分钟」(默认约 7 分钟)——回收后要留足「下轮重拉、再下轮盖印」的时间,任务才不可能在盖印前到点;
|
|
90
|
+
2. 卡在提问界面、超过 3 分钟没登记的预热会话,即使你的交互会话就在同目录也会被终止重拉(默认行为下它会被你的会话「掩护」、跳过这次回收)。
|
|
91
|
+
|
|
92
|
+
> 这同样依赖逆向出来的内部机制:任务文件的三个归属字段、以及「进程是 `script → zsh → claude` 三层、靠祖先 pid 认亲」都不是官方承诺的接口。识别退化(如 `ps` 输出异常)时巡检会发告警并**保守退回默认行为**(不另拉会话、暂停盖印),绝不会在同目录堆会话。残余的回退窗口(电脑休眠漏轮、会话恰在盖印前后换代)见「已知限制」。
|
|
93
|
+
|
|
77
94
|
安装产物:
|
|
78
95
|
|
|
79
96
|
| 产物 | 位置 |
|
|
@@ -162,15 +179,16 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
162
179
|
- **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
|
|
163
180
|
- **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,在写入配置之前就被响亮拒绝,非法值不会落盘。
|
|
164
181
|
- **会话保留策略(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`。开启后你本人的会话不再被动承接定时任务——巡检另拉独立预热并把窗口内任务的归属字段盖给它(完整语义与代价见安装一节的「任务归属预热」)。两个附带变化:window 回收余量从约 2 分钟收紧为约 7 分钟(间隔 + 2 分钟);卡在提问界面的预热会话在你的会话旁也会被 3 分钟卡死退休(默认是跳过)。
|
|
165
183
|
- **长驻会话自动换代(`always` 模式与异常情况的兜底)**:连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
|
|
166
|
-
- **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up
|
|
184
|
+
- **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止;默认配置下同目录当时还有你本人打开的会话时除外,见「已知限制」;`stampOwner` 开启时不除外)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
|
|
167
185
|
- **日志大小**:后台会话的界面输出是持续的全屏渲染流。`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
|
|
186
|
+
- **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有四重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、interactive 登记的 pid/工作目录字段集体无法解读、或存活会话的进程父链读不完整(仅影响 stampOwner 的认亲与盖印,不影响基础保活)时,「需要留意」会明确提示登记格式或进程信息疑似变更;看到这类告警后,在重新核对之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
|
|
169
187
|
- **巡检心跳与沉默告警**:每轮巡检成功跑完都会刷新 `~/.local/share/cron-up/heartbeat`。总览页的巡检区显示「上次巡检:N 分钟前」;launchd 显示已加载、但心跳超过两个轮次间隔没刷新时,「需要留意」会报「巡检已沉默」——启动脚本静态检查全绿但运行期拉不起来(或轮次持续崩在半道)只有靠它发现。轮首发现距上轮过久也会在巡检日志里记一条漏轮提示(电脑休眠后 launchd 会合并非错过的间隔,醒来只补一轮,属正常现象)。
|
|
170
188
|
|
|
171
189
|
## 安全说明
|
|
172
190
|
|
|
173
|
-
- 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions
|
|
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(默认关)时存在第三种改写**:把投递窗口内任务(含一次性任务)的 `createdBySessionId`、`createdByPid`、`createdByProcStart` 三个归属字段盖成本轮预热会话的值,不碰 `createdInProject`、prompt 与 cron;同样幂等,任务的归属只在「窗口打开且预热换了一代」时才再写一次。三种写入合并在同一次原子替换里。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再来(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
|
|
174
192
|
- **续期会改变三条生命周期/边界**:
|
|
175
193
|
1. 被打 `permanent` 的周期任务不再被 Claude Code 在创建满 7 天后删除,cron-up 为它保活的后台会话随之**长期常驻**,不再随任务过期被回收——这等于主动关闭了 Claude Code 对自主会话的寿命护栏。
|
|
176
194
|
2. 7 天上限本也是一道**自动淘汰**防线:一个你没留意、随 `git pull` 或他人提交进入工作目录的周期任务,原本最多存活 7 天;autoRenew 会无差别把它也永久化。因此**不要把他人可写、或你不完全掌控的仓库纳入 roots**(与下一条任务文件即执行输入的警告同理);拉取外部代码后先检查 `.claude/scheduled_tasks.json` 里有没有意料之外的周期任务,必要时先关闭 autoRenew。
|
|
@@ -185,11 +203,13 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
|
|
|
185
203
|
- Claude Code 的周期任务本会在创建满 7 天后失效;cron-up 默认已通过给任务补 `permanent` 字段自动续期,仅在关闭 autoRenew 后才需要按「使用定时任务时的三个约定」手动续签。`permanent` 是未公开字段,若新版 Claude Code 改变其语义需重新核对(见开头 WARNING)。
|
|
186
204
|
- autoMinId 改小 id 依赖逆向出来的投递哈希算法(见「投递抖动」),不是官方承诺的接口:新版 Claude Code 若更换算法,小 id 不再能保证准点——届时关掉 autoMinId、改用「cron 往前提 30 分钟」即可,`list` 的预计时间与 findings 的验算脚本可用来发现这种漂移。此外在交互会话里用 `CronCreate` 重建任务会生成新的随机大 id(需等下轮巡检再改回),改时间请直接编辑 cron 字段而不是重建。
|
|
187
205
|
- autoMinId 改写 id 的当轮起,巡检按新 id(投递延迟不足 1 分钟)计算投递尾窗,而**已在运行的会话仍按旧 id 投递**(顶格约 30 分钟后):若保活会话恰在「新投递时刻 ~ 旧投递时刻」这最长约 29 分钟的空隙里死亡,该次投递漏跑(周期任务无补执行),下个周期恢复。window 模式下会话执行完即回收、重拉的新会话读到新 id,风险窗口至多覆盖一次执行;always 模式则持续到 7 天换代。在意的话,等一轮执行完(会话已重拉)再跑 `cron-up renew` 可完全避开。
|
|
188
|
-
- 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code
|
|
206
|
+
- 默认 `sessionRetain: "window"` 下,周期任务的每次触发各自使用新拉起的会话(触发完毕即回收、下次窗口重拉);**时间上重叠或紧邻的任务仍会共享同一会话**——任务路由由 Claude Code 决定,无法做到「每个任务专属一个并发会话」。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。`stampOwner` 开启后,时间上错开的任务会强制走进程独立的预热会话、不再借用你的交互会话,但同目录重叠任务的归属仍由 Claude Code 在预热会话内部裁决,真正的任务级并发隔离还是需要官方支持任务路由。
|
|
189
207
|
- 手写的任务文件若缺 `createdAt` 字段,错过补执行的判定不生效:错过的一次性任务不会被预热补救,`list` 也不显示「已错过」。`CronCreate` 创建的任务始终带该字段,不受影响。
|
|
190
208
|
- 工作区从巡检视野里消失(`.claude/scheduled_tasks.json` 被整个删除,或目录被移出 `roots`)后,cron-up 不再巡检它:其中仍在运行的保活会话不会被回收或换代,会一直驻留到进程自己退出。无参数 `cron-up` 的「需要留意」会列出这类会话,不需要时请手动结束对应进程(`kill <pid>`)。
|
|
191
|
-
- 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up
|
|
192
|
-
-
|
|
209
|
+
- 同一目录同时存在两个交互会话时,定时任务是否会被各执行一次,官方没有明确说明。cron-up 默认的策略是「只在没有任何会话时才启动」,正常流程不会制造第二个会话;`stampOwner` 开启时会显式制造「用户会话 + 预热会话」并存的状态,但靠把任务归属盖给预热会话来保证只有它执行(你的会话按 Claude Code 自身规则主动回避,预热死亡才允许接管),不依赖「谁先动手」,因此不会各执行一次。
|
|
210
|
+
- 「已有会话在消费」按目录判断:默认配置下分不清消费者是我们的预热会话还是你本人打开的会话,卡在提问界面(始终不会登记)的预热会话,若同目录恰有你的交互会话在场,3 分钟的卡死回收会被跳过——最晚由 7 天自动换代收掉,或在你关掉自己的会话后、下次有任务需要会话的那轮巡检被回收。`stampOwner` 开启时改为按进程祖先链精确认亲(state 记的 `script` 是登记中 claude 进程的祖父),只有父链读不完整(`ps` 异常、进程正在退出)时才退回分不清的保守状态——该轮不另拉会话、不盖印并发出告警,下轮自动恢复。
|
|
211
|
+
- `stampOwner` 开启后仍有一个盖印跟不上的回退窗口:预热会话换代或被回收后,要经历「下轮巡检重拉(≤ 一个间隔)→ 新会话登记 → 再下轮盖印」两步。若电脑休眠漏轮、或换代恰好发生在落点前不久,任务可能由你的交互会话按 Claude Code 原生规则(持有调度锁且创建者已死)接管这一次——不会漏跑、不会重复,下轮自动盖回新预热,等同功能关闭时的行为。window 模式的回收余量已因此从约 2 分钟收紧为一个间隔 + 2 分钟(默认约 7 分钟),把这个窗口压缩到极端情况。
|
|
212
|
+
- `stampOwner` 依赖任务文件的三个 `createdBy*` 归属字段与三代进程结构(核对自 Claude Code 2.1.300):新版若移除或改名这些字段、改变启动进程结构,盖印会失效或认不到自己的预热——表现为归属不再改写(字段名变更时巡检的乐观锁不会破坏文件,但也写不进新归属)或同目录不再另拉会话(认亲失败按歧义保守处理)。核对方法同投递抖动:发现任务又弹进自己的会话时,检查任务文件字段与会话登记格式,必要时先关闭开关。
|
|
193
213
|
- 扫描不深入子目录里的符号链接(`roots` 本身的符号链接会先解析成真实路径):容器里用 symlink 指向别处的仓库不会被发现,其中的任务不会被保活。
|
|
194
214
|
- 系统设置「登录项与扩展 → 允许在后台」里,巡检条目显示为 **cron-up-service**:macOS 按 launchd 拉起的第一个可执行文件(启动脚本)给条目起名。条目小字可能标注「来自未识别的开发者」——启动脚本没有 Apple 签发证书的签名,这行小字没有本地手段改;主名称与开关行为不受影响。
|
|
195
215
|
- 若升级 Node 时删掉了启动脚本 PATH 指向的版本目录(多见于手动删除 nvm/fnm 版本),launchd 会静默拉不起巡检;总览页会报「PATH 目录里找不到 node」,重跑一次 `cron-up install` 即可修复。
|
package/package.json
CHANGED
package/src/args.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// 子命令选项规格与选项名转换。独立于 cli:install 的「未生效参数」名单
|
|
2
|
+
// (service.mjs)从同一张选项表派生,新增可持久化选项时两处自动同步。
|
|
3
|
+
|
|
4
|
+
// 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无值
|
|
5
|
+
// 开关;tri 支持 --name / --no-name(install 的 auto-renew、auto-min-id)。
|
|
6
|
+
export const SPECS = {
|
|
7
|
+
install: {
|
|
8
|
+
options: {
|
|
9
|
+
roots: 'string',
|
|
10
|
+
interval: 'int',
|
|
11
|
+
lead: 'int',
|
|
12
|
+
force: 'bool',
|
|
13
|
+
'auto-renew': 'tri',
|
|
14
|
+
'auto-min-id': 'tri',
|
|
15
|
+
'session-retain': 'string',
|
|
16
|
+
'stamp-owner': 'tri',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
uninstall: {
|
|
20
|
+
options: {
|
|
21
|
+
purge: 'bool',
|
|
22
|
+
'keep-sessions': 'bool',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
list: { options: {} },
|
|
26
|
+
run: { options: {} },
|
|
27
|
+
renew: { options: {} },
|
|
28
|
+
logs: {
|
|
29
|
+
options: { follow: 'bool', f: 'bool' },
|
|
30
|
+
positionalMax: 1,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// 选项名统一成代码里用的 camelCase(--keep-sessions → keepSessions)。
|
|
35
|
+
export function thisCamel(name) {
|
|
36
|
+
return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
37
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -10,35 +10,7 @@ import { cmdInstall, cmdUninstall } from './service.mjs';
|
|
|
10
10
|
import { cmdList } from './list.mjs';
|
|
11
11
|
import { cmdOverview } from './overview.mjs';
|
|
12
12
|
import { cmdLogs } from './logs.mjs';
|
|
13
|
-
|
|
14
|
-
// 选项类型:string 吃一个值;int 吃一个值且必须是严格十进制;bool 是无值
|
|
15
|
-
// 开关;tri 支持 --name / --no-name(install 的 auto-renew、auto-min-id)。
|
|
16
|
-
const SPECS = {
|
|
17
|
-
install: {
|
|
18
|
-
options: {
|
|
19
|
-
roots: 'string',
|
|
20
|
-
interval: 'int',
|
|
21
|
-
lead: 'int',
|
|
22
|
-
force: 'bool',
|
|
23
|
-
'auto-renew': 'tri',
|
|
24
|
-
'auto-min-id': 'tri',
|
|
25
|
-
'session-retain': 'string',
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
uninstall: {
|
|
29
|
-
options: {
|
|
30
|
-
purge: 'bool',
|
|
31
|
-
'keep-sessions': 'bool',
|
|
32
|
-
},
|
|
33
|
-
},
|
|
34
|
-
list: { options: {} },
|
|
35
|
-
run: { options: {} },
|
|
36
|
-
renew: { options: {} },
|
|
37
|
-
logs: {
|
|
38
|
-
options: { follow: 'bool', f: 'bool' },
|
|
39
|
-
positionalMax: 1,
|
|
40
|
-
},
|
|
41
|
-
};
|
|
13
|
+
import { SPECS, thisCamel } from './args.mjs';
|
|
42
14
|
|
|
43
15
|
// 严格十进制整数命令行值(谓词 isDecInt 见 internals):
|
|
44
16
|
// Number("0x10")、parseInt("5x") 都会静默给出错误结果,必须显式拒绝。
|
|
@@ -102,11 +74,6 @@ function consumeOption(tok, tokens, i, spec, opts) {
|
|
|
102
74
|
return inlineValue !== undefined ? i + 1 : i + 2;
|
|
103
75
|
}
|
|
104
76
|
|
|
105
|
-
// 选项名统一成代码里用的 camelCase(--keep-sessions → keepSessions)。
|
|
106
|
-
function thisCamel(name) {
|
|
107
|
-
return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
108
|
-
}
|
|
109
|
-
|
|
110
77
|
export function parseCli(argv) {
|
|
111
78
|
let command = null;
|
|
112
79
|
const opts = {};
|
|
@@ -161,7 +128,7 @@ const USAGE = `cron-up —— 为定时任务提前备好交互会话
|
|
|
161
128
|
|
|
162
129
|
用法:
|
|
163
130
|
cron-up 查看总览(服务、配置、任务、会话)
|
|
164
|
-
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)
|
|
165
132
|
cron-up uninstall 卸载(--purge 连配置日志一起删,--keep-sessions 保留后台会话)
|
|
166
133
|
cron-up list 逐个列出定时任务的设定/预计实际触发时间
|
|
167
134
|
cron-up run 执行一轮巡检(launchd 入口)
|
package/src/config.mjs
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
deps,
|
|
9
|
+
ExitError,
|
|
10
|
+
isPlainObject,
|
|
11
|
+
isDecInt,
|
|
12
|
+
ensurePrivateDir,
|
|
13
|
+
} from './internals.mjs';
|
|
8
14
|
import { DEFAULT_CONFIG, PID_T_MAX, HEARTBEAT_STALE_FACTOR } from './constants.mjs';
|
|
9
15
|
import { expandHome, isDir } from './tasks.mjs';
|
|
10
16
|
|
|
@@ -31,7 +37,7 @@ export function isInt(x) {
|
|
|
31
37
|
// 宽容整型化(手改的 state/登记文件里字段可能是字符串或浮点):纯十进制
|
|
32
38
|
// 字符串接受,浮点截断,布尔拒绝;非法输入抛错由调用方按「字段不存在」
|
|
33
39
|
// 处理。
|
|
34
|
-
|
|
40
|
+
function lenientInt(v) {
|
|
35
41
|
if (typeof v === 'boolean') throw new Error('布尔不是整数字段');
|
|
36
42
|
if (typeof v === 'number') {
|
|
37
43
|
if (!Number.isFinite(v)) throw new Error('数值越界');
|
|
@@ -87,6 +93,10 @@ export function configFieldErrors(cfg) {
|
|
|
87
93
|
errs.push(['sessionRetain',
|
|
88
94
|
`sessionRetain 应是 "window" 或 "always",当前为 ${JSON.stringify(cfg.sessionRetain)}`]);
|
|
89
95
|
}
|
|
96
|
+
if (typeof cfg.stampOwner !== 'boolean') {
|
|
97
|
+
errs.push(['stampOwner',
|
|
98
|
+
`stampOwner 应是布尔值(true/false),当前为 ${JSON.stringify(cfg.stampOwner)}`]);
|
|
99
|
+
}
|
|
90
100
|
return errs;
|
|
91
101
|
}
|
|
92
102
|
|
|
@@ -160,18 +170,11 @@ export function loadState() {
|
|
|
160
170
|
return out;
|
|
161
171
|
}
|
|
162
172
|
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
// 持现状。
|
|
173
|
+
// state 含工作区路径与 pid,目录按 ensurePrivateDir 收紧;文件以 0600 原
|
|
174
|
+
// 子替换——state 每轮都写、config 每次 install 都写,老版本留下的宽松权
|
|
175
|
+
// 限会自动跟进。
|
|
167
176
|
function atomicSave(file, doc) {
|
|
168
|
-
|
|
169
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
170
|
-
try {
|
|
171
|
-
fs.chmodSync(dir, 0o700);
|
|
172
|
-
} catch {
|
|
173
|
-
// 维持现状。
|
|
174
|
-
}
|
|
177
|
+
ensurePrivateDir(path.dirname(file));
|
|
175
178
|
const tmp = `${file}.tmp`;
|
|
176
179
|
// 以 0600 创建;残留的旧 tmp 更宽时也收回来,rename 保持该模式位。
|
|
177
180
|
fs.writeFileSync(tmp, `${JSON.stringify(doc, null, 2)}\n`, {
|
|
@@ -214,6 +217,17 @@ export function heartbeatStale(hb, intervalSeconds, nowSec) {
|
|
|
214
217
|
return hb !== null && nowSec - hb.ranAt > intervalSeconds * HEARTBEAT_STALE_FACTOR;
|
|
215
218
|
}
|
|
216
219
|
|
|
220
|
+
// 失败冷却的唯一判据:cooldownUntil 是秒级时间戳,0/缺省表示无冷却。巡检
|
|
221
|
+
// 决策、kept 过滤与总览展示共用,冷却表示法只渗到这两个函数。
|
|
222
|
+
export function isCooling(ent, cur) {
|
|
223
|
+
return (ent?.cooldownUntil ?? 0) > cur;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 距冷却结束还有多少整分钟(已结束为 0),只用于总览文案。
|
|
227
|
+
export function cooldownEtaMinutes(ent, cur) {
|
|
228
|
+
return Math.max(0, Math.floor(((ent?.cooldownUntil ?? 0) - cur) / 60));
|
|
229
|
+
}
|
|
230
|
+
|
|
217
231
|
export function saveConfig(cfg) {
|
|
218
232
|
atomicSave(deps.paths.configPath, cfg);
|
|
219
233
|
}
|
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 不对),退避
|
|
@@ -100,7 +107,7 @@ export const CC_JITTER = {
|
|
|
100
107
|
// maxN,常量只保留「目标延迟」这一个可调项。
|
|
101
108
|
export const AUTO_MIN_ID_TARGET_DELAY_MS = 60 * 1000;
|
|
102
109
|
|
|
103
|
-
// 投递尾窗判定的回看/前探窗口(wanted 与
|
|
110
|
+
// 投递尾窗判定的回看/前探窗口(wanted 与 nextDelivery 共用,见
|
|
104
111
|
// src/cron.mjs 的 recurringTailWindow):找上一落点用 prevAtOrBefore、算
|
|
105
112
|
// 周期用 nextAfter,两个方向都要罩住月/年任务之外的 2 月 29 日——相邻落
|
|
106
113
|
// 点隔 4 年,世纪非闰年(2100)隔 8 年;窗口小了尾窗会塌缩成零:落点已
|
package/src/cron.mjs
CHANGED
|
@@ -249,9 +249,9 @@ export function taskIdHash(taskId) {
|
|
|
249
249
|
// 不是 nextAfter 给出的下个落点——后者严格在未来,会把展示拐到明天),
|
|
250
250
|
// 否则是下一落点 + 按 id 的抖动。先于窗口检查尾窗:2 月 29 日这类长周期
|
|
251
251
|
// 任务的下一落点可能在展示窗口之外,正在等待的投递却依然成立。taskView
|
|
252
|
-
// 的 nxt/fire 与
|
|
253
|
-
//
|
|
254
|
-
//
|
|
252
|
+
// 的 nxt/fire 与 wanted 都以这里的落点为单一来源,两边不会各说各话。返回
|
|
253
|
+
// {slot, fire},均为 Date 或 null(窗口内没有落点时双双为 null)。cron 须
|
|
254
|
+
// 已解析且 satisfiable。
|
|
255
255
|
export function nextDelivery(cron, task, now, withinDays = DISPLAY_SEARCH_DAYS) {
|
|
256
256
|
if (taskIsOneshot(task)) {
|
|
257
257
|
const slot = cron.nextAfter(now, withinDays);
|
|
@@ -285,16 +285,6 @@ export function nextDelivery(cron, task, now, withinDays = DISPLAY_SEARCH_DAYS)
|
|
|
285
285
|
// 经过该分支,故不予复刻。
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
// 任务按 Claude Code 的抖动规则预计的实际触发时刻(nextDelivery 的
|
|
289
|
-
// fire);cron 无效、不在尾窗内、窗口内没有落点时返回 null。基准为 now
|
|
290
|
-
// (上游调度器以上次触发时刻为基准,展示只关心从现在起的下一次,两者算
|
|
291
|
-
// 出的落点一致)。
|
|
292
|
-
export function predictedFire(task, now, withinDays = DISPLAY_SEARCH_DAYS) {
|
|
293
|
-
const cron = parseCronOrNone(task?.cron);
|
|
294
|
-
if (cron === null || !cron.satisfiable) return null;
|
|
295
|
-
return nextDelivery(cron, task, now, withinDays).fire;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
288
|
// 周期任务从某个落点起的投递延迟(ms):hash(id)·frac·周期,封顶 30 分。
|
|
299
289
|
// slot 后取不到下一落点(窗口边缘、不可满足)时返回 0。
|
|
300
290
|
function recurringDelayMs(cron, slot, taskId, withinDays) {
|
package/src/display.mjs
CHANGED
|
@@ -122,7 +122,8 @@ function sameDay(a, b) {
|
|
|
122
122
|
&& a.getMonth() === b.getMonth()
|
|
123
123
|
&& a.getDate() === b.getDate();
|
|
124
124
|
}
|
|
125
|
-
|
|
125
|
+
// 「同一分钟」的唯一判据,fmtFireRange 的箭头省略与总览「设定」附注共用。
|
|
126
|
+
export function sameMinute(a, b) {
|
|
126
127
|
return sameDay(a, b) && a.getHours() === b.getHours()
|
|
127
128
|
&& a.getMinutes() === b.getMinutes();
|
|
128
129
|
}
|
|
@@ -142,10 +143,10 @@ export function fmtFireRange(nxt, fire) {
|
|
|
142
143
|
}
|
|
143
144
|
const r = new Date(Math.round(fire.getTime() / 1000) * 1000);
|
|
144
145
|
if (sameMinute(nxt, r)) return fmtMDHM(nxt);
|
|
146
|
+
const secs = r.getSeconds() ? `:${pad2(r.getSeconds())}` : '';
|
|
145
147
|
const tail = sameDay(nxt, r)
|
|
146
|
-
? `${pad2(r.getHours())}:${pad2(r.getMinutes())}`
|
|
147
|
-
|
|
148
|
-
: `${fmtMDHM(r)}${r.getSeconds() ? `:${pad2(r.getSeconds())}` : ''}`;
|
|
148
|
+
? `${pad2(r.getHours())}:${pad2(r.getMinutes())}${secs}`
|
|
149
|
+
: `${fmtMDHM(r)}${secs}`;
|
|
149
150
|
return `${fmtMDHM(nxt)} → ${tail}`;
|
|
150
151
|
}
|
|
151
152
|
|
package/src/internals.mjs
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// 纯系统叶子的默认实现放这里;业务函数(discover、spawnSession…)由各自
|
|
7
7
|
// 模块注册。
|
|
8
8
|
|
|
9
|
+
import fs from 'node:fs';
|
|
9
10
|
import { spawn, spawnSync, execFileSync } from 'node:child_process';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
10
12
|
import { paths } from './paths.mjs';
|
|
11
13
|
|
|
12
14
|
export class ExitError extends Error {
|
|
@@ -64,6 +66,29 @@ function terminalWidth() {
|
|
|
64
66
|
return Math.max(60, process.stdout.columns ?? 100);
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
// 自产物目录(state/config、会话日志)含工作区路径与会话记录,按私密收紧:
|
|
70
|
+
// 建好后 chmod 0700,顺带收回老版本留下的宽松权限;失败维持现状。三处写入
|
|
71
|
+
// 点共用这一条纪律。
|
|
72
|
+
export function ensurePrivateDir(dir) {
|
|
73
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
74
|
+
try {
|
|
75
|
+
fs.chmodSync(dir, 0o700);
|
|
76
|
+
} catch {
|
|
77
|
+
// 维持现状。
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
|
|
82
|
+
// 版本实验特性的依赖);读不到返回 null,总览页降级显示。
|
|
83
|
+
export function packageVersion() {
|
|
84
|
+
try {
|
|
85
|
+
const pkg = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
86
|
+
return JSON.parse(fs.readFileSync(pkg, 'utf-8')).version ?? null;
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
67
92
|
export const deps = {
|
|
68
93
|
paths,
|
|
69
94
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
package/src/logs.mjs
CHANGED
|
@@ -42,8 +42,9 @@ export function resolveLogPath(workspace, announce) {
|
|
|
42
42
|
const err = path.join(deps.paths.logDir, 'launchd.err.log');
|
|
43
43
|
if (!fs.existsSync(out)) return err;
|
|
44
44
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const st = fs.statSync(err);
|
|
46
|
+
if (st.size > 0) {
|
|
47
|
+
say(`注:${err} 有内容(最后写入 ${fmtMDHM(st.mtime)}),`
|
|
47
48
|
+ '巡检若异常先看它');
|
|
48
49
|
}
|
|
49
50
|
} catch {
|
package/src/overview.mjs
CHANGED
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
|
|
8
|
-
import { deps } from './internals.mjs';
|
|
8
|
+
import { deps, packageVersion } from './internals.mjs';
|
|
9
9
|
import {
|
|
10
10
|
loadConfig,
|
|
11
11
|
loadState,
|
|
12
12
|
validateConfig,
|
|
13
13
|
loadHeartbeat,
|
|
14
14
|
heartbeatStale,
|
|
15
|
+
isCooling,
|
|
16
|
+
cooldownEtaMinutes,
|
|
15
17
|
} from './config.mjs';
|
|
16
18
|
import { taskFileExists } from './tasks.mjs';
|
|
17
|
-
import { packageVersion } from './paths.mjs';
|
|
18
19
|
import { serviceLine, launcherHealth } from './service.mjs';
|
|
19
20
|
import {
|
|
20
21
|
humanDelta,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
elapsedZh,
|
|
27
28
|
fmtMDHM,
|
|
28
29
|
fmtHM,
|
|
30
|
+
sameMinute,
|
|
29
31
|
} from './display.mjs';
|
|
30
32
|
|
|
31
33
|
// 总览页任务盘点。返回 [告警, 已在告警里说明冷却的工作区集合, 本轮发现
|
|
@@ -81,11 +83,10 @@ function overviewTasks(cfg, cols, consumers, state) {
|
|
|
81
83
|
}
|
|
82
84
|
if (wanted && !consumers.has(ws)) {
|
|
83
85
|
const ent = state[ws] ?? {};
|
|
84
|
-
|
|
85
|
-
if (cooldown > cur) {
|
|
86
|
+
if (isCooling(ent, cur)) {
|
|
86
87
|
// 与「下轮会自动启动」互斥:冷却中就是不会启动,两条同现会自相
|
|
87
88
|
// 矛盾,这里合并成一条。
|
|
88
|
-
const eta =
|
|
89
|
+
const eta = cooldownEtaMinutes(ent, cur);
|
|
89
90
|
alerts.push(
|
|
90
91
|
`${ws} 有任务即将执行(或错过待补执行),但保活会话连续失败 `
|
|
91
92
|
+ `${ent.fails ?? '?'} 次正处冷却中,约 ${eta} 分钟后才会重试;`
|
|
@@ -109,8 +110,7 @@ function overviewTasks(cfg, cols, consumers, state) {
|
|
|
109
110
|
if (soonest) {
|
|
110
111
|
const [fire, ws, summary, nxt] = soonest;
|
|
111
112
|
// 预计与设定不在同一分钟时,附注 cron 落点,解释两个时间的差。
|
|
112
|
-
const setTag = (nxt && (
|
|
113
|
-
|| fire.getDate() !== nxt.getDate()))
|
|
113
|
+
const setTag = (nxt && !sameMinute(fire, nxt))
|
|
114
114
|
? `,设定 ${fmtHM(nxt)}` : '';
|
|
115
115
|
const line = `最近:${fmtMDHM(fire)}(${humanDelta(fire.getTime() - now.getTime())}`
|
|
116
116
|
+ `${setTag}) ${path.basename(ws)} · ${summary}`;
|
|
@@ -132,7 +132,7 @@ function overviewSessions(cols, state, suppressCooling, discovered) {
|
|
|
132
132
|
const cooling = [];
|
|
133
133
|
const dead = [];
|
|
134
134
|
for (const [ws, ent] of Object.entries(state)) {
|
|
135
|
-
if ((ent
|
|
135
|
+
if (isCooling(ent, cur)) cooling.push([ws, ent]);
|
|
136
136
|
else if (deps.trackedAlive(ent)) live.push([ws, ent]);
|
|
137
137
|
else dead.push(ws); // 记录还在、进程已没:带失败计数的保留至老化,其余下轮清理
|
|
138
138
|
}
|
|
@@ -151,7 +151,7 @@ function overviewSessions(cols, state, suppressCooling, discovered) {
|
|
|
151
151
|
}
|
|
152
152
|
for (const [ws, ent] of cooling) {
|
|
153
153
|
if (suppressCooling.has(ws)) continue; // 任务区已给过带上下文的合并告警
|
|
154
|
-
const eta =
|
|
154
|
+
const eta = cooldownEtaMinutes(ent, cur);
|
|
155
155
|
alerts.push(`${ws} 保活连续失败 ${ent.fails ?? '?'} 次,冷却中,约 ${eta} `
|
|
156
156
|
+ '分钟后重试');
|
|
157
157
|
}
|
|
@@ -200,12 +200,15 @@ 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);
|
|
207
210
|
const heartbeat = loadHeartbeat();
|
|
208
|
-
|
|
211
|
+
let coolingWarned = new Set();
|
|
209
212
|
let discovered = null;
|
|
210
213
|
|
|
211
214
|
if (cfgError !== null) {
|
|
@@ -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
|
? ',会话常驻' : ',会话按执行回收';
|
|
224
|
-
|
|
227
|
+
const stampTag = cfg.stampOwner ? ',任务归属预热' : '';
|
|
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 后生效');
|
|
@@ -252,9 +256,10 @@ export async function cmdOverview(args) {
|
|
|
252
256
|
}
|
|
253
257
|
alerts.push(...validateConfig(cfg));
|
|
254
258
|
deps.print('');
|
|
255
|
-
const [taskAlerts,
|
|
259
|
+
const [taskAlerts, coolingWarnedNow, found] = overviewTasks(
|
|
260
|
+
cfg, cols, consumers, state);
|
|
256
261
|
alerts.push(...taskAlerts);
|
|
257
|
-
|
|
262
|
+
coolingWarned = coolingWarnedNow;
|
|
258
263
|
discovered = found;
|
|
259
264
|
}
|
|
260
265
|
|
package/src/paths.mjs
CHANGED
|
@@ -3,8 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import fs from 'node:fs';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
6
|
|
|
9
7
|
export const LABEL = 'local.cron-up';
|
|
10
8
|
|
|
@@ -56,14 +54,3 @@ export const paths = makePaths({
|
|
|
56
54
|
plistPath: path.join(HOME, 'Library', 'LaunchAgents', `${LABEL}.plist`),
|
|
57
55
|
sessionDir: path.join(HOME, '.claude', 'sessions'),
|
|
58
56
|
});
|
|
59
|
-
|
|
60
|
-
// 版本号读 package.json(用 fs 读而非 JSON import attribute,避免对 Node
|
|
61
|
-
// 版本实验特性的依赖);读不到返回 null,总览页降级显示。
|
|
62
|
-
export function packageVersion() {
|
|
63
|
-
try {
|
|
64
|
-
const pkg = fileURLToPath(new URL('../package.json', import.meta.url));
|
|
65
|
-
return JSON.parse(fs.readFileSync(pkg, 'utf-8')).version ?? null;
|
|
66
|
-
} catch {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
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 } from './internals.mjs';
|
|
8
|
+
import { deps, packageVersion } from './internals.mjs';
|
|
9
9
|
import {
|
|
10
10
|
FAIL_LIMIT,
|
|
11
11
|
COOLDOWN_SECONDS,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
loadHeartbeat,
|
|
26
26
|
saveHeartbeat,
|
|
27
27
|
heartbeatStale,
|
|
28
|
+
isCooling,
|
|
28
29
|
} from './config.mjs';
|
|
29
30
|
import {
|
|
30
31
|
maintainWorkspace,
|
|
@@ -38,7 +39,7 @@ import {
|
|
|
38
39
|
scriptProcLogPath,
|
|
39
40
|
rotateOnce,
|
|
40
41
|
} from './sessions.mjs';
|
|
41
|
-
import { TASK_REL
|
|
42
|
+
import { TASK_REL } from './paths.mjs';
|
|
42
43
|
import { elapsedZh, pad2 } from './display.mjs';
|
|
43
44
|
|
|
44
45
|
// 事件类型 → 轮末汇总里的中文标签。patrolWorkspace 与 cmdRun 往同一个
|
|
@@ -50,6 +51,7 @@ const EVENT_LABELS = [
|
|
|
50
51
|
['exit', '自行退出'],
|
|
51
52
|
['stuck', '卡死退休'],
|
|
52
53
|
['adopt', '接管'],
|
|
54
|
+
['reown', '过户'],
|
|
53
55
|
['fail', '启动失败'],
|
|
54
56
|
['cool', '进入冷却'],
|
|
55
57
|
['warn', '告警'],
|
|
@@ -129,7 +131,7 @@ export function acquireRunLock() {
|
|
|
129
131
|
return null;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
|
|
134
|
+
function releaseLock(handle) {
|
|
133
135
|
if (!handle?.lockPath) return;
|
|
134
136
|
try {
|
|
135
137
|
fs.unlinkSync(handle.lockPath);
|
|
@@ -158,7 +160,11 @@ export function rotatePatrolLogs() {
|
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
// 下次「需要会话在场」的时刻(ms):各任务下次触发的最小值;有错过待补
|
|
161
|
-
// 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity
|
|
163
|
+
// 执行的一次性任务时视为立刻需要。窗口内无安排返回 Infinity。锚点刻意取
|
|
164
|
+
// cron 裸落点而非 nextDelivery 的抖动后投递时刻:落点总不晚于投递时刻,
|
|
165
|
+
// 按落点算余量更小、回收更保守,是安全方向;真正的重拉闸门是下面的
|
|
166
|
+
// wanted(leadMs),margin 只需保住新会话登记。若改用 fire,落点后、投递
|
|
167
|
+
// 前的整段延迟窗内都不会回收,回收频率被悄悄改变。
|
|
162
168
|
function needHorizonMs(tasks, now) {
|
|
163
169
|
let horizon = Infinity;
|
|
164
170
|
for (const t of tasks) {
|
|
@@ -175,9 +181,13 @@ function needHorizonMs(tasks, now) {
|
|
|
175
181
|
// cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。scriptProcs
|
|
176
182
|
// 是进程表快照或 null:null 时仅在需要认领孤儿(state 丢失的兜底分支)当
|
|
177
183
|
// 下才拍快照,平静轮不付全量 ps 的代价。
|
|
184
|
+
// stamp=null 表示 stampOwner 关(一切旧行为);开启时为
|
|
185
|
+
// {verdict, recycleMarginSec}:verdict 是 classifyWorkspace 对轮初消费者
|
|
186
|
+
// 快照的认亲结论(own/ambiguous/user,换代或回收导致快照变化时在 retire
|
|
187
|
+
// 内重算),recycleMarginSec 是 window 回收余量。
|
|
178
188
|
export async function patrolWorkspace(
|
|
179
189
|
ws, state, now, leadMs, cur, consumers, scriptProcs = null,
|
|
180
|
-
retain = 'always', stats = null,
|
|
190
|
+
retain = 'always', stats = null, stamp = null,
|
|
181
191
|
) {
|
|
182
192
|
const tasks = deps.readTasks(ws);
|
|
183
193
|
let ent = state[ws] ?? null;
|
|
@@ -212,23 +222,38 @@ export async function patrolWorkspace(
|
|
|
212
222
|
return;
|
|
213
223
|
}
|
|
214
224
|
|
|
225
|
+
// 结束本代会话的完整收尾:停掉 → 摘 state → 重扫消费者。换代与 window
|
|
226
|
+
// 回收是同一个动作,差别只在触发条件与文案;消费者快照拍于轮初,刚结束
|
|
227
|
+
// 的这代可能正是快照里的消费者,不重扫会让陈旧登记挡住下面的重拉。
|
|
228
|
+
let mineAlive = deps.trackedAlive(ent);
|
|
229
|
+
// verdict 是轮初快照的认亲结论;换代/回收杀掉本代后消费者集合变了,
|
|
230
|
+
// 必须按重扫结果重算而不是手改布尔——幸存下来的断链会话(进程组杀不
|
|
231
|
+
// 掉的用户会话)仍应是歧义态,否则会被误当普通用户会话、在它旁边双开。
|
|
232
|
+
let verdict = stamp?.verdict ?? null;
|
|
233
|
+
const retire = async (kind, msg) => {
|
|
234
|
+
await deps.stopSession(ent);
|
|
235
|
+
note(kind, msg);
|
|
236
|
+
delete state[ws];
|
|
237
|
+
ent = null;
|
|
238
|
+
mineAlive = false;
|
|
239
|
+
if (stamp) {
|
|
240
|
+
const [nextConsumers, , nextDetails] = await deps.scanSessions({ lineage: true });
|
|
241
|
+
consumers = nextConsumers;
|
|
242
|
+
verdict = deps.classifyWorkspace(nextDetails, ws, null, false);
|
|
243
|
+
} else {
|
|
244
|
+
consumers = (await deps.scanSessions())[0];
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
|
|
215
248
|
// 换代:超龄且日志静默(无任务在执行的证据)的会话主动结束,需要时下
|
|
216
249
|
// 文自然重拉。换代是维护,不计失败。
|
|
217
|
-
let mineAlive = deps.trackedAlive(ent);
|
|
218
250
|
if (ent !== null && mineAlive) {
|
|
219
251
|
const age = cur - (ent.startedAt ?? cur);
|
|
220
252
|
if (age > SESSION_MAX_AGE_SECONDS
|
|
221
253
|
&& sessionLogIdleSeconds(
|
|
222
254
|
ent.log || sessionLogPath(ws), cur) > SESSION_IDLE_SECONDS) {
|
|
223
|
-
await
|
|
224
|
-
note('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
|
|
255
|
+
await retire('ageout', `换代 ${ws} pid=${ent.pid}:会话已连续运行 `
|
|
225
256
|
+ `${Math.floor(age / 86400)} 天且近期无输出,结束后按需重拉`);
|
|
226
|
-
delete state[ws];
|
|
227
|
-
ent = null;
|
|
228
|
-
mineAlive = false;
|
|
229
|
-
// 消费者快照拍于轮初,被换代的这代可能正是快照里的消费者:重扫一
|
|
230
|
-
// 次,别让陈旧登记挡住下面的重拉。
|
|
231
|
-
consumers = (await deps.scanSessions())[0];
|
|
232
257
|
}
|
|
233
258
|
}
|
|
234
259
|
|
|
@@ -236,47 +261,57 @@ export async function patrolWorkspace(
|
|
|
236
261
|
// 缺一不可:本代会话期间真的有任务触发过(lastFiredAt 晚于 startedAt
|
|
237
262
|
// ——区分「执行完的空闲」与「等待触发的空闲」,后者是预热缓存在干活,
|
|
238
263
|
// 回收它只会每轮空转重拉);界面静默超时(TUI 执行期间持续渲染,静默
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
264
|
+
// 即不在干活);距下次需要会话还有余量(回收后下轮巡检重拉的新会话来
|
|
265
|
+
// 得及登记,不会漏掉到点任务)。时间上重叠或紧邻的任务仍共享会话:任
|
|
266
|
+
// 务路由在 Claude Code 手里,同目录并发多会话无法指定归属,还有重复
|
|
267
|
+
// 执行的风险。回收不计失败——任务成功执行过是最强的健康证据,陈年计
|
|
268
|
+
// 数一并清零。
|
|
269
|
+
// 余量取值:默认只保登记抖动(约 2 分钟);stampOwner 开启时必须再留
|
|
270
|
+
// 出一个巡检间隔——回收后要靠「下轮重拉→再下轮过户」两步把 owner 盖
|
|
271
|
+
// 到新会话,余量小于间隔就可能在过户完成前到点(cmdRun 传入
|
|
272
|
+
// SESSION_FIRE_MARGIN_SECONDS + intervalSeconds)。
|
|
273
|
+
const fireMarginSec = stamp
|
|
274
|
+
? stamp.recycleMarginSec
|
|
275
|
+
: SESSION_FIRE_MARGIN_SECONDS;
|
|
244
276
|
if (ent !== null && mineAlive && retain === 'window'
|
|
245
277
|
&& tasks.some((t) => typeof t?.lastFiredAt === 'number'
|
|
246
278
|
&& t.lastFiredAt > (ent.startedAt ?? 0) * 1000)
|
|
247
279
|
&& sessionLogIdleSeconds(ent.log || sessionLogPath(ws), cur)
|
|
248
280
|
> SESSION_RECYCLE_IDLE_SECONDS
|
|
249
281
|
&& needHorizonMs(tasks, now) - now.getTime()
|
|
250
|
-
>
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
ent = null;
|
|
257
|
-
mineAlive = false;
|
|
258
|
-
// 同上:快照里的消费者可能正是刚结束的这代。用户自己的会话若在场,
|
|
259
|
-
// 重扫依旧看见,不会在同目录双开。
|
|
260
|
-
consumers = (await deps.scanSessions())[0];
|
|
282
|
+
> fireMarginSec * 1000) {
|
|
283
|
+
// stampOwner 关时:用户自己的会话若在场,retire 的重扫依旧看见,不
|
|
284
|
+
// 会在同目录双开;开启时:旧代整组结束后重扫只可能剩用户会话,下面
|
|
285
|
+
// 的逻辑会按需另拉新预热。
|
|
286
|
+
await retire('recycle', `回收 ${ws} pid=${ent.pid}:本代任务已执行完毕且`
|
|
287
|
+
+ '界面静默,结束会话,下次需要时重拉');
|
|
261
288
|
}
|
|
262
289
|
|
|
290
|
+
// 在场的消费者足以让我们放手吗?功能关时任何消费者都算;开启时只有
|
|
291
|
+
// 「认到自己的预热」与「父链有歧义、无法排除是自己」算——仅用户会话
|
|
292
|
+
// 不算,下面继续走到 wanted→卡死退休/冷却→spawn,在同目录另起自己
|
|
293
|
+
// 的预热(该开关的显式目的;全程只 stop 自己的 ent,不碰用户会话)。
|
|
294
|
+
const consumerHolds = consumers.has(ws)
|
|
295
|
+
&& (!stamp || verdict?.kind === 'own' || verdict?.kind === 'ambiguous');
|
|
296
|
+
|
|
263
297
|
// 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
|
|
264
298
|
// 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
|
|
265
|
-
//
|
|
266
|
-
|
|
299
|
+
// 却,连一次新尝试都不给。仅用户会话在场不清零(它不证明 launchd 拉起
|
|
300
|
+
// 的环境健康);歧义态保守当真,与「不双开」方向一致。
|
|
301
|
+
if (ent !== null && mineAlive && consumerHolds) {
|
|
267
302
|
ent.fails = 0;
|
|
268
303
|
}
|
|
269
304
|
|
|
270
305
|
// 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
if (
|
|
306
|
+
// 盖),也可能是用户的交互会话——后者默认放手:不在这里删死条目,失败
|
|
307
|
+
// 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
|
|
308
|
+
// 由轮末 kept 过滤统一处理。先于 wanted 判定返回:有消费者在场的平稳
|
|
309
|
+
// 态不必为整组任务跑一遍 cron 求值。
|
|
310
|
+
if (consumerHolds) return;
|
|
276
311
|
|
|
277
312
|
if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
|
|
278
313
|
|
|
279
|
-
const cooling =
|
|
314
|
+
const cooling = isCooling(ent, cur);
|
|
280
315
|
|
|
281
316
|
// 活着但没有消费。健康只在真实启动所需的时间内成立;超时就是卡在无人
|
|
282
317
|
// 应答的提问界面,永不登记——退休它并推进失败计数。
|
|
@@ -359,7 +394,12 @@ export async function patrolWorkspace(
|
|
|
359
394
|
fails,
|
|
360
395
|
log: sessionLogPath(ws),
|
|
361
396
|
};
|
|
362
|
-
|
|
397
|
+
if (stamp && consumers.has(ws) && verdict?.kind === 'user') {
|
|
398
|
+
note('spawn', `已另启动预热会话 ${ws} pid=${spawned.pid}:你的交互会话仍在`
|
|
399
|
+
+ `,窗口内任务将改由预热会话承接(连续失败计数 ${fails})`);
|
|
400
|
+
} else {
|
|
401
|
+
note('spawn', `已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
|
|
402
|
+
}
|
|
363
403
|
}
|
|
364
404
|
|
|
365
405
|
// 跑一轮巡检;抢不到锁返回 false。
|
|
@@ -433,12 +473,32 @@ export async function cmdRun(args) {
|
|
|
433
473
|
// 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
|
|
434
474
|
// 属这里。进程表快照不在这里拍:只有孤儿认领分支需要,按需现拍(见
|
|
435
475
|
// patrolWorkspace 的 scriptProcs 形参)。
|
|
436
|
-
|
|
476
|
+
// lineage 只在 stampOwner 开启时拍:祖先链要一次全机 ps,关闭时
|
|
477
|
+
// scanSessions 不付这个代价(见 registry 的 scanSessions 注释)。
|
|
478
|
+
const [consumers, registryAlert, details] =
|
|
479
|
+
await deps.scanSessions({ lineage: cfg.stampOwner });
|
|
437
480
|
if (registryAlert) {
|
|
438
481
|
bump(stats, 'warn');
|
|
439
482
|
deps.log(`告警:${registryAlert}`);
|
|
440
483
|
}
|
|
441
484
|
|
|
485
|
+
// stampOwner 的认亲表:每个有消费者的工作区一个结论(own/ambiguous/
|
|
486
|
+
// user),判定规则全部收口在 registry.classifyWorkspace,patrol 只
|
|
487
|
+
// 消费结论。target 非空才可盖印(认到自己但身份字段缺失的一轮只
|
|
488
|
+
// own、不过户)。
|
|
489
|
+
const verdicts = new Map();
|
|
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;
|
|
501
|
+
|
|
442
502
|
// 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
|
|
443
503
|
// 日志时间线就是「退出 → 重拉」,而不是反过来。
|
|
444
504
|
for (const [ws, old] of prevDead) {
|
|
@@ -450,13 +510,20 @@ export async function cmdRun(args) {
|
|
|
450
510
|
for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
|
|
451
511
|
found += 1;
|
|
452
512
|
// 一个工作区读不动不能中断整轮:discover 是生成器,这里抛了会跳过
|
|
453
|
-
// 后面所有工作区。续期/改 id
|
|
454
|
-
//
|
|
455
|
-
|
|
513
|
+
// 后面所有工作区。续期/改 id/过户与保活各自独立守卫——维护失败绝不
|
|
514
|
+
// 压制该工作区的会话保活。三者合并为同一次原子写(见
|
|
515
|
+
// maintainWorkspace)。
|
|
516
|
+
const target = verdicts.get(ws)?.target ?? null;
|
|
517
|
+
if (cfg.autoRenew || cfg.autoMinId || target) {
|
|
456
518
|
try {
|
|
457
|
-
const { renewed, minified } = maintainWorkspace(taskfile, {
|
|
519
|
+
const { renewed, minified, reowned } = maintainWorkspace(taskfile, {
|
|
458
520
|
renew: cfg.autoRenew,
|
|
459
521
|
minify: cfg.autoMinId,
|
|
522
|
+
// target 多出的祖先链字段 tasks 层不读,展开即可,不必手抄
|
|
523
|
+
// 三个盖印字段。
|
|
524
|
+
reown: target
|
|
525
|
+
? { ...target, isWanted: (t) => taskWanted(t, now, leadMs) }
|
|
526
|
+
: null,
|
|
460
527
|
}) ?? {};
|
|
461
528
|
if (renewed) {
|
|
462
529
|
deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
|
|
@@ -468,6 +535,11 @@ export async function cmdRun(args) {
|
|
|
468
535
|
+ '已在运行的会话要等其结束重拉(window 模式执行完即回收、'
|
|
469
536
|
+ 'always 模式换代)后才读到新 id');
|
|
470
537
|
}
|
|
538
|
+
if (reowned) {
|
|
539
|
+
bump(stats, 'reown');
|
|
540
|
+
deps.log(`已过户 ${ws}:${reowned} 个窗口内任务的归属改到预热会话`
|
|
541
|
+
+ ` pid=${target.pid},这些任务将只在该会话触发`);
|
|
542
|
+
}
|
|
471
543
|
} catch (e) {
|
|
472
544
|
bump(stats, e instanceof TaskFileChanged ? 'warn' : 'error');
|
|
473
545
|
if (e instanceof TaskFileChanged) {
|
|
@@ -481,7 +553,10 @@ export async function cmdRun(args) {
|
|
|
481
553
|
try {
|
|
482
554
|
await patrolWorkspace(
|
|
483
555
|
ws, state, now, leadMs, cur, consumers, null,
|
|
484
|
-
cfg.sessionRetain, stats
|
|
556
|
+
cfg.sessionRetain, stats,
|
|
557
|
+
stampBase
|
|
558
|
+
? { ...stampBase, verdict: verdicts.get(ws) ?? null }
|
|
559
|
+
: null);
|
|
485
560
|
} catch (e) {
|
|
486
561
|
bump(stats, 'error');
|
|
487
562
|
deps.log(`错误 ${ws}:${e.constructor.name}:${e.message}`);
|
|
@@ -507,7 +582,7 @@ export async function cmdRun(args) {
|
|
|
507
582
|
const kept = Object.fromEntries(
|
|
508
583
|
Object.entries(state).filter(([, ent]) => {
|
|
509
584
|
if (deps.trackedAlive(ent)) return true;
|
|
510
|
-
if ((ent
|
|
585
|
+
if (isCooling(ent, cur)) return true;
|
|
511
586
|
if ((ent.fails ?? 0) > 0) {
|
|
512
587
|
ent.deadSince ??= cur;
|
|
513
588
|
return cur - ent.deadSince < FAIL_TTL_SECONDS;
|
package/src/registry.mjs
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
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';
|
|
8
14
|
|
|
9
|
-
import { deps } from './internals.mjs';
|
|
15
|
+
import { deps, isPlainObject } from './internals.mjs';
|
|
10
16
|
import { parsePid } from './config.mjs';
|
|
11
17
|
import { lexReal } from './tasks.mjs';
|
|
12
18
|
|
|
@@ -27,15 +33,73 @@ 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
|
+
// stampOwner 的认亲结论。输入是 scanSessions 第三元素、工作区、state 条
|
|
83
|
+
// 目与调用方按指纹判好的存活布尔(registry 不认识 state 条目的语义):
|
|
84
|
+
// own 祖先链里有本代 script pid——在场消费者就是我们的预热;
|
|
85
|
+
// target 给出可盖印的登记详情,身份字段缺失时为 null(在场
|
|
86
|
+
// 但这一轮还不能过户);
|
|
87
|
+
// ambiguous 没有自己的预热,但在场会话父链不完整——无法证明不是自己,
|
|
88
|
+
// 调用方按「不双开」保守处理;
|
|
89
|
+
// user 父链完整且不含本代 pid——只有用户的会话,可以在旁边另拉。
|
|
90
|
+
export function classifyWorkspace(details, ws, ent, entAlive) {
|
|
91
|
+
const list = details?.get(ws) ?? [];
|
|
92
|
+
if (ent?.pid && entAlive) {
|
|
93
|
+
const own = list.find((d) => d.ancestors.includes(ent.pid));
|
|
94
|
+
if (own) {
|
|
95
|
+
return {
|
|
96
|
+
kind: 'own',
|
|
97
|
+
target: own.sessionId && own.procStart ? own : null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (list.some((d) => !d.chainComplete)) return { kind: 'ambiguous', target: null };
|
|
102
|
+
return { kind: 'user', target: null };
|
|
39
103
|
}
|
|
40
104
|
|
|
41
105
|
// 读一个登记文件;读不出返回 null。Claude Code 会就地(非原子)重写登
|
|
@@ -60,8 +124,12 @@ async function readSessionFile(file) {
|
|
|
60
124
|
return null;
|
|
61
125
|
}
|
|
62
126
|
|
|
63
|
-
// 返回 [拥有活消费者会话的工作区集合, 告警字符串|null]。
|
|
64
|
-
|
|
127
|
+
// 返回 [拥有活消费者会话的工作区集合, 告警字符串|null, 会话详情 Map]。
|
|
128
|
+
// 第三个元素是 Map<cwd, Array<{pid, sessionId, procStart, ancestors,
|
|
129
|
+
// chainComplete}>>;祖先链要付一次全机 ps,只有 stampOwner 需要,所以
|
|
130
|
+
// lineage 默认关:关时恒为空 Map,总览/list/平静巡检不增加任何进程开销
|
|
131
|
+
// (与本仓「全量 ps 按需现拍」的既有纪律一致)。
|
|
132
|
+
export async function scanSessions({ lineage = false } = {}) {
|
|
65
133
|
let files = [];
|
|
66
134
|
try {
|
|
67
135
|
files = fs.readdirSync(deps.paths.sessionDir)
|
|
@@ -73,17 +141,34 @@ export async function scanSessions() {
|
|
|
73
141
|
const interactive = [];
|
|
74
142
|
for (const f of files) {
|
|
75
143
|
const s = await readSessionFile(f);
|
|
76
|
-
if (s &&
|
|
144
|
+
if (isPlainObject(s) && s.kind === 'interactive') {
|
|
77
145
|
interactive.push(s);
|
|
78
146
|
}
|
|
79
147
|
}
|
|
80
148
|
|
|
149
|
+
// 全轮一张父子表快照:所有会话的祖先链取自同一时刻,且只付一次全量
|
|
150
|
+
// ps。仅在 lineage 显式开启时拍——快照失败给 null,祖先链一律按断链
|
|
151
|
+
// 处理(stampOwner 退化为「全部歧义、绝不双开」),consumers 判定不受
|
|
152
|
+
// 影响。
|
|
153
|
+
let parentMap = null;
|
|
154
|
+
if (lineage) {
|
|
155
|
+
try {
|
|
156
|
+
parentMap = deps.processParentMap();
|
|
157
|
+
} catch {
|
|
158
|
+
parentMap = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
81
162
|
const consumers = new Set();
|
|
82
|
-
const
|
|
163
|
+
const details = new Map();
|
|
164
|
+
const shapeCache = new Map();
|
|
83
165
|
let liveOther = 0;
|
|
84
166
|
// interactive 认得出来,却读不出有效 pid 或工作目录的条目数:pid/cwd
|
|
85
167
|
// 字段改名就是这种形态,而下面两条自检只看 kind 与进程形态,兜不住。
|
|
86
168
|
let suspect = 0;
|
|
169
|
+
// 已是消费者、但进程父 pid 或祖先链读不完整的条目数:stampOwner 会因
|
|
170
|
+
// 此无法认亲,只能保守不双开(过户暂停),需要一行告警让人看见退化。
|
|
171
|
+
let lineageMissing = 0;
|
|
87
172
|
for (const s of interactive) {
|
|
88
173
|
// pid 也是外部程序写的字段:null、非数值、非正数、超 pid_t 的巨大整
|
|
89
174
|
// 数都读不出活进程。这段代码在 per-workspace 保护圈之外,一个畸形登
|
|
@@ -95,16 +180,35 @@ export async function scanSessions() {
|
|
|
95
180
|
continue;
|
|
96
181
|
}
|
|
97
182
|
if (!deps.alive(pid)) continue; // 陈旧登记(崩溃留下):正常,不计
|
|
98
|
-
if (!
|
|
99
|
-
|
|
183
|
+
if (!shapeCache.has(pid)) {
|
|
184
|
+
shapeCache.set(pid, deps.processShape(pid)); // ps 调用按 pid 缓存
|
|
100
185
|
}
|
|
101
|
-
|
|
186
|
+
const shape = shapeCache.get(pid);
|
|
187
|
+
if (shape.isClaude) {
|
|
102
188
|
// cwd 同样不可信:非字符串当定位不到工作区;空串与相对路径会被
|
|
103
189
|
// resolve 拼到巡检进程自己的 cwd 下,凭空造出消费者目录,可能令预
|
|
104
190
|
// 热被静默跳过。
|
|
105
191
|
const cwd = s.cwd;
|
|
106
192
|
if (typeof cwd === 'string' && path.isAbsolute(cwd)) {
|
|
107
|
-
|
|
193
|
+
const ws = lexReal(cwd);
|
|
194
|
+
consumers.add(ws);
|
|
195
|
+
if (lineage) {
|
|
196
|
+
const chain = parentMap
|
|
197
|
+
? ancestorChain(pid, parentMap)
|
|
198
|
+
: { ancestors: [], chainComplete: false };
|
|
199
|
+
if (shape.ppid === null || !chain.chainComplete) lineageMissing += 1;
|
|
200
|
+
const list = details.get(ws) ?? [];
|
|
201
|
+
list.push({
|
|
202
|
+
pid,
|
|
203
|
+
sessionId: typeof s.sessionId === 'string' ? s.sessionId : null,
|
|
204
|
+
// procStart 是 ps lstart 原文(单位数日期含双空格),只许原样
|
|
205
|
+
// 传递给任务文件,绝不能自己拼。
|
|
206
|
+
procStart: typeof s.procStart === 'string' ? s.procStart : null,
|
|
207
|
+
ancestors: chain.ancestors,
|
|
208
|
+
chainComplete: chain.chainComplete,
|
|
209
|
+
});
|
|
210
|
+
details.set(ws, list);
|
|
211
|
+
}
|
|
108
212
|
continue;
|
|
109
213
|
}
|
|
110
214
|
suspect += 1; // 活着的 claude 却没有可用 cwd:cwd 字段漂移
|
|
@@ -125,11 +229,18 @@ export async function scanSessions() {
|
|
|
125
229
|
alert = '会话登记中的 interactive 会话都读不出有效的 pid 或工作目录;'
|
|
126
230
|
+ 'Claude Code 的会话登记字段可能已变更,cron-up 会把已有会话误判为'
|
|
127
231
|
+ '不存在,在同目录重复拉起会话';
|
|
232
|
+
} else if (lineage && lineageMissing > 0) {
|
|
233
|
+
alert = `${lineageMissing} 个存活会话的进程父子关系读不完整(ps 输出异常或`
|
|
234
|
+
+ '会话进程正在退出);stampOwner 本轮按保守策略不另拉预热会话、暂停'
|
|
235
|
+
+ '任务过户,下轮自动恢复';
|
|
128
236
|
}
|
|
129
|
-
return [consumers, alert];
|
|
237
|
+
return [consumers, alert, details];
|
|
130
238
|
}
|
|
131
239
|
|
|
132
240
|
Object.assign(deps, {
|
|
133
|
-
|
|
241
|
+
processShape,
|
|
242
|
+
processParentMap,
|
|
243
|
+
ancestorChain,
|
|
244
|
+
classifyWorkspace,
|
|
134
245
|
scanSessions,
|
|
135
246
|
});
|
package/src/service.mjs
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
deps,
|
|
9
|
+
ExitError,
|
|
10
|
+
isPlainObject,
|
|
11
|
+
ensurePrivateDir,
|
|
12
|
+
} from './internals.mjs';
|
|
8
13
|
import { paths, LABEL } from './paths.mjs';
|
|
14
|
+
import { SPECS, thisCamel } from './args.mjs';
|
|
9
15
|
// paths.home 仅用于 resolveLauncher 的默认 HOME;其余产物路径一律走
|
|
10
16
|
// deps.paths(测试要整体重定向)。
|
|
11
17
|
import { scriptProcLogPath } from './sessions.mjs';
|
|
@@ -33,7 +39,9 @@ export function launchctl(...args) {
|
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export function launchctlInfo() {
|
|
36
|
-
|
|
42
|
+
// 走 deps 而非同文件裸名:与 bootout/bootstrap 的打桩面保持一致,测试可
|
|
43
|
+
// 在 launchctl 一层整体模拟 launchd。
|
|
44
|
+
const r = deps.launchctl('print', `${guiTarget()}/${LABEL}`);
|
|
37
45
|
if (r.status !== 0) return null;
|
|
38
46
|
const info = {};
|
|
39
47
|
for (const key of ['state', 'last exit code']) {
|
|
@@ -244,15 +252,11 @@ export function launcherHealth() {
|
|
|
244
252
|
// ---- install / uninstall ----
|
|
245
253
|
|
|
246
254
|
// install 的可覆盖项(配置键 → 命令行旗标):--force 缺席时逐项点名「未
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
[
|
|
252
|
-
['autoRenew', '--auto-renew'],
|
|
253
|
-
['autoMinId', '--auto-min-id'],
|
|
254
|
-
['sessionRetain', '--session-retain'],
|
|
255
|
-
];
|
|
255
|
+
// 生效的参数」。名单直接从解析器的选项表派生——force 不落盘,其余选项都
|
|
256
|
+
// 会写进配置;新加可持久化选项时这里自动跟进,不会静默漏报。
|
|
257
|
+
const IGNORED_INSTALL_FLAGS = Object.keys(SPECS.install.options)
|
|
258
|
+
.filter((name) => name !== 'force')
|
|
259
|
+
.map((name) => [thisCamel(name), `--${name}`]);
|
|
256
260
|
|
|
257
261
|
export async function cmdInstall(args) {
|
|
258
262
|
// --config 在这里只会造成误解:install 写盘、launchd 每轮读取的都是固定
|
|
@@ -263,12 +267,8 @@ export async function cmdInstall(args) {
|
|
|
263
267
|
+ `使用 ${deps.paths.configPath},如需临时改配置请直接编辑该文件`);
|
|
264
268
|
}
|
|
265
269
|
fs.mkdirSync(deps.paths.dataDir, { recursive: true });
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
fs.chmodSync(deps.paths.sessionLogDir, 0o700);
|
|
269
|
-
} catch {
|
|
270
|
-
// 维持现状。
|
|
271
|
-
}
|
|
270
|
+
// dataDir 只建不 chmod:本分支可能保留旧配置而不写盘,不搭车改权限。
|
|
271
|
+
ensurePrivateDir(deps.paths.sessionLogDir);
|
|
272
272
|
|
|
273
273
|
let cfg;
|
|
274
274
|
if (fs.existsSync(deps.paths.configPath) && !args.force) {
|
|
@@ -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/sessions.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 } from './internals.mjs';
|
|
8
|
+
import { deps, ensurePrivateDir } from './internals.mjs';
|
|
9
9
|
|
|
10
10
|
// pid 是否对应活进程。走到这儿的 pid 已经过入口范围校验。
|
|
11
11
|
export function alive(pid) {
|
|
@@ -97,12 +97,7 @@ export function rotateOnce(file) {
|
|
|
97
97
|
// 拉起一个 detached 的 pty TUI,返回 {pid, procStart}。
|
|
98
98
|
export function spawnSession(ws) {
|
|
99
99
|
const logPath = sessionLogPath(ws);
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
fs.chmodSync(path.dirname(logPath), 0o700);
|
|
103
|
-
} catch {
|
|
104
|
-
// 目录权限维持现状。
|
|
105
|
-
}
|
|
100
|
+
ensurePrivateDir(path.dirname(logPath));
|
|
106
101
|
// window 模式下「一代会话 = 一次执行」:script(1) 打开即截断,不轮转的
|
|
107
102
|
// 话上一代(上一次任务执行)的界面记录会随本次拉起被抹掉。无条件保留
|
|
108
103
|
// 一代(.1)——总量上界两代,活着的一代增长再快也只影响自身,不会经
|
package/src/tasks.mjs
CHANGED
|
@@ -124,17 +124,11 @@ export function* discover(roots, maxDepth) {
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// 读一个工作区的任务列表,坏形状一律返回 null
|
|
127
|
+
// 读一个工作区的任务列表,坏形状一律返回 null。读盘与顶层形状校验复用
|
|
128
|
+
// loadTaskDoc(renew 也要同一口径 round-trip 整份文档)。
|
|
128
129
|
export function readTasks(ws) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
doc = JSON.parse(fs.readFileSync(
|
|
132
|
-
path.join(ws, TASK_REL), 'utf-8'));
|
|
133
|
-
} catch {
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
if (!isPlainObject(doc) || !Array.isArray(doc.tasks)) return null;
|
|
137
|
-
return doc.tasks.filter(isPlainObject);
|
|
130
|
+
const doc = deps.loadTaskDoc(path.join(ws, TASK_REL));
|
|
131
|
+
return doc === null ? null : doc.tasks.filter(isPlainObject);
|
|
138
132
|
}
|
|
139
133
|
|
|
140
134
|
// 任务文件在「读到」与「写回」之间被别的写者改了(Claude Code 更新
|
|
@@ -254,12 +248,21 @@ export function minifyIds(tasks) {
|
|
|
254
248
|
return changed;
|
|
255
249
|
}
|
|
256
250
|
|
|
257
|
-
// 一轮巡检对一个任务文件做的维护:renew(补 permanent
|
|
258
|
-
//
|
|
259
|
-
// .cron-up-tmp、
|
|
260
|
-
//
|
|
251
|
+
// 一轮巡检对一个任务文件做的维护:renew(补 permanent)、minify(id 改
|
|
252
|
+
// 小)、reown(把窗口内任务的 owner 三字段盖给指定预热会话)合并为同一
|
|
253
|
+
// 次受 mtime 乐观锁保护的原子写。先取 mtimeNs、清残留 .cron-up-tmp、
|
|
254
|
+
// loadTaskDoc round-trip 整文档;三项计数都为 0 时字节级不动。返回
|
|
255
|
+
// {renewed,minified,reowned};null 表示文件缺失/损坏;读到写间被改抛
|
|
261
256
|
// TaskFileChanged。
|
|
262
|
-
|
|
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
|
+
}) {
|
|
263
266
|
let mtimeNs;
|
|
264
267
|
try {
|
|
265
268
|
mtimeNs = fs.statSync(taskfile, { bigint: true }).mtimeNs;
|
|
@@ -290,9 +293,28 @@ export function maintainWorkspace(taskfile, { renew = false, minify = false }) {
|
|
|
290
293
|
}
|
|
291
294
|
}
|
|
292
295
|
const minified = minify ? minifyIds(doc.tasks) : 0;
|
|
293
|
-
|
|
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
|
+
}
|
|
294
316
|
atomicWriteJson(taskfile, doc, mtimeNs);
|
|
295
|
-
return { renewed, minified };
|
|
317
|
+
return { renewed, minified, reowned };
|
|
296
318
|
}
|
|
297
319
|
|
|
298
320
|
Object.assign(deps, { discover, readTasks, loadTaskDoc });
|