本記事の内容は私(R2-san)の体験(実装コード)・判断・指示に基づきますが、文章作成は生成 AI が行い、私の監修・確認を経て公開しています。
今回は、
R2 を、サーバー用のコードとクライアント用のコードを並行して開発できるようにする。
という事を実装した話です。
先に結論を書きます。
危ない使い方を、禁止しない。通したうえで、起きることをこちらが引き受ける。
重ね登録とは何か
手元のフォルダはこうなっています。
~/work/app/ ← 全体の入れ物。共通の設定やドキュメントが置いてある
├── server/ ← サーバー側のコード
└── client/ ← クライアント側のコード
サーバーだけ直したいときもあれば、共通の設定を両方まとめて見てほしいときもあります。
R2(R2 Fugu Agent Runtime。私が個人で作っている AI coding agent の runtime です)では、「ここを触っていい」と登録したフォルダを登録リポジトリ、「ここをこう直したい」という 1 件の依頼を Task と呼びます。Task は、調べる → AI モデルに案を作らせる → ファイルへ当てる(実際に書き込む)→ テストを走らせる、までがひとまとまりです。当てる直前には、あとから元へ戻せるように、そのファイルの今の内容を控えとして保存します。
そして、1 つの Task は、1 つの登録リポジトリだけを対象にします。 範囲がはっきりしていないと、失敗したときに何を戻せばいいのかも決まりません。
この決まりを変えずに、どう成り立たせるか。答えは単純でした。3 つとも登録すればいい。
登録 1 ~/work/app (親。全体が対象)
登録 2 ~/work/app/server (子。server だけが対象)
登録 3 ~/work/app/client (子。client だけが対象)
登録が入れ子になる。これを重ね登録と呼んでいます。範囲は、どの登録で Task を作るかで決まる。 新しい設定項目も、新しい概念も要りません。
このとき私が伝えた要望は、作業指示書にこう要約されて残っています。
リポジトリ名 → 共通 / サーバー / クライアント → タスクリスト
画面にこう見えてほしい、という話です。重ねて登録すると、親の下に子がぶら下がり、それぞれに Task が並ぶ形が、そのまま出ます。
いちばん簡単な案は、「拒否する」だった
重ねて登録すればいい ―― これは当たり前に見えると思います。ただ、設計の段階で並んだ案には、逆向きのものがありました。
親子関係にあるフォルダを、両方登録することを拒否する。
理屈は通っています。親と子は同じファイルを指しうる。同じファイルを 2 つの Task が別々に書き換えたら、何が起きるか読めない。
却下しました。記録の言葉をそのまま引きます。
人の意図を妨げる。R2 の一貫した方向性は人がやろうとすることを可能な限り許し、帰結を R2 が引き受けることであり、禁止で安全を作らない。
禁止は、いちばん安い安全です。実装も簡単で、事故も起きません。やりたいことができなくなるだけです。 しかも禁止された側には理由が分かりません。並行で開発したいだけの人にとって「親子は登録できません」は理不尽な仕様にしか見えず、道具としてはそこで使われなくなります。
ほかの案も全部却下しました。理由だけ短く。
- 命名規約 / 「同時に動かさないで」という運用ルール … ユーザーに名付けさせる・覚えさせる・気を付けさせる案。この形の案は、いつも採りません。できる人にはできるので設計の場では通ってしまい、できなかった人のところで事故になる。
- 親を登録したら中の子を自動で登録する … 意図していないフォルダを勝手に管理下へ置きます。候補として見せるのは情報提供、登録するのは権限の話。
-
フォルダ名(
server、clientなど)から推測する … その名前を使っていないリポジトリでは見つけられず、たまたま同名の無関係なフォルダは拾います。 - Task に「サーバー用」のようなグループ属性を足す … 登録の入れ子という既存の事実と食い違いうる 2 つ目の事実になります。
却下理由を残してあるのは、後から同じ案をもう一度思いつくからです(AI も人も思いつきます)。
調べたら、穴はロックだけだった
では、禁止しないと決めて何を作ったか。安全に関わる部分は、ほとんど何も作っていません。
壊れる箇所を先に洗い出したら、1 か所だけでした。作業指示書の言葉を引きます。
鮮度検証・divergence check は実ファイルの内容ハッシュを見るので、子の Task が書いた後の親の restore は『diverged』として既に fail-closed に止まる。穴はロックだけである。
言い換えます。R2 には「当てる前に、今そこにある内容が思っていたとおりか確かめる」仕組みと、「戻すときに、当てた内容と今の内容が食い違っていないか確かめる」仕組みがあります。この食い違いを 乖離 と呼びます。乖離があれば、差分を見せて確認を取るまで書き戻しません(確認が取れないなら、止まるほうを選ぶ。これを fail-closed と言います。扉や弁が壊れたとき、開くのではなく閉じるほう)。
この 2 つはどちらも、ファイルの中身そのものを見ています。 パスの文字列でも、登録の構造でもない。だから、親の Task が当てたファイルを子の Task が書き換えても、親を戻そうとした時点で気づきます。親子だからといって足すものが無かった。(大仕事に聞こえたものが、切り分けたら大半は出来ていた、という形です。)
開いていた窓 ― 同時に書けてしまう、短い隙間
R2 は、2 つの Task が同時に同じ場所へ書き込まないよう、書き込みの経路にだけ鍵を掛けています。問題は、その鍵が登録リポジトリごとに独立していたことでした。
時間 →
親の Task ①今の内容を控える ③書き換える
子の Task ②今の内容を控える ④書き換える
↑ここで控えたのは ↑親が書いた分を
親がまだ書く前の内容 上書きしてしまう
②の控えは「親が書く前の内容」なので、あとで子を戻すと、親の仕事まで一緒に消えます。
親の鍵と子の鍵は別物なので、互いに見えません。控えが壊れると、戻せるという保証そのものが壊れます。 R2 は「最後は git がある」を前提にしていないので、これは致命的です。
作ってから、見回す
塞ぎ方は地味でした。
1. 自分の鍵を作る(すでに在ったら作れない、という作り方で)
2. 作れたら、鍵の置き場を見回す
3. 自分の親か子にあたる鍵が生きていたら、
いま作ったばかりの自分の鍵を消して、「取れませんでした」と返す
順番が肝です。先に作ってから、見回す。 見回してから作ると、2 つが同時に「誰もいませんね」と確認して両方とも作ってしまいます。先に作れば、後から作り終えた側は先の鍵を必ず見つける。両方が引き下がる場合も残りますが、外れ方が安全な側なので、もう一度押せば解消します。
判断の材料は、鍵のファイルに書いてある「どのフォルダのものか」だけ。登録の一覧は見に行きません。登録が増減しても鍵の意味が変わらないようにするためです。
待たない。そして、誰が持っているかを見せる
鍵が取れなかったとき、R2 は待ちません。即座に、使用中で実行できないという理由付きの失敗を返します。使うのが画面の前にいる個人だからです。待たせると、押した本人に何が起きているか分からなくなる。失敗して、もう一度押してもらうほうが分かりやすい。
ただし重ね登録では、それだけでは足りません。親も子も自分のもので、どちらも自分が動かしています。どっちのことを言われているのか分からない。 そこで、今それを使っている側 ―― 親リポジトリ「app」の Task なのか、子なのか、このリポジトリ自身なのか ―― を添えました。ここで 2 つ決めています。この問い合わせは、鍵を取れるかどうかの判断には一切関わらない(表示のためだけ)。そして取れなかったら推測で埋めない。登録と完全一致しなければ、フォルダのパスをそのまま出します。
余談 ― .DS_Store で全部止まった
「作ってから見回す」には副作用があります。見回す途中で失敗したら、作ったばかりの自分の鍵が残る。 実際、鍵の置き場に macOS が勝手に作る .DS_Store が 1 つ紛れ込むだけで想定外のエラーになり、残った鍵のせいでそのプロセスからの適用が以後すべて失敗する ―― という経路が見つかりました。見つけたのはレビュー AI です。実装 AI の報告を読んで納得したのではなく、突き合わせの検証でここまで辿り着いています。
親子関係は、保存しない
画面に入れ子を出すには、どれが誰の親かを知る必要があります。登録時に保存しておくか、毎回その場で導き出すか。毎回導き出すほうを選びました。保存すると、登録の追加や解除のたびに整合を保つ仕事が生まれ、正しさの根拠が 2 か所に増えます。この 2 つは、いつかずれます。毎回導けば、ある登録を解除した瞬間に、次の答えが自動的に正しくなります。
そしてもう 1 つ。「誰の親か、誰の子か、同じものか」を判定する関数を、1 つしか作らない。 鍵の相互排他も、画面の入れ子も、後で出てくる「この変更は誰の仕事か」の突き合わせも、全部これを呼びます。別々に実装すると「鍵は無関係だと思っているのに画面は親子として見せている」が起こり得る。テストで見つける問題ではなく、実装の構造で潰せる問題です。
子の候補は、観測できる事実だけで出す
親を登録したとき、中にまだ登録していないプロジェクトがあれば教えてほしい。判定は、そのフォルダに独立したプロジェクトの目印になるファイルがあるかだけにしました。ex. パッケージ設定のファイル、ビルド設定のファイル、バージョン管理のフォルダ。目印の存在は、そのフォルダがプロジェクトのいちばん上だと、道具が実際に目印として使っている事実です。名前は事実ではありません。
見せ方も、見つかった目印を並べるだけ。「これはサーバーですね」という分類はしません。そして、見つけても勝手には登録しません。 押すかどうかは、使う側が決めます。
余談 ― ちょうど 100 件で、嘘をついた
探す件数には上限(100)があり、打ち切ったときは「上限に達したため一部のみ表示しています」と添えます。最初の実装はそれを返ってきた件数が上限と同じかどうかで判定していたので、ちょうど 100 件で本当に全部だったときだけ嘘になりました。是正は、数える側が上限の次の 1 件まで探しに行き、打ち切ったかどうかを自分で答えること。受け取る側は件数から推測しません。
App と app が同じ場所のとき
2 つ目の山場です。親子かどうかの判定は、結局パスの文字列を比べています。ところが macOS の既定では ~/work/App と ~/work/app は同じ場所です。 一方を App、もう一方を app と打って登録すると、R2 は「無関係」と判定します。実体は親子なのに。塞いだはずの穴が、綴り違いでもう一度開く。
素直な対処は「macOS なら区別しないものとして扱う」です。却下しました。
大文字小文字の区別可否はファイルシステムのフォーマット時の性質であり、OS の性質ではない。
同じ macOS でも、区別する設定でフォーマットしたボリュームがあります。OS の名前から決めるのは、推測です。 なので、測ることにしました。
対象のパスに近い、実在するフォルダをひとつ取る ex. .../work/app
その名前の 1 文字だけ、大文字と小文字を入れ替える ex. .../work/App
それが実在して、しかも元と同じ場所を指しているか
→ 指していれば、このボリュームは区別しない
レビューで見つかった不備 ―「測らずに True」
第 1 版には欠陥がありました。レビュー AI が見つけています。
この測り方は、名前に大文字小文字の区別を持つ文字があることが前提です。ところがかな・漢字・ハングルは、大文字小文字を入れ替えても元と同じ文字列のままです。「入れ替えた名前」が元と一字一句同じになる。当然その場所は実在し、当然同じ場所を指します。ファイルシステムを一切測らないまま、常に「区別しない」と答えていました。
しかも机上の話ではありません。このリポジトリ自身が、日本語の名前が入ったパスの下に置いてあります。 レビュー AI も設計・監督 AI も、実際に動かして到達できることを確認しました。是正は、入れ替えた名前が元と違う文字列になり、長さも変わらない場合だけ測定に使う、というものです。
測定は、排他を足すことしかできない
同じレビューで、もう 1 つ指摘が出ています。測定が「区別する」(=親子と見なす範囲を狭める側)と答えたとき、それをそのまま信じていいのか。いいえでした。間違えたときに起きることが、どちらに間違えたかで釣り合っていないからです。
誤って「親子だ」と判定した → 余計に排他され、一時的に実行できないだけ。押し直せば済む
誤って「無関係だ」と判定した → 塞いだ穴が、また開く
測定は、排他の対象を増やすことしかできない。減らすことは一切できない。 効くのは「区別しないと分かった」ときだけで、「区別すると測れた」「測れなかった」は何も動かしません。そしてこれを、書き方で気を付けるのではなく、やり取りする値の形そのものを、片側にしか動けないものにしました(「区別する」という測定結果は、そもそも先へ渡りません)。気を付けることを人に要求しない、をコードの内側でもやっている例です。
「この Task の後に変更が加わっています」の、その先
3 つ目の山場です。
親の Task A が触ったファイルを、あとから子の Task B が触る。重ね登録では、これが普通に起きます。 同じファイルが、親からも子からも射程に入っているからです。そのあと A を戻そうとすると、R2 は乖離を検出して「この Task の後に変更が加わっています ―― この復元はそれも巻き戻します」と言います。
正しい表示です。嘘はひとつもありません。ただ、画面の前の人には何も分かりません。 自分が動かしたもう 1 つの Task の仕事なのか、外で何かが変えたのか。前者なら「ああ、あれか」で終わり、後者なら手を止めて調べる必要があります。
中身の指紋で、逆に引く
R2 は、当てたときに「このファイルにはこういう内容を書いた」という短い印(中身から計算する指紋のようなもの。1 文字でも変われば印も変わります)を残しています。なので、今そこにある内容の印を計算して、記録を逆に引けばいい。
ここに、重ね登録ならではの落とし穴がありました。親と子では、同じファイルの相対パスが違います。
親から見たとき server/api.py
子から見たとき api.py
文字列では一致しません。照らし合わせは、フォルダの先頭からの完全なパス(絶対パス)で行います(「判定関数は 1 つだけ」が、ここでも効いています)。
一致が複数あったら、全部返します。 同じ内容を別々の Task が独立に作ることはあり得るので、1 つに絞る基準を探すと、どれも推測になるからです。時刻を使う案も却下しました。「いちばん最近このファイルを触った Task」はもっともらしいけれど、時系列の近さは因果を証明しません。
「無い」と「読めなかった」は、違う
ここがレビューで出たいちばん大きな是正です。
最初の実装は、逆引きが空振りしたときに「R2 の Task が残した内容とは一致しません」と表示していました。一見、正しい。でも空振りには理由が何種類もあります。 どの記録とも一致しなかったのか。記録に問い合わせる手段が使えなかったのか。それとも、今そこにある内容を読むことができなかったのか。
3 つ目が問題でした。バイナリだったり読み取りの上限を超えていたりして中身を読めないとき、読めないまま逆引きに進んでいた。そして実際に、実在するバイナリファイルが、無関係な Task の「ファイルを削除した」という記録と偶然一致して、「Task B が残した内容と一致します」と表示されました。 レビュー AI が、動かして再現しています。
記録の言葉を引きます。
この製品が最も避けるべき『分からないことを分からないと言わず、たまたま一致した無関係な事実を確定的な帰属として見せる』という振る舞いそのもの
是正は、表示を 3 つに分けることでした。
一致ゼロ → R2 の Task が残した内容とは一致しません
照会できない → 変更元を照会できませんでした
読み取れない → 現在の内容を読み取れないため変更元を照会できませんでした
表面上はどれも「分かりません」です。でも、ユーザーが次に取る行動は違います。1 つ目なら外の変更を疑う。3 つ目なら、そもそも調べられていないと分かる。
二度目の是正 ― 生きた symlink
同じところを、もう一度直しています。「読めなかったら逆引きしない」と決めたのに、別名で参照しているだけのファイル(symlink)は、参照先まで辿って読めてしまう経路が残っていました。辿った先が普通のファイルなら中身が読めるので、「読めなかった」の分岐に入らない。外から中身を同じ内容の別ファイルへの参照に置き換えると、参照先の内容で逆引きされて「Task B が残した内容と一致します」と表示されました。 これも動かして確かめたうえでの報告です。
是正は、読めるかどうかを判断する前に、無条件に「これは別名参照か」を先に見ることでした。2 回とも同じ形の見落としです。「読めた」と「読めた内容が、この場所の内容である」は違う。
まとめ
重ね登録は、機能としては地味です。フォルダを 3 つ登録できるようにしただけとも言えます。でも、そこから出てきた判断は、この製品でやりたいことを一番よく表していました。
1. 禁止で安全を作らない
通したうえで、同時に書き込みが起きないようにし、起きたら理由を添えて失敗し、誰が使っているかを見せる。禁止は、こちらが引き受けるべき帰結を、ユーザーに押し付け返す行為です。
2. 着手する前に、どこまでが既に効いているかを切り分ける
控えも、鮮度の確認も、乖離の検出も、ファイルの中身を見ていたおかげで、親子でも無改造のまま効いていました。 開いていたのは、親と子が同時に書き込める窓だけ。先に切り分けたので、作るものはそこだけで済みました。
3. 分からないときに分からないと言う。そのために「分からない」を分ける
一致ゼロ、照会できない、読み取れない。表面上はどれも「分かりません」ですが、次に取る行動は違います。たまたま一致した無関係な事実を確定した答えとして見せることが、この製品にとっていちばん悪い振る舞いです。
次回は、リポジトリの索引が、宣言しないまま Python 専用になっていた話を書く予定です。Python 専用だとはどこにも書いていないのに、実際に働いていたのはほぼ Python だけだった、という回です。
あとがき
現在、R2 Fugu Agent Runtime は開発中断しています。記事を書きながら、R2 Fugu Agent Runtime を公開できればいいな、と考えていましたが、記事の方が製品開発に追いついてしまいそうです。
中断理由はサブスクリプション AI のリソース(トークン)を他のものにかけているためです。財布は有限。
関連記事
このシリーズ(Qiita・製品設計):
- AI coding agentは、なぜ毎回リポジトリを調べ直すのか ― Repository理解を使い捨てにしない設計
- AIに全部判断させるのをやめた ― AI ModelとCoding Agent Runtimeの責務をどう分けたか
- AIも人間も設計判断を忘れる ― Repository Decision Memoryを作った理由
- 失敗したら、何も変えない ― AIの変更提案を、リポジトリに当てるまで
- 「検証に通りました」が嘘になっていた ― 許可と検証を、ユーザーが握る
この製品を AI に作らせる開発の進め方については、Zenn に別のシリーズを書いています:
- 設計も実装もレビューもAIがやる個人開発で、精度をどう出しているか — 全体像
- 作業指示書は、最初に書かせない ― 設計AIとのやり取りを全部見せる — 設計 AI 編
- 実装AIには、指示書のパスを渡すだけ ― コピペで始めた運用が仕組みになるまで — 実装 AI 編
- レビューAIには、報告を信じさせない ― 突合レビューをどうさせているか — レビュー AI 編