0
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?

Claude Code × git worktree で並列実行 — 黙って壊れた3つと、分割の境界線

0
Posted at

本記事は Zenn に公開した記事の転載です。原本はこちら: https://zenn.dev/genkunjc/articles/ead8b520c22733

AIコーディングエージェントを1体ずつ順番に走らせていると、経過時間はほぼ「自分が待っている時間」になります。独立した作業を複数のサブエージェントに分けて同時に走らせれば、そこは縮みます。

ただ、素直にやると壊れます。しかもエラーを出さずに壊れるものが混ざっているのが厄介でした。型チェックは通り、ビルドも通り、エージェントの報告には「完了しました」と書いてある。それでも中身が違う。

この記事は、Claude Code のサブエージェントを isolation: "worktree"(各エージェントが独立した git worktree を持つモード)で運用して踏んだ罠と、そこから決めた運用ルールの記録です。

先に結論:baseRef: "head" を入れる

{
  "worktree": {
    "baseRef": "head"
  }
}

~/.claude/settings.json(またはプロジェクトの .claude/settings.json)です。理由は以下。

node_modules をどうするかについては、最初は symlink で実体を共有するつもりでいました。ですがこれは Next.js のプロジェクトでは罠でした(罠2)。

罠1:baseRef の既定値は「今いるブランチ」ではない

これが一番危険でした。

既定値は "fresh" で、意味はリモートのデフォルトブランチ(多くの場合 main)から分岐です。「今いるブランチから分岐」ではありません。

つまり feature ブランチで作業していて、その続きをサブエージェントに渡したつもりでいると、エージェントは main の内容を見て作業を始めます。渡したはずの変更が存在しない世界で、それらしいコードを書いて帰ってきます。

なぜ気づけないか

さらに厄介なのが、リモート未設定のローカルリポジトリでは "fresh" でもローカル HEAD にフォールバックするという挙動です。

だから個人リポジトリで試している間は一見正しく動きます。設定を何も変えていないのに、あとで git remote add origin ... した瞬間に分岐元が変わる。動作確認を済ませたあとに壊れる形なので、「前は動いていたのに」から入ることになります。

エラーは出ません。ビルドも通ります。差分を読むまで気づけません。

分岐元は毎回確認する

疑わしいときは、エージェントの worktree の中で直接 HEAD を見ます。

# いま存在する worktree の一覧とパスを出す
git worktree list

# 該当の worktree に入って、分岐元のコミットを確認する
cd ../<worktree のパス>
git log --oneline -3

# 自分のブランチの先頭と一致しているか
git -C <元のリポジトリ> log --oneline -1

先頭のコミットが自分の作業ブランチのものでなければ、main から生えています。この時点で気づけば捨てるだけで済みます。差分を読んでから気づくと、書かれたコードの一部を救おうとして余計に時間を使います。

feature ブランチ上の作業を渡すなら "head" は必須です。

なお "head" にしても分岐元は HEAD コミットなので、未コミットの変更は渡りません(罠3)。

罠2:node_modules を symlink で共有しようとして、ビルドを壊しました

git worktree は fresh checkout です。.gitignore されているものは当然入りません。

なのでサブエージェントは起動直後に npm install から始めます。1体につき数十秒〜数分。3体並列なら3回。並列化で稼いだ時間をそこで捨てるようで、もったいなく感じました。

そこで symlinkDirectoriesnode_modules の実体を共有する設定を入れました。

{
  "worktree": {
    "baseRef": "head",
    "symlinkDirectories": ["node_modules"]
  }
}

これで即作業に入れる、はずでした。

実際には Next.js のプロジェクトで next build がパニックで停止しました。

Symlink [project]/node_modules is invalid, it points out of the filesystem root

Turbopack が、プロジェクトルート外を指す node_modules の symlink を無効と判定して拒否する挙動でした。手元のプロジェクトは事実上すべて Next.js なので、この設定はほぼ確実に踏みます。

型チェックだけ見ていると気づけない

厄介だったのはここです。同じ状態で、型チェックは普通に通ります。

npx tsc --noEmit   # 通る
npm run build      # ここで初めて死ぬ

型チェックはファイルの解決ができれば通るので、symlink の向き先がプロジェクトルートの外かどうかは問題になりません。バンドラだけがそれを拒否します。

合否をどちらで判定するかで、結果が変わります。 型チェックだけ見て「動いている」と判断していたら、しばらく気づけなかったと思います。

以来、サブエージェントには本題に入る前に一度ビルドを通させるようにしました。

# worktree に入った直後、最初にこれをやらせる
ls node_modules > /dev/null 2>&1 || npm install
npm run build

ここが通らない状態で書き始めると、自分の変更が壊したのか元から壊れていたのかが切り分けられなくなります。

この確認を省いて、一番高くついた失敗

これを省いたときに、15ファイル・581行が一度もビルドされないまま積み上がった状態で返ってきたことがありました。

