🔄 🔄 cronが死んだ日に書いた一括launchd移行スクリプト — リーダー×
🔄

🔄 cronが死んだ日に書いた一括launchd移行スクリプト

#automation#claudecode#副業2026-08-31 · 約33

解雇から半年、月商0から120万円まで持ち直した自律環境がある朝8時に黙って止まっていた話をします。原因はmacOSのアップデートによるcron daemonの無効化。対策として書いたのが、crontabを1行ずつ解析してlaunchd plistを自動生成する97行のシェルスクリプトです。

なぜこの仕組みが効くのか

「動いているはず」は最も危険な確信

副業で月60万を稼いでいた時期、自動化の恩恵はほぼ全額がcronジョブに乗っていました。noteの公開タイミング調整、SNS投稿のスケジューリング、日次のデータ集計——これらがすべてcrontab -lに並んでいた。解雇されて0になり、Claude Codeで環境を再構築するときも、crontabをそのまま引き継ぐのが最速だと判断しました。

macOS Sequoia(15系)に上げた直後は何も変わっていないように見えました。crontab -lを叩けばエントリは全部ある。しかしdaemonは起動していない。AppleはmacOS Ventura以降、段階的にcron daemonをユーザーセッションから切り離してきており、Sequoia/Tahoeでは/usr/sbin/cronは存在するのにlaunchctl list | grep cronでは何も返ってこない環境が普通にあります。

気づくのが遅れたのは、自動化が止まってもエラーが出ないからです。cronが動いていないなら/var/mail/ユーザー名にエラーメールが来る——という想定が崩れていた。Sequoideではデフォルトでポストオフィスに届かない。朝8時のデイリーブリーフが来ない、11時のSNS投稿が流れない、それでようやく気づく。この「静かな死」が怖い。

launchdが正解である理由

macOSにおいてプロセスの起動・管理を担うのはlaunchd(PID 1)です。cronは歴史的な互換として残っているだけで、Appleが本来推奨するのはlaunchd経由のジョブ管理です。launchdはdaemonがクラッシュしたときの自動再起動、マシンスリープ中にスケジュールされたジョブのスリープ解除後自動実行、標準出力・エラー出力のファイルへの直接リダイレクト、環境変数の明示的な注入、といった機能をplist 1ファイルで宣言的に扱えます。

cronが*/5 * * * * cmdと1行で書けるのに対し、launchdのplistは20〜30行のXMLになる——この冗長さがlaunchd移行の最大の心理的障壁です。手作業で10本を書き直すのは現実的でない。だからスクリプトで自動生成します。

実際に稼働しているplistを1本見ると、構造が腑に落ちます。~/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>

注目点が3つあります。

EnvironmentVariablesの明示。launchdはユーザーのシェル設定(.zshrc.bashrc)を読みません。nvm経由でインストールしたnodeを使うスクリプトは、launchd管理下ではPATHが通っておらずnode: command not foundで死ぬ。cronから移行したジョブが突然動かなくなる原因の9割がこれです。plistにPATHを明示することで、シェルが何であれ同じバイナリを確実に呼べます。

StartCalendarIntervalの配列形式。1日に複数回実行したい場合、<array>の中に<dict>を並べます。daily-briefは8:00と10:30の2回。cronなら0 8,10 * * *と書けますが、launchdは時刻ごとに辞書を作る必要がある。この記述差を自動生成スクリプトがどこまでカバーするか、後述の落とし穴に関わります。

LowPriorityIONice。バックグラウンドジョブにI/O優先度を下げ、CPUスケジューラのnice値を10に設定しています。フォアグラウンドの作業(エディタ・ブラウザ)への影響を最小化するための設定で、自律環境の「存在感を消す」哲学に沿っています。

作業でなく環境に投資する意味

月商120万の内訳は、自分が手を動かしている部分がほぼない。note連載・SNS更新・データ集計の多くが自動化されています。この環境の維持コストは、cronを正しく動く基盤に移し替えることに尽きます。1度書いた定期タスクが3年後も動いているのがlaunchd管理下のゴールです。AppleのlaunchdはmacOS 10.4(2005年)から変わらない安定したAPIで、cronのように「気づかず死ぬ」ことがない。launchctl list com.shun.daily-briefで即座にLastExitStatusと次回実行予定が確認できます。

環境を整える90分は、毎朝「ちゃんと動いているか確認する」5分×365日(=30時間)を買い戻す投資です。

全体の流れ

処理の地図

crontab -l
  │  grep -vE '^\s*#' | grep -v '^$'  ← コメント行・空行を除外
  ↓
