AIエージェント 2026年11月4日

AIの成果物をAIに検証させた261回を読み直した。差し戻し15回のうち12回は受入条件が全部通っていて、testゲートは216回中211回skipだった

XECIN コードレビュー品質ゲート運用技術検証

9月の終わりに、社内の検証エージェントのハーネスを直していて、手が止まりました。

判定記録の test の欄が、ほとんど skip だったんです。

うちでは、Redmine に積んだチケットを実装役の AI エージェント(以下 Worker)が処理し、終わると別のセッションの検証役(以下 Checker)が、自分の作業コピーに取り直して受入条件とコードを確かめています。Worker の完了レポートは成果物の場所を知るために読むだけで、合否の根拠にはしません。Checker が合格を出したものを、最後に人が受け入れてマージします。8月の頭からこの形で回していて、「テストはゲートで見ているから大丈夫」と私は思っていました。

そのときはゲートを1つ足して終わりにしました。ただ、足したゲートの様子も見たくなって、1か月あまり経った今、判定記録を最初から全部読み直すことにしました。8月2日から10月25日までの12週間、4つのプロジェクト、239チケット、261回の判定です。

261回の判定と、15回の差し戻し

まず全体の数字からです。261回のうち合格が246回、差し戻しが15回(5.7%)でした。チケット単位では、239本のうち225本(94%)が最初の検証で合格しています。検証1回の所要時間は中央値で約13分、合計55.6時間なので、差し戻し1回あたり3.7時間ぶんの検証を回した計算になります。

受入条件は1回の判定で中央値8項目あり、延べ2,046項目を判定して、落ちたのは4項目だけでした。

Worker は、書かれたことはきちんとやっていたわけです。では Checker は何を止めていたのか。15回を1件ずつ読んで、中身で分けたのが次の表です。分類は私が読んで決めたものなので、境界には異論があるかもしれません。

差し戻しの中身回数例受入条件
動かすと違う8モックと本物の型の違い、別オリジンだと読めないレスポンスヘッダ、API を呼ばず見た目だけ変わるトグル、外部 API の文字数上限7回は全部通過
機械で取れる3finally の中の throw、コードブロックの言語指定の漏れ(2回)全部通過
着手後の追記の見落とし2Worker の着手から4〜8分後に足された要件を反映していない不合格
保守性1同じ表示定義が2つのファイルに重複全部通過
文書の事実誤り1調査メモの参照先の行が、説明している処理とは別の分岐の中にあった全部通過

15回のうち12回は、受入条件が全部通っていました。止めていたのは、チケットに書かれていないところだったんですよね。

いちばん多い「動かすと違う」は、読んでいて冷や汗が出るものばかりでした。たとえば、金額が変わったときだけ履歴を追記する処理です。「同じ値なら追記しない」というテストは緑でしたが、モックが金額を数値で返していました。実際の PostgreSQL ドライバは bigint を文字列で返すので、動かすと '1000' !== 1000 が常に真になり、金額を変えていない再送でも履歴が増えていきます。Checker はドライバの型パーサーをその場で呼んで、戻り値が文字列であることを確かめていました。

確認モーダルの文字が白、背景が淡い灰色で、コントラスト比が 1.05:1 になっていたケースもありました。テストは「文字があるか」しか見ていないので緑です。Checker はヘッドレス Chromium で開いて computed style を読んでいました。

つい脱線しますが、私が好きなのは次の例です。入口に置いた防御処理のテストが、ルートの無いパスで呼ばれていて、本体の処理まで届いていませんでした。防御処理を本体の後ろへずらす変更を入れても、そのファイルの9件を含む単体テスト863件が全部通ります。テストがあることと、テストが確かめていることは別だ、という話です。

「テストは緑です」という報告を読むだけなら、どれも人の受け入れレビューまで素通りしていたと思います。

ここで少し、止める基準の話もしておきます。コードレビューの指摘は BLOCKER / MAJOR / MINOR / INFO に分けていて、差し戻すのは原則 BLOCKER と MAJOR だけです。コードの検証216回のうち、差し戻し12回のほかに84回が MINOR 付きの合格でした。MINOR でも止めていたら、少なくとも96回(44%)が差し戻しになっていた計算です。ここは好みが分かれるところですが、1往復に中央値で24分かかるので、私は記録だけ残して流す今の形を選んでいます。

