The Morning My cron Jobs Went Silent: A 97-Line Script That Migrated Everything to launchd

The Morning My cron Jobs Went Silent: A 97-Line Script That Migrated Everything to launchd

我的 cron 任务静默失效的那天:用 97 行脚本将一切迁移至 launchd

Six months after being laid off, I’d rebuilt my income from zero to ¥1.2M/month on an autonomous setup. Then one morning at 8:00, it just wasn’t there — no error, no alert, nothing. The cause: a macOS update had quietly disabled the cron daemon. My fix was a 97-line shell script that parses crontab line by line and auto-generates launchd plists. 在被裁员六个月后,我通过一套自动化系统将收入从零重建到了每月 120 万日元。然而某天早上 8 点,一切都消失了——没有报错,没有提醒,什么都没有。原因在于:一次 macOS 更新悄悄禁用了 cron 守护进程。我的解决方案是一个 97 行的 Shell 脚本,它能逐行解析 crontab 并自动生成 launchd 的 plist 文件。

Why This Approach Works

为什么这种方法有效

“It Should Be Running” Is the Most Dangerous Kind of Confidence. Back when my side business was earning ¥600K/month, nearly every yen of that automation benefit rode on cron jobs. Timing note publications, scheduling social posts, daily data aggregation — all of it lined up in crontab -l. When I was laid off and dropped to zero, rebuilding the environment with Claude Code, I decided carrying the crontab over as-is was the fastest path. “它应该在运行”是世界上最危险的自信。当我的副业每月赚取 60 万日元时,几乎每一分自动化收益都依赖于 cron 任务。定时发布笔记、安排社交媒体发帖、每日数据汇总——所有这些都排列在 crontab -l 中。当我被裁员收入归零,并使用 Claude Code 重建环境时,我决定直接沿用 crontab,因为这是最快的路径。

Right after upgrading to macOS Sequoia (15.x), nothing appeared to have changed. Run crontab -l and every entry is still there. But the daemon isn’t running. Since macOS Ventura, Apple has been progressively decoupling the cron daemon from the user session, and on Sequoia/Tahoe it’s perfectly normal to have /usr/sbin/cron present while launchctl list | grep cron returns nothing at all. 升级到 macOS Sequoia (15.x) 后,一切看起来并无异常。运行 crontab -l,所有条目依然存在,但守护进程并没有运行。自 macOS Ventura 以来,苹果一直在逐步将 cron 守护进程与用户会话解耦。在 Sequoia/Tahoe 上,即使 /usr/sbin/cron 存在,但 launchctl list | grep cron 返回空值,这完全是正常的。

The reason I was slow to notice is that when automation stops, no error appears. My assumption was that if cron isn’t running, an error mail lands in /var/mail/ — and that assumption had collapsed. On Sequoia it doesn’t reach the post office by default. The 8:00 daily brief doesn’t arrive, the 11:00 social post doesn’t go out, and only then do you notice. That “silent death” is what scares me. 我之所以反应迟钝,是因为当自动化停止时,没有任何错误提示。我原本以为如果 cron 没运行,错误邮件会发送到 /var/mail/<username>,但这个假设失效了。在 Sequoia 上,默认情况下邮件根本不会到达。8 点的每日简报没发出来,11 点的社交媒体没更新,直到那时你才会察觉。这种“静默死亡”最让我后怕。

Why launchd Is the Right Answer

为什么 launchd 是正确答案

On macOS, process launching and management belongs to launchd (PID 1). cron survives only as historical compatibility; what Apple actually recommends is job management via launchd. launchd handles automatic restarts when a daemon crashes, automatic execution after wake for jobs scheduled while the machine was asleep, direct redirection of stdout/stderr to files, and explicit injection of environment variables — all declaratively, in a single plist file. 在 macOS 上,进程启动和管理属于 launchd (PID 1)。cron 的存在仅仅是为了历史兼容性;苹果真正推荐的是通过 launchd 进行任务管理。launchd 可以处理守护进程崩溃后的自动重启、机器休眠期间错过任务的唤醒后自动执行、将标准输出/错误直接重定向到文件,以及显式注入环境变量——所有这些都以声明式的方式写在一个 plist 文件中。

cron lets you write */5 * * * * cmd on one line; a launchd plist becomes 20–30 lines of XML. That verbosity is the biggest psychological barrier to migrating to launchd. Rewriting ten of them by hand isn’t realistic. So you generate them with a script. Looking at one plist that’s actually in production makes the structure click. cron 允许你用一行写下 */5 * * * * cmd;而 launchd 的 plist 则需要 20-30 行 XML。这种冗长是迁移到 launchd 最大的心理障碍。手动重写十个任务是不现实的,所以你需要用脚本来生成它们。看一眼实际生产环境中的 plist,你就能瞬间理解其结构。

