「これ、コードは悪くないやつだ」——そう思うのに、手が止まる。
ビルドが通らない。評価役のAIは正直に「ビルドが失敗しています」と報告してくる。ところが同じコードを本番のCIに乗せると、あっさり緑になる。落ちていたのはコードではなく、AIが動いている実行環境のほうだった——。
AIエージェントに実装から評価まで任せる自動化を組んだことがある人なら、この空振りに覚えがあると思います。コードは無実だと頭では分かっているのに、ループのほうは律儀に止まる。エージェントは嘘をついていません。手元では本当にビルドが失敗している。でもその失敗は、コードの欠陥ではなく、隔離環境(sandbox)がプロセス生成やポートバインドを制限しているせいだったりします。
問題は、ループがこの2種類の「赤」を区別しないことです。区別しないと、環境のせいで落ちただけの検証結果を「バグあり」とみなして、ループが無駄に止まるか、直す必要のないコードを無駄に直し始める。どちらも、自動化から取り戻したはずの時間を静かに溶かしていきます。
AIに評価まで任せたとき、この「環境のせいの赤」にどう向き合えばいいのか。理屈は分かっても実運用でちゃんと回るのか、実際どうなの? ——それが今回の主題です。
この記事は、私たちが自動開発ワークフロー(社内では dev-flow と呼んでいます)を Claude Code(Pro $20/月、Max $100-200/月)の上で運用しながら、直近の改修で「環境のせいの失敗」を仕分けて空振りを止めた過程を、コミット履歴ベースで振り返った記録です。
この記事で学べること
- 評価時の指摘(concern)を「環境要因」と「コード欠陥」に分類し、扱いを変える設計
- 環境要因を、PRのCIが緑になったという決定論的な証拠だけで機械的に閉じる仕組み
- 判定できないときは閉じずに人間へ残す、fail-open ポリシーの考え方
- CI連携で実際にハマった、CI未設定PRの独特な挙動への対処
前提:緑のはずが赤くなる、その赤は誰のせいか
以前、品質チェックを任せたAIが、チェックの攻略を始めたという記事で、品質ゲートが甘すぎてすり抜けられる話を書きました。テストを緑にしろと指示すると、AIはテストのほうを弱体化させて緑にしにくる。ゲートが空振りして、赤にすべきものを緑で通してしまう方向の失敗です。
今回の記事は、その逆方向の失敗を扱います。ゲートが厳しすぎて、緑にすべきものを赤で止めてしまう。原因はAIのズルではなく、実行環境のノイズです。同じ「ゲートの校正」でも、向きが真逆なわけです。
なぜ環境のノイズが混ざるのか。具体例が一番早いので挙げます。隔離環境の中で本番ビルドを走らせると、プロセス生成やポートバインドの制限に引っかかって、内部エラーで決定的に失敗することがあります。実運用で言えば、次世代バンドラを使ったビルドが TurbopackInternalError (os error 1) で毎回こける、という既知事象がありました。コードは何も悪くない。ただこの環境ではそのビルド経路が使えない、というだけです。
エージェントから見ると、これは「ビルド失敗」という赤い結果でしかありません。人間なら「あ、これsandboxのやつね」と一瞬で読み替えられる。その読み替えを、ループにも持たせる必要があります。
まず仕分ける:環境要因かコード欠陥か
最初にやったのは、評価役エージェントが出す指摘を、表示する前に分類することでした。
それまでは、評価の指摘を一律で並べていました。sandbox 由来の環境要因(ビルドが環境制限で落ちた、権限エラーで書き込めなかった、といった類い)も、実コードの欠陥も、同じ「concern」として同じ場所に表示される。結果、レビューする側は毎回、本物の指摘とノイズを目で選り分けることになります。指摘が10個あっても、6個が環境ノイズだと、残り4個の重要度が薄まってしまう。
そこで、指摘を決定論的なルールで2つに振り分けました。
- 環境要因: 既知パターン(sandbox のビルド失敗、権限エラーなど)にマッチするもの。折りたたんだ「環境ノート」へ回し、注意喚起レベルに落とす
- コード欠陥: それ以外。従来どおり、対応が必要な指摘としてそのまま残す
ポイントは、この振り分けをLLMの判断に委ねなかったことです。分類ロジックは既知パターンとのマッチングだけで動く決定論関数にしました。LLMに「これは環境要因ですか?」と毎回聞くと、判定がぶれるうえに、ぶれること自体がまた別のノイズになる。仕分けの基準は、揺れない場所に置くべきです。
これで、レビュー時に見るべき指摘とそうでないものが視覚的に分離されました。ただ、環境ノートに回しただけでは「注意喚起」が残り続けます。次に必要なのは、この環境ノートを根拠を持って閉じる手段です。
決定論で閉じる:CI の green を「証拠」にする
環境ノートに「sandboxではビルドできませんでした」と書いてあるとして、それは本当にコードの問題ではないと、どうやって証明するのか。
答えは、エージェントの手元ではなく、本番のCIに聞くことでした。sandbox で検証できないなら、ちゃんとしたCI環境で同じビルドが緑になっているかを見ればいい。緑なら、その環境ノートは「コードは問題なかった」という決定論的な証拠付きで閉じられます。
実装では、マージ手前の判定時にビルド系のCI check(ホスティング基盤のデプロイチェックなど)の合格状態を取得し、該当する環境ノート項目を機械的にチェック済みにしました。ここでも判定はLLMに委ねていません。実装ではこの意図を、こう明文化しています。
buildChecksGreen(決定論)のみで LLM に委ねず
そして、この閉じる操作には厳しい制限を付けました。CI green を根拠に閉じてよいのは、あらかじめ許可リストに載せた環境ノート項目だけ。実際、最初は特定のビルド用sandbox項目に対して、許可リスト限定で機械的にチェックを付けるところから始めています。無条件に「CIが緑だから全部OK」とはしない。緑という証拠が意味を持つ範囲を、明示的に絞ったわけです。
環境ノートの表には「状態」列を足して、CIで確認済みの項目には確認済みの印を付けるようにしました。レビューする人が「これはなぜ閉じたのか」を後から追えるようにするためです。機械的に閉じるからこそ、閉じた理由が読めることが大事になります。
一般化するとき、誤爆が牙をむく
最初はビルド専用だったこの仕組みを、他の環境要因にも広げたくなります。たとえば、あるテストランナーが sandbox に入っていなくて検証できなかった、というノートも、CIで該当テストが緑なら同じ理屈で閉じられるはずです。
そこで、ビルド専用だった green 判定を一般化しました。実装メモには、その狙いをこう書き留めています。
env_key ごとの check-name regex 判定 (envChecksGreen) に一般化
環境要因の種類ごとに「どのCI checkの名前を見れば緑と判断できるか」を正規表現で持たせて、ビルド以外の環境ノートも同じ経路で閉じられるようにした、という改修です。
ここで一度、派手にやらかしました。判定を広げた分岐が、汎用的なテストcheck(別のテストフレームワークなど)が緑なだけで「緑だ」と判定してしまったのです。本来見るべきは特定のテストランナーのcheckなのに、無関係な緑を根拠に環境ノートを閉じてしまう。偽の緑です。
さらに悪いことに、check名を部分文字列マッチで探していたため、名前がたまたま部分一致する無関係なcheckにも反応しました。この失敗は、実装メモにこう書き留めてあります。
部分文字列マッチで無関係な check(Contest Deploy 等)にも誤爆
test という文字を含むcheckを探したら、コンテスト用デプロイの Contest に反応した、というオチです。笑い話に見えて、これは怖い。環境ノートは「安全側に倒しておくべき注意喚起」なのに、誤爆で勝手に閉じられると、本物の問題が緑の顔をして通ってしまう。
修正では、判定に使うcheck名のマッチを、そのテストランナーを指す表現だけに絞り込みました。そして、対象のcheckが1つも見つからないときは、無理に判定せず据え置くようにして、その挙動を回帰テストで固定しました。「マッチするものが無い」を「緑」と読み替えないこと。これが次の話につながります。
実世界のCIは素直じゃない:check の無いPR
CIの合格状態を取りに行く、と一言で書きましたが、現実のCLIの挙動は思ったより素直ではありませんでした。
CI checkの一覧を取得するコマンドは、checkがまったく設定されていないPRに対して、素直に「空っぽです」と返してくれると思っていました。ところが実際に判明したのは、終了コード1(=失敗)と一緒に、標準エラーへ次のようなメッセージを返してくる挙動でした。
no checks reported on the '<branch>' branch
これに最初はまんまと引っかかりました。「配列が空なら check なしと判定する」という分岐を書いていたのですが、そもそも空配列が返ってこない。終了コード1が返るので、一律で「本物のAPIエラー」扱いになり、CI確認ステップが error で止まる。しかも、状態としては確定しているのに、リトライで無駄に待つおまけ付きでした。
対処はシンプルです。リトライループの中で「終了コード1、かつ標準エラーに no checks reported を含む」ケースだけを特別扱いし、これを「checkなし(確定)」と判定して即座に抜ける。本物のAPIエラー(認証・ネットワーク・未知のフィールド)は、従来どおりエラーのまま残します。
CI未設定のPRは、ワークフロー上は「CIによる異議なし」として扱えばいい。それを、CLIの終了コードの都合で error に落とさないための、地味だけど効くパッチでした。外部ツールの終了コードは、意味ではなく都合で決まっていることがある。ここは実際にstderrの中身まで見ないと分からない類いのエッジケースです。
判断できないときは、閉じない
ここまでの仕組みには、一本の背骨が通っています。判定できないときは、安全側に倒すという方針です。
CIの取得に失敗した。checkがまだpending(実行中)だ。該当するビルド系のcheckがそもそも存在しない。こうした「緑とも赤とも言い切れない」状況では、環境ノートを閉じません。据え置いて、人間が見る場所に残します。これを fail-open と呼んでいます。ここでの open は「開けっ放しにして自動で閉じない=人間に委ねる」という意味です。
自動化を組んでいると、つい「全部自動で判定しきりたい」と欲が出ます。でも、判定の確信度が低い領域まで機械で閉じにいくと、静かな誤判定が積み上がる。だから、機械が自信を持って言えることだけを機械にやらせて、それ以外は人間への出口に流す。マージの最後のボタンを人間が押す設計と同じ発想です(このあたりはPRのマージ前に人間のレビューを残す設計にも書きました)。
fail-open は一見「詰めが甘い」ように見えます。でも、自動で閉じた誤判定は誰も気づかないのに対し、人間に残した判断は少なくとも誰かの目に触れる。気づけない失敗より、気づける手間のほうがまし、という優先順位です。
原因を1つに潰さない
最後に、この一連の改修の底に流れている考え方を1つだけ。「同じ見た目の警告を、原因ごとに分ける」というものです。
環境要因とコード欠陥を分けたのも、その一例でした。もう1つ例を挙げると、評価した時点とPRの最終状態がズレているときに出る「内容が古いかも」という警告があります。以前はこれが単純な真偽値で、「本当にズレている」のか「自動修正が適用されて中身は担保済み」なのかを区別できず、どちらも同じ警告になっていました。
これを、原因を分けた列挙型に置き換えました。実装上は、この分類をこう表現しています。
none/hash_mismatch/iterate_incomplete/
ズレなし・ハッシュ不一致・反復が未収束・修正適用済み、の4つに分ける。最後の「修正適用済み」は、再レビューで中身が担保されているので、警告ではなく情報として区別して出す。同じ「⚠️」に見えていたものを、対応の要否で仕分けたわけです。
sandbox のビルド失敗に fallback 規約を用意したのも、根は同じでした。次世代バンドラが環境制限でこけるなら、別のビルド経路に切り替えて検証してよい、という規約を決めておく。「ビルド失敗」を「この経路がこの環境で使えないだけ」と読み替えられるように、あらかじめ逃げ道を規約化しておく。
警告やエラーを「対応が必要なもの」と「そうでないもの」にまとめて放り込むと、ノイズに埋もれて本物が見えなくなります。原因ごとに分けて、それぞれに合った扱いを与える。地味ですが、AIに任せるループでは、この仕分けの精度がそのまま「無駄に止まらない・無駄に直さない」に効いてきます。
まとめ
AIの自動開発ループで評価が赤くなったとき、その赤が「コードのせい」なのか「環境のせい」なのかを、ループ自身に仕分けさせる設計を紹介しました。
- 評価の指摘を、決定論的なルールで環境要因とコード欠陥に分類し、環境要因は折りたたむ
- 環境要因は、本番CIが緑という証拠がある場合だけ、許可リスト限定で機械的に閉じる
- 一般化するときは部分文字列マッチの誤爆に注意し、対象が見つからなければ据え置く
- CI未設定PRのような外部ツールの独特な挙動は、終了コードだけでなく中身まで見て判定する
- 判定できないときは閉じずに人間へ残す(fail-open)
環境のノイズを欠陥として扱わないことは、AIをサボらせることではありません。むしろ、ループが本当に見るべきコードの問題に集中できるようにするための、地ならしです。仕分けの基準を揺れない場所に置いて、機械が自信を持てる範囲だけを機械に任せる。残りは人間の目に流す。この線引きが、自動化を「速いけど信用できないもの」から「速くて任せられるもの」に変えていきます。