往復の話が出たので、もうひとつ。差し戻し15回は、すべて次の1回の検証で通りました。2往復したものはありません(うち2回はハーネスの不具合で次の判定が記録に残らなかったので、Redmine のコメントで確かめました)。効いていると感じているのは、差し戻しの指示に「対象・観測した症状・期待する状態」を必須にしていることです。実物を少し書き換えると、こんな形です。

対象: src/jobs/runJob.ts 157-164 行
観測: finally の中の throw が ESLint の no-unsafe-finally でエラーになり、
      npm run lint:check が失敗する(master は 0 件)。
      build は lint を含むので、このまま入れるとデプロイのビルドが止まる
期待: throw を finally の外へ移す。本体の例外を優先し、本体が成功したときだけ
      後片付けの失敗を投げる動きは変えない

「ここを直して」の一言だと往復が増えるのは、人間同士のレビューでもよくある話だったりします。

「ゲートがある」と「ゲートが走る」は別だった

ここからが、手が止まった話です。

Checker には、LLM が読んで判断する観点のほかに、終了コードで機械的に決まるゲートがあります。最初のアプローチでは、test と lint を次のように定義していました。

# 最初の定義: リポジトリ直下に package.json があれば npm test を流す
id: test
kind: gate
detect: package.json
run: npm test
skip_if_absent: true   # 見つからなければ失敗ではなく skip
severity: fail

# 後から足した定義: 差分が触れたサブプロジェクトだけで流す
id: subproject-gates
kind: gate
run: bash scripts/subproject-gate.sh --base {base}
severity: concern      # 赤でも判定は変えない(観察運用)

うちのリポジトリの多くは、サブプロジェクトを並べたモノレポです。直下に package.json がありません。すると detect が空振りして、skip_if_absent で静かに skip になります。失敗ではないので判定には響かず、合格のコメントも普段どおり付きます。

判定記録からゲートだけを数えると、一目瞭然でした。

import collections
import json
import sys

# 判定記録(1行1判定の JSONL)から、決定的なゲートだけを取り出して数える
counts = collections.defaultdict(collections.Counter)
with open(sys.argv[1], encoding="utf-8-sig") as f:
    for line in f:
        for check in json.loads(line)["checks"]:
            if check["type"] == "gate":
                counts[check["id"]][check["result"]] += 1

print(f'{"gate":<20}{"pass":>5}{"fail":>5}{"skip":>5}{"skip率":>7}')
for gate, c in sorted(counts.items()):
    total = sum(c.values())
    rate = c["skip"] / total
    note = "  <- ほとんど見ていない" if rate >= 0.5 else ""
    print(f'{gate:<20}{c["pass"]:>5}{c["fail"]:>5}{c["skip"]:>5}{rate:>8.0%}{note}')

# 実行結果:
# gate                 pass fail skip  skip率
# artifact-resolved     261    0    0      0%
# deliverable-exists     45    0    0      0%
# lint                    5    0  211     98%  <- ほとんど見ていない
# subproject-gates       48   34   20     20%
# test                    5    0  211     98%  <- ほとんど見ていない

コードの検証216回のうち、test と lint の skip が211回です。pass の5回は、直下に package.json がある小さなリポジトリのものでした。新しいゲートを足す前の8週間に限ると、114回のうち109回で、どちらも一度も走っていません。

もうひとつ、この表から分かることがあります。ゲートが判定を差し戻しに変えた回数は0回です。 15回の差し戻しを決めたのは、すべて LLM の読みでした。

ただ、テストがまったく流れていなかったわけではありません。109回のうち88回には、受入条件の確認の中で Checker がテストを流した跡が残っていました(証跡の文面をキーワードで拾った数なので、目安です)。残り21回はほとんどが SQL や CloudFormation だけの変更です。正直なところ、起票する側が受入条件に「テストが通ること」と書く習慣があったから助かっていただけで、仕組みが保証していたわけではありませんでした。

ゲートが走っていれば秒で取れたはずの指摘もあります。表の「機械で取れる」3回です。同じパターンの見本を手元で測ると、ESLint の no-unsafe-finally は約0.5秒、markdownlint の MD040(コードブロックの言語指定)は約0.3秒で検出しました。この3回の判定に合計で約19分、差し戻しから次の判定が出るまでにさらに約82分かかっています。

