90個のスケジュールジョブを運用する本番システムで、「定期実行が数日間止まっているのに誰も気づかない」という障害が繰り返し発生した。リソース監視が16日間無効化されていた事故、plist設定ファイル30個が壊れていた事故、Dry-Runが稼働していなかった事故――いずれも共通する根本原因は**「exit code 0 = 正常動作」という前提**だった。
この問題をAIとの対話の中で分析し、ワークフロー全体に「意図検証(Intent Verification)」の原則を組み込んだ過程を記録する。
何が起きたのか:3つのサイレント障害
事故1:リソース監視の16日間無効化
リソース監視スクリプトが16日間、実質的に何もしていなかった。
1# 表面上の状態2$ launchctl list | grep monitor_resources378429 0 jp.data-processing.monitor_resources ← exit code 0、正常に見える4
5# 実際の状態6$ tail -1 /tmp/data_processing/monitor_resources.log7[2026-03-08 08:05:01] INFO: Starting resource monitor...8[2026-03-08 08:05:01] INFO: No resources to monitor. ← アクティブリソースがないのではなく、API接続できていない9[2026-03-08 08:05:01] INFO: Monitor completed. ← exit code 0 で正常終了原因は、launchd plistの EnvironmentVariables に MONITORING_API_KEY が未設定だったこと。スクリプトは os.getenv("MONITORING_API_KEY") で None を取得し、APIクライアントの初期化をスキップした。エラーは出ず、「監視対象なし」として正常終了していた。
事故2:plist 30ファイルの形式破損
AIに依頼して生成させたlaunchd設定ファイル(plist)30個が、実はXML形式ではなくJSON形式で出力されていた。
1# AIが書いたコード(問題のあるバージョン)2import json3
4def generate_plist(config: dict, output_path: str) -> None:5 with open(output_path, 'w') as f:6 json.dump(config, f, indent=2) # ← JSON形式で出力7
8# macOS launchdはXML plistのみ受け付ける9# しかしキャッシュがあるため、既にロード済みのジョブは動作し続ける10# → macOS再起動で全ジョブ消失のリスクがあったlaunchctl list にジョブが表示されるため正常に見えたが、plistファイル自体は壊れていた。
事故3:Dry-Runの長期間停止
複数のDry-Run(テスト運用)ジョブが、デプロイ時の設定ミスで数日間停止していた。
1# ユーザーの発見時のメッセージ(会話履歴より)2「多くのDry-Runが稼働していなかった理由は何ですか?3 原因と防止策を提案、本番ブランチでプラクティクスモード4 移行時に同様の不具合が発生しないか検討。」ジョブ自体は起動していたが、PYTHONPATH の設定不備やインポートエラーで即座に終了。exit code は 0 ではなかったが、Slackへのエラー通知は設定されておらず、誰にも通知されなかった。
核心の対話:「あなたは動作確認していない」
会話履歴を振り返ると、ユーザーからAIへの決定的な指摘があった。
1ユーザー: 「定期実行が正しく実行されず数日間動いていませんでした。2 というケースが多いように思う。3 そもそもテストに漏れがあるのではないか。」さらに、修正を実施した後に核心を突く指摘が入る。
1ユーザー: 「あなたは動作確認していないから本番デプロイを2 失敗し続けるのではないですか?3 エラーコードが出ていない=正しく動いているではないです。4 意図目的に沿っているかを確認するにはどうすれば良いですか?」そしてワークフロー全体への改善要求が続く。
1ユーザー: 「今後、要件定義やテストなどに意図目的が合っているかの2 チェックや確認、項目の追加が必要ではありませんか?」この対話が、「意図検証の原則(Intent Verification)」をワークフローに組み込む起点となった。
根本原因の分析:なぜAIは「exit code 0 = 正常」と判断したか
AIの思考パターン
AIがデプロイ後の確認で行っていた検証は、以下の3つだけだった。
1# AIが行っていた「動作確認」21. launchctl list | grep "data-processing" # ジョブが登録されているか32. tail -5 /tmp/logs/latest.log # エラーが出ていないか43. echo $? # exit code が 0 かこれは「プロセスが異常終了していないか」の確認であり、「意図通りの結果が生成されているか」の確認ではない。
問題の構造
1 正常終了 (exit 0)2 │3 ┌────────┴────────┐4 │ │5 意図通りの出力あり 意図通りの出力なし6 (本当の正常) (サイレント障害)7 │8 ┌──────────┤9 │ │10 ファイル未生成 接続失敗を11 だが正常終了 スキップして12 正常終了核心: exit code は「プロセスの終了状態」を示すだけであり、「ビジネスロジックの達成度」は示さない。データベースに0件インサートしても exit code 0。ファイルを0個生成しても exit code 0。API接続をスキップしても exit code 0。
解決策:意図検証の原則(Intent Verification)
CLAUDE.md に追加した原則
1### 意図検証の原則(Intent Verification)2
3**exit code 0 = 正常動作ではない**。4各ステージで「意図通りに動いているか」を明示的に定義・検証すること。5
6- **Requirements(B-4)**: 「成功の定義」セクション必須7 — このジョブ/機能は**何を生成すべきか**8 (ファイル種別・パス・件数・鮮度)を明記9
10- **Test Design(B-6)**: ユニットテスト + 結合テストに加え、11 **運用テスト**(スケジュール環境で意図通りの生成物が出るか)を設計12
13- **デプロイ後検証(C-10)**: exit code 確認だけでなく、14 **生成物の存在・鮮度・件数を確認**ワークフローへの組み込み:5ステージへの浸透
Stage 1: 要件定義に「成功の定義」を必須化
1## 成功の定義2
3このジョブは以下を生成する:4- ファイル: `/tmp/analysis_results/{date}_*.parquet`5- 件数: 最低10ファイル(10データソース分)6- 鮮度: 実行日の日付が含まれること7- サイズ: 各ファイル 1KB 以上8- 下流依存: 集計ジョブが読み取り可能なスキーマ「何を生成するか」を自然言語で書くだけだが、これがあるだけでAIの動作確認の質が変わる。
Stage 2: テスト設計に「運用テスト」を追加
従来のテスト設計にはユニットテストと結合テストしかなかった。ここに「運用テスト」を追加した。
1## 運用テスト設計2
3### 生成物の存在確認4- [ ] 手動実行後、期待されるファイルが出力ディレクトリに存在するか5- [ ] ファイルの件数が「成功の定義」の最小件数以上か6- [ ] ファイルのサイズが 0 バイトでないか7
8### 下流パイプラインの消費確認9- [ ] 生成されたファイルを下流ジョブが読み取れるか10- [ ] スキーマ(カラム名・型)が下流の期待と一致するか11
12### スケジュール環境での検証13- [ ] スケジュール実行後、ログにエラーがないか14- [ ] 翌日のヘルスチェックで鮮度が確認できるかStage 3: デプロイ手順書に「意図検証」セクションを必須化
1## デプロイ後検証2
3### 基本検証4- [ ] `plutil -lint <plist>` でXML構文検証5- [ ] 手動実行でエラーなし6- [ ] `validate_launchd_health.sh` でエラー 0 件7
8### 意図検証(生成物チェック)← 新規追加9手動実行後に**期待される生成物が実際に存在するか**を確認。10exit 0 だけで判断しない。11
121. 出力ディレクトリにファイルが生成されたか132. ファイルの件数・サイズが妥当か143. 下流ジョブが消費できる形式か15
3 collapsed lines
16### 翌日監視17- ヘルスチェックでログ鮮度 + パイプライン出力を確認18 (スケジュールが発火し、かつ**意図通りの生成物が出ているか**を検証)自動検証の実装:validate_launchd_health.sh の Check 9
「意図検証」を人間の記憶に頼っては同じ失敗を繰り返す。自動化した。
1#!/bin/bash2# validate_launchd_health.sh(抜粋)3# Check 9: Pipeline Output Verification4
5echo "=== Check 9: Pipeline Output Verification ==="6
7check_pipeline_output() {8 local name="$1"9 local path_pattern="$2"10 local min_count="$3"11 local max_age_hours="$4"12
13 # ファイルの存在確認14 local count15 count=$(find "$path_pattern" -type f 2>/dev/null | wc -l)34 collapsed lines
16
17 if [ "$count" -lt "$min_count" ]; then18 echo "ERROR: $name: expected >= $min_count files, found $count"19 ERRORS=$((ERRORS + 1))20 return21 fi22
23 # 鮮度確認(最新ファイルが指定時間以内か)24 local newest25 newest=$(find "$path_pattern" -type f -printf '%T@\n' 2>/dev/null \26 | sort -rn | head -1)27
28 if [ -n "$newest" ]; then29 local age_hours30 age_hours=$(echo "scale=1; ($(date +%s) - $newest) / 3600" | bc)31
32 if (( $(echo "$age_hours > $max_age_hours" | bc -l) )); then33 echo "ERROR: $name: newest file is ${age_hours}h old (max: ${max_age_hours}h)"34 ERRORS=$((ERRORS + 1))35 else36 echo "OK: $name: $count files, newest ${age_hours}h ago"37 fi38 fi39}40
41# パイプライン出力の検証定義42check_pipeline_output "Data Analysis Parquet" \43 "$DATA_DIR/calcAnalyticsDatas/*.parquet" 10 4844
45check_pipeline_output "Trigger Files" \46 "$DATA_DIR/processTriggers/*.json" 1 4847
48check_pipeline_output "Report Parquet" \49 "$DATA_DIR/calcReportDatas/*.parquet" 50 48このスクリプトは毎朝06:30に自動実行され、結果をSlackに通知する。
1# 正常時2✅ [launchd ヘルスチェック] 2026-03-29 06:303対象: 90ジョブ | エラー: 0 | 警告: 04Check 9 Pipeline Output: ALL OK5
6# 異常時7❌ [launchd ヘルスチェック] 2026-03-29 06:308対象: 90ジョブ | エラー: 3 | 警告: 19Check 9 Pipeline Output:10 ERROR: Trigger Files: expected >= 1 files, found 011 ERROR: Report Parquet: newest file is 72.3h old (max: 48h)「意図」をAIに理解させるための対話パターン
plistの設定変更を行う際に、設定の「経緯と意図と目的」をAIに調査させる対話パターンが定着した。
1# 会話履歴より2ユーザー: 「#1を本当に実施して良いか再度詳細調査、3 2つのplistが生成された経緯と意図と目的を把握」4
5ユーザー: 「中程度 #3: data_source_a.monitor_resources が月曜のみ6 (機会損失)再度詳細調査、7 Weekday=1が設定された経緯、意図目的を把握」これは「変更前に、なぜ現在の設定がそうなっているかを調べろ」という指示であり、AIがgit log、コミットメッセージ、ADR、ドキュメントを横断的に調査して「意図」を復元する。
1# AIが実行する調査コマンドの例2git log --all --oneline -- '**/data_source_a*monitor*.plist'3git show <commit_hash> -- _launched/data_source_a.monitor_resources.plist4grep -r "Weekday" _launched/*.plist意図が不明な設定は変更しない。これが「意図検証の原則」のデプロイ前バージョンである。
導入前後の比較
Before(意図検証なし)
1デプロイ2 ↓3launchctl list で確認 ← ジョブ登録のみ確認4 ↓5exit code 0 を確認 ← プロセス終了のみ確認6 ↓7「正常」と判断8 ↓9数日後:「あれ、ファイルが生成されていない?」検出までの平均時間: 3〜16日(人間が別の作業中にたまたま気づく)
After(意図検証あり)
1デプロイ2 ↓3plutil -lint で構文検証 ← ファイル形式の確認4 ↓5手動実行 + 生成物確認 ← 意図通りの出力があるか6 ↓7翌朝 validate_launchd_health.sh ← 自動検証(Check 9)8 ↓9Slack通知で結果確認 ← 「来ない=異常」のハートビート検出までの時間: 最大24時間(翌朝のヘルスチェックで自動検出)
意図検証チェーンの全体像
1Requirements → Design → Test Design → Implementation → Deploy2 │ │ │ │ │3 「成功の定義」 設計の 運用テスト設計 テスト実行 意図検証4 を明記 意図を を追加 + QA (生成物5 ADRに記録 ワークフロー チェック)6 │7 validate_launchd_8 health.sh Check 99 (毎朝自動実行)各ステージで「意図」が伝搬される仕組みになっている。要件定義の「成功の定義」が、最終的にヘルスチェックの検証項目になる。
学んだこと
-
exit code 0 は「プロセスが異常終了しなかった」だけであり、「期待する出力が生成された」ことは保証しない。この区別をCLAUDE.mdに明記したことで、AIの動作確認の精度が根本的に変わった
-
「意図」は伝搬しなければ消える。要件定義に書いた「成功の定義」がテスト設計・デプロイ手順書・自動検証まで一貫して伝わる仕組みが必要。人間の記憶に頼ると、ステージをまたぐたびに忘れる
-
自動検証が最後の砦。手動のチェックリストは「忘れる」「面倒でスキップする」の2つのリスクがある。
validate_launchd_health.shの Check 9 のような自動検証を毎日走らせることで、人間もAIも見落とすサイレント障害を24時間以内に検出できる
まとめ
-
「定期実行が数日間止まっていた」障害の根本原因は、AIが「exit code 0 = 正常」と判断していたこと。ユーザーからの「エラーコードが出ていない = 正しく動いているではない」という指摘が、ワークフロー全体の見直しにつながった
-
解決策は「意図検証の原則」をワークフローの全5ステージに組み込むこと。要件定義の「成功の定義」から始まり、テスト設計の「運用テスト」、デプロイ手順書の「生成物チェック」、そして自動ヘルスチェックの Check 9 まで、一貫して「意図通りの結果が出ているか」を検証する
-
この原則はAI駆動開発に限った話ではない。しかし、AIは「エラーが出ていなければ正常」と判断しやすい傾向があり、「ビジネスロジックの達成度」を自発的に検証しない。CLAUDE.mdで明示的に指示することで初めて、AIは「意図」を意識した検証を行うようになった