cron-up 26.10.27 → 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 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 入口脚本)。这些行为可能随版本变化。会话保活已在 **Claude Code 2.1.278** 实测;7 天判定与 `permanent` 豁免的代码逻辑核对自 **2.1.280**;定时任务的投递抖动与任务 id 哈希算法(autoMinId 依赖它)逆向核对自 **2.1.285**(均为 macOS)。升级 Claude Code 后建议重新核对。
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 主动终止;同目录当时还有你本人打开的会话时除外,见「已知限制」)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
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 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有三重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、或 interactive 登记的 pid/工作目录字段集体无法解读时,「需要留意」会明确提示登记格式疑似变更;看到这条后,在重新核对目录结构之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
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/`(判断目录里是否已有会话)与 roots 下的任务文件,并写入自己的配置、状态与日志目录。**开启 autoRenew 或 autoMinId(两者默认都开)时,它还会改写 roots 下各工作目录的 `.claude/scheduled_tasks.json`**:在同目录写临时文件(先按 0600 创建)再原子替换、保留原文件权限(写一半被杀留下的临时文件,下一轮巡检会顺手清掉)。改写只针对显式周期任务、只动两个字段——补 `permanent: true`(autoRenew)、把哈希过大的 `id` 换成文件内未占用的小号(autoMinId),**绝不改动 prompt 与 cron,也不碰一次性任务**;两项都幂等,每个任务只在首次需要时写一次,之后字节级不再重写。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再来(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
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 决定,无法做到「每个任务专属一个并发会话」,同目录多会话还有重复执行的风险。`sessionRetain: "always"` 恢复常驻:同一周期内每次触发都追加进同一条会话记录,上下文随时间累积(反复 compact、每轮执行都携带全部历史),每个有周期任务的工作区也因此常驻一个 claude 进程。真正的并发任务级隔离需要 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
- - 「已有会话在消费」按目录判断,分不清消费者是我们的预热会话还是你本人打开的会话:卡在提问界面(始终不会登记)的预热会话,若同目录恰有你的交互会话在场,3 分钟的卡死回收会被跳过——最晚由 7 天自动换代收掉,或在你关掉自己的会话后、下次有任务需要会话的那轮巡检被回收。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.10.27",
3
+ "version": "26.10.28",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/args.mjs CHANGED
@@ -13,6 +13,7 @@ export const SPECS = {
13
13
  'auto-renew': 'tri',
14
14
  'auto-min-id': 'tri',
15
15
  'session-retain': 'string',
16
+ 'stamp-owner': 'tri',
16
17
  },
17
18
  },
18
19
  uninstall: {
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
- const [consumers, registryAlert] = await deps.scanSessions();
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
@@ -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,13 @@ function needHorizonMs(tasks, now) {
180
181
  // cfg.sessionRetain,直接调用(测试)默认 'always' 保持旧语义。scriptProcs
181
182
  // 是进程表快照或 null:null 时仅在需要认领孤儿(state 丢失的兜底分支)当
182
183
  // 下才拍快照,平静轮不付全量 ps 的代价。
184
+ // stamp=null 表示 stampOwner 关(一切旧行为);开启时为
185
+ // {verdict, recycleMarginSec}:verdict 是 classifyWorkspace 对轮初消费者
186
+ // 快照的认亲结论(own/ambiguous/user,换代或回收导致快照变化时在 retire
187
+ // 内重算),recycleMarginSec 是 window 回收余量。
183
188
  export async function patrolWorkspace(
184
189
  ws, state, now, leadMs, cur, consumers, scriptProcs = null,
185
- retain = 'always', stats = null,
190
+ retain = 'always', stats = null, stamp = null,
186
191
  ) {
187
192
  const tasks = deps.readTasks(ws);
188
193
  let ent = state[ws] ?? null;
@@ -221,13 +226,23 @@ export async function patrolWorkspace(
221
226
  // 回收是同一个动作,差别只在触发条件与文案;消费者快照拍于轮初,刚结束
222
227
  // 的这代可能正是快照里的消费者,不重扫会让陈旧登记挡住下面的重拉。
223
228
  let mineAlive = deps.trackedAlive(ent);
229
+ // verdict 是轮初快照的认亲结论;换代/回收杀掉本代后消费者集合变了,
230
+ // 必须按重扫结果重算而不是手改布尔——幸存下来的断链会话(进程组杀不
231
+ // 掉的用户会话)仍应是歧义态,否则会被误当普通用户会话、在它旁边双开。
232
+ let verdict = stamp?.verdict ?? null;
224
233
  const retire = async (kind, msg) => {
225
234
  await deps.stopSession(ent);
226
235
  note(kind, msg);
227
236
  delete state[ws];
228
237
  ent = null;
229
238
  mineAlive = false;
230
- consumers = (await deps.scanSessions())[0];
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
+ }
231
246
  };
232
247
 
233
248
  // 换代:超龄且日志静默(无任务在执行的证据)的会话主动结束,需要时下
@@ -246,36 +261,53 @@ export async function patrolWorkspace(
246
261
  // 缺一不可:本代会话期间真的有任务触发过(lastFiredAt 晚于 startedAt
247
262
  // ——区分「执行完的空闲」与「等待触发的空闲」,后者是预热缓存在干活,
248
263
  // 回收它只会每轮空转重拉);界面静默超时(TUI 执行期间持续渲染,静默
249
- // 即不在干活);距下次需要会话还有余量(回收后同轮重拉,新会话来得及
250
- // 登记,不会漏掉到点任务)。时间上重叠或紧邻的任务仍共享会话:任务路
251
- // 由在 Claude Code 手里,同目录并发多会话无法指定归属,还有重复执行的
252
- // 风险。回收不计失败——任务成功执行过是最强的健康证据,陈年计数一并
253
- // 清零。
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;
254
276
  if (ent !== null && mineAlive && retain === 'window'
255
277
  && tasks.some((t) => typeof t?.lastFiredAt === 'number'
256
278
  && t.lastFiredAt > (ent.startedAt ?? 0) * 1000)
257
279
  && sessionLogIdleSeconds(ent.log || sessionLogPath(ws), cur)
258
280
  > SESSION_RECYCLE_IDLE_SECONDS
259
281
  && needHorizonMs(tasks, now) - now.getTime()
260
- > SESSION_FIRE_MARGIN_SECONDS * 1000) {
261
- // 用户自己的会话若在场,retire 的重扫依旧看见,不会在同目录双开。
282
+ > fireMarginSec * 1000) {
283
+ // stampOwner 关时:用户自己的会话若在场,retire 的重扫依旧看见,不
284
+ // 会在同目录双开;开启时:旧代整组结束后重扫只可能剩用户会话,下面
285
+ // 的逻辑会按需另拉新预热。
262
286
  await retire('recycle', `回收 ${ws} pid=${ent.pid}:本代任务已执行完毕且`
263
287
  + '界面静默,结束会话,下次需要时重拉');
264
288
  }
265
289
 
290
+ // 在场的消费者足以让我们放手吗?功能关时任何消费者都算;开启时只有
291
+ // 「认到自己的预热」与「父链有歧义、无法排除是自己」算——仅用户会话
292
+ // 不算,下面继续走到 wanted→卡死退休/冷却→spawn,在同目录另起自己
293
+ // 的预热(该开关的显式目的;全程只 stop 自己的 ent,不碰用户会话)。
294
+ const consumerHolds = consumers.has(ws)
295
+ && (!stamp || verdict?.kind === 'own' || verdict?.kind === 'ambiguous');
296
+
266
297
  // 登记成功即健康证据,当场清零失败计数,不等「有任务想要会话」的轮
267
298
  // 次——健康会话常驻时计数会一直挂着,一次无关死亡就能把陈年计数顶满冷
268
- // 却,连一次新尝试都不给。
269
- if (ent !== null && mineAlive && consumers.has(ws)) {
299
+ // 却,连一次新尝试都不给。仅用户会话在场不清零(它不证明 launchd 拉起
300
+ // 的环境健康);歧义态保守当真,与「不双开」方向一致。
301
+ if (ent !== null && mineAlive && consumerHolds) {
270
302
  ent.fails = 0;
271
303
  }
272
304
 
273
305
  // 任务正在被消费。可能是我们的预热会话(已登记——上面的健康清零已覆
274
- // 盖),也可能是用户的交互会话——后者放手:不在这里删死条目,失败计数
275
- // 要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),由轮
276
- // 末 kept 过滤统一处理。先于 wanted 判定返回:有消费者在场的平稳态不必
277
- // 为整组任务跑一遍 cron 求值。
278
- if (consumers.has(ws)) return;
306
+ // 盖),也可能是用户的交互会话——后者默认放手:不在这里删死条目,失败
307
+ // 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
308
+ // 由轮末 kept 过滤统一处理。先于 wanted 判定返回:有消费者在场的平稳
309
+ // 态不必为整组任务跑一遍 cron 求值。
310
+ if (consumerHolds) return;
279
311
 
280
312
  if (!tasks.some((t) => taskWanted(t, now, leadMs))) return;
281
313
 
@@ -362,7 +394,12 @@ export async function patrolWorkspace(
362
394
  fails,
363
395
  log: sessionLogPath(ws),
364
396
  };
365
- note('spawn', `已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
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
+ }
366
403
  }