では skip を禁止すればいいかというと、それは選びませんでした。SQL だけの変更に npm test を流しても意味がないからです。skip は必要で、問題は理由が見えないことでした。新しいゲートは終了コードを 0(適合)/ 1(不適合)/ 2(対象なし)/ 3(環境要因で一部を実行できず)に分けて、理由を証跡に書かせています。足してからの skip は20回で、すべて「変更がサブプロジェクトの外だけ」でした。これなら読んで納得できます。

足したゲートは、3回に1回赤かった

改善後のアプローチは、差分が触れたサブプロジェクトごとに install、format:check、lint:check、test を順に流すスクリプトです。

このとき、いきなり不合格で止める設定にはせず、severity: concern で様子を見ることにしました。赤でも判定は変えず、参考として載せるだけです。結果から言うと、これは正解でした。

102回のうち、緑48回、赤34回、skip 20回。3回に1回は赤でした。

  • 27回は、同じ10スイート・138件の失敗。検証環境に認証情報が無いので毎回落ちる結合テストです
  • 7回は format:check。表の列揃えなど、master にも同じ形で残っている整形の違反です

Checker の切り分けでは、今回の変更が原因だったものは0回です。もし最初から不合格で止めていたら、102回のうち34回が、作業と関係のない理由で差し戻されていました。

想定外だったのは、赤の意味が消えていく速さです。観察を始めた9月26日から10月25日までに、通るテストは422件から954件まで増えました。その間ずっと、138件は落ち続け、終了コードは1のままです。ここに新しい失敗が1件混じっても、終了コードは同じ1なんですよね。

今は赤のたびに Checker がログを読み、「いつもの10本です」と証跡に書いています。つまり、機械で決めるはずのゲートの解釈を LLM に頼っているわけです。検証時間の中央値も、10.5分から15.5分に伸びました。

これをやめるには、「この環境で落ちるテスト」を台帳にして、差分だけで判定すればいいはずです。Jest の JSON 出力を読む小さなスクリプトを書いて、手元の見本で確かめました。

// 使い方: node check-known-failures.mjs jest-result.json known-failures.txt
import { readFileSync } from "node:fs";
import { relative } from "node:path";

const [resultPath, ledgerPath] = process.argv.slice(2);
const result = JSON.parse(readFileSync(resultPath, "utf8"));
const known = new Set(
  readFileSync(ledgerPath, "utf8").split(/\r?\n/).filter((l) => l && !l.startsWith("#")),
);

const failed = new Set();
for (const suite of result.testResults) {
  const file = relative(process.cwd(), suite.name).replaceAll("\\", "/");
  const tests = suite.assertionResults.filter((t) => t.status === "failed");
  for (const t of tests) failed.add(`${file} > ${t.fullName}`);
  // 読み込みで落ちたスイートは個々のテストが1件も報告されないので、ファイル単位で拾う
  if (suite.status === "failed" && tests.length === 0) failed.add(`${file} > (スイートが実行できない)`);
}

const added = [...failed].filter((name) => !known.has(name));
const gone = [...known].filter((name) => !failed.has(name));
console.log(`失敗 ${failed.size} 件: 台帳どおり ${failed.size - added.length} 件 / 新規 ${added.length} 件`);
added.forEach((name) => console.log(`  新規: ${name}`));
gone.forEach((name) => console.log(`  台帳から外せる: ${name}`));
process.exit(added.length > 0 ? 1 : 0);

// 実行結果(Jest 30.5.2。台帳には認証の2件を登録):
// A: いつもの失敗だけ       jest exit=1 / 台帳チェック exit=0
//    失敗 2 件: 台帳どおり 2 件 / 新規 0 件
// B: 新しい失敗が混じった   jest exit=1 / 台帳チェック exit=1
//    失敗 3 件: 台帳どおり 2 件 / 新規 1 件
//      新規: tests/price.test.js > 税込価格を計算できる
// C: 認証情報が入って直った jest exit=0 / 台帳チェック exit=0
//    失敗 0 件: 台帳どおり 0 件 / 新規 0 件
//      台帳から外せる: tests/auth.test.js > 認証 トークンを取得できる
//      台帳から外せる: tests/auth.test.js > 認証 一覧を取得できる
// D: スイートごと読み込めない jest exit=1 / 台帳チェック exit=1
//    失敗 3 件: 台帳どおり 2 件 / 新規 1 件
//      新規: tests/broken.test.js > (スイートが実行できない)

