定期実行ジョブを Claude Code に組ませて、ローカルテスト OK・本番 launchd 登録 OK・ログにも exit 0。「動いた」と思って翌朝確認したら、生成すべきファイルが1件も存在しない。
これは AI 駆動開発で何度もハマったパターンです。本記事は、「exit code 0 = 正常動作」ではないという当たり前を、仕様駆動の中で一級市民の検証項目に格上げする運用ルールの記録です。
事の発端:「成功」したジョブが何も生成していなかった
ある集計ジョブを Claude Code に作ってもらい、launchd で日次実行に登録しました。翌日ログを確認:
12026-05-16 03:00:01: job started22026-05-16 03:00:14: job completed, exit code: 0完璧に見えました。ところが下流のパイプラインが「入力ファイルがありません」とエラーを吐いていました。出力ディレクトリを覗くと、昨日のファイルが存在しない。
ジョブのロジックを読み直すと、引数で渡される日付フォーマットが本番環境と違って、内部で try / except が握りつぶしていました。エラーは起きていたが、exit code は正常返しになっていたのです。
なぜ exit code 0 を信じてしまうのか
人間の認知バイアスとして、**「プロセスが正常終了した = 仕事が終わった」**という思い込みがあります。これは Unix の伝統に強く根ざしていて、シェルスクリプトでも CI でも $? を見るのが基本動作です。
しかし、現代のジョブはほぼ全て 「処理を実行する」 + 「生成物を残す」 の二段構えになっています。exit code が示すのは前者だけです。
| 観点 | exit code が答えるもの | 答えないもの |
|---|---|---|
| プロセス終了 | 正常終了したか | - |
| ロジック | エラーを吐かなかったか | 意図通りに動いたか |
| 生成物 | - | ファイルが出来たか |
| 鮮度 | - | 出来たファイルが新しいか |
| 件数 | - | 期待される件数か |
つまり exit code は 「プロセスが死ななかった」 という極めて弱い保証しかしません。それを「成功」と読み替えるのが事故の元です。
意図検証を仕様の一級市民にする
この問題への対処は、Requirements 段階で「成功の定義」を生成物ベースで書くことです。
NG な書き方(プロセス指向)
1## 受入基準2- ジョブが exit 0 で終了すること3- ログにエラーが出ないことこれは前述の事故を防げません。両方とも満たしながら、生成物が0件のパターンが成立します。
OK な書き方(生成物指向)
1## 成功の定義2このジョブは以下を満たす場合に「成功」とする:3
41. 出力ディレクトリ: /data/aggregates/daily/52. 期待ファイルパターン: aggregate_YYYY-MM-DD.parquet63. 鮮度閾値: 実行日と同日付のファイルが存在74. 最小件数: ファイル内のレコード数 >= 10085. 下流互換性: スキーマが下流ジョブの期待と一致5つすべてを満たして初めて「動いた」と言えます。exit code はその十分条件ではなく、必要条件の一つに過ぎないという位置付けです。
デプロイ後の機械検証
この「成功の定義」を、デプロイ後に機械検証するスクリプトに落とし込みます。
1#!/bin/bash2TODAY=$(date +%Y-%m-%d)3OUTPUT_FILE="/data/aggregates/daily/aggregate_${TODAY}.parquet"4
5# Check 1: ファイル存在6[ -f "$OUTPUT_FILE" ] || { echo "FAIL: file not found"; exit 1; }7
8# Check 2: 鮮度(24時間以内)9FILE_AGE=$(($(date +%s) - $(stat -f %m "$OUTPUT_FILE")))10[ $FILE_AGE -lt 86400 ] || { echo "FAIL: file too old ($FILE_AGE sec)"; exit 1; }11
12# Check 3: 件数13RECORD_COUNT=$(python -c "import pandas; print(len(pandas.read_parquet('$OUTPUT_FILE')))")14[ $RECORD_COUNT -ge 100 ] || { echo "FAIL: too few records ($RECORD_COUNT)"; exit 1; }2 collapsed lines
15
16echo "PASS: intent verified"このスクリプトを launchd の 別ジョブとして登録します。本体ジョブの 1 時間後など、適切なタイミングで実行されるようにします。
ポイントは「本体と別プロセスで検証する」ことです。同一プロセス内に検証を組み込むと、本体が死んだ場合に検証も走らず、結局気づけないままになります。
なぜ AI agent はこれを忘れがちなのか
AI agent にジョブ実装を依頼すると、ほぼ確実に 「処理ロジック + try/except + logging」 までは作ってくれます。しかし 「生成物の検証」 を最初から提案してくることは稀です。
理由を考えると、
- 一般的なチュートリアルが「処理が完了したらログを出して終わり」で終わるパターンが多い
- exit code = 成功という前提が広く共有されている
- 生成物の検証は「運用」の領域とされ、「実装」の責務外と認識されている
つまり、agent の常識は 「実装」と「運用」の境界を引いているのです。しかし現代のジョブはその境界を越えて初めて価値を出すので、Requirements 段階で agent に境界を越えさせる指示を明示的に与える必要があります。
CLAUDE.md に書き込む
1## 仕様駆動開発:意図検証の原則2
3Requirements 段階で「成功の定義」セクションを必ず含めること。4exit code 0 や「ログにエラーなし」だけでは成功とみなさない。5
6成功の定義に含めるべき項目:7- 出力ディレクトリと期待ファイルパターン8- 鮮度閾値(最新ファイルが何時間以内に生成されているか)9- 最小件数(または件数の妥当性基準)10- 下流ジョブが消費できる形式であること11
12デプロイ後検証のチェックスクリプトを併せて設計すること。13本体ジョブとは別プロセスで実行する仕組みにする。これを書いておくと、Claude Code は Requirements を起こす際に必ず「成功の定義」を質問してきます。
三層の検証構造
ジョブの「動作確認」を整理すると、以下の3層になります。
層1: プロセス層
- exit code
- 標準エラー出力の有無
- プロセスが規定時間内に終了したか
これは launchd や Cron、CI が自動で見てくれる部分です。
層2: ロジック層
- ユニットテストが通る
- 統合テストが通る
- ローカル実行で期待値が出る
これは開発時に CI で担保される部分です。
層3: 意図層(最重要)
- 本番環境で実際に生成物が出ているか
- 生成物の鮮度・件数が妥当か
- 下流が消費できる形式か
層1と層2が OK でも、層3で落ちることが頻繁にあります。本記事の発端ケースも、層1・層2はパスしていました。
「あの時動いたのに今動かない」を防ぐ
ジョブを長期運用していると、以下のような失敗がよく起きます。
- 環境差異: ローカルでは動くが本番で動かない(パス・権限・タイムゾーン)
- 依存変化: 外部 API が仕様変更
- データ変化: 入力データのスキーマが変わって parse 失敗
- 時刻依存: 営業日カレンダーが切り替わって対象データが0件
これらすべて、exit code は正常を返す可能性があります。try/except で握りつぶされた瞬間にプロセスは「成功」終了するからです。
層3の意図検証だけが、これらを翌朝までに気づかせてくれます。
まとめ:「動いた」を再定義する
AI 駆動開発で繰り返しジョブを組ませる時代、「exit code 0 = 動いた」という素朴な等式は通用しません。
「動いた」とは、
- プロセスが死ななかった(exit code)
- ロジックがエラーを吐かなかった(log)
- 意図した生成物が、意図した鮮度で、意図した件数で残った(intent)
この3つすべてを満たすことです。3番目を仕様駆動の Requirements 段階で一級市民の項目として書き込み、デプロイ後に機械検証する。これが AI 時代の運用設計の最低ラインだと考えています。
agent は「処理を書く」のは得意ですが、「処理が意図通り動いた」を検証する責務を仕様で与えないと、永遠に exit code を信じる実装を出してきます。仕様で枠を作るのが人間の仕事です。