Claude Codeのcron失敗を調査する手順と失敗時だけ通知する書き方
crontabから呼ぶclaude -pがエラーを出さずに止まる原因を、終了コード・stdout・実行履歴から切り分け、失敗した回だけ通知するラッパーを組みます。
crontabからclaude -pを呼ぶジョブが「動いているはずなのに成果物が出ていない」とき、原因は大きく3つに分かれます。claudeが失敗した、そもそも起動していない、起動したが予定の時刻に動かなかった、です。切り分けに必要なのは、終了コード・stdout・実行履歴の3点を、ジョブ側で残しておくことです。
この記事では、その3点を残す約20行のラッパーと、失敗した回だけ通知する書き方を示します。後半では、Claude Code自身の/loopやCronCreateが予定どおりに動かない理由も扱います。
エラーが表に出ない原因は「cronの種類」で変わる
「Claudeのcron」という言葉は、2つの別物を指します。どちらの話かで、調べる場所がまったく違います。
| 種類 | 動かす主体 | 失敗の痕跡が残る場所 | 典型的な原因 |
|---|---|---|---|
OSのcrontab(systemd timerも同型)からclaude -pを呼ぶ | 動かす主体OSのスケジューラ | 失敗の痕跡が残る場所ジョブ自身が書いたログのみ | 典型的な原因認証が通らない、stdoutを捨てている、環境が違う |
Claude Code内蔵の/loop・CronCreate | 動かす主体開いているセッション | 失敗の痕跡が残る場所セッションの中(CronListの表示) | 典型的な原因セッションが閉じている、7日で失効、取りこぼし |
前者はOSが「コマンドを起動した」ことしか知りません。claudeが失敗しても、ジョブが何も書かなければ痕跡は残りません。後者はセッションの中で完結するので、OS側のログには何も出ません。
claude -pの失敗はどこに出るか
非対話実行の終了コードについて、ドキュメントは次のように述べています。成功なら0、失敗なら0以外で、スクリプトは終了コードで分岐できます。そのうえで失敗の出力先が二手に分かれます。
- 不正なフラグなど、実行が始まる前の失敗は標準エラー出力に出る
- 認証の欠落など、実行の中で起きた失敗は、結果として標準出力に出る
後者が落とし穴です。2>> error.logだけを書いたcron行では、認証切れの理由が標準出力側に出るため、エラーログが空のまま残ります。cronの行を>> job.log 2>&1にするか、後述のラッパーのように両方を別々に保存してください。
認証はもう1つの定番です。--bareは便利ですが、OAuthの認証情報やキーチェーンを読まないため、サブスクリプションのログインでは動きません。ANTHROPIC_API_KEYを環境に渡す必要があります。対話シェルでは動くのにcronでだけ失敗するときは、まずここを疑います。
# cronに近い素の環境でclaudeが動くか試す(環境変数を最小にして実行)
env -i HOME="$HOME" PATH=/usr/bin:/bin \
/usr/local/bin/claude --bare -p "ping" --output-format json; echo "exit=$?"cronはシェルの初期設定を読まずに起動するのが一般的なので、env -iで環境を絞ると再現しやすくなります。claudeのパスは絶対パスで書き、ANTHROPIC_API_KEYなどの必要な変数はラッパー側で明示的に渡します。
終了コード・stdout・履歴を残すラッパーを作る
調査の材料は、事後には作れません。ジョブを呼ぶ段階で、次の3つを必ず残します。
- 実行ごとの1行履歴(開始時刻・終了コード・ジョブ名)
- stdoutとstderrの両方(別ファイル)
- 失敗時の通知
#!/usr/bin/env bash
# claude-job.sh <ジョブ名> <プロンプト>
set -u
job="$1"; prompt="$2"
dir="$HOME/.local/state/claude-jobs"; mkdir -p "$dir"
start=$(date +%Y-%m-%dT%H:%M:%S%z)
/usr/local/bin/claude --bare -p "$prompt" \
--output-format json --max-turns 20 --permission-prompts none \
>"$dir/$job.out" 2>"$dir/$job.err"
code=$?
printf '%s\t%s\t%s\n' "$start" "$code" "$job" >> "$dir/history.tsv"
if [ "$code" -ne 0 ]; then
msg="$job failed: exit=$code at $start"
curl -fsS -X POST -d "{\"text\":\"$msg\"}" "$NOTIFY_URL" || true
fi
exit "$code"crontabの側は、時刻とラッパーだけを書きます。
# 平日9時台に実行(分は混雑を避けて3分にずらす)
3 9 * * 1-5 ANTHROPIC_API_KEY=... NOTIFY_URL=... /usr/local/bin/claude-job.sh daily-report "昨日のログを要約して"ラッパーの要点は4つです。
--output-format jsonにしておくと、結果・セッションID・メタデータが1つのJSONで.outに残る。失敗の説明も結果として入る--max-turnsは上限に達すると「エラーで終了」する。暴走を止めた回が非0で履歴に残る--permission-prompts noneは、承認する人がいない実行向けのフラグで、v2.1.259以降で使える。承認待ちで固まらず、プロンプトが必要になった操作は拒否されて実行が続く- 通知は
codeが0以外のときだけ。成功のたびに通知すると、失敗の通知が埋もれる
最後の点は運用上の意味が大きいです。毎回届く「成功しました」メールは数日で読まれなくなります。失敗時だけ出すと、通知が届いた事実そのものが信号になります。
履歴から「実行されていない回」を拾う
終了コードを記録しても、ジョブが一度も起動していなければ履歴は増えません。cronが止まった、マシンが寝ていた、crontabが消えた、といった場合です。この種の欠落は「失敗の記録」ではなく「記録がないこと」でしか見つかりません。
別のcronで、履歴ファイルの更新時刻を点検します。
# 25時間(1500分)以内に更新された履歴が無ければ通知する(日次ジョブの例。ファイルが無い場合も通知される)
[ -n "$(find "$HOME/.local/state/claude-jobs/history.tsv" -mmin -1500 2>/dev/null)" ] \
|| curl -fsS -X POST -d '{"text":"daily-report: 履歴が25時間以上止まっています"}' "$NOTIFY_URL"人手で確認するときは、履歴を整形して眺めるだけで足ります。
# 終了コードが0以外の回だけ
awk -F'\t' '$2 != 0' "$HOME/.local/state/claude-jobs/history.tsv"
# ジョブごとの最終実行
sort -k3,3 -k1,1 "$HOME/.local/state/claude-jobs/history.tsv" | awk -F'\t' '{last[$3]=$0} END{for (j in last) print last[j]}'ログローテーションの前後で抜けを作らない
詳細ログ(.out / .err)はジョブごとに上書きされるので、肥大化はしません。ただし「履歴」と「詳細」を同じファイルにすると、ローテーションの境目で調査に必要な行が別ファイルに移り、grepに引っかからなくなります。1行履歴のhistory.tsvは小さいまま追記し続け、ローテーションが必要になる規模までは切らない構成にしておくと、抜けの調査が楽になります。詳細ログを残したいジョブは、実行時刻をファイル名に入れて回しておけば、後から実行単位で辿れます。
失敗の切り分け: 症状から原因を絞る
履歴と.out / .errが揃ったら、症状から順に絞ります。
cronが気づかないうちに失敗する場合の切り分け順
- Qhistory.tsvに、予定の時刻の行はあるか?答えがない答えがはい
ジョブが起動していません。crontab本体・cronデーモン・マシンの稼働状況を見ます。systemd timerなら
list-timersとjournalctlの対象です。 - Q終了コードは0か?答えが0以外答えがはい
.outのJSONを開きます。認証・レート制限などの理由が結果として入っています。.errは起動前の失敗の置き場です。 - Q終了コードは143以外か?答えが143答えがはい
SIGTERMで止められています。タイムアウト管理やsupervisorが殺した可能性があり、実行中のターンは結果なしで終わります。
- Q成果物はあるか?答えが無い答えがはい
拒否された操作がないか(
permission_denials)、--max-turnsの手前で作業が終わっていないかを見ます。
ここまで該当なし
プロンプトの指示そのものが曖昧な可能性があります。同じプロンプトを手元で対話実行して、挙動を確かめます。
SIGINTでターンを終わらせてから止める手順はclaude -pの非対話実行にあります。
通知をClaude Code側のフックに任せる場合
hooksには、API側の失敗でターンが終わったときに動くStopFailureイベントがあります。マッチャーに入る値は、rate_limit・overloaded・authentication_failed・billing_error・max_output_tokensなどです。ラッパーでは区別しにくい「どの種類の失敗か」で通知を分けたいときに使えます。
{
"hooks": {
"StopFailure": [
{
"matcher": "rate_limit|authentication_failed|billing_error",
"hooks": [
{ "type": "command", "command": "$HOME/bin/notify-failure.sh" }
]
}
]
}
}注意点が2つあります。StopFailureはフックの出力(JSON)を無視するイベントなので、通知は外部コマンドの副作用で出す形になります。また--bareはhooksの自動読み込みを飛ばすので、上のラッパー(--bareを使用)ではこの設定は効きません。通知の確実さを優先するなら、ラッパー側の終了コード判定を主にし、フックは種類別の補助として足す構成が堅実です。フックの全イベントはHooks完全ガイドにまとめています。
内蔵スケジューラ(/loop・CronCreate)が動かない場合
/loopやCronCreateで作ったタスクは、OSのcronとは別の仕組みです。ログは残らず、次の条件で、エラーを出さずに止まったように見えます。
| 症状 | 原因 | 確かめ方 |
|---|---|---|
| 夜間に止まった | 原因タスクはClaude Codeが動いていて、かつアイドルのときだけ発火する | 確かめ方ターミナルやセッションが生きているか |
| 1週間後に消えた | 原因繰り返しタスクは作成7日後に最後の1回を発火して自動削除される | 確かめ方CronListで一覧を見る |
| 時刻がずれる | 原因ジッターで毎回遅れる。毎時・毎日なら最大30分遅れる | 確かめ方next runの表示(v2.1.299以降) |
| 処理中に時刻が過ぎた | 原因取りこぼしは遡って実行されず、アイドルになった時点で1回だけ発火する | 確かめ方長いリクエストの有無 |
| 再開後に回らない | 原因自己ペースの/loopは復元されない | 確かめ方/loopを再実行する |
発火の遅れは「異常」ではなく仕様です。0 9 * * *と書いても9時ちょうどには動かず、予定の時刻を過ぎてから動き始めます。繰り返しタスクで遅れが乗らないのは*/5のような5分刻みだけで、分をずらしても遅れは消えません。:00や:30を避けて早まりを防げるのは単発タスクのほうです。遅れの幅を見込んだ設計にします。式の書き方と失効の詳細はcron式リファレンスにあります。
動いているのに回らない場合は、スケジューラ自体が止められている可能性があります。CLAUDE_CODE_DISABLE_CRON=1が環境にあるとcronツールと/loopは使えなくなり、登録済みのタスクも発火しません(詳しくはCLAUDE_CODE_DISABLE_CRON)。
放置しても確実に動いてほしい処理は、内蔵スケジューラに置かない選択になります。端末を閉じても残るのは、クラウドのスケジュールタスク、Desktopのスケジュールタスク、GitHub Actionsのscheduleトリガーです。GitHub Actionsはデフォルトブランチのワークフローだけを実行し、公開リポジトリではリポジトリの活動が60日ないとスケジュールが無効化されます。また、定期実行のワークフローをBotが最後に更新した場合は、allowed_botsの指定が要ります。Actions側の設定はGitHub Actionsへの組み込みの対象です。常駐の仕組みとして書きたい場合は、systemdのUnitファイルの手順が出発点になります。
点検のチェックリスト
- cron行は絶対パスで書き、stdoutとstderrの両方を保存している
- 認証の渡し方(
--bareならANTHROPIC_API_KEY)をcronの環境で確かめた - ジョブごとに、開始時刻と終了コードの1行履歴が残る
- 通知は終了コードが0以外の回だけに限っている
- 実行されなかった回を拾う、履歴の更新時刻の点検が別に動いている
- 内蔵スケジューラのタスクは、7日失効とジッターを前提にしている
まとめ
cronの失敗は、claudeの出力の置き場所を把握し、ジョブ側で終了コードと履歴を残しておけば、たいていは原因まで辿れます。特に、実行中の失敗がstdoutに出る点と、--bareがサブスクリプションのログインを使わない点は、cronでだけ失敗するケースの入り口になります。ラッパーに通知を1か所だけ足すだけでも、失敗に気づかない期間は大きく縮まります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。