その日のあらすじ
AIエージェントに規約を文書で渡しても、セッションが長くなると読まれなくなり、同じ違反が再発します。複数のエージェントが同一リポジトリを並列に読み書きする開発では、こうして規約が守られなくなると、未保存の変更が消える、古いmainを読んだまま作業する、同一ファイルへの書き込みが競合するといった事故につながります。
そこで、規約を守らせること自体に頼るのをやめ、破ろうとしても破れない場所へ規約を移してきました。この記事は、その対策を三つのリポジトリ群で運用してきた記録で、別々に動かしてきた三つが、作業を独立したディレクトリへ切り出すgit worktreeによる隔離という同じ型へ落ち着いています。
文書に書いていた規約のうち、状態を変えるgitコマンドを実行前のフックで止めること、mainへの直接のpushをホスティング側の設定で禁じること、環境変数ファイルをセッション開始時のフックでworktreeへつなぐことの三つを、エージェントの判断を経ずに効く仕組みの層へ移すと、文書では再発していた違反が起きなくなりました。作業ディレクトリを一つ切り出すのにかかる時間は0.25秒で、エージェントごとに分けても切り出し自体はほとんど時間を食いません。
複数のエージェントに同じリポジトリを触らせる開発を考えているなら、そのまま試せる隔離の型と、仕組みで防げる事故と手順に頼るしかない事故の切り分けを、この記録から持ち帰れます。
並列開発の事故とworktree隔離の型
複数のAIエージェントに同じリポジトリを並列に読み書きさせる開発を、個人会社で開発しているものと個人で運営しているものをあわせた三つのリポジトリ群で続けてきました。この形の開発では、未保存の変更が消える、古いmainを読んだまま古い分析を返す、同一ファイルへの書き込みが競合する、gitのcheckoutやresetでリポジトリの状態そのものを壊す、といった事故が起こりえます。この記事は、そうした事故を防ぐ規約をどこに置くか、という話です。たどり着いた答えは、規約を文書に書いてエージェントに守らせることに頼らず、フックやホスティング側の設定やディレクトリ構造のような、エージェントの判断を経ずに効く仕組みの層へ規約を移す、というものでした。この層を、本文では仕組みの層と呼ぶことにします。
gitには、一つのリポジトリから独立した作業ディレクトリを複数切り出せるworktreeという仕組みがあります。事故を防ぐ隔離の型は、このworktreeをリポジトリの階層の中に作り、すべての作業をそこへ閉じ込める運用で、読み取りだけの調査ならどのブランチにも属さないdetached HEADのworktreeを、PRを作る作業ならブランチつきのworktreeを切ります。この型は、三つのリポジトリ群でそれぞれ運用するうちに、同じ形へ収束しました。worktreeを/tmp配下に作ると、エージェントに何を許可するかという権限の設定が引き継がれないため禁止、という運用にしているリポジトリもあります。
仕組みとしては、コミットを格納するオブジェクトデータベースと、ブランチなどの参照は全部のworktreeで共有され、HEADとインデックスと作業ツリーという手元の状態だけがworktreeごとに独立します。つまり、履歴は一つのまま、手元の状態だけをエージェントごとに分けられるわけです。worktreeの作成にかかる時間も、数百コミット規模のリポジトリとApple SiliconのMacの実測で、git worktree add –detachが0.25秒と一瞬でした。実際に時間を食っていたのは、worktreeの作成よりも依存のインストールの側です。Node.jsを使うプロジェクトでは、親リポジトリのnode_modulesをworktreeへシンボリックリンクで流用してこの待ちを回避し、依存の版を固定するlockfileが変わった日だけ入れ直しています。
worktreeの隔離とあわせて、運用の決めごとが二つあります。一つは、何かを参照する前に必ずgit fetch origin mainを実行することです。別のセッションや自動実行がmainを進めていくので、ローカルのcheckoutは常に古い、という前提で動きます。もう一つは並列の書き込みの捌き方で、競合の危険が小さい順に、各エージェントが決まった形式でデータを返して書き込みをまとめる役のエージェントが一箇所で書く方式、書き込むファイルを分割する方式、worktreeで隔離する方式、の三つから選び、commitやpushのような取り消せない操作は書き込みをまとめる役に限定します。この三方式も、行き着いた形はどのリポジトリ群でも変わりませんでした。
文書に書いた規約は、読まれなくなっていく
ところが、こうした運用を文書の規約に書き、指示で従わせるやり方は、守られるかどうかを確率でしか言えない手段でした。セッションが長くなると規約が読まれなくなり、同じ違反が再発するのです。履歴を振り返ると、セッションの序盤に読んだ規約が、長い作業の後半では参照されなくなることがありました。文書の規約が確率的な手段だというのは、この意味です。
規約を仕組みの層へ移す
そこで規約を、文書から仕組みの層へ移すことにしました。移した規約は三つあります。エージェントがツールを実行する前に割り込むフックは、worktreeの指定がないまま状態を変えるgitコマンドを機械的に拒否します。ホスティング側には、指定したブランチへの直接pushを禁じるbranch protectionという設定があり、mainへの直接pushはこれで禁じました。環境設定は、セッションの開始時に走るフックが、親リポジトリの環境変数ファイルへのシンボリックリンクをworktreeに張って引き継ぎます。文書に書いても再発していた違反が、フックを入れてからは起きていません。導入の前と後で再発が止まったというこの前後関係を、効いたかどうかの根拠にしています。この三つの規約とは別に、worktree運用に伴う設定の直しも一つありました。worktreeをリポジトリの階層の中に作ると、コードを機械的に検査する静的解析がそのままではworktreeの中まで走査してしまうので、この走査からworktreeを除外する設定も、文書の注意書きではなく設定ファイルに置いてあります。状態を変えるgitコマンドの拒否と、mainへの直接pushの禁止と、環境設定の引き継ぎという移すと決めた三つの規約が、文書の指示から、エージェントの判断を通らずに効くフックとホスティング側の設定へ、つまり仕組みの層へ乗ったことになります。
破りたくても破れない、が効くのです。
三つの運用が収束した先にあったのは、エージェントの信頼性を上げて事故を防ぐのではなく、フックやbranch protectionやディレクトリ構造という仕組みの層に規約を置いて、事故が構造的に起きないように作りの側を変える、という発想の転換でした。
仕組みの層に置けたものと手順に残るもの
ただ、仕組みの層に置けた規約は一部です。状態を変えるgitコマンドの制限と、mainへの直接pushの禁止と、環境設定の引き継ぎまでは置けましたが、git fetchを省いて古いmainを読んでしまう事故はフックでは止まらず、いまも手順を守ることに頼っていますし、並列書き込みの三方式のどれを選ぶかも運用判断のままです。node_modulesをシンボリックリンクで流用する高速化も、lockfileが変わると壊れて入れ直しが要ります。このgit fetchの徹底と三方式の選択は、どちらもコマンドの形では止められない、判断の側に残った規約で、ここを設定やフックの側へどこまで落とせるかは、いまはまだ手をつけ切れておらず、やってみないと分かりません。
それでも、規約は破れない場所に置くものです。この向きは変えずにやっていきます。