Claude Codeに自作のiOSアプリのビルドと実機インストールを任せていたとき、「ビルド成功」「インストール完了」という報告が何度も続いたのに、実機のアプリには半日分の修正が1つも入っていなかったことがあります。
結論から書くと、原因はAIの気まぐれではなくシェルの仕様でした。flutter build ios ... 2>&1 | tail -60のようにパイプでtailをつなぐと、終了コードは最後のtailのものになります。ビルドが失敗しても0(成功)が返るので、失敗に気づけません。同じ形の事故は、zshのnoclobberでも起きました。
この記事では、2つの事故の中身と、手元のzsh 5.9とbash 3.2.57で再現した結果、いまAIに作業させるときに決めているルールをまとめます。
スポンサーリンク
AIがビルド失敗を「成功」と報告した原因は何か
結論:ビルドコマンドの後ろにパイプでtailをつないでいたため、ビルドが失敗しても終了コードが0になっていました。
2026年7月26日、Claude CodeにFlutterアプリの修正とビルドを繰り返させていました。報告はずっと「ビルド成功」「インストール完了」でした。ところが実機で確かめると、午後に入れた修正が1つも反映されていません。実機に入っていたのは、その日の12時09分にビルドした版のままでした。
調べると、AIは検証のたびに次の形でビルドを実行していました。ログが長いので、末尾の60行だけを読むための書き方です。
flutter build ios --release 2>&1 | tail -60
実際にはビルドは毎回失敗していました。原因はホーム画面ウィジェットのSwiftコードで、ViewBuilderのクロージャの中で関数を宣言していたことです。
Swift Compiler Error: Closure containing a declaration cannot be used with result builder 'ViewBuilder'
ただ、問題の本質はこのコンパイルエラーではありません。失敗しているのに終了コードが0だったので、誰も失敗に気づけなかったことです。失敗したビルドの上に、何ラウンドも修正を積み上げていました。ウィジェット拡張まわりで出やすいビルドエラーは、FlutterにWidget Extensionを足すと出るビルドエラー3つにまとめています。
パイプの終了コードはどのコマンドのものになるのか
結論:パイプでつないだ全体の終了コードは、いちばん最後のコマンドの終了コードです。途中のコマンドが失敗しても、最後が成功すれば0になります。
手元のzsh 5.9で確かめました。falseは必ず失敗する(終了コード1を返す)コマンドです。
% false | tail -1; echo "exit=$?"
exit=0
macOS標準のbash 3.2.57でも、同じくexit=0でした。途中のコマンドの終了コードを見る方法と、途中の失敗を全体の失敗として扱う方法は、シェルごとに用意されています。
| シェル | 途中のコマンドの終了コードを見る | 途中の失敗を全体の失敗にする |
|---|---|---|
| zsh 5.9 | ${pipestatus[1]}(配列は1始まり) |
setopt pipefail |
| bash 3.2.57 | ${PIPESTATUS[0]}(配列は0始まり) |
set -o pipefail |
zshで確かめた結果です。pipestatusを見ればfalseの1が残っていて、pipefailを有効にすると全体の終了コードも1になりました。
% false | tail -1; echo "pipestatus=${pipestatus[*]}"
pipestatus=1 0
% setopt pipefail
% false | tail -1; echo "exit=$?"
exit=1
noclobberでファイルの作成に失敗しても「完了」になったのはなぜか
結論:zshのnoclobberが有効だと、既存ファイルへの>によるリダイレクトは失敗します。ただし次の行のコマンドはそのまま実行されるので、最後のコマンドが成功すれば全体は成功に見えます。
2026年9月7日には、AIにコードの監査結果をIMPROVEMENTS.mdへ書き出させていました。AIはcat > IMPROVEMENTS.md <<EOFの形でファイルを作り、作業を「完了」と報告しました。ところがファイルの中身は前回のままで、新しい監査結果は1行も書かれていませんでした。
noclobberは、>で既存のファイルを誤って上書きしないためのオプションです。私の環境のzshではこれが有効になっていて、AIが使うシェルにもその設定が効いていました。
手元のzsh 5.9で再現した結果です。
% echo old > nc.txt
% setopt noclobber
% echo new > nc.txt
zsh: file exists: nc.txt
% echo "exit=$?"
exit=1
% cat nc.txt
old
リダイレクト自体は終了コード1で失敗しています。問題は、スクリプトの次の行が何事もなかったように実行されることです。最後にecho "作成しました"のような成功するコマンドがあれば、全体としては成功に見えます。
bash 3.2.57でもset -o noclobberで同じことが起き、メッセージはbash: nc2.txt: cannot overwrite existing fileでした。上書きしてよい場面では、>|を使えばnoclobberが有効でも上書きできます。
% echo new >| nc.txt
% cat nc.txt
new
スポンサーリンク
AIの「成功」をどう確かめればいいのか
結論:終了コードを加工せずに確かめることと、できあがった成果物そのものを確かめることの2つです。
2つの事故のあと、AIに作業させるときの指示書(Claude CodeのCLAUDE.mdとコマンド定義)に次のルールを足しました。
▼いまAIに守らせているルール
① ビルド・テスト・静的解析の出力をパイプで加工しない。ログはファイルに書き出し、直後に終了コードを表示する
② 「成功」と報告する前に、成果物の更新時刻がソースより新しいかを確かめる
③ ファイルを生成したら、中身を読み返して確かめる(上書きが必要な場面は>|を使う)
①はこう書きます。ログを短く読みたいなら、終了コードを取ったあとでファイルの末尾を読めば済みます。
flutter build ios --release > build.log 2>&1
echo "終了コード: $?"
tail -60 build.log
成果物がソースより新しいかを確かめる方法
結論:2つのファイルの新しさは、testコマンドの-nt(newer than)で比べられます。
7月26日の事故は、これで防げていました。実機に入っていたアプリのバイナリは12時09分のもので、午後に書き換えたソースより古かったからです。
if [ build/ios/iphoneos/Runner.app/Runner -nt lib/main.dart ]; then
echo "成果物はソースより新しい"
else
echo "成果物が古い(ビルドが反映されていない)"
fi
ウィジェットのように拡張ターゲットがあるアプリは、Runner.app/PlugIns/の中の拡張のバイナリも同じように比べます。本体だけ新しくて、拡張のビルドに失敗しているということもあるためです。
AIが末尾だけ読む書き方を選びやすい理由
結論:ビルドやテストのログは数百行を超えることが多く、AIは読む量を減らすためにtailやgrepで切り出す書き方をよく使うからです。
私が作業を任せている範囲では、この書き方は何度も出てきます。末尾だけ読むこと自体は合理的です。困るのは、読みやすくするための加工が、そのまま失敗の証拠を消してしまうことです。
報告に「失敗」と書かれていないことは、失敗していないことを意味しません。テストケースに手順は書くべきかという記事で、確認していないこと自体が報告に出てこない構造について書きましたが、AIの報告でも同じことが起きます。
AIに作業の前後処理を任せる別の例として、Claude Codeのフックで古いプロセスを掃除する方法をPlaywright MCPで前のブラウザが残り進まない問題の原因と対処に書いています。
まとめ
AIがビルドの失敗を「成功」と報告した原因は、次の2つのシェルの仕様でした。
▼要点
① パイプの終了コードは最後のコマンドのもの。| tailをつなぐとビルドの失敗が隠れる
② 途中の失敗はpipestatus(zsh)・PIPESTATUS(bash)で見られる。pipefailで全体を失敗扱いにもできる
③ noclobberが有効だと既存ファイルへの>は失敗するが、次の行はそのまま実行される
④ AIの「成功」は、ログをファイルに書き出して終了コードを見ることと、成果物が新しいかを見ることで確かめる
いちばん痛かったのは、失敗したビルドの上に半日分の作業を積み上げてしまったことでした。AIの報告を疑うというより、報告の根拠になっている終了コードが正しく取れているかを先に疑う。そう決めてから、同じ種類の事故は起きていません。
※zsh 5.9とGNU bash 3.2.57(いずれもmacOS)で、2026年9月12日に確認した内容です。