Claude Media
Claude Codeのcron失敗を調査する手順と失敗時だけ通知する書き方

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. 実行ごとの1行履歴(開始時刻・終了コード・ジョブ名)
  2. stdoutとstderrの両方(別ファイル)
  3. 失敗時の通知
#!/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が気づかないうちに失敗する場合の切り分け順

  1. Qhistory.tsvに、予定の時刻の行はあるか?
    答えがない

    ジョブが起動していません。crontab本体・cronデーモン・マシンの稼働状況を見ます。systemd timerならlist-timersとjournalctlの対象です。

    答えがはい
  2. Q終了コードは0か?
    答えが0以外

    .outのJSONを開きます。認証・レート制限などの理由が結果として入っています。.errは起動前の失敗の置き場です。

    答えがはい
  3. Q終了コードは143以外か?
    答えが143

    SIGTERMで止められています。タイムアウト管理やsupervisorが殺した可能性があり、実行中のターンは結果なしで終わります。

    答えがはい
  4. 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か所だけ足すだけでも、失敗に気づかない期間は大きく縮まります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/scheduled-tasks
  2. code.claude.com/docs/en/headless
  3. code.claude.com/docs/en/hooks
  4. code.claude.com/docs/en/github-actions
この記事を共有:XはてブLinkedIn