1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI に iOS アプリを実装させるとき、失敗を種類で分ける

1
Posted at

この記事は 2026 年 10 月時点の情報です。GitHub の Issue から AI(Claude Code)が実装し、ビルド・テスト・E2E で確かめてから PR を作る仕組みを、iOS アプリで作ったときの話です。記事中のスクリプトは説明用に書き起こした最小の例で、手元で動作を確認しています。

はじめに

AI に実装を任せる仕組みを作ると、「失敗したらエラーを渡して、もう一度やらせる」というループをまず作ります。これ自体は正しいのですが、動かしてみると次のことが起きます。

  • 一生通らないテストを、AI が通そうとし続ける
  • 通すために、変えてほしくない所まで書き換える
  • そもそも依頼の内容が足りないのに、AI が想像で埋めて進む

原因は、失敗を 1 種類として扱っていることでした。失敗には「やり直せば直るもの」と「何回やり直しても直らないもの」があります。分けないままループに入れると、AI は直らない失敗を直そうとします。

この記事では、失敗をどう分けたか、分け間違えやすい所はどこか、を書きます。前半は言語を問わない話、後半は iOS(Xcode とシミュレータ)ならではの話です。

なお、AI に触らせてはいけないファイルの守り方は、AI に iOS アプリを実装させるとき、触らせてはいけないファイルと守り方 に書いています。

失敗は 5 種類。やり直す価値があるのは 1 つだけ

失敗を、「次に誰が動くべきか」で分けました。

種類 例 やり直しで直るか 次に動く人
依頼が足りない 完成の条件が書かれていない 直らない 依頼を書いた人が、条件を足す
大きすぎる 変更が上限(ファイル数・行数)を超えた 直らない 依頼を書いた人が、依頼を分ける
環境の問題 道具がない、AI が未認証、シミュレータが決まらない 直らない 環境を管理する人
禁止ファイルに触った CI の設定やビルドの設定を書き換えた 直らない 人が中身を確かめる
実装の問題 ビルド・テスト・E2E が落ちた 直るかもしれない AI がやり直す

AI にやり直させる意味があるのは、最後の 1 種類だけです。上の 3 つは、AI がコードをどう直しても解決しません。環境に SwiftLint が入っていなければ、AI が何回実装し直しても lint は通りません。

4 つ目の「禁止ファイルに触った」は少し性質が違います。やり直せば、触らずに済むかもしれません。それでもやり直させないのは、AI が触る必要があると判断したこと自体が、人が見るべき出来事だからです。依頼が禁止の範囲を必要としているのか、AI が依頼から外れたのかは、人が確かめないと分かりません。

分ける基準を「エラーの内容」ではなく「次に動く人」にしたのは、失敗の通知を受け取った人が、最初に知りたいのがそれだからです。全部まとめて「失敗しました」と通知すると、人はログを読んで原因を探す所から始めることになります。

いつ分けるか

種類によって、分かるタイミングが違います。早く分かるものほど、安く止められます。

タイミング 分かる失敗 調べ方
AI を動かす前 依頼が足りない 依頼の本文に、完成の条件が書かれているか
AI を動かす前 環境の問題 道具、バージョン、シミュレータ
AI の実装の直後(コミットの前) 禁止ファイルに触った、大きすぎる 変更されたファイルの名前、数、行数
検証 実装の問題 ビルド・lint・テスト・E2E

「依頼が足りない」の検査には、落とし穴がありました。最初は「完成の条件」という見出しがあるかを見ていましたが、依頼のテンプレートを使うと、見出しは必ず入ります。中身が空の依頼がそのまま通り、AI が動き出してしまいました。見るべきは、見出しの有無ではなく、見出しの下に中身があるかです。

分類は、終了コードで返す

分類は、スクリプトの終了コードで返します。終了コードは、プログラムが終わるときに返す数字です。0 が成功、それ以外が失敗という決まりだけがあり、失敗の種類は作る側が決められます。

