cron-up 26.9.6 → 26.9.21

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
@@ -46,7 +46,7 @@ cron-up install --roots ~/code,~/work --interval 300 --lead 600
46
46
 
47
47
  第二步会写入配置与 LaunchAgent 并立即跑首轮巡检;参数都可省略,不带参数则用默认扫描范围与间隔。安装不需要 sudo(前提是你的 npm 全局目录在用户家目录下,fnm/volta/nvm/Homebrew 默认都满足)。
48
48
 
49
- `--roots` 填写存放 agent 工作目录的父目录(逗号分隔)。不填时默认扫描家目录下两层以内(`~/` 自身、`~/*` 与 `~/*/*`),并自动跳过 `Library`、`Documents` 等系统目录和 `.git`、`node_modules`、`.cache` 等目录;建议显式指定,范围越小越安全、越快。使用默认范围安装时 install 会再打印一次安全提示:今后 clone 进家目录的任何仓库都会进入巡检,其中的周期任务会被自动续期永久化(原理见「安全说明」)。
49
+ `--roots` 填写存放 agent 工作目录的父目录(逗号分隔)。不填时默认扫描 `~/space`、`~/workspace`、`~/tasks` 三个容器(不存在的目录跳过),深度两层以内,并自动跳过 `Library`、`Documents` 等系统目录和 `.git`、`node_modules`、`.cache` 等目录;目录布局不同或需要收窄时建议显式指定,范围越小越安全、越快。使用默认范围安装时 install 会再打印一次安全提示:今后 clone 进这三个容器的仓库也会进入巡检,其中的周期任务会被自动续期永久化(原理见「安全说明」)。
50
50
 
