AI使った並列開発で頻発するコンフリクトを自動解消する仕組みを作った

CI/CDシリーズの4本目。1本目はデプロイパイプラインの土台2本目はPRごとのプレビュー環境前回はDB操作のワークフロー化について書いた。今回はAIエージェント(Devin、Claude)による並列開発で発生するコンフリクトの自動解消について。

背景: AIエージェント並列開発のコンフリクト問題

普段、DevinとClaudeを使った並列開発をしているので複数のAIエージェントが同時にPRを作成・更新するわけだが、その結果コンフリクトが頻発するようになった。

これはある意味で構造的に避けられない問題で、devブランチにPRがマージされるたびに他のオープンPRとの間でコンフリクトが生じる可能性がある。人間が1人で開発しているならPRは基本的に直列だし、複数人でもAIと比べると圧倒的に実装速度が遅いからレビューしたりマージする側にも余裕があって結果的に問題になりにくい。でもAIエージェントを並列で動かすスタイルで実装させるとあっという間にPRの数が積み上がる。その結果、マージの頻度も跳ねあがるしコンフリクトも発生しがち。

コンフリクトが発生するとそのPRはマージできなくなるので、誰かが解消しなければならないがこれを人間でやると圧倒的なボトルネックになる。というかなった。AIエージェントに実装させているのに、コンフリクト解消は人間がやるのではちょっと本末転倒な気がしてきたのである。

AIエージェントの使い分け

さて、コンフリクト解消以外にもAI支援系のワークフローをいくつか動かしている。PRレビューは基本的にDevin Reviewに任せていて、マージ前に自動でレビューが入る運用。それとは別にClaudeとCodexのレビューをGitHubのPRコメントで手動で呼び出せるようにもしていて、別の視点で見たいときや複数AIの意見を比較したいときに使う。

複数のAIを使い分けてはいるが、コンフリクト解消については今のところDevinに任せることにした。

auto-conflict-resolution.yml

というわけで、コンフリクトの検知と解消を自動化するワークフローを作った。

トリガーと基本構成

devブランチへのpush(= PRマージ)をトリガーとして実行される。

name: Auto Conflict Resolution
on:
  push:
    branches:
      - dev

jobs:
  check-conflicts:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            // dev向けのオープンPRを全件取得
            const pulls = await github.paginate(
              github.rest.pulls.list,
              {
                owner: context.repo.owner,
                repo: context.repo.repo,
                state: 'open',
                base: 'dev'
              }
            );
            
            for (const pr of pulls) {
              // conflict-resolution-in-progress ラベルが付いていたらスキップ
              const hasLabel = pr.labels.some(
                l => l.name === 'conflict-resolution-in-progress'
              );
              if (hasLabel) {
                console.log(`PR #${pr.number}: skipped (resolution in progress)`);
                continue;
              }
              
              // mergeable状態を取得(リトライ付き)
              let mergeable = null;
              for (let attempt = 0; attempt < 3; attempt++) {
                const detail = await github.rest.pulls.get({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  pull_number: pr.number
                });
                mergeable = detail.data.mergeable;
                if (mergeable !== null) break;
                // GitHubが計算中の場合は5秒待ってリトライ
                await new Promise(r => setTimeout(r, 5000));
              }
              
              if (mergeable === false) {
                console.log(`PR #${pr.number}: conflict detected`);
                // Devin APIでコンフリクト解消セッションを起動
                await startDevinSession(pr);
              }
            }

github.paginateを使っているのでPRが多くても全件取得できる。

mergeableのリトライ処理

上のコードにも書いたが、GitHubのmergeable状態は非同期で計算されるため、PRマージ直後のAPIコールでnullが返ることがある。nullはコンフリクトがあるわけでもないわけでもなく、単に「まだわからない」という状態。最大3回、5秒間隔でリトライしてmergeableの値が確定するのを待つ。

Devin APIの呼び出し

コンフリクトが検出されたPRに対してDevin APIにセッション作成リクエストを送る。

async function startDevinSession(pr) {
  // PRタイトルのJSON injection対策
  const safeTitle = pr.title.replace(/[\\"]/g, '');
  const safeBranch = pr.head.ref.replace(/[\\"]/g, '');
  
  const prompt = `
Repository: ${context.repo.owner}/${context.repo.repo}
Branch: ${safeBranch}
PR #${pr.number}: ${safeTitle}

Tasks:
1. Clone the repository and checkout the branch "${safeBranch}"
2. Run "git merge origin/dev" to resolve conflicts
3. Respect the intent of the existing code when resolving
4. Run tests and lint to verify the resolution
5. Push the changes
6. Remove the "conflict-resolution-in-progress" label from PR #${pr.number}
7. Add a comment to the PR noting the resolution is complete
8. DO NOT make any functional changes — conflict resolution only
`;
  
  const response = await fetch(
    `https://api.devin.ai/v3/organizations/${orgId}/sessions`,
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${devinToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ prompt })
    }
  );
  
  const session = await response.json();
  
  // ラベルを付与して重複防止
  await github.rest.issues.addLabels({
    owner: context.repo.owner,
    repo: context.repo.repo,
    issue_number: pr.number,
    labels: ['conflict-resolution-in-progress']
  });
  
  // PRにコメント投稿
  await github.rest.issues.createComment({
    owner: context.repo.owner,
    repo: context.repo.repo,
    issue_number: pr.number,
    body: `コンフリクトを検出しました。Devinによる自動解消を開始します。\n\nSession: ${session.url}`
  });
}

PRタイトルやブランチ名に含まれる"\をエスケープしているのがJSON injection対策。PRタイトルは自由に書けるので、そこに含まれる文字列をそのままJSONに埋め込むのは危険。

ラベルによる重複防止

conflict-resolution-in-progressラベルは重複防止の仕組みとして用意した。既にこのラベルが付いているPRはスキップされるので、同一PRに対して複数のDevinセッションが同時に起動されることはない。

Devinがコンフリクト解消を完了したらラベルを削除する。これにより、その後また別のPRマージでコンフリクトが再発した場合に再度検知される。ラベルの付与と削除でセッションの状態を管理している感じ。外部のデータストアを使う必要もないし、GitHub上で誰でも状態が確認できる。

4回の改善コミットで堅牢化

このワークフローは最初のリリースから4回の改善コミットを経て今の形になった。初版を出した時点では動作はするけど穴だらけで、運用するうちに次々と問題が見つかった。具体的には以下のような修正をしている。

  • PRタイトルやブランチ名に"\が混じった場合のJSON injection対策
  • GitHubのmergeable状態がnullで返るケースへのリトライ処理(最大3回、5秒間隔)
  • Devinへのプロンプトにラベル削除手順を追加(解消後に再検知できるように)
  • チェック対象を全オープンPRからdev向けPRのみに絞り込み

どれも実際に運用してみないとわからなかった類の問題で、最初から全部考慮しようとすると永遠にリリースできない。まず動くものを出して問題が出たら直す、というサイクルでやってよかったと思う。

最後に

Devin APIで自動コンフリクト解消する判断は、最初はちょっとやりすぎかなと思っていた。コンフリクトくらい手動で解消すればいいと考えていたけど、AIエージェントの並列数が増えると本当にボトルネックになる。1日に何回もコンフリクト解消するのは面倒だし、辛い。

ちなみにここまできたらオートマージってのも視野に入ってくるのだがそれはまだやってない

©Keisuke Nishitani, 2023   プライバシーポリシー