A と B は、Jest の終了コードだけなら区別できません。台帳を挟むと 0 と 1 に分かれます。

D はエッジケースですが、見落としやすいところです。読み込みで落ちたスイートは個々のテストが報告されないので、テスト名だけを見ていると失敗に数えられません。実際にこの分岐を外して流すと、D の判定は A と同じ exit=0 になりました。

このスクリプトには、正直に言って改善の余地があります。テスト名の完全一致で見ているので、名前を変えただけで「新規」になりますし、たまに落ちる不安定なテストは扱えません。それに、台帳を Worker が書き換えられる場所に置くと、自分で台帳に足して通せてしまいます。答案を書く人に採点表を渡さないよう、置き場所は検証側に寄せるつもりです。

実際のログでの裏取りはまだ小さく、残っていた4回分のログを突き合わせて、落ちた10スイートの名前が4回とも同じだと確かめたところまでです。本番のゲートにはまだ入れていません。

合格のあとで戻された6本

最後に、Checker が通したものはどうなったのか。合格の後でもう一度 Worker に戻されたのは、239本のうち6本でした。

戻された理由本数
社外の専門家の回答が後から届き、要件が変わった3
承認条件そのものが設計書と食い違っていた1
Worker が懸念として申し送った点を、人が判断して仕様を変えた1
チケットの範囲外にある運用の説明文が、変更の影響を受けていた1

6本とも、チケットに書かれた内容に照らせば合格で正しいものでした。

印象に残っているのは承認条件の1本です。起票時に「当日の集計が未実施なら null を返す」と書いてしまい、画面設計と API 設計は「前回の値を返す」になっていました。Worker は承認条件に忠実に実装し、Checker も合格を出しました。受け入れレビューで人が気づいて差し戻したときのコメントには、「実装そのものに落ち度はありません」「指示した側の誤りです」とあります。そのまま入っていたら、集計が走る前の時間帯はダッシュボードが全部 0 になるところでした。

運用の説明文の1本も同じ構図です。ある状態の再処理を断る変更でしたが、本番のアラームの説明文が、止まった処理の回収方法としてまさにその再処理を案内していました。これも人が読んで気づいています。

Checker が見ているのは「チケットどおりか」と「コードとして動くか」です。チケットの外にある事情と、チケット自体の誤りは見えません。この限界がはっきりしたのは、数えてよかったところです。

数字の読み方には注意が要ります。6本は「同じチケットに戻された」数で、後から別のチケットとして切られた修正は入っていません。12週間・4プロジェクトの社内の記録なので、どの現場でも同じ割合になるとは限りません。

振り返って

今思えば、「Checker を入れて品質が上がった」の中身を、私は取り違えていました。ゲートが守っていると思っていたところは、実際には LLM が読んで、ときには自分で動かして守っていました。これは悪いことではありません。モックと本物の型の違いや、1.05:1 の文字は、動かして見にいく役がいないと捕まらないものです。ただ、0.5秒で取れる指摘に12分かけるのはもったいないですし、skip が合格と同じ顔をしていたのは単純に設計の穴でした。

ここから先は、次の順で手を入れていきたいと考えています。

1つ目は、ゲートごとの skip 率を毎週集計して、高いものを人が見ることです。2つ目は、既知の失敗の台帳を本番のゲートに入れて、severity: concern を不合格で止める設定に上げること。3つ目は、機械で取れる指摘をリンターへ移すこと。4つ目は、Worker が完了レポートを書く前にチケットを読み直す手順を足すことです。着手して数分後の追記を見落とした2回は、これで防げるはずです。

いちばん悩んでいるのは、承認条件そのものの誤りをどこで捕まえるかです。Checker に設計書との突き合わせをさせると、チケットの外まで読むぶん検証が重くなります。起票する側で確かめるほうが筋がいい気もしていて、まだ決めきれていません。もう少し数字が溜まったら、また書こうと思います。