#   0  = 実装できた
#   2  = 使い方の誤り(必要な設定が渡されていない、など)
#   10 = input_insufficient    依頼が足りない
#   11 = scope_too_large       大きすぎる
#   12 = environment_failure   環境の問題
#   13 = forbidden_change      禁止ファイルに触った
#   14 = implementation_failed 実装の問題

10 から始めているのは、小さい番号がすでに使われているからです。1 は「一般的な失敗」として多くのコマンドが返し、2 はシェルの組み込みコマンドが「使い方の誤り」に使います。自分で決める番号を 10 以上にしておけば、呼び出した別のコマンドが返した番号と見間違えません。

名前(environment_failure など)も、番号とは別にファイルへ書き出しておきます。後で人への通知を組み立てるときに使います。

やり直しのループと、2 つの上限

「実装の問題」だけを、ループでやり直します。

implement-and-verify.sh
#!/usr/bin/env bash
# AI の実装と検証を往復させる。
# 終了コード: 0=すべて通過 / 10〜13=やり直しても直らない失敗 / 14=上限まで試して直らなかった
#             決めていない終了コードは、環境か仕組み自体の問題として 12 にまとめる
set -uo pipefail

PHASES=(build lint test e2e)   # 安いものから順に
MAX_SAME_FAILURE=2             # 同じ段階でのやり直しは 2 回まで(3 回目の失敗で止める)
MAX_TOTAL_ATTEMPTS=6           # 全体の試行は 6 回まで
LOG_DIR="${LOG_DIR:-./logs}"
mkdir -p "$LOG_DIR"
rm -f "$LOG_DIR"/failcount-*   # 前回の実行の数え残しを消す
: > "$LOG_DIR/previous-failure.txt"   # 1 回目は「前回の失敗」が空

give_up() {   # give_up <理由>
  echo "implementation_failed: $1" >&2
  echo "implementation_failed" > "$LOG_DIR/classification.txt"
  exit 14
}

env_failure() {   # env_failure <理由>
  echo "environment_failure: $1" >&2
  echo "environment_failure" > "$LOG_DIR/classification.txt"
  exit 12
}

attempt=1
while :; do
  [ "$attempt" -le "$MAX_TOTAL_ATTEMPTS" ] || give_up "全体の試行が上限(${MAX_TOTAL_ATTEMPTS} 回)を超えた"

  # 1. AI に実装させる(2 回目以降は、前回の失敗ログを渡す)
  ./implement.sh "$LOG_DIR/previous-failure.txt"
  code=$?
  case "$code" in
    0) ;;
    10|11|12|13|14) exit "$code" ;;   # 決めた分類は、そのまま上へ返す。やり直さない
    *) env_failure "implement.sh が想定外の終了コード $code を返した" ;;
  esac

  # 2. 検証を順に実行し、最初に落ちた段階で止める
  failed=""
  for phase in "${PHASES[@]}"; do
    ./verify.sh "$phase" > "$LOG_DIR/attempt${attempt}-${phase}.log" 2>&1
    code=$?
    case "$code" in
      0) continue ;;
      12) env_failure "$phase の途中で環境の問題が起きた" ;;   # シミュレータが落ちた、など
      *) failed="$phase"; break ;;
    esac
  done

  # 3. 全部通ったら成功
  if [ -z "$failed" ]; then
    echo "verified: attempt=$attempt"
    exit 0
  fi

  # 4. 同じ段階で落ちた回数を数える
  count=$(( $(cat "$LOG_DIR/failcount-$failed" 2>/dev/null || echo 0) + 1 ))
  echo "$count" > "$LOG_DIR/failcount-$failed"
  echo "attempt $attempt: $failed で失敗(この段階で ${count} 回目)" >&2
  if [ "$count" -gt "$MAX_SAME_FAILURE" ]; then
    echo "$failed" > "$LOG_DIR/failed-phase.txt"
    give_up "$failed の失敗が上限(${MAX_SAME_FAILURE} 回)を超えた"
  fi

  # 5. 失敗ログの末尾を、次の実装へ渡す
  tail -n 50 "$LOG_DIR/attempt${attempt}-${failed}.log" > "$LOG_DIR/previous-failure.txt"
  attempt=$((attempt + 1))
