Claude Codeのスケジュール実行でハマった3つの罠——cronとLaunchAgent連携のデバッグ記録
Claude Codeの/scheduleコマンドとcron/LaunchAgentを組み合わせた自動実行で遭遇した3つの問題と解決策。環境変数、認証、タイムアウトの罠を実体験ベースで解説。
エンジニアのゆとです。
Claude Codeのスケジュール実行を本格的に使い始めて、「なんで手元では動くのに自動実行だと死ぬんだ」という時間を相当費やした。
LaunchAgentとcronで何度か同じ罠を踏んだので、再現性のある形で記録しておく。「スケジュール実行で詰まった」という人には刺さるはず。
念のため前提を書いておくと、Claude Codeのスケジュール実行には大きく2種類ある。Anthropicのクラウドで動く /schedule コマンド(Cloud Scheduled Tasks)と、ローカルのLaunchAgent / cronを使った自前運用だ。今回ハマったのは主に後者、ローカル自前運用の話。Cloud版については別記事にまとめている。

/scheduleコマンドで何ができるか(ざっくり整理)
本題に入る前に、/scheduleの基本を確認しておく。
/schedule はClaude Codeセッション内で使えるコマンドで、タスクを定期実行に登録できる。
# 毎朝9時にPRレビュー
/schedule daily at 9am review all open PRs and post summary to Slack
# 1時間おきにビルドチェック
/schedule hourly check for failed CI runs and create Linear issue
Cloud Scheduled Tasksとして登録すると、Anthropicのインフラ上で実行される。PCが閉じていても動く。
一方、「ローカルのファイルを操作したい」「PythonスクリプトをClaude Codeから動かしたい」という用途では、LaunchAgent(macOS)やcronでClaudeコマンド自体を起動する構成になる。これが自由度は高いが、罠も多い。
具体的にはこういう構成:
# cronで毎朝6時に実行
0 6 * * * /usr/local/bin/claude --headless -p "daily-review" \
--system "$(cat ~/.claude/system-prompt.md)" \
"~/Projects/myapp" "今日のコード品質チェックを実行して"
理屈の上ではシンプル。でも実際に動かすと、3つの壁にぶつかる。
罠1: LaunchAgentのPATH問題——CLIでは動くのにcronで動かない
一番最初にハマったやつ。体感的に、ローカル自動化で詰まる人の半分はこれだと思う。
症状
ターミナルでは動く:
$ claude --headless -p "test-task" ~/Projects/myapp "ファイル一覧を表示して"
# → 正常動作
LaunchAgentに同じコマンドを書いたら沈黙。エラーログすら出ない。
原因
LaunchAgentが持っているPATH環境変数は、ターミナルで使っているそれとは別物だ。
ターミナルを開くと ~/.zshrc が読み込まれ、homebrewのパス(/opt/homebrew/bin)やnvmのパス、pyenvのパス、各種CLIツールのパスがPATHに追加される。
LaunchAgentはそういった初期化処理を一切やらない。ログインシェルを立ち上げず、純粋にOSが設定したデフォルトパス(だいたい /usr/bin:/bin:/usr/sbin:/sbin)だけで動く。
claude コマンドは npm install -g @anthropic-ai/claude-code でインストールしていたから、homebrew管理のNode.jsのパスが通っていないと見つからない。
確認方法
まず、ターミナルで動いているclaudeコマンドの実体を確認する:
$ which claude
/opt/homebrew/bin/claude
$ type claude
claude is /opt/homebrew/bin/claude
次に、LaunchAgentのPATHがどうなっているか確認する。plistに一時的にこれを仕込む:
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>echo $PATH > /tmp/launchagent-path.txt && which claude >> /tmp/launchagent-path.txt 2>&1</string>
</array>
/tmp/launchagent-path.txt を見ると、想定外の短いPATHが記録されているはず。
解決策
plistファイルで EnvironmentVariables を明示的に設定する。
<?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>com.yutolab.daily-review</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HOME</key>
<string>/Users/yuto</string>
<key>NODE_PATH</key>
<string>/opt/homebrew/lib/node_modules</string>
</dict>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/claude</string>
<string>--headless</string>
<string>-p</string>
<string>daily-review</string>
<string>/Users/yuto/Projects/myapp</string>
<string>今日のコード品質チェックを実行して</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>6</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>StandardOutPath</key>
<string>/tmp/daily-review.log</string>
<key>StandardErrorPath</key>
<string>/tmp/daily-review-error.log</string>
</dict>
</plist>
ポイントが2つ。
1つ目は ProgramArguments の第一要素に claude ではなく /opt/homebrew/bin/claude と絶対パスを書くこと。PATHを設定していても、ProgramArgumentsの実行ファイル自体は絶対パスで指定しておくほうが安全。
2つ目は HOME を明示的に設定すること。LaunchAgentは HOME が空になるケースがあり、Claudeのセッションデータや設定ファイルが見つからなくてコケることがある。
cronの場合も同様で、スクリプト先頭でPATHを上書きするのが定番:
#!/bin/bash
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin"
export HOME="/Users/yuto"
claude --headless -p "daily-review" ~/Projects/myapp "今日のコード品質チェックを実行して"
罠2: 認証トークンの期限切れ——headlessモードで再認証できない
これはLaunchAgent運用の中で一番困った問題。発覚するまで2週間くらい気づかなかった。
症状
最初の数日は正常動作していたのに、ある日から突然ログに何も出なくなった。エラーもない。ただ実行した形跡がない。
ログファイルを見ると:
Error: Authentication required. Please run 'claude auth login' to authenticate.
原因
Claude CodeはOAuthベースの認証を使っていて、アクセストークンに有効期限がある。通常のターミナル操作では、トークンが切れたタイミングでブラウザが開いて再認証を促してくれる。
--headless で動かしていると、ブラウザが開けないので再認証のUIが表示できない。その結果、認証エラーのメッセージだけがstderrに出て、タスクがサイレントに終了する。
しかも StandardErrorPath を設定し忘れていると、そのエラーメッセージさえ残らない。
確認方法
まずエラーログを必ず設定する(前述のplist例に含めてある)。
それとは別に、現在の認証状態を手動で確認する:
$ claude auth status
# 認証済みの場合:
# Logged in as: yuto@example.com
# Token expires: 2026-04-20 10:30:00
# 期限切れの場合:
# Not authenticated. Run 'claude auth login' to authenticate.
解決策
短期的には、定期的に手動で再認証を実行するしかない:
$ claude auth login
ブラウザが開いてAnthropicの認証画面が表示される。ログインすれば新しいトークンが発行される。
根本的な対策として、スクリプトの中でトークンの有効期限を事前にチェックして、切れていたらTelegramに通知する仕組みを入れた:
#!/bin/bash
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
export HOME="/Users/yuto"
# 認証チェック
AUTH_STATUS=$(claude auth status 2>&1)
if echo "$AUTH_STATUS" | grep -q "Not authenticated"; then
# Telegramに通知
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}&text=Claude認証が切れています。手動で再認証してください。"
exit 1
fi
# メインタスク実行
claude --headless -p "daily-review" ~/Projects/myapp "今日のコード品質チェックを実行して"
APIキー認証(ANTHROPIC_API_KEY 環境変数)を使う方法もある。APIキーはOAuthトークンと違って自動的に期限切れにならないので、headless運用との相性が良い。
export ANTHROPIC_API_KEY="sk-ant-..."
claude --headless ~/Projects/myapp "今日のコード品質チェックを実行して"
ただし、Maxプランのレート制限などはOAuth認証を通じて適用されるので、APIキー経由だとプランの扱いが変わる点に注意。APIキー使用分はAPI料金として別途課金される。用途に合わせて選ぶ必要がある。
罠3: 長時間タスクのタイムアウト——セッションが途中で死ぬ
「1時間かかるコード分析を夜中に回しておこう」と思って朝起きたら、ログが途中で途切れていた。これも何度か踏んだ。
症状
ログが途中までしか出ていない。最後の行が処理の途中で、完了メッセージがない。
[02:15:33] ファイル分析開始
[02:15:45] src/components/ 処理中...
[02:16:02] src/utils/ 処理中...
[02:16:18] src/api/ 処理中...
↑ここで終わり。完了ログなし。
原因
いくつか原因が重なっていた。
1. macOSのスリープMacがディスプレイをオフにしてからしばらくすると、システムスリープに入る。デフォルト設定だと、AC電源接続でも数分〜数時間でスリープする。プロセスはサスペンドされ、タスクが止まる。
2. LaunchAgentのTimeout設定plistに明示的なタイムアウトを設定していなくても、システム側でデフォルトのタイムアウトが適用されるケースがある。
3. Claude CodeのセッションTimeout--headless モードのClaude Codeには、内部的なセッションタイムアウトがある。長時間の処理で中間の出力が一定時間止まると、タイムアウトと判定されてセッションが終了することがある。
解決策
スリープ対策plistに caffeinate コマンドをラップして使う:
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>caffeinate -i /opt/homebrew/bin/claude --headless -p "long-analysis" ~/Projects/myapp "コード全体の品質分析を実行して"</string>
</array>
caffeinate -i は、指定したコマンドが動いている間、システムがスリープに入らないようにする。コマンドが終了すると、caffeinate自体も終了して、普通のスリープが再開される。
電源接続時のスリープを設定で無効にする方法もあるが、バッテリーへの影響があるので caffeinate を使うほうが安全だ。
「1つの大きなタスクを1回のセッションで全部やる」ではなく、「小さなタスクを複数回の実行で分割する」設計にする。
例えば「リポジトリ全体を分析」ではなく:
# plist 1: src/components/ だけを分析
# 毎朝6:00
claude --headless ~/Projects/myapp "src/components/ディレクトリのコード品質を分析して、結果を~/analysis/components-$(date +%Y%m%d).mdに書き出して"
# plist 2: src/api/ だけを分析
# 毎朝6:30
claude --headless ~/Projects/myapp "src/api/ディレクトリのコード品質を分析して、結果を~/analysis/api-$(date +%Y%m%d).mdに書き出して"
# plist 3: 結果をまとめてレポート作成
# 毎朝7:00
claude --headless ~/Projects/myapp "~/analysis/以下の今日の分析ファイルを読んで、サマリーレポートを作成して"
各タスクの実行時間が短くなるので、タイムアウトに引っかかりにくくなる。
途中結果をファイルに書き出す長時間タスクを分割できない場合は、途中結果を逐次ファイルに書き出すようにタスク指示に含める:
claude --headless ~/Projects/myapp \
"コード品質分析を実行して。分析結果はディレクトリごとに ~/analysis/<dirname>-analysis.md に随時書き出しながら進めること。全体が終わったら ~/analysis/summary.md にサマリーを作成すること。"
こうすると、途中でセッションが死んでも、それまでの結果がファイルに残る。再実行時に「すでに作成済みのファイルをスキップして残りから再開して」と指示すれば、続きから再実行できる。
デバッグの手順
詰まったときの調査手順をまとめておく。
ステップ1: ログを確認する
まずStandardOutPathとStandardErrorPathに出力されているログを見る。plistに設定していない場合は、今すぐ追加する。
# 最新50行を確認
tail -50 /tmp/daily-review.log
tail -50 /tmp/daily-review-error.log
エラーがなくログも出ていない場合は、プロセス自体が起動していない可能性が高い。
ステップ2: LaunchAgentの状態を確認する
# 登録されているか確認
launchctl list | grep com.yutolab
# 詳細な状態確認(終了コードが0以外なら失敗している)
launchctl print gui/$(id -u)/com.yutolab.daily-review
"LastExitStatus" = 1 や "LastExitStatus" = 127 が出ていたら実行は試みられているが失敗している。
終了コード127は「コマンドが見つからない」を意味するので、PATH問題(罠1)を疑う。
ステップ3: plistを手動でロードして即時実行する
スケジュールを待たずに、今すぐ手動でテスト実行する:
# 一度アンロード
launchctl unload ~/Library/LaunchAgents/com.yutolab.daily-review.plist
# ロードし直す
launchctl load ~/Library/LaunchAgents/com.yutolab.daily-review.plist
# 手動実行
launchctl start com.yutolab.daily-review
手動実行直後にログを見れば、問題がすぐ分かる:
# ログをリアルタイムで追う
tail -f /tmp/daily-review.log &
tail -f /tmp/daily-review-error.log &
# 実行
launchctl start com.yutolab.daily-review
ステップ4: --debug フラグでClaudeの詳細ログを出す
Claudeコマンドに --debug フラグを付けると、詳細なデバッグ出力が得られる:
claude --headless --debug -p "test" ~/Projects/myapp "簡単なテストを実行して" 2>&1
stderrに内部状態の詳細ログが出る。認証エラー、ツール実行の詳細、ネットワーク状態など、通常の出力には出てこない情報が確認できる。
ステップ5: シェルを明示的に指定してデバッグする
cronやLaunchAgentの環境を模倣してコマンドを実行するには:
# LaunchAgentと同じ環境を再現
env -i HOME=/Users/yuto \
PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin" \
/opt/homebrew/bin/claude --headless --debug ~/Projects/myapp "テスト実行"
env -i で環境変数をリセットしてから、必要なものだけ渡す。ターミナルのフル環境では動くのにLaunchAgentで動かない場合、この方法で問題を再現できる。
LaunchAgentの正しい書き方(チェックリスト)
これまでの内容を踏まえたplistのベストプラクティス版:
<?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>
<!-- 1. ユニークなラベル -->
<key>Label</key>
<string>com.yutolab.daily-review</string>
<!-- 2. 環境変数を明示(PATHとHOMEは最低限必須) -->
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HOME</key>
<string>/Users/yuto</string>
<key>ANTHROPIC_API_KEY</key>
<string>sk-ant-...</string>
</dict>
<!-- 3. シェルスクリプト経由で実行(複数コマンド・エラーハンドリングが書ける) -->
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/yuto/.local/scripts/daily-review.sh</string>
</array>
<!-- 4. スケジュール設定 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>6</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<!-- 5. ログ出力(必須)-->
<key>StandardOutPath</key>
<string>/Users/yuto/logs/daily-review.log</string>
<key>StandardErrorPath</key>
<string>/Users/yuto/logs/daily-review-error.log</string>
<!-- 6. 起動失敗時の自動再起動は慎重に -->
<!-- KeepAlive: trueはLaunchDaemon向け。LaunchAgentでは予期しない挙動の原因になりやすい -->
<!-- 7. 実行環境の作業ディレクトリ -->
<key>WorkingDirectory</key>
<string>/Users/yuto</string>
</dict>
</plist>
plistはXML形式なので、構文エラーがあるとサイレントに失敗する。ロードする前にLintをかけておくと安全:
plutil -lint ~/Library/LaunchAgents/com.yutolab.daily-review.plist
# OK の場合: com.yutolab.daily-review.plist: OK
# エラーの場合: エラーの内容が出力される
cronとLaunchAgentの比較
「結局どっちを使えばいいか」は、用途次第。
cronが向いているケース
シンプルさ > 安定性 の用途
- スクリプトが数行で完結するシンプルなタスク
- Linuxにも同じ設定を持ち込む予定がある
- 設定をバージョン管理したい(crontabはテキストファイルとして管理しやすい)
crontabの基本:
# crontabを編集
crontab -e
# 毎朝6:00に実行
0 6 * * * /Users/yuto/.local/scripts/daily-review.sh >> /Users/yuto/logs/daily-review.log 2>&1
cronの弱点は、macOSでは厳密なスケジュール管理が難しい点。スリープ中に実行タイミングを迎えると、起動後にキャッチアップ実行されないことがある。「絶対に毎日実行したい」なら要注意。
LaunchAgentが向いているケース
安定性 > シンプルさ の用途
- macOS専用でいい
- スリープ後のキャッチアップ実行が必要
- ログ管理・環境変数設定を細かくコントロールしたい
- GUIアプリとの連携(通知センターなど)が必要
LaunchAgentはmacOSのネイティブな仕組みなので、スリープ・再起動・ログインシェルとの連携が素直に機能する。設定ファイルがXMLで冗長なのが難点だが、その分コントロールが細かい。
個人的には、Claude Code連携の自動化タスクはLaunchAgentで統一している。cronの方が書きやすいけど、macOSで使う限りLaunchAgentの方が安定しているから。
まとめ
Claude Codeのローカルスケジュール実行でハマった3つの罠:
- 罠1 PATH問題: plistでPATHを明示的に設定する。実行ファイルは絶対パスで指定
- 罠2 認証トークン切れ: スクリプト先頭で認証状態をチェックして、切れていたら通知する。またはAPIキー認証を使う
- 罠3 タイムアウト: caffeinate でスリープを防ぐ。タスクを小さく分割する。途中結果を随時ファイルに書き出す
デバッグの順番は「ログを確認 → LaunchAgent状態確認 → 手動実行でテスト → --debugフラグで詳細確認 → env -i で環境模倣」。これで大体の問題は潰せる。
「なんで手元では動くのに自動実行だと死ぬんだ」の答えは、9割がPATHか環境変数か認証の問題。環境の違いを疑うところから始めると解決が早い。



