Blog

ブログ

AIエージェントをgit worktreeで並列に動かす

長いので先にまとめ

  • Docker Composeで開発環境を組んだプロジェクトで、Claude Codeの自作ハーネスにgit worktreeを使って複数のIssueを並列実装させている。
  • ハーネスはブラウザでアプリを開かないので、ホストにポートを公開しない。worktreeごとに起動するのはwebとviteだけで、MySQLとRedisはメインのスタックを共有し、DB名とRedisのDB番号で分ける。
  • 既存の docker-compose.yml は変えず、worktree用のcomposeファイルを1つ足しただけで済んだ。
  • 運用中に起きた問題を6つ紹介する(composeのネットワーク別名、localhost とCSRF、差分の比較対象、root所有のファイル、中断時に残るもの、Workflowの呼び出し方)。

    はじめに

    Laravel + InertiaのB2B向けECを、Claude Codeを中心にしたチームで開発しています。Issueを1件渡すと、実装・検証・レビューを経てDraft PRまで自律で進める仕組みを自作して使っていて、この記事ではこれを「ハーネス」と呼びます。

    「ハーネス」は、一般的にまだ定義にブレがある言葉ですが、この記事の中では、複数のAIエージェントに作業を割り振り、決まった手順で動かすためのスクリプトをハーネスと呼びます。具体的には、Claude CodeのWorkflow機能で書いたJavaScriptです。どの段階をどのエージェントに任せるか、どの順に進めるか、検証が通らなければ何回までやり直すかをコードで決めておき、各段階の中の作業はエージェントが行います。

    当初はIssueを1件ずつ直列に処理していました。その頃の問題は2つです。

    1. ハーネスの実行中は、人間がメインの作業ツリーを使えない。ハーネスが同じツリーで git checkout やコミットをするので、その間に人が別ブランチで作業すると衝突します。人はハーネスが終わるまで待つことになります。
    2. 依存のないIssueでも同時に進められない。1件ずつしか処理できないので、着手を待つIssueが溜まります。

    そこで、git worktreeでIssueごとに作業ツリーを分けることにしました。この記事では、Docker前提のプロジェクトでworktreeを使うための構成と、運用中に起きた問題を紹介します。

    git worktreeを使う

    git worktreeは、1つのリポジトリに作業ツリー(ファイルをチェックアウトしたディレクトリ)を複数持たせるgitの機能です。

    # ../feature-a に、ブランチ feature-a をチェックアウトした作業ツリーを作る
    git worktree add ../feature-a feature-a
    
    git worktree list                # 作業ツリーの一覧
    git worktree remove ../feature-a # 作業ツリーを消す(ブランチは残る)

    作った作業ツリーは、それぞれ別のブランチをチェックアウトした独立したディレクトリになります。一方の作業ツリーで git checkout やコミットをしても、もう一方のファイルは変わりません。

    リポジトリをもう1つ別ディレクトリにcloneする方法と比べると、次の点が違います。

    • .git の中身(コミットやオブジェクト)は元のリポジトリと共有するので、ダウンロードし直す必要がなく、ディスクも余計に使わない
    • 一方の作業ツリーで作ったコミットやブランチは、すぐにもう一方からも見える
    • 同じブランチを2つの作業ツリーで同時にチェックアウトすることはできない

    ハーネスがIssueごとにworktreeを作り、その中でブランチを作って実装すれば、人間はメインの作業ツリーで関係なく作業を続けられます。gitの側は git worktree add の1行で済みます。

    問題はコンテナの側です。このプロジェクトでは、1つの docker-compose.yml で次のコンテナを動かしています。

    サービス 役割
    web nginx + PHP-FPM。Laravelのアプリ本体で、ソースはホストからマウントする
    vite Node。開発サーバー(HMR)と、フロントエンドのビルド・lint
    mysql MySQL 8.4
    redis Redis 7
    phpmyadmin DBの閲覧用
    mailhog アプリが送るメールの受け口
    minio S3互換のストレージ(バケットを作るための使い捨てのコンテナも付く)
    stripe-cli StripeのWebhookをローカルに転送する。必要なときだけ起動
    e2e PlaywrightのE2E。必要なときだけ起動

    どれも同じDockerのネットワークに参加していて、サービス名で互いを解決します。ほとんどのサービスは container_name を指定し、ホストにポートを公開しています。

    テストもビルドもコンテナの中で実行するので、worktreeごとに、そのファイルをマウントしたコンテナが要ります。ところが、メインと同じcomposeファイルでworktreeごとにコンテナ一式を起動すると、次のことが起きます。

    • worktreeごとに docker compose up すると、container_name とホストポートが衝突する
    • MySQL・Redis・MinIO・MailHogまですべて複製すると、メモリもポートも足りない
    • テストやマイグレーションはDBを触るので、worktree同士でDBを分けないと互いのデータを壊す

    また、メインの docker-compose.yml は人間が普段の開発で使っているので、並列化のために変えることはしたくありません。

    構成:軽いものだけ複製し、重いものは共有する

    ハーネスがコンテナの中ですることを見ると、必要なものは限られます。

    • ブラウザでアプリを開かない。lint・ビルド・テストはすべて docker compose exec で完結する
    • PHPUnitは sqlite:memory で動く。MySQLを使うのは migrate の確認くらい
    • MinIOやMailHogは使わない

    そこで、次のように構成しています。

    • ホストにポートを公開しない。ブラウザで開かないのでポートは要らず、公開しなければ衝突もしません。
    • worktreeごとに起動するのはweb(PHP)とvite(Node)だけ。container_name は付けず、別のcompose project名で起動します。
    • MySQLとRedisはメインのスタックを共有する。メインのDockerのネットワークに参加し、サービス名で解決します。使う量が少ないので、データを持つサービスをworktreeの数だけ複製するより安く済みます。
    • 分離は名前で行う。DBは同じMySQLサーバー上に nunolab_wt_<issue番号> というデータベースを作り、RedisはworktreeごとにDB番号を変えます。

    これらはworktree用のcomposeファイル docker-compose.worktree.yml を1つ足すだけで実現でき、メインの docker-compose.yml は変えずに済みます(ファイルの中身は問題1で紹介します)。

    worktreeはIssueごとに1つ作ります。ベースブランチの最新のコミットから git worktree add --detach で作り、その中でハーネスがブランチを作ります。メインの作業ツリーには触りません。

    ハーネスは親子の2段構成です。親ハーネスがIssueごとに「worktreeの用意 → 子ハーネスをworktreeに向けて起動 → 片付け」を並列に実行します。子ハーネスは、もともとIssueを1件ずつ処理していたハーネスそのもので、中身は変えていません。用意と片付けは、それぞれ担当のエージェントがシェルで行います。

    ハーネスの構成と役割分担。人間が親ハーネスにIssueを渡すと、親ハーネスはIssueごとのworktreeで「用意担当 → 子ハーネス → 片付け担当」を並列に実行する。子ハーネスの中は段階ごとに別のエージェントが受け持ち、前提確認、実装、検証と修正(通るまで最大2回)、レビューとセキュリティレビュー(並行)、修正、検証、E2Eのテスト選び、E2E、指摘の記録を経てDraft PRを作る。各worktreeと人間は、メインのスタックのMySQL・Redisを共有する

    worktreeの .env はメインのものをコピーし、次の3行だけ書き換えます。

    DB_DATABASE=nunolab_wt_<N>   # worktree ごとの database
    REDIS_DB=2                   # 同時に実行する Issue の順に 2, 4, 6 …(メインは 0 と 1)
    REDIS_CACHE_DB=3             # REDIS_DB + 1

    DB_HOST と REDIS_HOST は変えません。共有ネットワーク上で、メインの mysql と redis がそのまま名前で見えるからです。データベースはメインのMySQLにrootで作ります。

    docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" -e "
      CREATE DATABASE IF NOT EXISTS \`nunolab_wt_<N>\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
      GRANT ALL PRIVILEGES ON \`nunolab_wt_<N>\`.* TO '<アプリのユーザー>'@'%';"

    コンテナの起動には、worktreeごとのcomposeコマンドをシェル関数にしたものを使います。

    MAIN=$(git rev-parse --show-toplevel)
    WT="$MAIN/.worktrees/wt-<N>"
    dc() { docker compose --project-directory "$WT" -p nunolab_wt_<N> -f "$MAIN/docker-compose.worktree.yml" "$@"; }
    
    dc up -d
    dc exec -T web composer install --no-interaction
    dc exec -T vite npm ci

    composeファイルはメインツリーの絶対パスで指定し、--project-directory で ./ をworktreeに向けます。こうすると、worktreeにチェックアウトしたコミットにcomposeファイルが含まれていなくても(=composeファイルがまだmainにマージされていなくても)動きます。

    運用中に起きた問題

    動くようになった後、運用中に次の6つの問題が起きました。

    # 起きたこと 対処
    1 メインのE2Eが、worktreeの web にアクセスしてしまう 共有ネットワークには待機用のコンテナだけを参加させる
    2 localhost でアクセスするとCSRFの照合が省略され、CSRFのテストが必ず失敗する localhost 以外の名前(wt-app)でアクセスする
    3 差分をローカルの main と比べると、マージ済みの変更までレビューされる origin のブランチと比べる
    4 rootが作ったファイルを消せず、worktreeを消せない 消す前に、コンテナの中でファイルの持ち主を戻す
    5 中断すると、worktreeやコンテナ、DBが残る 残ったworktreeで子ハーネスを起動し、続きから進める
    6 実行中のWorkflowから、自作のWorkflowを名前で呼べない スクリプトのパスで呼ぶ

    起きた場所で分けると、1と2がDockerのネットワーク、3〜5がworktreeの作成から片付けまで、6がハーネス自体の問題です。以下、順に説明します。

    Dockerのネットワーク

    1. メインのE2Eが、worktreeの web にアクセスしてしまう

    E2E(Playwright)を導入した後、メイン側のE2Eが、変更と関係のないところでときどき失敗するようになりました。

    原因はcomposeの仕様です。composeは、ネットワークに参加するコンテナにサービス名を別名として必ず付けます(無効にする設定はありません)。worktreeのwebが共有ネットワークに web という別名で参加すると、メインのE2Eがアクセスする http://web:3000 がworktree側のwebに解決されることがありました。

    対処として、待機するだけの net というサービスを追加し、共有ネットワークにはそれだけを参加させるようにしました。webとviteは network_mode: service:net でそのネットワーク名前空間を共有します。

    最終的な docker-compose.worktree.yml は次のとおりです(E2E用のサービスなどは省いています)。

    services:
      # 共有ネットワークに参加するのはこれだけ。待機するだけのコンテナ
      net:
        image: node:24.22.1-alpine
        command: sleep infinity
        extra_hosts:
          - "wt-app:127.0.0.1"   # 問題2で使う
        networks: [shared]
    
      web:
        image: ${MAIN_PROJECT:-nunolab}-web   # メインでビルド済みのイメージを使い回す
        volumes:
          - ./:/var/www/html                  # ./ は --project-directory で指した worktree
        network_mode: service:net             # net の network namespace を共有する
    
      vite:
        image: node:24.22.1-alpine
        working_dir: /var/www/html
        command: sleep infinity               # lint / build 専用。HMR サーバーは起動しない
        volumes:
          - ./:/var/www/html
          - node_modules:/var/www/html/node_modules
        network_mode: service:net
    
    volumes:
      node_modules:
    
    networks:
      shared:
        external: true                        # メインスタックが作ったネットワークに参加する
        name: ${MAIN_PROJECT:-nunolab}_b2b_promo_network

    これで共有ネットワーク上の web はメインのものだけになり、worktreeからは今までどおり mysql や redis を名前で解決できます。ホストのポートは1つも公開していないので、いくつ並べても衝突しません。

    MAIN_PROJECT はメインのcompose project名です。メインのディレクトリ名が環境によって違っても動くように、用意担当のエージェントが起動中の mysql コンテナのラベルから実際の名前を調べ、worktreeの .env に書いています。

    2. localhost だとCSRFのテストが必ず失敗する

    問題1のとおり、web という名前はメインや他のworktreeのwebにもつながりうるので、worktreeのE2Eは最初 http://localhost:3000 でアクセスしていました。すると、「CSRFトークンが無効なままフォームを送ると 419 のページが出る」ことを確かめるテストだけが、419 ではなく 302 になって毎回失敗しました。

    localhost はブラウザにとって「安全なコンテキスト」なので、フォームの送信に Sec-Fetch-Site: same-origin ヘッダーが付きます。Laravelはこのヘッダーがあると、CSRFトークンを照合せずにリクエストを通します。そのため、トークンが無効でも 419 になりません。アプリの不具合ではなく、テストする環境の問題です。

    当初は、ハーネスのプロンプトで「このテストの失敗は環境が原因として扱う」と指示してしのいでいました。しかしこれでは、本当にCSRFの処理を壊したときにも、同じく環境のせいとして片付けられてしまいます。この点は、レビュー担当のエージェントからも指摘されました。

    そこで、localhost 以外の名前でアクセスするようにしました。上の docker-compose.worktree.yml で、net の extra_hosts に wt-app:127.0.0.1 を足しています。webとviteは net のネットワーク名前空間を共有しているので、/etc/hosts も共有され、E2Eからは http://wt-app:3000 で自分のworktreeのwebに届きます。wt-app は安全なコンテキストではないので、Sec-Fetch-Site は付かず、CSRFトークンの照合が行われます。

    あわせて、プロンプトの「環境が原因として扱う」という指示は外しました。worktreeの中でE2Eを実行し、419 のテストを含めて通ることを確かめています。

    worktreeの作成から片付けまで

    3. 差分をローカルの main と比べると、マージ済みの変更までレビューされる

    worktree内のローカル main は、worktreeを作った時点のままです。そこを基準に git diff main...HEAD を取ると、他のIssueですでにマージされた変更まで自分の差分に含まれ、レビュー担当のエージェントが「Issueの範囲外の変更がある」と指摘していました。比較の基準は origin/<base> に統一し、ハーネスのJSで1箇所に定義しています。

    // ローカルの main は worktree を作った時点のままなので使わない
    const DIFF_BASE = `origin/${base}`
    // レビュー担当へのプロンプトでは、git fetch origin のあとに
    // git diff ${DIFF_BASE}...HEAD で比べるよう指示する

    4. 片付けでworktreeが消えない

    webコンテナはrootで動くため、vendor/ や storage/ のファイルはrootの所有になり、ホストのユーザーでは消せません。そのため git worktree remove が失敗し、片付け担当のエージェントは「worktreeの外をマウントしたコンテナで rm -rf する」という方法を取るようになりました。これはworktreeの外まで消しかねない操作です。

    いまは、down の前に webコンテナの中からworktreeを chown -R <ホストの uid:gid> で持ち主に戻し、ホストのユーザーのまま git worktree remove を実行しています。

    # 1. down より前に、コンテナの中から持ち主をホストのユーザーに戻す
    #    $(id -u) はホスト側で展開されるので、ホストの uid がそのまま渡る
    dc exec -T web chown -R "$(id -u):$(id -g)" /var/www/html
    
    # 2. コンテナを止めて volume ごと消す
    dc --profile e2e down -v
    
    # 3. ホストのユーザーのまま worktree を消す(ブランチは残る)
    git worktree remove --force "$WT"
    
    # 4. database も消す(3 が失敗しても実行する)
    docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" \
      -e "DROP DATABASE IF EXISTS \`nunolab_wt_<N>\`;"

    chown の対象はコンテナの /var/www/html、つまりそのworktreeをマウントした範囲だけなので、worktreeの外には触れません。外側をマウントしての削除やsudoでの削除はプロンプトで禁止しました。3が失敗したときは、別の方法で消そうとせず、残ったファイルの例を報告して止まります。

    5. 中断すると、worktreeやコンテナが残る

    APIの上限などで途中で止まると、片付けも実行されず、worktree・コンテナ・DB・ブランチが残ります。

    一方で、実装担当のエージェントはworktreeの作業ツリーに変更を書くだけで、コミットはしません。つまり worktreeを消さなければ、途中までの実装は残っています。そこで、捨ててやり直すのではなく、次の手順で続きから最後まで進めています。

    • 親ハーネスはもう一度実行しない。git worktree add が、残っているworktreeとぶつかって失敗するからです。
    • メインのスタック(MySQL・Redis)とworktreeのコンテナが起動しているかを docker compose ls -a で確かめる。
    • 残っているworktreeごとに、子ハーネスを直接起動する。引数は親が渡すものと同じで、作業ツリーにある途中の実装をそのまま引き継ぎます。
    // worktree ごとに1件ずつ起動する
    {
      issue: <N>,
      base: 'main',
      workdir: '<メインのツリー>/.worktrees/wt-<N>',
      dc: 'docker compose --project-directory <メインのツリー>/.worktrees/wt-<N> -p nunolab_wt_<N> -f <メインのツリー>/docker-compose.worktree.yml',
    }
    • Draft PRまで進んだら、問題4の片付けの4ステップを手で実行し、最後に git worktree prune を実行する。ブランチとPRは残します。

    片付けはシェルのコマンドだけで済み、エージェントは要りません。APIの上限に当たっている最中でも片付けられます。

    ハーネス

    6. 実行中のWorkflowから、子のWorkflowを名前で呼べない

    親ハーネスから子ハーネスを workflow('child-harness', …) のように名前で呼ぶと、全Issueが no workflow with that name で失敗しました。実行中のWorkflowの中では、名前で呼べるのは組み込みのWorkflowだけだからです。対処として、パスで直接指定するようにしました。

    // NG: 実行中の Workflow からは、自作の Workflow を名前で呼べない
    await workflow('child-harness', args)
    
    // OK: スクリプトのパスで指す(相対パスはリポジトリのルート基準で解決される)
    await workflow(
      { scriptPath: '.claude/workflows/child-harness.js' },
      { issue, base, workdir: setup.workdir, dc: setup.dc },
    )

    子には、用意担当が作ったworktreeのパス(workdir)と、そこで使うcomposeコマンド(dc)を渡します。これで子ハーネスを変えずに、worktreeの中で動かせます。

    結果

    • 依存のないIssueを2〜3件まとめて渡し、並行してDraft PRまで進められるようになった
    • ハーネスの実行中も、人間はメインのツリーで普通に作業できる
    • 既存の docker-compose.yml は変えていない

    数字で見ると、ハーネスが作ったPRは、もっとも多かった3週間で277件、多い日は1日46件でした。ハーネスを使い始めてから最初の2か月半ほどは、合計8件です(親ハーネス経由と、子ハーネスを単独で実行したものの両方を含みます)。

    新しく出てきた問題:Issueが減らない

    実装が速くなったことで、別の問題が出てきました。

    レビュー担当のエージェントは、指摘を2種類に分けて返します。このPRで直すべきものは、ハーネスがその場で直します。直すほどではないが気になる点は、1件ずつファイルに記録し、後で人間がIssueにするかどうかを決めます。

    当初は、気になる点をできるだけIssueにしていました。すると、「Issueを実装する → レビューが新しい気になる点を出す → それをIssueにする → そのIssueを実装する」という循環ができます。並列化で実装が速くなったぶん、この循環も速く進みました。上のPRが277件作られた3週間に、新しく作られたIssueは322件で、PRの数を上回っていました。PRを作るそばから同じくらいIssueが増えるので、残っているIssueは40〜50件前後のまま減りませんでした。

    そこで方針を変え、Issueにするのは金額・権限・データの消失に関わるような致命的な問題だけにしました。それ以外の気になる点は、記録に残すだけにしています。その後、新しく作られるIssueは週に10〜30件台まで減りました。

    まとめ

    • コマンドをすべてコンテナ内で実行するプロジェクトでも、ホストにポートを公開しないと決めれば、worktreeでの並列化は単純になる
    • 重いサービスは複製せずに共有し、分離はDB名とRedisのDB番号で行う
    • composeはサービス名を必ずネットワーク上の別名にする。共有ネットワークに参加させるサービスは最小限にする
    • エージェントに、テストの失敗を「環境が原因」として片付けさせない。そうすると、本当に壊したときも見逃す。環境のせいで失敗するなら、環境のほうを直す
    • 運用中に起きた問題の多くは、ハーネスのプロンプトやJSに対処を組み込み、エージェントが同じところで迷わないようにした

    並列化もできて、人間が待たされなくなったことは大きな改善でした。しかし、待たなくてよくなったぶん、Draft PRは人間がレビューする速さを超えて届くようになりました。1件レビューし終わる頃には、次の2件が並んでいます。ハーネスを待つ時間がなくなりましたが、永遠にレビューし続けなくてはならなくなりました🥲