done

ポイントは 4 つです。

  • 直らない失敗は、ループに入れない。 implement.sh が 10〜13 を返したら、そのまま上へ返して終わります。やり直しません
  • 決めていない終了コードは、環境の問題にまとめる。 コマンドが見つからない(127)、途中で止められた(130)など、自分で決めた番号以外が返ってきたら、仕組みの側の問題として 12 にします。そのまま外へ返すと、通知の側が分類できません
  • 検証の途中の環境の問題も、やり直さない。 事前に環境を調べていても、シミュレータが落ちる、Xcode が一時的に不調になる、といったことは検証中にも起きます。検証のスクリプトが 12 を返したら、AI にやり直させずに止めます
  • 検証は、安いものから並べる。 ビルド → lint → テスト → E2E の順です。手前で落ちれば、時間のかかる E2E を動かさずに済みます
  • やり直したら、最初の段階から全部やり直す。 E2E で落ちて直した場合も、ビルドから確かめ直します。直した結果、別の所が壊れているかもしれないからです
  • 上限を 2 種類持つ。 下で説明します

なぜ上限が 2 種類要るか

最初は「同じ段階でのやり直しは 2 回まで」だけでした。同じ段階で 3 回目に落ちたら止めます。同じ所を 2 回直しても通らないなら、AI の手に負えないか、依頼そのものが間違っている可能性が高いからです。

ところが、これだけだと抜けがあります。ビルドで落ち、直すと lint で落ち、直すとテストで落ち、と違う段階で落ち続けると、段階ごとの回数は増えないまま、試行だけが重なります。4 段階あれば、理屈の上では 9 回まで回れます(各段階で 2 回ずつ落ちて 8 回、その次で 9 回目)。

iOS のビルドとテストと E2E は、1 周に十数分かかります。9 回も回ると CI の制限時間に当たり、強制終了されます。強制終了されると、「どの種類の失敗だったか」の記録も残りません。

そこで、全体の試行回数にも上限を付けました。実際に動かすと、こうなります。

attempt 1: build で失敗(この段階で 1 回目)
attempt 2: lint で失敗(この段階で 1 回目)
attempt 3: test で失敗(この段階で 1 回目)
attempt 4: e2e で失敗(この段階で 1 回目)
attempt 5: build で失敗(この段階で 2 回目)
attempt 6: lint で失敗(この段階で 2 回目)
implementation_failed: 全体の試行が上限(6 回)を超えた

どの段階も 3 回目の失敗には達していませんが、全体の上限で止まっています。

このループ自体も、偽物の implement.sh と verify.sh を差し込んでテストしています。「同じ段階で 3 回落ちたら 14 を返す」「環境の失敗は 1 回もやり直さずに 12 を返す」「想定外の終了コードは 12 にまとめる」といった動きを、AI もビルドも動かさずに数秒で確かめられます。

分け間違えやすい所

分類の表を作るのは簡単です。難しいのは、実際の失敗を正しい種類に振り分けることでした。間違えやすかった所を 4 つ書きます(「依頼が足りない」の見出しの話も、同じ種類の間違いです)。

1. 未認証の AI は「成功」で終わる

私が確かめた時点の Claude Code は、認証していない状態で動かすと「Not logged in」と表示して、成功の終了コード(0)で終わりました。エラーにはなりません。

すると、仕組みの側からは「AI は正常に終わったが、何も変更しなかった」と見えます。これを素直に扱うと「実装の問題」に分類され、「AI が実装できませんでした」と人に通知されます。本当は環境の問題なのに、依頼を書いた人が「依頼の書き方が悪かったのか」と悩むことになります。

対策として、AI の出力に未認証を示す文言がないかを先に調べ、あれば「環境の問題」に振り分けています。

