本文へスキップ
hdknr blog
戻る

AI エージェントにリファクタさせるときの「完了の定義」の引き方 — 局所的に正しい修正が遠くで壊れる前に

TL;DR


起きたこと(要約)

ある取込フローで「Celery タスクが DB コミット前に .delay() され、未コミット行を掴めずサイレント失敗する」という実バグがあった。AI コード監査がこれを見つけ、.delay()transaction.on_commit 経由の enqueue に直した。本番として 100% 正しい修正で、新挙動のユニットテストまで足されていた。

ところが 1 日後、日次テスト監査が別の結合テストを赤にした。原因は、それらのテストが高速化のため TestCase(トランザクションを rollback するので on_commit が発火しない)へ移行済みだったこと。これは非局所な副作用だ。修正は「commit されること」を新たな前提に加えたが、commit しないテストの上ではその前提が崩れる。

詳しい技術的経緯は別記事に譲る。ここで注目したいのは、エージェントは何も間違えていないという点だ。では何が問題だったのか。順に見ていく。

エージェントは「完了の定義」に忠実すぎる

修正を生んだ指示は、実質「コードを監査してバグを直せ」だった。エージェントはそれに極めて忠実に応えている。

つまり**「変更関数+そのユニットテスト」という暗黙の完了ラインの内側では完璧だった。事故はその外側で起きた。ここが本質だ。境界を広げるのは、エージェントではなく指示側の責任**である。

なぜこれが AI エージェント特有の罠なのか

熟練したエンジニアなら「共有ディスパッチ層を触った、遠くで何か壊れそうだ」という嗅覚が働く。エージェントにはそれがデフォルトで備わっていない。むしろ 3 つの性質が事故を後押しする。

1. 最小差分バイアス

エージェントは「差分を局所に絞るほど良い仕事」だと強く方向づけられている。だから blast radius(変更の影響範囲)を自分から広げようとしない。よく訓練されているほど、diff は小さく、レビューは通しやすく、そして影響調査は狭い

2. 完了宣言バイアス

指定したチェック(新ユニットテストが緑)が通った瞬間に「完了しました」と宣言したがる。指示が「テストを通せ」であれば、エージェントにとってのゴールは文字通り「そのテストが緑になること」であって、「変更が安全であること」ではない。

3. 暗黙不変条件を見ない

「このテスト群は on_commit 非依存という前提で高速化移行した」という契約は、実際にはコードコメントに書かれていた。だが指示の視界に入っていなければエージェントは参照しない。人間なら過去の議論や口伝で知っている不変条件を、エージェントは明示されない限り知らない。

まとめると、AI は与えた検証境界にぴったり最適化して、そこで止まる。放っておいて境界を広げてくれることは期待できない。

指示 Tips(そのまま CLAUDE.md / エージェントプロンプトに入る形)

以下は、共有層やフレームワークのイディオムを触るリファクタ/バグ修正をエージェントに任せるときに、指示へ明示的に組み込む項目である。

1. 変更ではなく blast radius を定義させる

「共有層(signals / dispatch / 基底クラス / middleware / ユーティリティ)を変更したら、その経路を叩く呼び出し元とテストを grep で全列挙し、各テストを実行モデル(commit するか rollback か / 同期か非同期か / mock か real か)で分類せよ」

今回の事故は、これがあれば TestCase 上の結合テストが視界に入り、当日中に気づけた。

2. 依存する/破る不変条件を言語化させる

「この変更が新たに依存する前提、または破る前提を箇条書きで明示せよ」

on_commit 化は「トランザクションが commit されること」を新しい前提に加えた。それを 1 行吐かせるだけで「では commit しないテストは?」という問いに自然に接続する。

3. 「テストが緑」を完了基準にしない

「変更した経路を実際に実行するテストを、ローカルの古い HEAD ではなくマージ先ブランチに対して走らせ、テストが新しいコードを通っていることを確認せよ」

事故当時、ローカルでは緑だった。だが手元の checkout が原因コミットより古く、「実行したつもり」で緑になっていただけだった。この落とし穴を指示側で前倒しに潰す。

4. 「正しいコード」と「安全な変更」を分けて考えさせる

「局所的に正しくても非局所な副作用がありうる。特に framework イディオム(on_commit / 非同期 dispatch / キャッシュ / signal / トランザクション境界)はテストと本番で挙動が変わる。テストの実行モデル上でも成立するか確認せよ」

5. 検証は「書いた本人」にやらせない(敵対的レビュー)

修正を書いたエージェントは確認バイアスがかかり、自分の変更を肯定しやすい。別コンテキスト/別エージェントに、「この変更はどこで・どの実行モデルのテストを壊しうるか」だけを探させる。これは、記事の校正で本文を書いたのとは別のエージェントにファクトチェックを回すのと同じ発想だ。マルチエージェント構成なら「実装役」と「破壊役」を分けるのが効く。