報告自体は丁寧で、何をどう変えたかが整理して書いてあります。ただ、検証結果の欄に実際のコマンド出力が無い。読み返すと、node_modules が無い worktree でいきなり書き始めて、最後まで一度もビルドしていませんでした。

そこから先が高くつきます。 581行のうちどこが正しいかを判定する手段が無いので、ビルドを通してからエラーを1つずつ潰すことになる。しかもエラーの原因が「この実装が間違っている」なのか「元からそうだった」なのかが分からない。結局、大部分を読み直しました。

install の数分を惜しんで、数十分を失う形です。 それ以降、サブエージェントに渡す指示の先頭に、この3ステップを固定で入れています。

  1. 指定した設計ドキュメントを最後まで読む
  2. node_modules が無ければ npm install を実走させる(symlink で共有しない)
  3. 本題に入る前にビルドを一度通す

3番目が本体です。1と2はそのための前提にすぎません。

結局、正しい運用は worktree ごとに実際に npm install を走らせることでした。symlink を使っていいのは、Next.js 以外のリポジトリを扱うときだけです(今のところそういうリポジトリは持っていません)。

この npm install の固定費は、並列化するかどうかの見積りに最初から入れる必要があります。子1体あたり数十秒〜数分かかるので、あとで出てくる「1ファイル内で完結する変更、見積り10分未満の作業」を worktree で分けると、install の時間だけで損をします。

罠3:未コミットの変更は渡らない

罠1の続きです。baseRef: "head" でも分岐元は HEAD コミットなので、ワーキングツリーの変更はエージェントに見えません。

なので起動前に必ずチェックポイントコミットを打ちます。

git add -A
git commit -m "checkpoint: サブエージェントに渡す前の状態"

「まだ中途半端だからコミットしたくない」という気持ちが出るところですが、コミットしないと渡らないので、ここは割り切るしかない部分です。あとで rebase なり squash なりで整えればいい。

この3つは、どれも起動した時点では正しく動いているように見えるという共通点があります。だから起動前のチェックリストにしてあります。

ここからが本題:並列化してはいけない境界線

設定そのものは baseRef の1行で終わりです。実際に効いたのは、むしろ何を並列化しないかを決めたことでした。

分割していい条件(すべて満たすとき)

  • 触るファイル群が重ならない。 同じファイルを2体が編集しない
  • 互いの成果物を入力にしない。 「Aの結果を待ってBを書く」になっていない
  • 分割後の各タスクが、追加の設計判断なしに完了まで進める粒度になっている

3番目が抜けがちです。サブエージェントは毎回コンテキストゼロで始まります。渡したタスクの途中に「ここはどう設計すべきか」という判断が残っていると、各エージェントがそれぞれ別の答えを出します。全部終わったあとに、整合しない3つの実装が手元に残ります。

分割してはいけないケース

設計・原因調査・仕様決めのフェーズ。
ここを分割すると判断がバラバラになって破綻します。並列化は「決まったことを適用する」フェーズの道具です。

1ファイル内で完結する変更、見積り10分未満の作業。
罠2で書いた install の固定費で損をします。

dev サーバーを実際に起動して確認する必要がある作業を2つ以上同時に。
固定ポート運用だと衝突します。どうしても必要なら、片方だけ起動して、もう片方は起動なしの静的検証(型チェック・ビルド・grep)にする。あるいはプロンプトで空きポートの使用を明示的に許可しておく。

そして、いちばん判定を間違えたのが次です。

失敗例:「ファイルが重ならない」の判定を間違えた

あるNext.jsアプリのデザイン刷新で、3体に分けようとしました。

  1. デザイントークンの定義(色・余白・角丸・タイポの値を1か所に集める)
  2. 全ページのカードスタイル置換(各画面に散らばった枠線・影・角丸の指定を、トークン参照に置き換える)
  3. ナビゲーションの作り直し

編集するファイルで見ると、きれいに分かれています。①はトークン定義ファイルとグローバルCSS、②は各ページのコンポーネント、③はナビゲーション周辺。重なりはゼロです。

何が起きたか

②と③は、①の成果物を読む前提になっていました。

「カードの枠線をトークン参照に置き換える」には、そのトークンが何という名前で、どういう単位で定義されるのかが決まっていなければ書けません。③のナビゲーションも同じで、余白と色をトークンから引きます。

①が確定しないと②③は書けない。にもかかわらず、②③のエージェントはコンテキストゼロで始まるので、手を止めずに「それらしいトークン名」を自分で決めて書き進めます。 ①が別の名前で定義していれば、統合時に噛み合いません。

分割の判定を、編集するファイルだけで見て、読む前提になっているファイルを見落としていたのが原因でした。起動した時点では、分割は完全に正しく見えます。

結局これは1体で直列にしました。分割と統合のコストが上回ります。

学んだこと

判定に使う基準を1つ足しました。