[1行ごとにループ]
  │  awk '{print $1...$5}' で schedule フィールド抽出
  │  cut -d' ' -f6-           で cmd 部分を切り出し
  │  basename からラベル生成  → com.shun.<script-name>
  ↓
StartCalendarInterval XML 組み立て
  │  ※ */N 形式は非対応(固定値のみ)← ここが落とし穴
  ↓
plist ファイル書き出し
  → [dry]   ~/.claude/scripts/launchd-proposed/*.plist
  → [apply] ~/Library/LaunchAgents/*.plist
               + launchctl unload → launchctl load
  ↓
⚠️  警告: crontab から手動削除しないと二重起動

スクリプト解剖(全97行)

スクリプトは~/.claude/scripts/cron-to-launchd.shに置いてあり、使い方は2通りです。

# 差分確認(ファイルを書くだけ、loadしない)
~/.claude/scripts/cron-to-launchd.sh dry

# 本番反映(LaunchAgentsにコピーしてlaunchctl load)
~/.claude/scripts/cron-to-launchd.sh apply

引数なしで呼べばdryがデフォルト(MODE="${1:-dry}")。いきなりapplyせず、まずdryで生成物を目視確認するのが鉄則です。

フェーズ1:crontabの読み込みとパース(20〜28行目)

CRON_LINES=()
while IFS= read -r line; do
  [ -n "$line" ] && CRON_LINES+=("$line")
done < <(crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$')

bash 3.2 互換とコメントにある通り、macOS標準のbashはバージョン3.2(GPLv2の都合でAppleが更新していない)です。mapfilereadarrayは3.2では使えないため、while IFS= read -rのループで配列を構築しています。crontab -l 2>/dev/nullでcrontabが空の場合のエラーを握りつぶし、grep -vE '^\s*#'でコメント行、grep -v '^$'で空行を除去します。

フェーズ2:各行から schedule と cmd を分解(28〜38行目)

minute=$(echo "$line" | awk '{print $1}')
hour=$(echo "$line" | awk '{print $2}')
dom=$(echo "$line"   | awk '{print $3}')
mon=$(echo "$line"   | awk '{print $4}')
dow=$(echo "$line"   | awk '{print $5}')
cmd=$(echo "$line"   | cut -d' ' -f6-)

cronの書式min hour dom mon dow cmd...をawkで1フィールドずつ取り出します。cmdは6番目以降をすべてcut -d' ' -f6-で取るため、コマンドに引数がいくつあっても正しく拾えます。

ラベルの生成ロジック(38〜40行目):

script=$(echo "$cmd" | grep -oE '~/.claude/scripts/[^ ]+' | head -1 | xargs basename 2>/dev/null)
if [ -z "$script" ]; then
  script="$(echo "$cmd" | awk '{print $1}' | xargs basename 2>/dev/null)-${minute}${hour}"
fi
label="com.shun.$(echo "$script" | sed -E 's/\.[a-z]+$//' | tr '_' '-')"

~/.claude/scripts/配下のスクリプトはbasenameから拡張子を除いてラベル化します。たとえばdaily-brief.shならcom.shun.daily-brief。それ以外の汎用コマンド(findbackup-rotateなど)は、コマンド名+分+時でユニーク性を確保します。アンダースコアはハイフンに変換(launchのLabel規約)。

フェーズ3:StartCalendarInterval XMLの組み立て(44〜52行目)

cal_xml="  <key>StartCalendarInterval</key>\n  <dict>\n"
# */N 周期は launchd では複数エントリで再現する必要 — ここでは固定値だけ対応
if [ "$minute" != "*" ]; then cal_xml+="    <key>Minute</key><integer>${minute}</integer>\n"; fi
if [ "$hour"   != "*" ]; then cal_xml+="    <key>Hour</key><integer>${hour}</integer>\n";   fi
if [ "$dom"    != "*" ]; then cal_xml+="    <key>Day</key><integer>${dom}</integer>\n";      fi
if [ "$mon"    != "*" ]; then cal_xml+="    <key>Month</key><integer>${mon}</integer>\n";    fi
if [ "$dow"    != "*" ]; then cal_xml+="    <key>Weekday</key><integer>${dow}</integer>\n";  fi
cal_xml+="  </dict>"

フィールドが*(ワイルドカード)なら対応するキーをXMLに含めない——これがlaunchdのStartCalendarIntervalの意味論です。たとえば0 8 * * *(毎日8時0分)はHour=8, Minute=0のみ書けばよく、Day/Month/Weekdayは省略することで「毎日」になります。

