規約を、破ろうとしても破れない仕組みへ移した日

English

この記事の目次

その日のあらすじ

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 の徹底と三方式の選択は、どちらもコマンドの形では止められない、判断の側に残った規約で、ここを設定やフックの側へどこまで落とせるかは、いまはまだ手をつけ切れておらず、やってみないと分かりません。

それでも、規約は破れない場所に置くものです。この向きは変えずにやっていきます。