367
404
 
368
405
  // 跑一轮巡检;抢不到锁返回 false。
@@ -436,12 +473,32 @@ export async function cmdRun(args) {
436
473
  // 登记每轮只读一次:否则每个工作区都要 readdir + ps 一遍,自检也归
437
474
  // 属这里。进程表快照不在这里拍:只有孤儿认领分支需要,按需现拍(见
438
475
  // patrolWorkspace 的 scriptProcs 形参)。
439
- const [consumers, registryAlert] = await deps.scanSessions();
476
+ // lineage 只在 stampOwner 开启时拍:祖先链要一次全机 ps,关闭时
477
+ // scanSessions 不付这个代价(见 registry 的 scanSessions 注释)。
478
+ const [consumers, registryAlert, details] =
479
+ await deps.scanSessions({ lineage: cfg.stampOwner });
440
480
  if (registryAlert) {
441
481
  bump(stats, 'warn');
442
482
  deps.log(`告警:${registryAlert}`);
443
483
  }
444
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
+
445
502
  // 轮间死亡在进入工作区循环前先报:随后该工作区若需要会话会打印重拉,
446
503
  // 日志时间线就是「退出 → 重拉」,而不是反过来。
447
504
  for (const [ws, old] of prevDead) {
@@ -453,13 +510,20 @@ export async function cmdRun(args) {
453
510
  for (const [ws, taskfile] of deps.discover(cfg.roots, cfg.maxDepth)) {
454
511
  found += 1;
455
512
  // 一个工作区读不动不能中断整轮:discover 是生成器,这里抛了会跳过
456
- // 后面所有工作区。续期/改 id 与保活各自独立守卫——维护失败绝不压制
457
- // 该工作区的会话保活。两者合并为同一次原子写(见 maintainWorkspace)。
458
- if (cfg.autoRenew || cfg.autoMinId) {
513
+ // 后面所有工作区。续期/改 id/过户与保活各自独立守卫——维护失败绝不
514
+ // 压制该工作区的会话保活。三者合并为同一次原子写(见
515
+ // maintainWorkspace)。
516
+ const target = verdicts.get(ws)?.target ?? null;
517
+ if (cfg.autoRenew || cfg.autoMinId || target) {
459
518
  try {
460
- const { renewed, minified } = maintainWorkspace(taskfile, {
519
+ const { renewed, minified, reowned } = maintainWorkspace(taskfile, {
461
520
  renew: cfg.autoRenew,
462
521
  minify: cfg.autoMinId,
522
+ // target 多出的祖先链字段 tasks 层不读,展开即可,不必手抄
523
+ // 三个盖印字段。
524
+ reown: target
525
+ ? { ...target, isWanted: (t) => taskWanted(t, now, leadMs) }
526
+ : null,
463
527
  }) ?? {};
464
528
  if (renewed) {
465
529
  deps.log(`已续期 ${ws}:${renewed} 个周期任务标记为 permanent,`
@@ -471,6 +535,11 @@ export async function cmdRun(args) {
471
535
  + '已在运行的会话要等其结束重拉(window 模式执行完即回收、'
472
536
  + 'always 模式换代)后才读到新 id');
473
537
  }
538
+ if (reowned) {
539
+ bump(stats, 'reown');
540
+ deps.log(`已过户 ${ws}:${reowned} 个窗口内任务的归属改到预热会话`
541
+ + ` pid=${target.pid},这些任务将只在该会话触发`);
542
+ }
474
543
  } catch (e) {
475
544
  bump(stats, e instanceof TaskFileChanged ? 'warn' : 'error');
476
545
  if (e instanceof TaskFileChanged) {
@@ -484,7 +553,10 @@ export async function cmdRun(args) {
484
553
  try {
485
554
  await patrolWorkspace(
486
555
  ws, state, now, leadMs, cur, consumers, null,
487
- cfg.sessionRetain, stats);
556
+ cfg.sessionRetain, stats,
557
+ stampBase
558
+ ? { ...stampBase, verdict: verdicts.get(ws) ?? null }
559
+ : null);
488
560
  } catch (e) {
489
561
  bump(stats, 'error');
490
562
  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,73 @@ function ps(pid, fmt) {
27
33
  }
28
34
  }
29
35
 
30
- // 活 pid 是否像 claude 进程;调用方先判存活,这里用来排除回收 pid 的无关
31
- // 进程。
32
- export function pidIsClaude(pid) {
33
- const comm = path.basename(ps(pid, 'comm=')).toLowerCase();
34
- if (comm.includes('claude')) return true;
35
- if (INTERPRETER_COMMS.has(comm)) {
36
- return CLAUDE_ENTRY_RE.test(ps(pid, 'args=').toLowerCase());
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 false;
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
- export async function scanSessions() {
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)
@@ -78,12 +146,29 @@ export async function scanSessions() {
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 commCache = new Map();
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 (!commCache.has(pid)) {
99
- commCache.set(pid, deps.pidIsClaude(pid)); // ps 调用按 pid 缓存
183
+ if (!shapeCache.has(pid)) {
184
+ shapeCache.set(pid, deps.processShape(pid)); // ps 调用按 pid 缓存
100
185
  }
101
- if (commCache.get(pid)) {
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
- consumers.add(lexReal(cwd));
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
- pidIsClaude,
241
+ processShape,
242
+ processParentMap,
243
+ ancestorChain,
244
+ classifyWorkspace,
134
245
  scanSessions,
135
246
  });
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)与 minify(id 改
252
- // 小)合并为同一次受 mtime 乐观锁保护的原子写。先取 mtimeNs、清残留
253
- // .cron-up-tmp、loadTaskDoc round-trip 整文档;两项计数都为 0 时字节级
254
- // 不动。返回 {renewed,minified};null 表示文件缺失/损坏;读到写间被改抛
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
- export function maintainWorkspace(taskfile, { renew = false, minify = false }) {
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
- if (renewed === 0 && minified === 0) return { renewed: 0, minified: 0 };
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 });