51
51
  launchd 的环境变量极少(PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`),npm 全局命令那个 `#!/usr/bin/env node` 壳在它下面找不到 node。因此安装时把**node 与 cron-up 所在的 bin 目录**(fnm 是 `~/.local/share/fnm/aliases/default/bin`——`fnm default` 切换时这个 symlink 自己跟着切;volta 是 `~/.volta/bin`;Homebrew/官网是系统 bin 目录;其余安装方式取当前 node 的 bin 目录)写进一个自有的启动脚本(`cron-up-service`)的 PATH,脚本直接 `exec cron-up run`——cron-up 与 node 每轮从 PATH 解析,**npm 升级自动跟随,无需重跑 install**,且 node 与包永远同版本。fnm/volta 切换默认版本后只需在新版本里装过一次 `npm i -g cron-up`,忘了装的话总览页会提示;nvm 等按版本目录安装的形态则绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切过去。自建启动脚本的另一个原因是显示名:macOS 系统设置「允许在后台」按 launchd 拉起的第一个可执行文件给条目归组,直连 node 会显示在「Node.js Foundation」(node 的签名者)名下,指向自有脚本则显示 **cron-up-service**。
52
52
 
@@ -126,19 +126,19 @@ cron-up spawn 的预热会话进程环境里带 `CRON_UP_SESSION=1`(值严格
126
126
 
127
127
  - **修改扫描范围**:直接编辑配置文件的 `roots`、`maxDepth`,最多 5 分钟后的下一轮巡检自动生效,无需重新加载服务。
128
128
  - **开关自动续期**:编辑配置文件的 `autoRenew`(`true`/`false`)同样在下一轮巡检生效;或运行 `cron-up install --force --auto-renew`(`--no-auto-renew` 关闭)。想立即处理一次而不改长期开关,用 `cron-up renew`。
129
- - **修改巡检间隔或提前量**:重新运行 `cron-up install --force --interval 600`,会重新生成 launchd 配置。`--force` 只更新命令行里显式给出的字段,其余配置(如 `roots`)保留;路径参数建议都加引号书写,例如 `--roots "$HOME/code,$HOME/work"`,避免 shell 对逗号后的波浪号不展开。
130
- - **升级**:`npm i -g cron-up@latest`,下一轮巡检自然跑新版。fnm/volta/Homebrew 下切换默认 Node 版本同样自动跟随(在新版本里装过一次 `npm i -g cron-up` 即可,忘了装总览会提示),全程无需重跑 install;nvm 等按版本目录安装的形态绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切到新版本。版本号按「年.月.序号」:两位年份、月份、月内发布序号(序号每月重置),如 2026 年 9 月第一次发布是 `26.9.1`,同月再发是 `26.9.2`,到 10 月是 `26.10.1`。发布方式:先把 package.json 的 version 改成新版本并合入 main,再打同名 `v<version>` tag(如 `v26.9.1`);GitHub Actions 会跑通测试(Node 22/24)、校验 package.json 版本与 tag 一致后 publish,版本不符直接失败,杜绝「代码是 26.9.2、tag 叫 26.9.3」这类漂移。push 到 main 的每次提交也会先跑一遍测试(ci workflow)。
129
+ - **修改巡检间隔或提前量**:重新运行 `cron-up install --force --interval 600`,会重新生成 launchd 配置。`--force` 只更新命令行里显式给出的字段,其余配置(如 `roots`)保留;不带 `--force` 时配置原样保留,命令行显式给的参数会被忽略并在安装输出里点名提示;路径参数建议都加引号书写,例如 `--roots "$HOME/code,$HOME/work"`,避免 shell 对逗号后的波浪号不展开。
130
+ - **升级**:`npm i -g cron-up@latest`,下一轮巡检自然跑新版。fnm/volta/Homebrew 下切换默认 Node 版本同样自动跟随(在新版本里装过一次 `npm i -g cron-up` 即可,忘了装总览会提示),全程无需重跑 install;nvm 等按版本目录安装的形态绑定装包时的目录,切换默认版本后重跑一次 `cron-up install` 才切到新版本。版本号与发布流程见下文「发布」一节。
131
131
  - **配置校验**:手改配置时字段类型写错(`roots` 不是数组、数值字段写成字符串或布尔、`autoRenew` 不是布尔)不会静默回退默认值——巡检会以退出码 2 响亮失败并在总览页列出具体字段,修好或重跑 `cron-up install --force`(坏字段回默认,其余保留)。软问题只警告不中止:提前量小于间隔(可能来不及在任务前启动会话)、扫描目录不存在。无参数的 `cron-up` 总览随时也会显示这些问题;其中 interval 显示的是 launchd **实际生效**的间隔,手动改过 config 但与实际不一致时会提示需要重新 install。
132
132
  - **路径大小写**:macOS 默认文件系统大小写不敏感,而 `realpath` 并不规范化大小写。扫描入口会把 `roots` 认回磁盘上的真实写法,`cron-up logs` 给绝对路径时也一样——大小写敲错不再是「警告一声然后匹配失效」,而是直接按同一路径工作。
133
133
  - **提前量与间隔的关系**:请保持 `leadSeconds ≥ intervalSeconds`,否则极端情况下任务会在两轮巡检之间到期而来不及启动。默认 600 ≥ 300 有一倍余量。`--lead 0` 是合法取值(表示不提前,只靠错过补执行兜底),会如实写入配置;`--interval 0` 非法,安装时直接报错中止。
134
134
  - **长驻会话自动换代**:被续期任务保活的会话会长期常驻,但常驻不等于长生不老——连续运行超过 7 天、且界面记录静默超过 1 小时(没有任务在执行、没有界面活动的证据)的会话会被主动结束,任务需要时自然重拉。换代让会话用上新的 claude 版本、界面记录从头写起,也让长期累积的上下文回到干净起点。日志静默是「没在跑任务」的判据,执行中的任务不会被砍;换代不计入失败次数。
135
- - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着但 3 分钟内始终没有登记成会话**(停在信任确认或工具授权提问上——这种进程不会自己退出,会被 cron-up 主动终止)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
135
+ - **连续失败自动暂停**:cron-up 能区分「自己启动的后台会话」和「你本人打开的会话」,只跟踪前者。后台会话有两种失败会被计入:**启动后立即退出**(通常是 shell 环境损坏),以及**进程活着却迟迟不登记会话**(判定线 3 分钟,停在信任确认或工具授权提问上——这种进程不会自己退出,会在下一轮巡检被 cron-up 主动终止)。同一目录连续失败 3 次后暂停重试 30 分钟,无参数 `cron-up` 总览显示「冷却中」;会话只要成功登记过一次,失败计数就清零;暂停结束后会给一次全新的重试机会,不会无限期停摆。失败计数**跨任务窗口保留 48 小时**——提前窗口常常只覆盖一两轮巡检,计数若在窗口外的轮次被清就永远数不满 3 次、冷却形同虚设;超过 48 小时的陈年计数连同条目一起老化,不再影响新尝试。总览对「有任务即将执行却没有会话」的告警会顺带标出近期失败次数,持续失败在进入冷却前就能看见。健康会话实测约 1 秒完成登记,距 3 分钟的判定线有充足余量。
136
136
  - **日志大小**:后台会话的界面输出是持续的全屏渲染流。单次运行产生的日志超过 10MB 时,会在下次启动该目录会话时轮转成 `.log.1`;而 `script(1)` 每次启动会清空旧日志,所以反复重启不会造成堆积。长驻会话的单代日志总量由自动换代(见上条)约束;唯一无法覆盖的是连续 7 天一直在输出、从不空闲的会话——受 macOS `script(1)` 能力限制,运行中不能切割日志。巡检日志(`launchd.out.log`/`.err.log`)只在有事件时才有输出(安静的一轮巡检一行都不写),超过 1MB 时同样轮转为 `.1`,只保留上一份。
137
137
  - **会话登记自检**:「有没有交互会话」的判断完全依赖 `~/.claude/sessions/` 的登记格式(见开头的 WARNING)。若升级 Claude Code 后该格式变化,cron-up 会把所有会话都误判为「不存在」,陷入反复重拉。总览页对此有三重烟雾告警:登记目录里有文件却一个 interactive 会话都识别不出、存活进程都不像 claude、或 interactive 登记的 pid/工作目录字段集体无法解读时,「需要留意」会明确提示登记格式疑似变更;看到这条后,在重新核对目录结构之前不要相信「当前没有会话」的判断。进程识别同时覆盖两种安装形态:原生安装看进程可执行名(含 claude),npm/bun 安装(进程名是 node/bun)看命令行里的 claude 入口路径;只有命令行里完全找不到 claude 痕迹的启动形态才会误触这条告警。
138
138
 
139
139
  ## 安全说明
140
140
 
141
- - 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions/`(判断目录里是否已有会话)与 roots 下的任务文件,并写入自己的配置、状态与日志目录。**开启 autoRenew(默认)时,它还会改写 roots 下各工作目录的 `.claude/scheduled_tasks.json`**:在同目录写临时文件(先按 0600 创建)再原子替换、保留原文件权限,且只给周期任务新增 `permanent: true`,绝不改动 prompt、cron、id;每个任务只在首次打标时写一次,之后幂等不再重写。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再续(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
141
+ - 后台会话拥有你在 Claude Code 中的全部权限(bypass permissions 模式下执行任何操作都无需确认)。**请只把 `roots` 指向你本人信任的工作目录**;cron-up 读取 `~/.claude/sessions/`(判断目录里是否已有会话)与 roots 下的任务文件,并写入自己的配置、状态与日志目录。**开启 autoRenew(默认)时,它还会改写 roots 下各工作目录的 `.claude/scheduled_tasks.json`**:在同目录写临时文件(先按 0600 创建)再原子替换、保留原文件权限(写一半被杀留下的临时文件,下一轮巡检会顺手清掉),且只给周期任务新增 `permanent: true`,绝不改动 prompt、cron、id;每个任务只在首次打标时写一次,之后幂等不再重写。写入采用乐观锁:若读出之后、写回之前 Claude Code 也改了该文件(例如任务刚触发、更新了 `lastFiredAt`,或一次性任务执行后被删除),cron-up 放弃本次写入、下轮巡检再续(否则可能让刚执行完被删的一次性任务复活、被再执行一次)。需要说明:乐观锁是在写回前比对文件 mtime,检查与替换之间不存在真正的互斥,竞态窗口只能收窄到微秒级、无法在原理上消除——这是改写另一个程序的文件固有的代价。
142
142
  - **续期会改变三条生命周期/边界**:
143
143
  1. 被打 `permanent` 的周期任务不再被 Claude Code 在创建满 7 天后删除,cron-up 为它保活的后台会话随之**长期常驻**,不再随任务过期被回收——这等于主动关闭了 Claude Code 对自主会话的寿命护栏。
144
144
  2. 7 天上限本也是一道**自动淘汰**防线:一个你没留意、随 `git pull` 或他人提交进入工作目录的周期任务,原本最多存活 7 天;autoRenew 会无差别把它也永久化。因此**不要把他人可写、或你不完全掌控的仓库纳入 roots**(与下一条任务文件即执行输入的警告同理);拉取外部代码后先检查 `.claude/scheduled_tasks.json` 里有没有意料之外的周期任务,必要时先关闭 autoRenew。
@@ -185,6 +185,14 @@ npm link # 本机把开发版链接成全局 cron-up,迭代
185
185
 
186
186
  用例名自说明覆盖面,不再这里另抄一份清单(抄来的清单只会腐化)。终端展示的东亚字符宽度用 `src/display.mjs` 里的一张 CJK 区间启发式判定,只覆盖任务摘要里真实出现的形态。
187
187
 
188
+ ## 发布
189
+
190
+ - **版本号**:按「年.月.序号」——两位年份、月份、累计发布序号。序号永不重置、不随月份归零,每次发布加一,始终等于含本次在内的历史发布总次数(0.x 时代的发布同样计入)。序号在 26.9.7 之后由月内计数改为累计计数,切换时历史共 20 次发布,故下一次是第 21 次:9 月内再发是 `26.9.21`,到 10 月发是 `26.10.21`,月份照常反映发布时间、序号只增不减。下一序号不用手数:发布前 `git fetch --prune --prune-tags --tags` 后 `git tag -l 'v*' | wc -l` 数出已有发布次数,加一即得(prune-tags 让删错的远端 tag 不至于把本地计数顶歪)。发布失败的 tag 重跑 release 工作流即可,序号不消耗。
191
+ - **发布方式**:先把 package.json 的 version 改成新版本并合入 main,再打同名 `v<version>` tag(如 `v26.9.21`);GitHub Actions 会跑通测试(Node 22/24)、校验 package.json 版本与 tag 一致(版本不符直接失败,杜绝「代码是 26.9.2、tag 叫 26.9.3」这类漂移),并复核 tag 序号等于含它在内的 `v*` tag 总数(序号数错当场失败),然后 publish。push 到 main 的每次提交也会先跑一遍测试(ci workflow)。
192
+ - **trusted publishing**:npm 经 GitHub OIDC 验证「确为 `adaex/cron-up` 仓库 `release.yml` 由该 tag 触发的运行」后放行,不使用任何长期凭证(仓库里没有 NPM_TOKEN,token 泄漏冒发这条路不存在),且每个版本自动附带 provenance 出处证明(npm 包页可见构建来源与 commit);前提是 npmjs.com 包设置里已登记 Trusted Publisher(repository `adaex/cron-up`、workflow `.github/workflows/release.yml`,一次性配置),没登记的话 publish 会被拒绝。
193
+ - **npm 传播延迟**:publish 成功后 npm 服务端约两分钟才完成处理,`latest` 标签与本机 npm 缓存各自再滞后一层——发布后立即 `npm i -g cron-up@latest` 可能装到旧版且毫无提示(`changed 1 package` 不含版本号);等 `npm view cron-up version` 刷新后用显式版本号安装,装完以总览首行版本号为准。
194
+ - **发版收尾**(每次发版必做):发版方本机按上述方式升级后,跑一次无参数 `cron-up` 确认三件事——首行是刚发的版本、服务已加载且上次退出码 0、「需要留意」为空——全部满足发版才算结束。
195
+
188
196
  ## License
189
197
 
190
198
  [MIT](LICENSE)
package/bin/cron-up.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // cron-up 入口。launchd 以「node 绝对路径 + 本文件绝对路径 + run」直接
3
- // 启动本文件(npm shim 的 /usr/bin/env node 在 launchd 最小 PATH 下找不
4
- // 到 node)。
2
+ // cron-up 入口(npm bin 的 shim 目标)。launchd 经自有的 cron-up-service
3
+ // 启动脚本拉起:脚本把 node 与本包所在的 bin 目录放进 PATH 后执行
4
+ // `cron-up run`,命令与本文件都从 PATH 解析,npm 升级自动跟随。
5
5
 
6
6
  import { main } from '../src/cli.mjs';
7
7
  import { ExitError } from '../src/internals.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cron-up",
3
- "version": "26.9.6",
3
+ "version": "26.9.21",
4
4
  "description": "为命令行 AI agent 的 cron 定时任务提前预热并保活交互式会话",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.mjs CHANGED
@@ -135,7 +135,7 @@ export function loadState() {
135
135
  pid = null;
136
136
  }
137
137
  ent.pid = pid && pid > 0 && pid <= PID_T_MAX ? pid : null;
138
- for (const k of ['startedAt', 'cooldownUntil', 'fails']) {
138
+ for (const k of ['startedAt', 'cooldownUntil', 'fails', 'deadSince']) {
139
139
  if (ent[k] !== null && ent[k] !== undefined) {
140
140
  try {
141
141
  ent[k] = lenientInt(ent[k]);
package/src/constants.mjs CHANGED
@@ -1,8 +1,11 @@
1
1
  // 全部调参常量集中一处,改动时便于通盘核对。
2
2
 
3
3
  export const DEFAULT_CONFIG = {
4
- // 默认扫家目录(两层深覆盖 ~/<root>/<workspace>);安装时可用 roots 收窄。
5
- roots: ['~'],
4
+ // 默认只扫本人的三个任务制容器(两层深覆盖 <容器>/<仓>及其一级子目录),
5
+ // 不扫整个家目录——他人可写仓库里的周期任务会被自动续期永久化,默认范围
6
+ // 必须是用户完全掌控的领地(见 README 安全说明)。其他布局用 --roots 覆
7
+ // 盖;目录不存在则跳过。
8
+ roots: ['~/space', '~/workspace', '~/tasks'],
6
9
  maxDepth: 2,
7
10
  intervalSeconds: 300,
8
11
  leadSeconds: 600,
@@ -17,6 +20,11 @@ export const DEFAULT_CONFIG = {
17
20
  // 而不是每 5 分钟无限重拉。
18
21
  export const FAIL_LIMIT = 3;
19
22
  export const COOLDOWN_SECONDS = 1800;
23
+ // 失败计数随死条目保留的时长。提前窗口通常只覆盖一两轮巡检(每小时任务
24
+ // 约 2 轮),计数若在窗口外的轮次被清理就永远数不满 FAIL_LIMIT——冷却只对
25
+ // 稠密任务生效。保留 48 小时让稀疏任务跨窗口累积;老化防止陈年计数误伤
26
+ // 已修好的环境。
27
+ export const FAIL_TTL_SECONDS = 48 * 3600;
20
28
  // pid 合法值域。state.json 与会话登记两个外部来源都在入口处按它校验:
21
29
  // 超大整数能穿过类型转换,直到 process.kill 才抛,所以必须入口卡范围。
22
30
  export const PID_T_MAX = 2 ** 31 - 1;
package/src/overview.mjs CHANGED
@@ -81,8 +81,13 @@ function overviewTasks(cfg, cols, consumers, state) {
81
81
  + `请运行 cron-up logs ${path.basename(ws)} 排查`);
82
82
  coolingWarned.add(ws);
83
83
  } else {
84
- alerts.push(`${ws} 有任务即将执行(或错过待补执行),但当前没有交互`
85
- + '会话,下轮巡检会自动启动');
84
+ // 失败计数放在句子中部:总览告警按终端宽度截尾,计数比尾部的
85
+ // 「下轮会自动启动」更该活下来;满额后的去向由「冷却中」那条合并
86
+ // 告警解释,这里不重复。
87
+ const fails = (state[ws] ?? {}).fails ?? 0;
88
+ const streak = fails > 0 ? `,近期已失败 ${fails} 次` : '';
89
+ alerts.push(`${ws} 有任务即将执行(或错过待补执行)${streak},`
90
+ + '但当前没有交互会话,下轮巡检会自动启动');
86
91
  }
87
92
  }
88
93
  }
@@ -115,7 +120,7 @@ function overviewSessions(cols, state, suppressCooling = new Set(),
115
120
  for (const [ws, ent] of Object.entries(state)) {
116
121
  if ((ent.cooldownUntil ?? 0) > cur) cooling.push([ws, ent]);
117
122
  else if (deps.trackedAlive(ent)) live.push([ws, ent]);
118
- else dead.push(ws); // 记录还在、进程已没:下轮巡检即清理
123
+ else dead.push(ws); // 记录还在、进程已没:带失败计数的保留至老化,其余下轮清理
119
124
  }
120
125
  let tail = '';
121
126
  if (cooling.length) tail += `,冷却 ${cooling.length} 个`;
package/src/patrol.mjs CHANGED
@@ -9,6 +9,7 @@ import { deps } from './internals.mjs';
9
9
  import {
10
10
  FAIL_LIMIT,
11
11
  COOLDOWN_SECONDS,
12
+ FAIL_TTL_SECONDS,
12
13
  WARMUP_GRACE_SECONDS,
13
14
  SESSION_MAX_AGE_SECONDS,
14
15
  SESSION_IDLE_SECONDS,
@@ -184,13 +185,11 @@ export async function patrolWorkspace(
184
185
 
185
186
  if (consumers.has(ws)) {
186
187
  // 任务正在被消费。区分我们的预热会话与用户的交互会话:前者继续跟踪
187
- // (活消费者证明健康 → 计数清零),后者彻底放手。
188
- if (ent !== null) {
189
- if (mineAlive) {
190
- ent.fails = 0;
191
- } else if (!cooling) {
192
- delete state[ws];
193
- }
188
+ // (活消费者证明健康 → 计数清零),后者放手——但不在这里删死条目,失败
189
+ // 计数要跨任务窗口存活(用户会话在场不证明 launchd 拉起的环境健康),
190
+ // 由轮末 kept 过滤统一处理。
191
+ if (ent !== null && mineAlive) {
192
+ ent.fails = 0;
194
193
  }
195
194
  return;
196
195
  }
@@ -214,7 +213,9 @@ export async function patrolWorkspace(
214
213
  // 冷却刚结束:给一次干净的缓刑拉起,而不是立刻再次顶满限制。
215
214
  fails = 0;
216
215
  } else {
217
- fails = (ent?.fails ?? 0) + (ent ? 1 : 0);
216
+ // 只有真的拉起过(pid 非空)的条目死亡才算一次新失败;pid 为空的条目
217
+ // 是当场就没拉起的尝试,那次失败已在写入时计过,不再重复累计。
218
+ fails = (ent?.fails ?? 0) + (ent?.pid ? 1 : 0);
218
219
  }
219
220
 
220
221
  if (fails >= FAIL_LIMIT) {
@@ -252,15 +253,29 @@ export async function patrolWorkspace(
252
253
  }
253
254
  }
254
255
 
255
- const { pid, procStart } = deps.spawnSession(ws);
256
+ // 成败只认一个事实——有没有拿到 pid:null、undefined、无 pid 的对象都算
257
+ // 没拉起,计数与日志同源,不会各说各话。
258
+ const spawned = deps.spawnSession(ws);
259
+ if (spawned?.pid == null) {
260
+ // 本次尝试已当场失败,立即计入(成功拉起则本次尚待观察,沿用 fails)。
261
+ const newFails = fails + 1;
262
+ state[ws] = {
263
+ pid: null,
264
+ startedAt: cur,
265
+ fails: newFails,
266
+ log: sessionLogPath(ws),
267
+ };
268
+ deps.log(`启动失败 ${ws}:会话进程没有拉起(连续失败计数 ${newFails})`);
269
+ return;
270
+ }
256
271
  state[ws] = {
257
- pid,
272
+ pid: spawned.pid,
258
273
  startedAt: cur,
259
- procStart,
274
+ procStart: spawned.procStart,
260
275
  fails,
261
276
  log: sessionLogPath(ws),
262
277
  };
263
- deps.log(`已启动 ${ws} pid=${pid}(连续失败计数 ${fails})`);
278
+ deps.log(`已启动 ${ws} pid=${spawned.pid}(连续失败计数 ${fails})`);
264
279
  }
265
280
 
266
281
  // 跑一轮巡检;抢不到锁返回 false。
@@ -316,10 +331,20 @@ export async function cmdRun(args) {
316
331
  }
317
332
  }
318
333
 
319
- // 丢掉已死且不处冷却的条目;保留冷却与活会话。
334
+ // 丢掉已死且不处冷却的条目;保留冷却、活会话,以及带失败计数的死条
335
+ // 目——计数跨任务窗口累积才能数满 FAIL_LIMIT(提前窗口常常只覆盖一两
336
+ // 轮巡检),首次发现死亡时打 deadSince,超过 FAIL_TTL 后连同计数一起
337
+ // 老化丢弃。
320
338
  const kept = Object.fromEntries(
321
- Object.entries(state).filter(([, ent]) =>
322
- deps.trackedAlive(ent) || (ent.cooldownUntil ?? 0) > cur),
339
+ Object.entries(state).filter(([, ent]) => {
340
+ if (deps.trackedAlive(ent)) return true;
341
+ if ((ent.cooldownUntil ?? 0) > cur) return true;
342
+ if ((ent.fails ?? 0) > 0) {
343
+ ent.deadSince ??= cur;
344
+ return cur - ent.deadSince < FAIL_TTL_SECONDS;
345
+ }
346
+ return false;
347
+ }),
323
348
  );
324
349
  saveState(kept);
325
350
  return true;
package/src/service.mjs CHANGED
@@ -26,9 +26,10 @@ export function guiTarget() {
26
26
  return `gui/${process.getuid()}`;
27
27
  }
28
28
 
29
- // launchctl 调用统一走不抛的 execFile,判成败看 status。
29
+ // launchctl 调用统一走不抛的 execFile,判成败看 status。与 ps 同款防御:
30
+ // launchctl 卡住时命令挂死不如超时报「未加载」——总览页至少还能出来。
30
31
  export function launchctl(...args) {
31
- return deps.execFile('/bin/launchctl', args);
32
+ return deps.execFile('/bin/launchctl', args, { timeout: 10000 });
32
33
  }
33
34
 
34
35
  export function launchctlInfo() {
@@ -265,6 +266,17 @@ export async function cmdInstall(args) {
265
266
  throw e;
266
267
  }
267
268
  deps.print(`保留现有配置:${deps.paths.configPath}(需要更新时加 --force)`);
269
+ // 不带 --force 时配置原样保留,命令行显式给的参数一律不生效:点名说清
270
+ // 被忽略的是哪些,而不是让用户以为已经改了。
271
+ const ignored = [];
272
+ if (args.roots !== undefined) ignored.push('--roots');
273
+ if (args.interval !== undefined) ignored.push('--interval');
274
+ if (args.lead !== undefined) ignored.push('--lead');
275
+ if (args.autoRenew !== undefined) ignored.push('--auto-renew');
276
+ if (ignored.length > 0) {
277
+ deps.print(`警告:参数 ${ignored.join('、')} 未生效(现有配置保留);`
278
+ + '要更新这些字段请带 --force 重跑,--force 只覆盖显式给出的字段');
279
+ }
268
280
  } else {
269
281
  // 即使 --force 也从现有配置(全新安装则默认值)起步,只覆盖命令行给
270
282
  // 的字段——否则 --force --interval 600 会悄悄把 roots 重置成默认。
@@ -328,9 +340,10 @@ export async function cmdInstall(args) {
328
340
  if (JSON.stringify(cfg.roots)
329
341
  === JSON.stringify(normalizeRoots(DEFAULT_CONFIG.roots))) {
330
342
  // 与 README 安全说明同一句话,在配置落定的这一刻当面再说一遍。
331
- deps.print('提示:未指定 --roots,扫描范围是整个用户目录,今后 clone 的'
332
- + '仓库也会进入巡检;自动续期会把其中的周期任务永久化。建议用 '
333
- + '--roots 显式限定范围(见 README 安全说明)');
343
+ deps.print('提示:未指定 --roots,默认扫描 ~/space、~/workspace、~/tasks'
344
+ + '(不存在的目录跳过);今后 clone 进这些容器的仓库也会进入巡检,'
345
+ + '自动续期会把其中的周期任务永久化。目录布局不同或需收窄时用 '
346
+ + '--roots 指定(见 README 安全说明)');
334
347
  }
335
348
 
336
349
  // npm 包没有「安装二进制」这一步:node 与入口文件已由 npm 就位,这里只
package/src/sessions.mjs CHANGED
@@ -128,6 +128,9 @@ export function spawnSession(ws) {
128
128
  // unref 后即使异步启动失败也不要变成 uncaught(监听器不影响 loop 退出)。
129
129
  child.on('error', () => {});
130
130
  child.unref();
131
+ // 拉不起来(spawn 当场失败,pid 是 undefined)时返回 null:没有进程可跟
132
+ // 踪,调用方按启动失败计数,不把 pid=undefined 写进 state。
133
+ if (child.pid === undefined) return null;
131
134
  return { pid: child.pid, procStart: deps.procStartedAt(child.pid) };
132
135
  }
133
136
 
package/src/tasks.mjs CHANGED
@@ -146,6 +146,13 @@ export function loadTaskDoc(file) {
146
146
  return doc;
147
147
  }
148
148
 
149
+ // 临时文件用专属后缀而非泛用的 .tmp:清扫时只认自己的名字,不会误删恰好
150
+ // 用同一约定的其他写者(巡检锁只隔离 cron-up 自己,管不到 Claude Code)
151
+ // 刚建好的临时文件。
152
+ function tmpSibling(file) {
153
+ return `${file}.cron-up-tmp`;
154
+ }
155
+
149
156
  // 旁边写再 rename 的原子替换,保留原文件权限位与中文字面量。
150
157
  // tmp 以 0600 创建(绝不更宽),写完再放宽到原 mode。expectedMtimeNs 是
151
158
  // 乐观锁:路径 mtime 自调用方读后变了说明别的写者抢先,抛
@@ -157,7 +164,7 @@ export function atomicWriteJson(file, doc, expectedMtimeNs) {
157
164
  } catch {
158
165
  // 新文件用 0600。
159
166
  }
160
- const tmp = `${file}.tmp`;
167
+ const tmp = tmpSibling(file);
161
168
  // JSON.stringify 两空格缩进:中文按字面写出(不转 \u),无尾换行。
162
169
  fs.writeFileSync(tmp, JSON.stringify(doc, null, 2), {
163
170
  flag: 'w',
@@ -197,6 +204,14 @@ export function renewWorkspace(taskfile) {
197
204
  } catch {
198
205
  return null;
199
206
  }
207
+ // 上次写一半被杀(SIGKILL/断电)留下的临时文件会永远躺在用户的仓库里
208
+ // ——已全部标好的文件不再触发写入,没人替它收尾。只清专属后缀的,顺手
209
+ // 且不碰别人。
210
+ try {
211
+ fs.unlinkSync(tmpSibling(taskfile));
212
+ } catch {
213
+ // 不存在即无事。
214
+ }
200
215
  const doc = deps.loadTaskDoc(taskfile);
201
216
  if (doc === null) return null;
202
217
  let tagged = 0;