ここでよくある問いが「敵対的レビューは別モデルを使うべきか?」だ。結論は、「別モデル」より「別コンテキスト+敵対的プロンプト」の方が本命。バイアスは文脈に宿るので、実装の言い訳を持たない新規コンテキストに「反証せよ/壊れる入力を探せ/デフォルトは有罪」と指示するだけで、同一モデルでも大きく効く。別モデルが要るかは、潰したい盲点の種類で決まる。

つまり別モデルは「共有された誤信念」リスクが高い変更での保険と考えるとよい。なお、弱いモデルを多様性目的で入れるのは逆効果(誤検出で時間を溶かす)。最も弱いのは同一モデル・同一コンテキストの自己レビューで、まずそこから脱するのが最優先だ。

6. 「監査 → 修正 → 横断テストスイープ」を 1 つの手順に固定する

AI 監査が複数のバグを直したら、最後の必須ステップは per-fix のユニットテストではなく、マージ結果に対する横断スイート/全体監査の即時実行にする。安全網(日次監査)は機能したが 1 日遅れた —— これは「横断変更なら PR 時点で全体監査」を手順に組み込めば消える遅延だ。

指示テンプレート例

上記を 1 つのプロンプトブロックにまとめると、たとえばこうなる。

このリファクタ/修正を行う際、以下を必ず満たすこと:

1. 影響範囲: 変更する関数・層を叩く呼び出し元とテストを grep で全列挙し、
   各テストを実行モデル(commit/rollback, sync/async, mock/real)で分類して報告。
2. 不変条件: この変更が新たに依存する前提・破る前提を箇条書きで明示。
3. 完了条件: 「テストが緑」ではなく「変更経路を実際に実行するテストが、
   マージ先ブランチ上で新コードを通って緑」であること。checkout の鮮度も確認。
4. framework イディオム(on_commit / 非同期 / cache / signal 等)を足す場合、
   テストの実行モデル上でも挙動が成立するか個別に検証。
5. 完了報告には「この変更が壊しうる遠い場所」の候補を最低3つ挙げること。

最後の「壊しうる遠い場所を最低 3 つ挙げよ」は特に効く。完了宣言バイアスを、あえて『不安を言語化する』方向に反転させるためのプロンプトだ。

Django プロジェクトでの CLAUDE.md の例

上のテンプレートは 1 タスク分の指示だが、同じ内容はプロジェクトの常設ルールとして CLAUDE.md(エージェントが毎回読む指示ファイル)にも書ける。むしろそちらに置く方が、指示し忘れても毎回効く。

ただしそのままコピーせず、汎用語を自プロジェクトの実名(基底クラス・層・監査ジョブ名)に具体化するのがコツだ。「framework イディオム」より on_commit と名指しし、「TestCase ベース」より実際のクラス名を書く方が、エージェントの注意は確実に向く。今回の事故を踏まえた Django プロジェクトなら、たとえばこうなる。

## 共有層・トランザクション境界を触るとき

- 変更する関数/層を叩く**呼び出し元とテストを grep で全列挙**し、各テストを
  実行モデルで分類する:
  - `RestTestCase`(= `TransactionTestCase`): commit する → `on_commit` 発火
  - `RestFastTestCase`(= `TestCase`): rollback する → `on_commit` 非発火
`CELERY_TASK_ALWAYS_EAGER=True` の eager 実行でも発火しない)
- `transaction.on_commit`**新規追加**したら、その経路を叩く `RestFastTestCase`
  ベースのテストを `self.captureOnCommitCallbacks(execute=True)` で囲むか、
  `RestTestCase` へ戻す。同期経路では `captureOnCommitCallbacks` は no-op なので
  sync/async 共通ヘルパにそのまま入れてよい。
- 完了条件は「テストが緑」ではなく「**変更経路を実際に実行するテストが、
  マージ先ブランチ上で新コードを通って緑**」であること。手元 checkout の鮮度も確認する。
- `files/` など全アプリ横断の signal / dispatch を変更した場合、マージ後に
  日次テスト監査を**手動トリガ**する(当日中に検知するため)。
- 完了報告には「この変更が壊しうる遠い場所」の候補を**最低 3 つ**挙げること。

ポイントは 2 つ。① 高リスクなイディオム(on_commit / 非同期 dispatch)だけ名指しでルール化し、残りは「共有層を触るときは影響範囲を全列挙」の一文に畳んで肥大化を防ぐ。② 完了条件と『壊しうる遠い場所を 3 つ』は普遍的に効くので、イディオムに依らず常設で置く。

なお、リファクタと無関係なリポジトリ(この技術ブログのような Hugo サイト等)にこのルールを足すのは逆効果だ。適用対象のないルールは CLAUDE.md のノイズになり、キャッシュ効率も落とす。ルールは「そこで実際に起きる作業」にひもづけて置く。

まとめ

「AI が正しいコードを書いた」は「変更が安全だった」ことを意味しない。両者を分けて完了条件を設計することが、AI エージェント時代のリファクタリングの肝になる。



前の記事
Django の transaction.on_commit と TestCase の相性 — 正しい本番修正が「昨日まで緑だったテスト」を赤にした話
次の記事
AhrefsとSemrushとは?2大SEOツールの仕組み・機能・競合を徹底比較