出力の文言で判定すると、誤って当たることがあります。ログイン機能の実装を頼むと、AI の説明文に「not logged in」という言葉が出てくるからです。出力の先頭の数行だけを見るなど、範囲を絞る必要があります。

教訓は、終了コードが 0 でも、成功とは限らないことです。「変更が 1 つもない」は、成功とは別の状態として調べます。

2. 変更する前から落ちているテスト

テストが落ちたとき、それは AI の変更のせいでしょうか。iOS のテストには、実行の順番や通信の状態によって、たまに落ちるものがあります。

AI が触る前のテスト結果を知らないと、元から落ちていたテストを AI のせいにしてしまいます。AI は自分の変更と関係ないテストを直そうとし、関係ないコードを書き換え始めます。

そこで、AI に実装させる前に、一度テストを動かして結果を記録しておきます。記録が「失敗」だった場合は、AI に渡す失敗ログの先頭に、次の注意を足します。

注意: 変更前の時点でも、テストは失敗していた。
以下が自分の変更に起因するものか、既存の失敗かを見極めること。

これがあると、AI は「この失敗は既存のもので、今回の変更では直せない」と判断できるようになります。

ただし、これは AI の判断に任せる方法です。AI が気を利かせて、既存の失敗まで直しに行く可能性は残ります。より確実にするなら、次のどちらかの運用があります。

運用 内容 向いている場面
既存の失敗を、別の種類として人に戻す 変更前から落ちていたら、AI を動かさずに「既存の失敗」として止める 既存の失敗がまれで、まず直してほしいとき
既存の失敗を一覧にして、増えた分だけを見る 変更前に落ちていたテストの名前を記録し、変更後に新しく落ちたテストだけを「実装の問題」にする 不安定なテストがいくつか残っていて、すぐには直せないとき

私の仕組みは、今は AI に注意を渡す方法です。不安定なテストが残っている間は、2 つ目の運用に寄せるのが次の改善だと考えています。

3. 環境の問題は、始める前に調べる

環境の問題は、AI を動かしてから気づくより、動かす前に調べるほうが安く済みます。道具があるか、バージョンが合っているかを先に確かめて、足りなければそこで止めます。AI を動かさないので、時間もトークンも使いません。

始める前に調べておくと、後でビルドが落ちたときに「環境は確認済みだから、原因はコードだ」と言い切れます。失敗の原因の候補を、先に 1 つ消しておく作業です。iOS で何を調べるかは、後半に書きます。

4. 時間切れは、分類が残らない

CI の制限時間で強制終了されると、スクリプトは終了コードを返す機会がありません。分類のファイルも書かれません。人に届くのは「タイムアウトしました」だけです。

対策は 2 つです。

  • 上で書いた、全体の試行回数の上限。制限時間に当たる前に、自分で「実装の問題」として止まる
  • 最後に動く時間のかかる処理(後で書く AI のレビュー)には、専用の制限時間を付ける。全体の制限時間に巻き込まれる前に、自分で「判定できなかった」として終わる

自分で止まれば記録が残り、止められると何も残りません。

iOS ならではの、分け間違えやすい所

ここからは、Xcode とシミュレータに特有の話です。どれも「失敗が成功に見える」か、「環境の失敗がコードの失敗に見える」かのどちらかです。

1. xcodebuild | xcpretty は、失敗を成功に変える

xcodebuild の出力は長いので、xcpretty などに通して読みやすくすることがよくあります。

xcodebuild test -scheme App -destination "$DESTINATION" | xcpretty

このままだと、テストが落ちても、この行は成功として扱われます。シェルは、パイプでつないだコマンドのうち、最後のコマンドの終了コードを全体の結果にするからです。xcodebuild が失敗しても、xcpretty が正常に終われば 0 です。

false | cat; echo "exit=$?"                    # exit=0
set -o pipefail; false | cat; echo "exit=$?"   # exit=1

