はじめに:33個のlaunchdジョブを抱えて
当社(合同会社ジョインクラス)は、記事公開・売上取得・朝のダイジェスト生成などをClaude Codeで自動化しています。動かしているのはmacOSのlaunchdで、2026年9月に棚卸ししたところ 33ジョブ ありました。社内のSTATE.mdには長いあいだ「17ジョブ」と書かれていて、実態と合っていませんでした。
運用していて特に痛かったのは次の2件です。
- 最初はcrontabで組んだが、フルディスクアクセス権限が足りずcronジョブが全滅した。launchdに全面移行して解決した
- Qiita公開ジョブのplistに
Disabled: trueが残っていて、5ヶ月間だれも気づかないまま止まっていた
そこで、Claude Codeに内蔵されたスケジューラ(/loop と、その裏で使われる CronCreate / CronList / CronDelete ツール)に一部を移しました。この記事は、実際に移行して分かった手順と落とし穴のまとめです。
先に結論を書きます。
内蔵cronはlaunchdを「全部」置き換えるものではない。 セッションの中で動くスケジューラなので、Claudeが文脈を持ったまま繰り返す作業だけを移す。毎日決まった時刻に必ず動いてほしいジョブはlaunchdに残す。
1. 両者の違いを最初に押さえる
| 観点 | launchd | Claude Code内蔵cron |
|---|---|---|
| 実行主体 | OS(ログインしていれば常に動く) | 起動中のClaude Codeセッション |
| 寿命 | 永続 | セッションを閉じると消える。定期タスクにも有効期限がある(執筆時点のドキュメントでは数日で自動失効) |
| 実行されるもの | シェルスクリプト | プロンプト(スラッシュコマンドも可) |
| 文脈 | 毎回ゼロから | 会話の文脈を引き継ぐ |
| 発火タイミング | 指定時刻ちょうど | セッションがアイドルのとき(作業中なら後ろにずれる) |
| 環境変数・PATH | 自分で全部書く必要あり | セッションの環境をそのまま使う |
この表で判断の大半がつきます。
- launchdに残す:売上取得、記事の定時公開、watchdog。止まると実害が出るもの
- 内蔵cronに移す:「デプロイが終わるまで5分おきに見て」「このPRのCIが通ったら次へ進めて」のように、作業中だけ必要な監視や、前回の結果を踏まえて判断する繰り返し
2. 移行前の棚卸しスクリプト
まず、今どのジョブがロードされていて、どれが止まっているかを確認します。当社の auto-watchdog.py がやっていることのうち、確認部分だけを抜き出したシェル版です。
#!/bin/bash
# ~/Library/LaunchAgents 配下の自社ジョブを一覧化し、
# Disabled / 未ロード / 直近の終了コード異常 を検出する
set -euo pipefail
PREFIX="${1:-com.joinclass}" # 自社plistのLabelプレフィックス
for plist in ~/Library/LaunchAgents/${PREFIX}.*.plist; do
label=$(/usr/libexec/PlistBuddy -c 'Print :Label' "$plist")
disabled=$(/usr/libexec/PlistBuddy -c 'Print :Disabled' "$plist" 2>/dev/null || echo false)
# launchctl list の出力: PID LastExitStatus Label
line=$(launchctl list | awk -v l="$label" '$3==l')
if [ -z "$line" ]; then
status="NOT_LOADED"
else
exit_code=$(echo "$line" | awk '{print $2}')
status=$([ "$exit_code" = "0" ] && echo "OK" || echo "EXIT=$exit_code")
fi
printf '%-45s disabled=%-5s %s\n' "$label" "$disabled" "$status"
done
$ ./list-launchd-jobs.sh # 出力例
com.joinclass.auto-morning disabled=false OK
com.joinclass.qiita-publish disabled=true NOT_LOADED ← これが5ヶ月止まっていた
com.joinclass.auto-kdp-sales disabled=false EXIT=1
...
落とし穴①:Disabled キーは launchctl load では上書きされない。 plist内に <key>Disabled</key><true/> が残っていると、loadしても黙って無視されます。launchctl bootstrap gui/$(id -u) <plist> を使い、launchctl enable gui/$(id -u)/<label> も併せて実行してください。
3. 移行候補の判定基準
棚卸しの結果を、次の3つの質問に当てはめて仕分けました。
- セッションを閉じたら止まってもいいか? → ダメならlaunchdに残す
- 実行時にClaudeの判断(文脈)が必要か? → 不要ならlaunchdで十分
- 数分〜数時間で終わる監視か? → Yesなら内蔵cronが最適
この基準で仕分けた結果、33ある常設ジョブは すべてlaunchdに残す ことになりました。どれも「止まると困る」側に入ったためです。移す価値があったのは、これまで人間(筆者)がターミナルに張り付いて「終わった?」と聞き直していた作業のほうでした。
4. 実際の移行手順
4-1. 例:Zennのデプロイ失敗を監視する
当社には、Zennへのpushは成功したのに記事が公開されない問題を検知して再試行する retry-zenn-deploy.sh があります。普段はlaunchdで定時実行していますが、新しい本を出す日だけは公開されるまで細かく見張りたい。ここは内蔵cronの出番です。
Claude Codeのセッションで次のように頼みます。
/loop 10m zenn-content-company の最新pushが https://zenn.dev/joinclass に
反映されたか確認して。未反映なら .company/scripts/retry-zenn-deploy.sh を実行。
反映を確認したらこのループを止めて、URLだけ報告して。
裏では CronCreate が呼ばれ、5フィールドのcron式(この場合は */10 * * * *)でタスクが登録されます。登録状況は CronList で確認でき、Claudeに「スケジュール一覧を見せて」と頼めば表示されます。
4-2. スクリプト側は「単体で実行できる」形にしておく
内蔵cronから呼ぶスクリプトも、launchdから呼ぶスクリプトも、同じ共通ヘルパーを読み込む ようにしておくと、移行しても切り戻しても挙動が変わりません。当社の全スクリプトが読み込んでいる common.sh の冒頭部分です。
#!/bin/bash
set -euo pipefail
# launchdはログインシェルのPATHを引き継がないので明示する
export PATH="$HOME/.nvm/versions/node/v22.17.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
LOG_DIR="$PROJECT_ROOT/.company/scripts/logs"
SCRIPT_NAME="$(basename "${BASH_SOURCE[1]:-${BASH_SOURCE[0]}}" .sh)"
mkdir -p "$LOG_DIR"
# 失敗したらSlackへ通知(どの実行基盤から呼ばれても同じ)
on_error_notify() {
local exit_code=$? line_no=${1:-unknown}
[ -n "${SLACK_WEBHOOK_URL:-}" ] || return 0
curl -sf -X POST -H "Content-Type: application/json" \
-d "$(jq -n --arg t ":x: $SCRIPT_NAME 失敗 (行:$line_no, コード:$exit_code)" '{text:$t}')" \
"$SLACK_WEBHOOK_URL" >/dev/null 2>&1 || true
}
trap 'on_error_notify $LINENO' ERR
load_env() {
[ -f "$PROJECT_ROOT/.env" ] || return 0
set -a; source "$PROJECT_ROOT/.env"; set +a
}
落とし穴②:PATHを固定しているので、nodeのバージョンを上げると壊れる。 内蔵cronではセッションのPATHが使われるため動くのに、launchdから呼ぶと node: command not found になる。こうした「片方だけ動く」状態が生まれます。移行期間中は、同じスクリプトを両方の経路から1回ずつ手で実行して確認してください。
4-3. launchd側を止めるときは「unload」ではなく「無効化+記録」
本当にlaunchdから外すジョブがある場合も、plistは削除しません。
LABEL=com.joinclass.example-job
launchctl bootout gui/$(id -u)/$LABEL
launchctl disable gui/$(id -u)/$LABEL
# なぜ止めたかをジョブ台帳に残す(ここを忘れたのが5ヶ月停止の原因)
echo "| $LABEL | 停止 | $(date +%F) 内蔵cronへ移行 |" >> .company/scripts/JOBS.md
当社の5ヶ月停止は「誰かが意図して止めたのか、事故なのか」を後から判断できなかったことが本当の原因でした。止めるたびに台帳へ1行書く、という運用にしています。
5. 落とし穴まとめ
| # | 症状 | 原因 | 対策 |
|---|---|---|---|
| ① | loadしても動かない | plistに Disabled: true が残っている |
bootstrap + enable を使う。棚卸しスクリプトで定期チェック |
| ② | 片方の経路だけ失敗する | PATH・環境変数の差 |
common.sh でPATHを明示し、両経路で手動テストする |
| ③ | 朝起きたら内蔵cronが動いていない | セッション終了・有効期限切れ | 永続が必要なジョブは移さない |
| ④ | 指定時刻からずれる | Claudeが作業中だと、アイドルになるまで発火しない | 時刻が重要なジョブは移さない |
| ⑤ | APIコストが想定より増える | 毎回Claudeの推論が走る | 判断が不要な処理はシェルのままlaunchdで回す。当社は check_cost_limit で日次上限を確認してから実行している |
| ⑥ | crontabが全滅する(macOS) | cronにフルディスクアクセス権がない | macOSではcrontabではなくlaunchdを使う |
6. 動作検証のやり方
移行したら、次の3点を確認して完了とします。
# 1) launchd側:残したジョブがすべてロードされ、終了コード0であること
./list-launchd-jobs.sh | grep -v ' OK$' # 何も出なければOK
# 2) launchd側:ジョブを今すぐ1回動かしてログを確認
launchctl kickstart -k gui/$(id -u)/com.joinclass.auto-morning
tail -n 20 .company/scripts/logs/auto-morning.log
- 内蔵cron側:Claude Codeに「登録中のスケジュールを一覧して」と頼み、
CronListの結果に意図したタスクだけが並んでいることを確認する。不要なものは「○○のループを止めて」と頼めばCronDeleteで削除されます。
あわせて、当社では auto-watchdog.py を1日2回(10:30/22:30)動かし、ロードが外れたジョブがあればSlackへ通知しています。監視役のジョブだけは、何があってもlaunchdに置くのが鉄則です。監視役がセッション依存だと、セッションが落ちたことに誰も気づけません。
おわりに
「内蔵cronが出たのだから、launchdは要らなくなる」と最初は期待していました。実際に移行してみると、役割はきれいに分かれました。
- launchd:止まってはいけない定時処理(OSに任せる)
- Claude Code内蔵cron:作業中の見張りや、判断を伴う繰り返し(Claudeに任せる)
自動化で一番コストがかかるのは、ジョブを作ることではなく 止まったことに気づかないこと です。当社は5ヶ月分のQiita記事をそれで失いました。どちらの基盤を使うにしても、棚卸しスクリプトと台帳はセットで用意しておくことをおすすめします。
もっと深く知りたい方へ
Hooks・スケジュール実行・launchd連携を含め、Claude Codeで業務を丸ごと自動化する設計と実装は、書籍『Claude Code 全自動化バイブル』にまとめています。この記事で紹介した common.sh を中心にした構成や、ジョブ監視の考え方も詳しく解説しています。