Here’s how ~/Library/LaunchAgents/com.shun.daily-brief.plist is composed (excerpted from the real file, paths converted to ~ notation): 以下是 ~/Library/LaunchAgents/com.shun.daily-brief.plist 的构成(摘自真实文件,路径已转换为 ~ 表示法):

<key>Label</key>
<string>com.shun.daily-brief</string>
<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin</string>
</dict>
<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Hour</key><integer>8</integer>
    <key>Minute</key><integer>0</integer>
  </dict>
  <dict>
    <key>Hour</key><integer>10</integer>
    <key>Minute</key><integer>30</integer>
  </dict>
</array>
<key>ProgramArguments</key>
<array>
  <string>~/.claude/scripts/claude-quota-guard.py</string>
  <string>--job</string>
  <string>com.shun.daily-brief</string>
  <string>--</string>
  <string>/bin/bash</string>
  <string>~/.claude/scripts/daily-brief.sh</string>
</array>
<key>LowPriorityIO</key><true/>
<key>Nice</key><integer>10</integer>
<key>RunAtLoad</key><true/>
<key>StandardOutPath</key>
<string>~/.claude/logs/com.shun.daily-brief.log</string>
<key>StandardErrorPath</key>
<string>~/.claude/logs/com.shun.daily-brief.log</string>

Three things stand out: 有三点值得注意:

  1. Explicit EnvironmentVariables. launchd does not read your shell configuration (.zshrc, .bashrc). A script that uses node installed via nvm has no PATH to it under launchd management and dies with node: command not found. This accounts for 90% of the cases where a job migrated from cron suddenly stops working. Writing PATH explicitly into the plist guarantees the same binary gets called no matter what the shell is.

  2. 显式的环境变量。 launchd 不会读取你的 Shell 配置文件(.zshrc, .bashrc)。一个使用 nvm 安装的 node 脚本在 launchd 管理下找不到 PATH,会因 node: command not found 而崩溃。90% 从 cron 迁移过来的任务突然失效都是因为这个。将 PATH 显式写入 plist 可以确保无论 Shell 是什么,调用的都是同一个二进制文件。

  3. The array form of StartCalendarInterval. When you want to run multiple times per day, you line up <dict> entries inside an <array>. daily-brief runs twice, at 8:00 and 10:30. In cron you’d write 0 8,10 * * *, but launchd requires a dictionary per time.

  4. StartCalendarInterval 的数组形式。 当你想每天运行多次时,需要在 <array> 中排列多个 <dict> 条目。daily-brief 在 8:00 和 10:30 运行两次。在 cron 中你只需写 0 8,10 * * *,但 launchd 要求每个时间点对应一个字典。

  5. LowPriorityIO and Nice. Background jobs get lowered I/O priority and a CPU scheduler nice value of 10. It’s a setting to minimize impact on foreground work (editor, browser), consistent with the “erase your presence” philosophy of an autonomous environment.

  6. LowPriorityIO 和 Nice。 后台任务会被降低 I/O 优先级,并将 CPU 调度 nice 值设为 10。这是为了最小化对前台工作(编辑器、浏览器)的影响,符合自动化环境“抹除自身存在感”的哲学。

What It Means to Invest in the Environment, Not the Work

投资于环境,而非工作本身

Of that ¥1.2M/month breakdown, almost none of it is me moving my hands. Most of the note series, social updates, and data aggregation are automated. The maintenance cost of this environment comes down to moving cron onto a foundation that actually runs. The goal of being under launchd management is that a scheduled task you wrote once is still running three years later. Apple’s launchd is a stable API unchanged since macOS 10.4 (2005), and it doesn’t “die unnoticed” the way cron does. launchctl list com.shun.daily-brief shows you LastExitStatus and the next scheduled run instantly. The 90 minutes spent setting up the environment is an investment that buys back 5 minutes × 365 days (= 30 hours) of “let me check whether it’s actually running” every morning. 在那每月 120 万日元的收入构成中,几乎没有一项是我亲手操作的。大部分笔记系列、社交更新和数据汇总都是自动化的。维护这个环境的成本,归根结底就是将 cron 迁移到一个真正可靠的基石上。使用 launchd 管理的目标是:你写下的一次定时任务,三年后依然在运行。苹果的 launchd 是自 macOS 10.4 (2005) 以来未曾改变的稳定 API,它不会像 cron 那样“悄无声息地死亡”。launchctl list com.shun.daily-brief 可以让你立即看到 LastExitStatus 和下一次运行时间。花 90 分钟配置环境是一项投资,它能为你换回每天早上 5 分钟 × 365 天(共 30 小时)的“检查任务是否在运行”的时间。