フェーズ4:plist本体の書き出し(54〜76行目)

cat > "$plist" <<XMLEOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>${label}</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/zsh</string>
    <string>-c</string>
    <string>${cmd//&/&amp;}</string>
  </array>
$(echo -e "${cal_xml}")
  <key>StandardOutPath</key>
  <string>${log}</string>
  <key>StandardErrorPath</key>
  <string>${log}</string>
  <key>ProcessType</key>
  <string>Background</string>
</dict>
</plist>
XMLEOF

コマンドは/bin/zsh -c "cmd"としてラップします。cronで走っていたコマンドはシェルの展開(~展開・グロブ)に依存していることが多く、ProgramArgumentsに直接渡すと動かないケースがある。zsh経由にすることでその差異を吸収しています。${cmd//&/&amp;}はXMLエスケープ——コマンドに&が含まれると不正なXMLになるため、ここで置換します。ログは~/.claude/logs/${label}.logに標準出力・エラー出力をまとめて吐きます。

フェーズ5:applyモードの配備(84〜96行目)

if [ "$MODE" = "apply" ]; then
  for f in "$PROPOSED"/*.plist; do
    cp "$f" "$TARGET_DIR/"
    launchctl unload "$TARGET_DIR/$(basename $f)" 2>/dev/null
    launchctl load   "$TARGET_DIR/$(basename $f)"
    echo "  loaded: $(basename $f)"
  done
  echo ""
  echo "🚨 cron 行は **手動で削除してください**:  crontab -e"
  echo "(誤って cron+launchd 両方走るのを避けるため)"
fi

launchctl unloadを先に呼ぶのは冪等性のためです。すでにloadされているplistに再度loadしようとするとエラーになる。事前にunloadしておくことで、applyを何度実行しても同じ結果になります。ただし注意点が1つ——スクリプトはapply後に「crontabから手動削除してください」と警告を出すだけで、削除は自動化していません。cron行を残したまま放置すると、macOSが将来的にcron daemonを復活させたタイミングでcron+launchdの二重起動が発生します。

実装の詳細

set -uo pipefail —— -e を外した理由

スクリプト1行目の宣言は set -uo pipefail です(実ファイル9行目)。-e(エラー即終了)が入っていないことに気づいた人もいるかもしれません。これは意図的な設計です。

applyモードのループを見てください(84〜96行目)。

launchctl unload "$TARGET_DIR/$(basename $f)" 2>/dev/null
launchctl load   "$TARGET_DIR/$(basename $f)"

launchctl unloadは、対象のplistがまだloadされていなければ終了コード非ゼロを返します。-eが有効なら、1本目の初回unloadでスクリプトが死ぬ。2>/dev/nullでエラー出力は消していますが、終了コードは残ります。-eなしにすることで「unloadが失敗してもループを続ける」という冪等な挙動を実現しています。

同じ理由で、crontab -l 2>/dev/null(22行目)も安全です。crontabが空のユーザー環境ではcrontab -lcrontab: no crontab for ユーザー名と非ゼロで終わりますが、2>/dev/nullで握りつぶしてループに進めます。-eがあればここで落ちていた。

-u(未定義変数エラー)と-o pipefail(パイプ失敗の伝播)は残す。 これは必要なガードです。変数名のタイポや、パイプ途中での失敗を拾うためです。-eだけが足を引っ張る状況——この判断はシェルスクリプトのエラーハンドリングで頻出するパターンです。

ラベル生成の正規表現と絶対パス依存

35行目のラベル生成ロジックを正確に読むと、1つ重要な仕様が見えます。

script=$(echo "$cmd" | grep -oE '~/.claude/scripts/[^ ]+' | head -1 | xargs basename 2>/dev/null)

正規表現が~/.claude/scripts/ではなく絶対パスでマッチしていることに注目してください。crontabのエントリが~/.claude/scripts/daily-brief.shと書かれていた場合、この正規表現にはマッチしません。~はシェルが展開する前の文字列として記録されているからです。マッチしなければscript変数は空になり、フォールバックに落ちます。

if [ -z "$script" ]; then
  script="$(echo "$cmd" | awk '{print $1}' | xargs basename 2>/dev/null)-${minute}${hour}"
fi

フォールバックは「コマンド名のbasename+分+時」です。たとえば~/.claude/scripts/daily-brief.sh0 8 * * *で登録していたなら、ラベルはcom.shun.daily-brief-08になります。daily-briefではなくdaily-brief-08。このズレが後でログを追いかけるときに混乱を生みます。

crontabに登録するときは常に絶対パスで書く、これがこのスクリプトと共存する唯一の正解です。

*/N 形式が壊れる仕組み——コードで追う

前半で触れた「*/N非対応」を、実際のコードフローで確認します。たとえばcrontabに*/15 * * * * ~/.claude/scripts/health-check.shという行があった場合、何が起きるか。

minute=$(echo "*/15 * * * * ~/.claude/scripts/health-check.sh" | awk '{print $1}')
# → "*/15"

次に条件分岐:

if [ "$minute" != "*" ]; then
  cal_xml+="    <key>Minute</key><integer>${minute}</integer>\n"
fi

"*/15" != "*"はtrueなので条件を通過し、生成されるXMLは:

<key>Minute</key><integer>*/15</integer>

<integer>タグの中に*/15という文字列が入ります。XMLとしては一応パースできますが、launchdがplistをロードするときに「Minuteは0〜59の整数でなければならない」という検証で蹴られます。launchctl loadは終了コード非ゼロを返し、loaded:の表示はされますが、実際にはスケジューリングが有効になっていません。

この「loadは通ったように見えるが実は動いていない」状態が厄介で、次の節でも登場します。

/bin/zsh -c ラップの意図と限界

生成されるplistのProgramArgumentsは(54〜66行目):

<key>ProgramArguments</key>
<array>
  <string>/bin/zsh</string>
  <string>-c</string>
  <string>${cmd}</string>
</array>

zshでコマンドをラップするのは、cronエントリに含まれがちな~展開・環境変数参照・グロブパターンを解釈させるためです。ProgramArgumentsにコマンドを直接渡すと、シェル展開なしでexecvpが呼ばれるため~がそのまま文字列として渡されてファイルが見つからないエラーになります。

ただし、/bin/zsh -cでもlaunchdはユーザーの.zshrcを読みません。これはlaunchdの設計です。zshのinteractiveモードではなくnon-loginのscriptモードで起動するため、source ~/.zshrcを書いていても読み込まれない。結果としてnvmで管理しているnode・pyenvのpython・Homebrewの各種コマンドはPATHが通っていない状態で起動します。

生成plistのテンプレートを見ると、EnvironmentVariablesキーがありません(54〜76行目全体を見てもない)。daily-brief.plistが手作業でEnvironmentVariablesを追記してあるのはそのためです。スクリプトが自動生成するplistには、このPATH注入が含まれていません。


私が詰まった話

詰まり1:*/15 * * * *がsilent failした

症状。 applyを実行するとloaded: com.shun.health-check.plistと表示された。しかし15分後も30分後も~/.claude/logs/com.shun.health-check.logには何も書かれない。

launchctl list com.shun.health-check
# → Could not find service "com.shun.health-check" in domain for port

loadされているはずのサービスが、launchctlのリストに存在しない。

原因。 */15がそのまま<integer>*/15</integer>に書かれ、launchdがplistの検証で内部的に棄却していました。launchctl loadコマンド自体は終了コード0を返したため(macOS Sequoiaでの挙動)、スクリプトのecho "loaded:"が走ってしまった。エラーが表示されないまま、サービスは存在しない状態になっていました。

直し方。 plutil -lint ~/.claude/scripts/launchd-proposed/com.shun.health-check.plistでplistを検証すると即座に弾かれます。*/15を含む行は移行前にcrontabから手動で書き換えが必要です。15分おきならlaunchdStartInterval(秒単位の間隔指定)に変えるか、00,15,30,45と展開した固定値を4エントリの配列として書きます。

<key>StartCalendarInterval</key>
<array>
  <dict><key>Minute</key><integer>0</integer></dict>
  <dict><key>Minute</key><integer>15</integer></dict>
  <dict><key>Minute</key><integer>30</integer></dict>
  <dict><key>Minute</key><integer>45</integer></dict>
</array>

あるいはStartIntervalで秒指定する方がシンプルです:

<key>StartInterval</key>
<integer>900</integer>

900秒=15分。この形式ならスクリプトの自動生成範囲外ですが、手書き1箇所で済みます。

詰まり2:crontabの~パスでラベルが衝突し古いplistを上書き

症状。 dryモードで確認した~/.claude/scripts/launchd-proposed/の中に、見覚えのないラベル名のplistが生まれていた。com.shun.daily-brief-08.plistcom.shun.note-publish-308.plistなど、末尾に時刻が付く命名。

原因。 crontabに~/.claude/scripts/daily-brief.sh~で書いていたため、35行目の絶対パスマッチ~/.claude/scripts/[^ ]+にヒットせず、フォールバックのコマンド名-分時形式になっていました。さらに、以前にapplyしたときに生成したcom.shun.daily-brief.plist~/Library/LaunchAgents/に残っているため、古いplistと新しいplistが別ラベルで二重に存在する状態になっていました。

launchctl list | grep com.shunを叩くと同じスクリプトを呼ぶエントリが2つ表示されていました。

直し方。 crontabをcrontab -eで開き、~を絶対パスに書き直します。次に~/Library/LaunchAgents/の旧ラベルplistを手動でunloadして削除します。

launchctl unload ~/Library/LaunchAgents/com.shun.daily-brief-08.plist
rm ~/Library/LaunchAgents/com.shun.daily-brief-08.plist

applyの前に必ずdryで生成ラベルを目視確認し、期待するcom.shun.<スクリプト名>形式になっているかを見る習慣が必要です。フォールバック形式(末尾に数字)が混じっていたら、crontabの書き方を疑います。

詰まり3:node・pythonがcommand not found

症状。 apply後、~/.claude/logs/com.shun.note-autolike.logに毎回同じエラーが出続ける。

/bin/zsh: node: command not found

手動でターミナルから同じコマンドを実行すると問題なく動く。

原因。 生成plistにEnvironmentVariablesが含まれないためです。/bin/zsh -cで起動しても.zshrcは読まれず、nvmが追加する~/.nvm/versions/node/v24.13.0/binはPATHに存在しない。ターミナルのシェルセッションと、launchd管理下のプロセスはまったく別のPATH環境で動きます。

直し方。 生成されたplistを手動で編集し、<key>ProgramArguments</key>の前にEnvironmentVariablesを追加します。daily-brief.plist(実ファイルから引用)が正解のモデルです:

<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>

本来はスクリプトの生成テンプレートにこのブロックを組み込むべきです。ただし「どのnodeバージョンを使うか」は環境によって異なるため、テンプレートにハードコードすると環境が変わったときに全plistの書き直しが必要になる。現在のスクリプトが意図的に省いているのは、この「バージョン固定の危うさ」を避けるためかもしれません——少なくとも自分でそう解釈して折り合いをつけています。

実際の運用では、nodeを使うジョブのplistには必ず手動でEnvironmentVariablesを書き足しています。スクリプト生成は「雛形の9割を作ること」、残り1割のPATH注入は手動、という分業です。

詰まり4:cron+launchdが同一スクリプトを二重起動していた

症状。 note自動投稿が1日2回走るはずが、ログを見ると投稿APIが1日4回呼ばれていた。レート制限に引っかかり、エラーレスポンスが出始めて発覚しました。

原因。 apply後にcrontab -eでcron行を削除するのを忘れていました。スクリプト末尾(94〜95行目)の警告を見落としていたのです。

🚨 cron 行は **手動で削除してください**:  crontab -e
(誤って cron+launchd 両方走るのを避けるため)

macOS Sequoida環境ではcron daemonが起動していないため「cron行があっても安全」と思い込んでいました。実際にはSequoideでもcron daemonが再起動するタイミング(主にOSアップデート後)があり、そのとき両方が動き始めます。今回はmacOSのマイナーアップデートがそのタイミングでした。

直し方。 crontab -lでlaunchd移行済みの行を確認し、全削除するか移行済み行をコメントアウトします。一番安全なのはcrontab -r(全削除)ですが、移行漏れがあると取り返しがつかないため、1行ずつ確認しながらcrontab -eで対処しました。

この失敗以来、applyの完了確認として次の2コマンドをセットで走らせています。

# launchd側の稼働確認
launchctl list | grep com.shun

# cron側の残骸確認(0行ならOK)
crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$' | wc -l

2つ目のコマンドが0を返せば、有効なcron行は存在しない。これが移行完了の判断基準です。

詰まり5:dryで確認したplistとapplyで配備されたplistが別物だった

症状。 dryで生成物を目視確認→問題なし→applyを実行→なぜかスケジュールが変わっている。

原因。 PROPOSEDディレクトリ(~/.claude/scripts/launchd-proposed/)に前回dryした古いplistが残っていました。今回dryでは別のcron行からの生成が走ったため、更新されたplistと古いplistが混在している状態になっていました。applyPROPOSED/*.plistを全部デプロイするため、意図していない古い世代のplistも~/Library/LaunchAgents/に上書きコピーされました。

for f in "$PROPOSED"/*.plist; do
  cp "$f" "$TARGET_DIR/"

この全コピーが問題の起点です。

直し方。 dryの前にPROPOSEDディレクトリを一度クリアする習慣をつけます。

rm -f ~/.claude/scripts/launchd-proposed/*.plist
~/.claude/scripts/cron-to-launchd.sh dry

あるいはapplyの直前にdiffPROPOSEDLaunchAgentsの差分を確認する。どちらも手間ですが、スクリプト側にクリーン処理が入っていないため、現状は手動の規律で補うしかありません。


ここまでの詰まりをまとめると、スクリプトが自動化してくれるのは「固定スケジュール・絶対パス・PATH不要」な行だけです。残り——*/N形式・~パス・nvm/pyenv依存——は手動で前処理または後処理が必要です。この境界を最初に把握していれば、4つの失敗のうち3つは防げました。97行のスクリプトは「cronの移行を全自動にする」ものではなく、「手作業の8割をスキップして残り2割を浮き彫りにする」ツールとして読むのが正確です。

つまずきポイント

前段の「詰まった話」では5本のエピソードを取り上げました。ここでは同じ失敗を繰り返さないために、落とし穴を体系的に整理します。まず「スクリプトが自動化できる範囲」を確認してから、見落としやすい罠を一気に並べます。

自動化できる範囲・できない範囲

cron-to-launchd.sh(97行)が正しく機能するのは、次の条件をすべて満たすcron行だけです。

  • cron式が固定値のみ——*/N形式を含まない
  • コマンドが絶対パスで記述されている——~展開ではない
  • コマンド文字列に&<>が含まれない
  • 同一スクリプトが1時刻にのみ登録されている

この4条件を外れた行は、自動生成が壊れるか、生成後の手動修正が必須です。「crontabの全行を全自動で移行する」ものではなく、「4条件を満たす行の雛形を8割作り、残り2割の手作業を浮き彫りにするツール」として使うのが正確です。

落とし穴リスト(実コード付き)

XMLエスケープが&のみ——<>を含む行でplistが破損する

スクリプト65行目を見てください。

<string>${cmd//&/&amp;}</string>

&&amp;に変換していますが、<&lt;>&gt;の変換はありません。crontabにcmd > /dev/null 2>&1のようなリダイレクト付きの行がある場合、生成されるplistの<string>タグ内に>が混入し、XMLパーサーがplistを読めなくなります。launchctl loadはエラーを返しますが、applyのループ内では次のplistに進むため、1本の破損を見落としやすい構造です。><を含む行は移行前にリダイレクトをスクリプト内部に移すか、plistを手書きしてください。

生成plistにRunAtLoadがない——apply直後の動作確認ができない

自動生成テンプレート(54〜76行目全体)にRunAtLoadキーは存在しません。一方、手動で仕上げたcom.shun.daily-brief.plistの実ファイル28〜29行目には<key>RunAtLoad</key><true/>が入っています。

RunAtLoadがないplistは、次のスケジュール時刻まで実行されません。apply直後にログを確認しても何も書かれていない状態は動作不良ではなく設計通りです。ただし「本当に動くのか」をその場でテストできないという問題があります。確認したい場合はlaunchctl kickstartを使います:

launchctl kickstart -k gui/$(id -u)/com.shun.xxx
tail -f ~/.claude/logs/com.shun.xxx.log

StartCalendarInterval<dict>単体——複数時刻は手動で<array>化が必要

生成スクリプトの46〜52行目のcal_xmlは<dict>1つで完結します。com.shun.daily-brief.plist(実ファイル33〜47行目)のように「8:00と10:30の2回」を表現するには配列が必要ですが、スクリプトは配列を生成しません。


<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key><integer>8</integer>
  <key>Minute</key><integer>0</integer>
</dict>

複数時刻を同一スクリプトに割り当てたい場合は、生成後にplistを手動で配列形式に書き直します。

同名スクリプトを複数時刻でcrontabに登録すると後者のplistが前者を上書き

daily-brief.shを8時と10時半に動かしたくてcrontabに2行書いたとします。

0  8  * * * /path/to/.claude/scripts/daily-brief.sh
30 10 * * * /path/to/.claude/scripts/daily-brief.sh

ラベル生成(40行目)はスクリプト名から拡張子を除くため、両行ともcom.shun.daily-briefになります。plistファイル名もcom.shun.daily-brief.plistで同一。後から処理された行(10:30の行)が前の行(8:00)を上書きし、8時の設定が消えます。スクリプト側に衝突検知はありません。dryで生成物を目視確認するしかありません。

*/N形式がsilent failする——launchctl loadは成功に見える

p2で詳述したエピソードの核心を一行でまとめます。*/15<integer>*/15</integer>として書き出され、launchdが内部検証でplistを棄却します。launchctl loadのコマンドは終了コード0を返すため成功に見えますが、launchctl list com.shun.xxxでサービスが見つからなければ棄却されています。移行前に*/Nを含む行を手動で変換することが唯一の解決策です。

~パスはラベル生成のフォールバックに落ちる

35行目の正規表現は絶対パスのみマッチします。~/.claude/scripts/note-autolike.shと書いていれば、フォールバック(36〜39行目)が動いてラベルがcom.shun.note-autolike-308のような末尾数字付きになります。以前に絶対パスで生成したcom.shun.note-autolike.plist~/Library/LaunchAgents/に残っていれば、2つの別ラベルが同一スクリプトを呼ぶ二重起動状態が作られます。crontabには必ず絶対パスで書いてください。

PROPOSEDをクリアしないままapplyすると古いplistが混入する

87行目のfor f in "$PROPOSED"/*.plistはPROPOSEDの全ファイルを無差別にコピーします。前回のdryで生成したが今回削除したcron行のplistが残っていると、削除したはずのジョブがapplyで復活します。dryを実行する前にrm -f ~/.claude/scripts/launchd-proposed/*.plistでクリアするのを習慣にします。

生成plistにEnvironmentVariablesがない——nvm・pyenv・Homebrewのコマンドが死ぬ

生成テンプレート(54〜76行目)にEnvironmentVariablesキーは含まれていません。launchdはユーザーの.zshrcを読まないため、nvmで管理するnodeを呼ぶスクリプトは起動直後にnode: command not foundで落ちます。ターミナルから手動実行すると問題なく動くのに、launchd経由では死ぬ——この非対称が診断を難しくします。com.shun.daily-brief.plistの6〜9行目のPATH文字列を手本に、nodeやpythonを使う全plistに追記してください。

LowPriorityIONiceが生成plistにない——自動化がフォアグラウンドを妨害する

com.shun.daily-brief.plistの12〜15行目にはLowPriorityIONice 10がありますが、生成テンプレートにはありません。設定がなければバックグラウンドジョブが通常のI/O優先度で走ります。ファイルの大量読み書きを伴うジョブがエディタやブラウザの応答を遅くした経験がある場合、このキーの有無を確認してください。

cron行の削除忘れは時限爆弾——次のOSアップデートで二重起動する

スクリプトのapply後(94〜95行目)に「cron行は手動で削除してください」と警告が出るだけで、削除は自動化されていません。Sequoiaではcron daemonが起動しないため「残しても安全」と思いがちですが、macOSのマイナーアップデートでcron daemonが復活するケースが実際にあります。note自動投稿が1日4回走ってAPIのレート制限に引っかかったのはこの失敗からです。移行完了の判定に「cron残骸がゼロ」を含めることで再発を防いでいます。


ベストプラクティス

97行のスクリプトと半年の運用から蒸留した、移行作業・日常運用の両方で使えるルール集です。

1. crontabのエントリは絶対パスで書く

~/.claude/scripts/xxx.shではなく、/home/.../.claude/scripts/xxx.shと書きます。35行目の正規表現にマッチし、ラベルが意図したcom.shun.xxxになります。過去のcron行を書き直す手間はかかりますが、移行後のラベル衝突・二重起動・フォールバック命名の混乱を3つ同時に防げます。

2. */N形式は移行前に手動変換する

*/15 * * * *(15分おき)は次のどちらかに変換します。間隔が固定ならStartInterval(秒指定)が最もシンプルです。

<key>StartInterval</key>
<integer>900</integer>  

特定分数での実行が必要な場合は固定値を配列で列挙します(0・15・30・45分)。変換漏れはplutil -lintで検証できます。

3. dryの前にPROPOSEDディレクトリをクリアする

rm -f ~/.claude/scripts/launchd-proposed/*.plist
~/.claude/scripts/cron-to-launchd.sh dry

この2行をセットで実行することで、古い世代のplistがapplyに混入する問題を防ぎます。

4. dry後にplutil -lintで全plistを検証してからapplyする

for f in ~/.claude/scripts/launchd-proposed/*.plist; do
  echo "--- $(basename $f)"
  plutil -lint "$f"
done

*/N混入・XMLエスケープ漏れ・構文エラーをApple公式ツールで事前に弾きます。plutil -lintOKを返さないplistはapplyしません。

5. apply直後に2コマンドで完了を確認する

# launchd側の稼働確認
launchctl list | grep com.shun

# cron残骸確認(0ならOK)
crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$' | wc -l

2行目が0を返してlaunchd側のエントリ数が移行対象行数と一致していれば、移行完了と判断できます。

6. nodeを使うジョブのplistにEnvironmentVariablesを手動追記する

生成直後に<key>ProgramArguments</key>の直前へ挿入します:

<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>

nvmのバージョン番号は実環境に合わせます。com.shun.daily-brief.plistの6〜9行目がモデルです。

7. 複数時刻のplistはStartCalendarIntervalを配列に書き直す

同一スクリプトを2時刻で動かすなら、1つのplistに配列を使います(crontabに2行書いてラベル衝突させない)。

<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>

com.shun.daily-brief.plistの33〜47行目の記述が実例です。

8. バックグラウンドジョブ全般にLowPriorityIONice 10を設定する

作業の邪魔をしないために、生成plistすべてに追記します:

<key>LowPriorityIO</key><true/>
<key>Nice</key><integer>10</integer>

自動化環境が「存在感を消す」ことで、人間の作業領域を侵食しない設計にできます。

9. コマンドに&<>が含まれる行は手動でplistを書く

生成に頼らず、XMLエスケープを正確に行います:

  • &&amp;
  • <&lt;
  • >&gt;

リダイレクトは呼び出すシェルスクリプトの内部に移すのが最もクリーンです。plistのXML内でリダイレクトを扱おうとすると、ほぼ必ずこのエスケープ問題に当たります。

10. launchctl list com.shun.xxxでLastExitStatusを定期確認する

launchctl list com.shun.daily-brief

"LastExitStatus" = 0が正常。0以外の場合はログを確認します。週次での一括確認:

launchctl list | grep com.shun | awk '{print $3}' | \
  xargs -I{} sh -c 'launchctl list "{}" 2>/dev/null' | \
  grep -E '"Label"|"LastExitStatus"'

11. launchctl kickstartでオンデマンド実行してデバッグする

スケジュール時刻を待たず即時実行したい場合:

launchctl kickstart -k gui/$(id -u)/com.shun.xxx

-kは実行中のインスタンスをkillして再起動する冪等なオプションです。ログに何も出なければPATHかスクリプトのパス問題です。

12. macOSアップデート後は全サービスの生死を確認する

マイナーアップデートでlaunchdの動作が変わることがあります。アップデート翌朝のデイリーブリーフが来なければ、まずlaunchctl list | grep com.shunを叩きます。サービスが消えていれば再applyで復活します。

13. 移行完了の「完了条件」を3つ定義しておく

移行作業の「終わり」が曖昧だと確認を省略しがちです。以下を完了条件として設定しています:

  • launchctl list | grep com.shunのエントリ数が移行対象のcron行数と一致する
  • crontab -l 2>/dev/null | grep -vE '^\s*#' | grep -v '^$' | wc -l0を返す
  • 全サービスのLastExitStatusが少なくとも初回実行後に0になっている

この3つを満たして初めて「移行完了」と言えます。

14. 移行作業全体の所要時間を事前に見積もる

cron行の本数・*/N形式の含有数・nvm依存ジョブの数を数えてから作業を始めます。10本以下であればdryplutil検証→手動修正→apply→完了確認の一連が90分以内に終わります。30本以上ある場合は4条件を満たす行を先に自動移行し、残りを翌日以降に手動移行する分割戦略が現実的です。


まとめ

macOSのcron daemonが静かに止まる問題は、エラーが出ないぶん発見が遅れます。crontab -lにエントリが並んでいるのに8時のデイリーブリーフが来ない、11時のSNS投稿が流れない——その違和感に気づくまで数時間かかる。launchdへの移行は「cronが壊れてから対処する」のではなく、「macOSのネイティブな仕組みに乗り直す先行投資」として捉えた方が正確です。

97行のスクリプトcron-to-launchd.shがやることは単純です。crontabを1行ずつ読み、5フィールドをXMLに変換し、plistとして書き出す。dry→plutil検証→applyの3ステップで、固定スケジュール・絶対パス・PATH不要な行の雛形を量産できます。ただし「全自動の魔法」ではありません。*/N形式・~パス・nvm/pyenv依存・複数時刻・XMLエスケープ対象文字——これらは手動で前処理または後処理が必要です。スクリプトが「9割の雛形を作る」ことで、手動作業の対象が明確になる。その構造を理解して使うことが、移行後に詰まらないための一番の近道です。

launchdに移し終えたジョブは、launchctl list com.shun.xxxで状態を即座に確認できます。LastExitStatusが0であることが、「動いているはず」ではなく「動いている」を証明します。自律環境の信頼性は、「動いていると思っていたら止まっていた」という発見を排除することで積み上がっていきます。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。

📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!