set -o pipefail を付けると、途中のコマンドの失敗が全体の結果になります。これがないと、ループは「全部通った」と判断して先へ進み、壊れた変更で PR を作ります。失敗を分ける以前に、失敗に気づけません。

pipefail は、設定したシェルの中でだけ効きます。ここにも落とし穴があります。

  • Makefile: 1 行ごとに別のシェルで動きます。set -o pipefail の行と、xcodebuild | xcpretty の行を分けると、効きません。set -o pipefail && \ のように、同じ行につなげます
  • GitHub Actions: run: の shell を指定しないと、bash -e {0} で動き、pipefail は付きません。shell: bash と明示すると、bash --noprofile --norc -eo pipefail {0} になり、付きます。どちらにしても、パイプを使うステップの中で set -o pipefail を書いておくと確実です

Makefile については、手元で確かめました。

all:
	@set -o pipefail; false | cat; echo "同じ行: exit=$$?"    # → 1
	@false | cat; echo "次の行: exit=$$?"                     # → 0(効いていない)

2. 同じ名前のシミュレータが 2 台あると、ビルドが始まらない

ビルド先を名前で指定していると、同じ名前・同じ OS のシミュレータが 2 台あるだけで xcodebuild は失敗します。使い捨てのシミュレータを 2 台作って確かめました。

xcodebuild: error: Unable to find a device matching the provided destination specifier:
		{ platform:iOS Simulator, OS:26.5, name:article-dup-sim }
	The requested device could not be found because multiple devices matched the request.

1 台のときは同じコマンドでビルドが通り、2 台にすると終了コード 70 で失敗しました。

これは AI のコードと無関係ですが、AI を動かした後に起きると「ビルドの段階で落ちた」としか見えません。AI は直せないコードを直そうとして、上限まで回ります。

自分たちで用意した Mac を CI に使っていると、シミュレータは前回の実行や人の作業で増えていきます。そこで、AI を動かす前に xcrun simctl list -j の結果を調べ、次の 3 つを確かめています。

  • 指定した OS のランタイムが入っているか
  • 指定した名前の端末があるか
  • 同じ名前の端末が 2 台以上ないか

どれかに当たれば「環境の問題」として止め、消すべき端末の ID をエラー文に出します。

3. Xcode は、ビルド番号まで比べる

xcodebuild -version は 2 行を返します。

Xcode 26.6
Build version 17F113

バージョンの数字(1 行目)だけを比べていると、ベータ版を選んだままの Mac を見逃します。ベータ版と正式版は、同じバージョンの数字でビルド番号だけが違うことがあるからです。リポジトリに「使う Xcode のバージョンとビルド番号」を書いたファイルを置き、2 行とも比べています。

もう 1 つ、xcodebuild -version 自体が失敗する場合があります。ライセンスに同意していない、xcode-select が Command Line Tools を指している、などです。これを「バージョンが合わない」と報告すると、人は間違った所を調べます。コマンドの失敗と、値の不一致は、別のエラー文にしています。

4. E2E が、古いビルドを動かしてしまう

E2E では、ビルドでできた .app をシミュレータに入れて動かします。手元では「DerivedData の中で一番新しい .app を探す」ようにしておくと便利です。

CI でこれをやると、危険です。ビルドの置き場所が違っていたり、前回の実行の残りがあったりすると、今回の変更が入っていない .app を見つけて動かします。E2E は通りますが、確かめたのは別のビルドです。

これは「検証の対象が違うのに成功する」という、一番気づきにくい種類の間違いです。CI では自動で探すのをやめ、ビルドの段階で使った置き場所から .app のパスを求めて、E2E に明示して渡しています。パスが渡されなければ、探しに行かずに止まります。

人に戻すときは、「次に何をするか」を書く

分類ができたら、人への通知を種類ごとに変えます。最初は、どの失敗でも同じ文でした。

パイプラインが失敗しました。実行ログ: (URL)

これを、種類ごとに「何が起きたか」と「次に何をするか」を書く形に変えました。