「そのタスクは、他のタスクの成果物を読まずに書き切れるか」

編集するファイルではなく、読む必要があるファイルで見る。共通の依存層(デザイントークン、型定義、定数、スキーマ)を触る変更が混ざっていたら、その層を先に1体で確定させてから、残りを並列にします。

プロンプトに書くべき3点

サブエージェントはコンテキストゼロで始まります。会話の前提は書かないと伝わりません。最低限これを書きます。

  1. 担当ファイル(どこを触るか)
  2. 完了条件(どうなったら終わりか。検証コマンドまで具体的に)
  3. 触ってはいけない領域(どこを触らないか)

3番目が効きます。書かないと、良かれと思って隣接コードを整理してきます。「この日本語の文章は一字も変えないでください」「この設定ファイルは読むだけ」まで書いて、ようやく期待通りになりました。

完了条件は grep まで落とす

「きれいにしてください」は完了条件になりません。検証可能な形にします。上のデザイン刷新では、最終的にこうしました。

- npx tsc --noEmit がエラー0
- npm run build 成功
- grep -rn "cardBorder" src/ が0件
- grep -rn "1px solid" src/ が0件
- grep -roE "text-\[[0-9]+px\]" src/ が0件

そして報告のときに、grep の実際の出力を貼らせます。「通りました」だけでは受け取らない。ここを緩めると、通っていないものが通ったことになります。

数字が0であることを目視できる形にしておくと、報告を読む時間も短くなります。

仕様が長いならファイルにして渡す

プロンプトに全部書くより、リポジトリ内に仕様書を1枚コミットして「これを読んでから始めて」と指示するほうが確実でした。単独で完結した文書にしておけば、エージェントが途中でコンテキストを失っても復帰できます。

副作用として、その仕様書がそのまま自分用の設計メモになります。

統合は親(自分)の責任

全エージェントが完了したら、差分を読み、衝突と重複を潰し、ビルドと型チェックを一度自分で通してから完了とします。

各エージェントが自分の worktree で「型チェック通りました」と言っていても、それは他の2体の変更を知らない世界での成功です。3つを合わせたときに通る保証はどこにもありません。

具体的には、統合のあとに毎回これを見ます。

  • 同じ問題を2体が別々に直していないか。 片方を消すだけで済むことが多い
  • 同じ定数・型・ユーティリティが2つ増えていないか。 名前が違うだけの重複は、統合直後にしか見つけられない
  • 各エージェントが「ついでに」触った範囲。 担当外のファイルに差分が出ていたら、そこは必ず読む
  • ビルドを自分の手で1回通す。 子の報告ではなく、自分の環境で

ここを省略すると、並列化で稼いだ時間を統合デバッグで返します。

起動は background で、親は待たない

run_in_background: true で起動して、結果が依存関係になるまで親は別の作業を進めます。

待っている間に実際にやっているのは、だいたいこの3つです。

  • 統合の準備。 どのファイルが衝突しうるかを先に洗い、統合後に見る観点を決めておく
  • 直列でしかできない部分。 dev サーバーを起動しての確認や、共通依存層の変更など、分割対象から外したもの
  • 次のフェーズの設計。 いま走っている作業が終わったあと、何を次に投げるかを決めておく

3番目が効きます。子が返ってきた瞬間に次を投げられるので、待ち時間が連続しません。

分割の条件を満たしている限り、待たないことで平均完了時間が縮みます。品質を落とすトレードオフは、少なくとも手元の運用では出ていません。

まとめ

  • baseRef: "head" を最初に入れる。node_modules は symlink で共有せず、worktree ごとに npm install する
  • 既定の "fresh"リモートの main から分岐。リモート未設定だと一見動くので気づけない
  • 起動前にチェックポイントコミット
  • サブエージェントには、本題に入る前にビルドを一度通させる。 ここを省くと、
    一度も検証されていないコードがまとめて積み上がり、切り分けの手段が無くなる
  • 合否は型チェックではなくビルドで決める。 型チェックだけ通って、ビルドで死ぬ壊れ方が実際にある
  • 並列化は「決まったことを適用する」フェーズの道具。設計・原因調査は直列
  • 「ファイルが重ならない」の判定に、読む前提になっているファイルを含める
  • 完了条件は grep まで落とし、実際の出力を貼らせる
  • 統合は親の責任。合わせて一度通すまで終わっていない

並列化の効果は設定ではなく、分割の判断でほぼ決まりました。設定自体は1行で終わる話で、難しいのはそこではなかった、という記録です。

ここに書いた並列実行まわりの罠のほかにも、委譲と検証まわり・モデルの切り替えまわり・環境まわりで、同じように「エラーを出さずに壊れる」ものを踏んでいます。あわせて13件を、症状/本当の原因/なぜ発見が遅れるか/対策の形で1冊にまとめました。この記事で扱った4件は、本のほうでも無料で読めます。

0
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
0
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?