種類 通知に書くこと
依頼が足りない 完成の条件が不足しています。条件を足して、もう一度ラベルを付けてください
大きすぎる 変更が上限を超えました(○ ファイル / ○ 行)。依頼を分けてください
環境の問題 環境の不備です。依頼の内容に問題はありません。管理者が対応します
禁止ファイルに触った 禁止ファイルへの変更がありました。変更は破棄済みです
実装の問題 ○○ の段階で、上限まで試して通りませんでした

もう 1 つ、やってよかったことがあります。AI 自身の診断を、通知に載せることです。

試しに動かしたとき、AI は「このテストの失敗は、既存の不安定なテストが原因で、今回の変更では直せない」と正しく診断していました。ところが、その診断はログの奥に埋もれ、人に届いたのは「実装の問題」という分類だけでした。「AI の実装が悪かった」という印象だけが伝わったことになります。

そこで、AI の出力の最後の行に、決まった形で診断を 1 行書かせ、それを通知に転記するようにしました。分類は機械が付け、理由は AI が書く、という分担です。

AI が書いた文を通知に載せるときは、信頼できない入力として扱います。秘密の値に見える文字列を伏せる、メンション(@)が飛ばないようにする、長さを切り詰める、といった処理を挟みます。

事実の失敗と、意見の失敗

最後に、ループに入れなかった失敗の話をします。

ビルド・テスト・E2E を全部通った後に、別の AI に変更をレビューさせています。機械の検証は「壊れていないか」しか見ないので、「頼んだことをやったか」「やりすぎていないか」を見るためです。

このレビューで重大な指摘が出たとき、どうするか。自然に考えると「AI に直させる」です。しかし、今はそうしていません。流れを止めず、PR を下書き(draft)にして、人に見せています。

起きたこと 確かさ 扱い
テストが落ちた 事実 AI がやり直す
AI のレビューが「重大」と言った 意見 下書きの PR にして、人が判断する

理由は 3 つあります。

  • レビューの指摘は、外れることがある。 テストが落ちたのは事実ですが、レビューの「重大」は意見です。外れた指摘に従って AI が直すと、通っていたものを壊します
  • 終わりがない。 テストには「通る」という終点がありますが、レビューは毎回違う指摘を出せます。直す → 別の指摘 → 直す、と続く恐れがあります
  • 動くものは、もうできている。 ここで止めると、検証を通った成果物が、人の目に触れないまま消えます

「判定できなかった」(レビューが途中で失敗した、時間切れになった)も、同じく下書きにします。ここで大事なのは、「判定できなかった」を「指摘 0 件」と区別することです。件数が取れなかったときに 0 件として扱うと、レビューが動いていないのに「問題なし」として通ってしまいます。

将来は、レビューの指摘もやり直しのループに入れたいと考えています。ただ、その前に、AI の「重大」が人の判断とどれくらい合うかを測る必要があります。合う率が分かるまでは人に渡し、分かってから機械の輪に入れる、という順番です。

まとめ

  • 失敗を「次に誰が動くか」で分ける。AI にやり直させる価値があるのは、実装の問題だけ
  • 分類は終了コードで返し、直らない失敗はループに入れない。決めていない終了コードは、環境の問題にまとめる
  • 上限は 2 種類。同じ段階での回数と、全体の回数
  • 終了コードが 0 でも成功とは限らない。未認証の AI、変更前から落ちているテスト、時間切れは、分け間違えやすい
  • iOS では、xcodebuild のパイプ、同じ名前のシミュレータ、Xcode のベータ版、古い .app が、失敗を見えなくしたり、環境の失敗をコードの失敗に見せたりする
  • 人に戻すときは、次に何をするかと、AI 自身の診断を書く
  • 事実の失敗は機械がやり直し、意見の失敗は人に渡す

AI に任せる範囲を広げるほど、「うまくいかなかったとき、どう止まるか」の設計が効いてきます。全部を自動にするより、どこまでを自動にして、どこを人に残すかを決めるほうが、作っていて難しく、面白い所でした。

参考

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?