1 つの code-review-graph MCP セッションから worktree ごとに独立したグラフ DB を使い分ける

code-review-graph は, コードベースの構造グラフを手元に持ち, 影響範囲や実行フローの問い合わせを MCP 経由で提供する OSS である. これを PR レビューの補助証拠として使っていたのだが, 1 つのセッションの中で複数 PR のレビューを並列に進めるという使い方, すなわち PR ごとに Git worktree を分けて同時に扱おうとした時点で構成が破綻した.

本記事は, その MCP ランチャーを 2 段階で作り直し, 1 つの stdio MCP セッションから worktree ごとに独立した子プロセスとグラフ DB を 安全に使い分けられるようにするまでの設計と実装をまとめたものである. 実装は 前回の記事 で触れた chezmoi 管理の dotfiles リポジトリに置いている. 併せて, 同じ仕組みを上流の本体へ提案した際, 自前のラッパ層とは何を変えたかにも触れる.

code-review-graph はどのようなツールか

先に対象を説明しておく. code-review-graph は, リポジトリを Tree-sitter で解析して 関数, クラス, インポートをノード, 呼び出し, 継承, テストの対応をエッジとする 構造グラフを作り, それを .code-review-graph/ 配下の SQLite ファイル 1 つへ持つツールである. ライセンスは MIT. 実装には Python 3.10 以上が必要になる.

狙いは「AI コーディングツールにコードベース全体を読ませない」ことにある. 変更されたファイルからグラフをたどって呼び出し元, 依存先, 関係するテストを列挙し (上流はこれを影響波及範囲, blast radius と呼ぶ), レビューに必要な最小限のファイル集合だけを返す. この問い合わせ口が MCP サーバとして実装されており, CLI とは同じ DB を共有する.

実装を読むうえで効いてくる性質は次のあたりである.

  • 増分更新 — 変更ファイルの SHA-256 を見て, ハッシュが変わったものだけ再解析する. README の計測では約 3,000 ファイルのリポジトリで 2 ファイルの編集が約 2.5 秒 (うち約 1.4 秒はプロセス起動)
  • ローカル完結 — 外部サービスへソースを送らない. 保存先は SQLite ファイル 1 つで, 外部の DB やクラウドを必要としない
  • リポジトリ単位のストレージ — デフォルトはリポジトリ直下の .code-review-graph/graph.db. ~/.code-review-graph/registry.json へ複数リポジトリを登録でき, data_dir で保存先を個別に指定できる. 本記事で扱う問題は, まさにこの解釈がずれたことに起因する
  • 広い言語対応 — Tree-sitter の文法定義があるものはそれを使い, 無い言語には個別の代替処理を当てる. 独自言語も設定ファイルで追加できる
  • そのほか Leiden 法によるコミュニティ検出, 実行フローの抽出, FTS5 による全文検索, 任意で有効にする埋め込み検索, CI 用の GitHub Action など

README にはベンチマークも載っている. 6 リポジトリ 13 コミットに対する計測で, 質問 1 件あたりのトークン数が全文読みに比べて中央値で約 63 分の 1 (レンジは 35〜358 倍), 影響解析の F1 が平均 0.693. ただし「recall 1.0」は正解集合を同じグラフから作っているので上界に過ぎない, という注記が README 自身に書かれている. 測り方と限界まで開示してあるぶん, 数字をそのまま信じ込まずに扱える.

どれくらい活発なプロジェクトか

自前のラッパを挟むか, 本体へ手を入れるかの判断材料として, 上流の勢いも見ておいた. 2026-09-21 時点の数字は次のとおりである.

  • リポジトリ作成は 2026-02-26. 7 か月弱でスター約 31,700, フォーク約 2,900, コントリビュータ 123 人
  • リリースは v2.3.0 (2026-04-11) から v2.3.9 (2026-09-18) まで, おおむね月 1 回程度の間隔
  • コミット数は直近 4 週 231 件, 直近 12 週 654 件. ただし週ごとの偏りは大きく, 0 件の週もあれば 168 件の週もある
  • 未クローズの PR が 53 本, issue が 87 件. 通算で作られた PR は 648 本
  • README は英語のほか中国語, 日本語, 韓国語, ヒンディー語が用意されている

2026 年に始まったばかりのプロジェクトが短期間で一気に伸びており, 開発はリリース前後に集中するバースト型である. 裏を返せば, 個々の運用シナリオは各自が埋める前提の段階でもある. 本記事のラッパ層は, まさにその埋め合わせとして始まったものだ. 一方で, これだけ人と変更が集まっているなら本体へ出す価値もある, という判断が後半の上流への提案につながっている.

なお本記事の構成は 2.3.8 を固定して使っており, 記事中の検証もそのバージョンに対するものである.

課題

レビュー待ちの PR が複数積まれる状況は常態化する. これをエージェントへ手伝わせるときの現実的な運用は, 1 つのセッションを開き, その中で複数 PR のレビューを同時に進める形である. PR A の影響範囲をグラフへ問い合わせている間に PR B の差分を読み, 必要なら両者の指摘を突き合わせてから順に投稿する, といった進め方である. PR ごとにセッションを立て直すのは, 立ち上げコストの面でも, レビュー観点をセッション間で共有できない面でも割に合わない.

この形を取るなら, チェックアウトは PR ごとに分かれていなければならない. 1 つのチェックアウトを git switch で往復させると, レビューの最中に足元のチェックアウトが別 PR の都合で動き, 読んでいた根拠が崩れる. グラフ DB は「ある時点の HEAD」を前提に構築されるため, 切り替えのたびに作り直しにもなる. PR ごとに独立した作業ツリーを Git の worktree 機能で用意すれば, この問題は解決する.

問題は MCP 側であった. MCP の stdio 接続はセッション単位で 1 本張られ, レビューの途中で張り替えられない. にもかかわらず当時のランチャーは起動時の 1 つのリポジトリルートに固定されており, tools/callrepo_root に別 worktree を書いても宛先は変わらなかった. つまり実質「1 セッション : 1 worktree」でしか使えない. 複数 PR を並列に扱うには, PR ごとに別の MCP 設定を用意してセッションを分けるか, グラフの利用自体を諦めるか, 結局は直列に戻すかしかなかった. 本記事の変更は, この「1 セッション : N worktree」を成立させるためのものである.

変更前の構成とその制約

初期実装のランチャーは POSIX sh で, 起動時の Git ルートを SHA-256 で要約し, MCP 専用の保存領域を作っていた.

repo_root=$(git rev-parse --show-toplevel)
repo_hash=$(printf '%s' "$repo_root" | shasum -a 256 | awk '{print $1}')
data_dir="$project/.crg-data/$repo_hash"

これを CRG_DATA_DIRCRG_HOME として env -i 経由で子プロセスへ渡していた. つまり ルートのハッシュ単位の隔離は元からあった. 異なるルートを別々の MCP プロセスで起動すれば DB は確かに分かれるので, ここを「隔離がなかった」と書くのは不正確である. 問題は隔離の有無ではなく, 粒度と CLI との不一致にあった.

1 点目は 1 プロセス = 1 ルートという粒度である. ランチャーは起動時のカレントディレクトリからルートを 1 つ決め, それを固定引数として境界検査プロセス (以下 guard) と上流サーバへ渡す. tools/call に別の repo_root を書いても, guard は「起動時ルートの配下か」を検証して弾くだけで, 宛先は変わらない.

2 点目は CLI と MCP が別の DB を見ていたことである. CLI は ~/.code-review-graph/registry.json という登録簿 (以下 Registry) と リポジトリ直下の .code-review-graph/graph.db を使うのに対し, MCP はハッシュ名のディレクトリを使う. 結果として CLI で構築したグラフが MCP から見えず, 両者のノード数, 更新時刻, グラフ構築時の Git SHA がそろわない. 当時の MCP は build_or_update_graph_tool を許可リストから外した読み取り専用構成で, 構築は CLI 側でしか行えなかったため, この不一致は回避不能であった. レビューの根拠として使う以上, これは致命的である.

第 1 段階: ストレージ契約を CLI とそろえる

最初に直したのは DB の解決規則である. 方針は「全リポジトリで 1 つの DB にする」ことではない. 同じリポジトリルートについて, CLI と MCP が同じ正規 DB を使うことである. 前者にすると別リポジトリのノードが混ざるため, この区別は重要である.

新しい規則は次の 2 段で, これは CLI の解決規則そのものである.

  1. Registry に対象ルートの data_dir が明示されていれば, それを使う
  2. 明示されていなければ, そのルートの .code-review-graph/graph.db を使う

あわせて, 全リポジトリへ一律に効いてしまう CRG_DATA_DIR はランチャーで明示的に拒否した. 保存先をリポジトリ単位で変えたい場合は Registry の data_dir で指定する. CRG_HOME は CLI と基準がずれないよう絶対パスのみ受け付け, 未設定なら ~/.code-review-graph を使う.

一方で上流サーバ自体には広い Registry を見せたくない. そこで guard は一時的なホームディレクトリを作り, 対象ルート 1 件だけを書いた Registry を子プロセスへ渡す.

(child_crg_home / "registry.json").write_text(
    json.dumps({"repos": [{"path": str(repo_root), "data_dir": str(fixed_data_dir)}]})
)

解決には共有 Registry を使い, 子プロセスへ渡すのは解決結果だけ. こうすることで CLI との一致と子プロセスの固定を両立させている. この段階で build_or_update_graph_tool も許可リストへ入れ, MCP からの更新が CLI と同じ DB へ書かれるようにした.

第 2 段階: 振り分けプロセスで 1 セッションを多重化する

ランチャーが guard を直接起動するのをやめ, 間に振り分け役の Python プロセス (以下 dispatcher) を 1 つ挟む構成にした.

子プロセス: 連結 worktree B

子プロセス: 連結 worktree A

子プロセス: 主チェックアウト

1 本の stdio セッション

主チェックアウト

worktree A

worktree B

MCP クライアント

dispatcher
repo_root による子プロセスへの振り分け
delete_graph_tool の実装

guard

guard

guard

uv run code-review-graph serve
--repo 主チェックアウト

uv run code-review-graph serve
--repo worktree A

uv run code-review-graph serve
--repo worktree B

Registry の data_dir
または 主チェックアウトの
.code-review-graph/graph.db

worktree A の
.code-review-graph/graph.db

worktree B の
.code-review-graph/graph.db

クライアントから見れば stdio 接続は 1 本のままである. dispatcher は tools/callarguments.repo_root を受け取り, 正規化と認可を行ったうえで, そのルート専用の子プロセスを遅延起動して振り分ける. 同じルートへの要求は同じ子プロセス, 同じ DB へ行き, 子プロセスは以降再利用される (上限 32).

並列性はここで効く. dispatcher は要求を子プロセスの標準入力へ書いたら直ちに次の入力へ戻り, 応答は子プロセスごとの読み取りスレッドが受け取って共有の標準出力へ書き戻す. したがって異なるルートへの要求を同時に処理でき, PR A への重いグラフ問い合わせが PR B の応答を塞がない. 更新中の読み取り抑止 (後述の graph_updating) もルート単位で閉じているため, 片方の worktree でグラフを更新していても, もう片方のレビューは進む. 子プロセスの起動時にはクライアントが送ってきた initialize のパラメータを dispatcher 内部で生成した ID を使って再生するため, 子プロセスは多重化を意識しない通常の MCP サーバとして振る舞えばよい. tools/list は主チェックアウトの子プロセスへ流し, 応答に dispatcher 自身が実装する delete_graph_tool を追記する.

多重化で最も壊れやすいのは要求 ID の対応付けである.

  • ID は json.dumps(value, sort_keys=True) で正規化した文字列をキーにする. 数値と文字列の混同や, オブジェクト形式の ID のキー順の揺れを避けるため
  • キーから子プロセスへの対応表を保持し, 同じ ID が同時に 2 つ飛ぶことを拒否する
  • 子プロセスから返った応答について「その ID の持ち主は本当にこの子プロセスか」を照合し, 一致しなければ応答を捨ててその子プロセスを落とす
  • 上限はメッセージ長 4 MiB, ID 長 4 KiB. 超過分は行単位で読み捨てて次のメッセージから復帰する. 送信済みでまだ応答が返っていない要求は, 子プロセスごとに 256 件まで受け付ける
  • 子プロセスの側から送られてくる要求は ping にのみ空の結果を返し, それ以外は -32601 で拒否する

子プロセスが死んだ場合, その子プロセスへ送ったまま応答待ちになっている要求を -32001 のエラーとしてクライアントへ返し, 対応表から取り除く. 後始末には一段の工夫が要った. guard は上流サーバを start_new_session=True で別セッションに置くため, guard を落としただけでは上流サーバの子孫が残りうる. そこで guard は起動直後, 上流サーバのプロセスグループ ID を CRG_MCP_UPSTREAM_PGID_FD で渡されたファイル記述子経由で dispatcher へ報告する. dispatcher はこれを保持し, 停止時には両者へ SIGTERM を送って一定時間後に SIGKILL へ昇格させる.

診断ログの扱いも実務上効いた. 子プロセスの標準エラー出力を同期で書き戻すと, クライアントが標準エラー出力を読まない場合に書き込みが詰まり, プロトコルの入出力ごと止まってしまう. そのため診断ログは上限 64 件のキューへ積み, ブロックしない設定のファイル記述子へ専用スレッドから書き, あふれた分と 64 KiB を超える行は捨てるか切り詰める. 診断ログは可能な範囲で出せばよいものであり, JSON-RPC を止めてよい理由にはならない.

worktree ごとの DB 分離と, ルートの許可

「DB を分けること」と「ルートを許可すること」は別の話である. DB の分離は前述の Registry 規則が担い, ルートの許可は dispatcher が担う.

def authorize_root(self, raw_root: object) -> Path:
    root, common = canonical_git_root(raw_root)
    if root == self.primary_root:
        return root
    if common == self.primary_common and root in self.git_worktree_roots():
        return root
    raise DispatchError(
        "repo_root is not the current repository or an authorized linked worktree"
    )

許可されるのは起動時のルート自身か, 同じ Git 共通ディレクトリ (--git-common-dir) に属し, かつ git worktree list --porcelain へ現れるルートだけである. 共有 Registry は DB の保存先解決には使うが認可には使わず, 無関係なリポジトリを Registry へ足しても認可範囲は広がらない.

canonical_git_root の側では, 絶対パスであること, シンボリックリンクでないこと, resolve(strict=True) できること, git rev-parse --show-toplevel の出力が自分自身と一致すること (= worktree のルートであること), --git-common-dir を解決できることを順に確認する. したがって任意のディレクトリ, 別のクローン, 同じリポジトリに属さない一時クローンは許可されない. テストでは .git ファイルに主リポジトリの参照先を書いただけの偽 worktree が -32602 で拒否されることを確認している. git worktree list に実在しない以上, .git の中身を真似ても通らない.

同じルートへの同時アクセスは, guard が CRG_HOME/.mcp-locks/<sha256(data_dir)>.lock に共有ロックを取り, SQLite の WAL とビジー待ち, BEGIN IMMEDIATE による書き込みロックで整合性を担保する. 同一 guard 内では, 更新の応答が返るまで読み取りと次の更新を graph_updating として拒否する.

セキュリティ境界

「安全にした」で済ませると何も言っていないのと同じなので, 何を拒否するかを挙げておく.

  • ランチャー: 実行環境のディレクトリと tools.txt がシンボリックリンクでない実体であること. tools.txt の各要素が ^[a-z][a-z0-9_]*$ に一致すること. 書き込みを伴うツール (apply_refactor_tool, embed_graph_tool, generate_wiki_tool, refactor_tool, run_postprocess_tool) を含まないこと. CRG_DATA_DIR を拒否し, CRG_HOME は絶対パスのみ受け付ける. env -i で環境変数を許可リストに絞り, ホームディレクトリはリポジトリではなく専用の実行時ディレクトリ (権限 700) とする. Python は 3.10 以上 3.13 以下に固定し UV_PYTHON_DOWNLOADS=never を設定する
  • Git: code-review-graph-git という薄い中継スクリプトを PATH の先頭に置き, filter.*.clean/smudge/process, diff.*.command/textconv, core.fsmonitor がリポジトリ内の設定にあれば実行を拒否する. git diff には --no-ext-diff --no-textconv を強制する. リポジトリ内の .gitattributes とローカル設定の組み合わせで任意コマンドが動くのを防ぐため
  • Registry: 所有者が自分自身で, グループとその他から書き込めない 1 MiB 以下の通常ファイルであること. 各エントリの path / data_dir が絶対パスかつシンボリックリンクでないこと. 複数リポジトリが同一の data_dir を指さないこと, 同一ルートの重複エントリがないこと
  • DB: データディレクトリは権限 700 の実ディレクトリとする. 起動時にそのディレクトリのデバイス番号と inode 番号を控えておき, 以後の操作でこの 2 つが一致しなければ, 同じパスでも中身が別のディレクトリにすり替わったとみなして拒否する. graph.db と WAL / SHM / journal も通常ファイルであることを要求する
  • ツール引数: repo_root は子プロセス自身のルートと完全一致. changed_filesfile_path_pattern はリポジトリ相対かつ実体が外へ出ないこと. base- 始まりを禁止する (オプション注入の防止). get_flow_tool および get_review_context_tool / detect_changes_tool のソース出力は無効化する

古いグラフと誤った結び付きへの対策

グラフが古いまま黙って答えるのは, 誤ったレビュー結果を出すのと同義である. 読み取り系のツールは次を満たさない限り結果を返さない.

  • DB の metadatarepository_root が現在のルートと一致すること
  • metadatagit_head_sha が現在の HEAD と一致すること
  • 作業ツリーに未コミットの変更がないこと
  • nodes / edgesfile_path がすべて現在のリポジトリ配下へ解決されること

満たさない場合は graph_not_ready あるいは stale_graph を返す. DB が未構築のときだけ状態確認用に get_minimal_context_tool を通し, 由来情報 (provenance) の不明な DB はこのツールも通さない. ここでいう由来情報とは「そのグラフがどのリポジトリの, どのコミットから作られたか」の記録である.

更新側も対称に固めた. 未コミットの変更があれば更新自体を拒否し, git ls-files で追跡対象を列挙して実体がリポジトリ外へ解決される シンボリックリンクがあれば構築を始めない. 記録済みの SHA が現在の HEAD と異なる場合は, 増分更新で古い由来情報が残り続けるのを避けるため全体の再構築へ切り替える.

更新成功後には完了時点で再検証する. Registry と DB の結び付きが更新中に変わっていないこと, nodesfile_hash と実ファイルの SHA-256 が一致すること, 再取得した built_at_sha の値が現在の HEAD かつ開始時の HEAD と一致し, 作業ツリーに変更がないこと. いずれかが崩れていれば成功応答をエラーへ書き換え, DB の metadatamcp_validation_error を書き込んで以後の読み取りを保守的に止める. この印は検証済みの再構築が完了した時点でのみ消える. 読み取りについても, 開始時に結び付き, HEAD, 未コミット変更の有無, グラフ内容のダイジェストを記録し, 応答を返す直前に照合して, 途中で動いていれば結果を捨てる.

グラフ削除の運用

レビュー中はグラフを読み取り専用の補助証拠として使い, 構築や更新で状態を変えない, というのが手順書 (skill) 側の方針である. そのうえで, 承認, Linear の更新, Slack のリアクションといった完了条件をすべて満たした後にだけ, 対象 worktree に対して delete_graph_tool を実行する. このツールは dispatcher が実装しており, 次の制約を持つ.

  • 引数は repo_rootconfirmation のちょうど 2 つで, confirmation"approved" のみ
  • ルートは authorize_root を通ること. さらに 同一セッション中にそのルートへのグラフ操作が 1 回以上成功していることを要求する
  • 対象の子プロセスを停止し, データディレクトリが起動時に控えたデバイス番号と inode 番号のままであること, つまり削除しようとしている先が当初と同じ実体であることを確認する
  • 排他ロックを取得する. 他プロセスが使用中なら graph_cleanup_busy で停止する
  • BEGIN IMMEDIATE で書き込みロックを取り, DB の metadatarepository_root の一致を確認する
  • 由来情報のない旧形式の DB は, リポジトリ内のデフォルトの位置にある場合だけ削除できる. 外部の data_dir に置かれた旧形式の DB は所属を証明できないため削除しない
  • 削除対象は graph.db と WAL / SHM / journal の付随ファイルのみ. ソースファイル, worktree, Registry の登録, 任意のパスは削除しない

指摘が残ったとき, 承認できなかったとき, MCP の失敗やグラフの古さを検出したときは削除せず, 次の再レビュー用にグラフを残す. そのため手順書側はグラフを使った時点の正規のルートを graph_repo_root として保持し, 後続の削除処理へそのまま引き渡す. カレントディレクトリや推測したパスで代用してはならない.

テストと検証

どのようなテストが要るか

この構成で壊れると困るのは次の 3 種類で, いずれも関数単位のテストでは捕まえられない.

  1. 境界 — 許可していないルートや引数が本当に拒否されるか. 判定はランチャー (sh), dispatcher, guard, 上流サーバの 4 層に分散しているので, 全部つないで動かさないと確かめられない
  2. ストレージの一致 — CLI と MCP が同じ DB を指しているか. 片方だけ動かしても意味がなく, 両方を交互に動かして同じ値が出ることを見るしかない
  3. プロセスの後始末 — 子や孫のプロセスが確実に止まるか. 素直に終了する相手でテストしても何も検証したことにならない

そこで実サーバを起動して標準入出力に JSON-RPC を流す形式を採り, 確認したい失敗が実物では再現しにくい場合にだけ, 上流サーバを偽物に差し替えている. なお, 以下の確認項目の大半は「拒否されること」である. 許可すべき経路は 1 本しかないのに対し, 拒否すべき経路は無数にあるためで, この層のテストは自然と否定形が主になる.

どう実装したか

2 本とも POSIX sh で書き, その中からヒアドキュメントで Python を起こしてクライアント役をさせている. MCP のクライアントライブラリは使わず, 生の JSON-RPC を自分で組み立てて流す. 理由は 2 つある.

  • ライブラリでは不正なメッセージを送れない. 4 MiB 超のメッセージ, 配列形式の一括要求, オブジェクトでない値, 4 KiB 超の ID といった「送ってはいけないもの」を意図的に送る必要がある
  • 応答の到着順とタイムアウトを自分で握りたい. 並列性の確認では 2 つの要求を続けて投げ, どちらが先に返ってもよい形で両方を待つ

実行環境は毎回 mktemp -d の一時ディレクトリに閉じ込め, そこへ偽のホームディレクトリ, 偽のリポジトリ, 各スクリプトの配置を作る. 実際のホームディレクトリや作業リポジトリには触れない.

プロセス制御の確認には, uv のふりをする小さな Python スクリプトを 3 種類用意した.

  • 応答を返した直後に標準エラーへ 64 KiB 以上を吐き続ける「詰まらせる役
  • SIGTERM を無視する孫プロセスを作り, その PID をファイルへ書き残す「止まらない役
  • 起動しただけで眠り続ける「初期化の途中で落とされる役

これらがないと, 診断ログの詰まりやプロセスグループ単位の停止は確かめようがない. 逆にグラフの中身に関わる確認は偽物では無意味なので, そちらは実際の上流サーバを動かす.

判定は素朴にしてある. Python 側は条件を満たさなければ例外を投げて即座に落ち, シェル側は testexit 1 で落ちる. テストフレームワークは使っていない. CLI と MCP の値を突き合わせる箇所だけは, MCP から得た値をいったんファイルへ書き出し, MCP プロセスを終了させてから, シェル側で jqsqlite3 を使って CLI の出力や DB の中身と比較する形にした. サーバが終了した後で見れば, 応答の数字が本当に DB へ書き終えられたものだと言えるためである.

何を確かめているか

code-review-graph-smoke-test.sh は境界とプロセスの後始末を担当する. uv lock --check, 上流のバージョン一致 (2.3.8), initialize の応答確認, tools/list が許可リスト + delete_graph_tool と完全一致すること, repo_root: "/" や外部を指すシンボリックリンクを含む changed_files の拒否, 一括要求 / 不正な JSON / オブジェクトでない値 / 4 KiB 超の ID の拒否, 4 MiB 超のメッセージでの停止と, 1,100 段ネストした JSON を送った後でも ping が通ること, SIGTERM を無視する孫プロセスがプロセスグループ単位で確実に停止すること, 標準エラー出力を読まないクライアントでも標準出力の応答が返ること, .gitattributes 経由の diff ドライバや filter を発火させないこと, そしてリポジトリ内に DB が作られていないことを確認する.

code-review-graph-shared-storage-test.sh は CLI と MCP の往復を実際の上流サーバで確認する.

  • CLI で register / build した後, MCP の list_graph_stats_tool のノード数が CLI の status --json と一致すること. MCP から更新した後も CLI 側の値と, DB の repository_root / git_head_sha / count(*) from nodes が一致すること
  • 未コミットの変更がある状態と, コミット後に古くなったグラフがそれぞれ stale_graph で拒否されること
  • Registry の data_dir 書き換えと重複エントリが拒否され, 一方で無関係なリポジトリの追加では稼働中の子プロセスが中断されないこと
  • git worktree add した連結 worktree について, 主チェックアウトと DB が別物であり, 1 本の MCP セッションから両方の統計を取得してそれぞれ CLI の値と一致し, かつ互いに異なること
  • 偽の worktree と不正な confirmation の拒否, 別途起動した guard がロックを保持している間の graph_cleanup_busy, ロック解放後の削除成功と付随ファイルの消滅, 他リポジトリの DB と Registry の登録が残ること, 削除後に graph_not_ready へ戻ること
  • 追跡対象に外部を指すシンボリックリンクを含むコミットへの更新が拒否され, DB の git_head_sha が更新されていないこと

このほか静的な検査として sh -n によるシェルの構文検査と, python -m py_compile による dispatcher と guard のコンパイル検査を実施し, いずれも通っている. 上記 2 本は上流パッケージの取得が要るためコミット時の自動検査には含めず, MCP 周りを変更したときに明示的に実行する運用としている. 本記事の執筆時点で, いずれも成功することを確認済みである.

上流へ還元する: --multi-worktree モード

ここまでの構成は dotfiles 側のラッパ層に閉じている. しかし「1 セッションから複数 worktree を扱いたい」という要求自体は code-review-graph を使う誰にでも生じるものなので, 同じ仕組みを本体側の, 明示的に有効化したときだけ働くモードとして PR #1063 で提案した. 新規モジュール 1 本 (multi_worktree.py) と CLI フラグの追加が中心である.

code-review-graph serve --multi-worktree --repo /path/to/primary-repository

差分から読み取れる方針は次のとおりである.

デフォルトの挙動を変えない. --multi-worktree を明示しない限り従来どおりの単一ルートのサーバが起動する. 新モードは stdio 専用で, --http との併用は引数解析の段階で弾く.

責務をプロセス隔離と振り分けに絞る. 本体がやるのは「ルートごとに通常の serve --repo <root> 子プロセスを遅延起動し, 要求を振り分ける」ことだけで, グラフの鮮度判定や由来情報の検証, 削除処理といった レビュー運用固有の関心は一切持ち込んでいない. それらは呼び出し側 (ラッパや手順書) の責務として残す. 本体が引き受けるのは「worktree ごとに状態を混ぜない」という一点である.

認可規則はラッパ層と同じ. 絶対パスであること, git rev-parse --show-toplevel の自己一致, --git-common-dir の一致, git worktree list --porcelain への実在を確認する. 別リポジトリと, 既に削除された worktree のパスは通らない. Git 実行時は GIT_* を全て落として設定を隔離する.

DB の重複割り当てを直接拒否する. ラッパ層では Registry の data_dir の重複として検査していたが, 本体側は DB パス解決関数の結果そのものをルートごとに保持し, 既に別のルートが使っている DB へ解決されたら子プロセスの起動を断る. 設定の書き方ではなく解決後のパスで見るぶん, こちらの方が確実である. プロセス全体に効く CRG_DATA_DIR は, 全 worktree を 1 つの DB へ縛るため起動時に拒否し, 子プロセスの環境からも除去する.

子プロセスへ渡す環境変数を許可リストに絞る. HOME / PATH / 一部の CRG_* など必要なキーだけを渡す. 起動は python -c から runpy で同じパッケージを呼ぶ形にし, 冒頭で sys.path.pop(0) してレビュー対象リポジトリからの取り込みを防いでいる.

ラッパ層から意図的に変えた点も 2 つある.

1 つは, サーバ側から送られてくる要求の扱いである. ラッパ側は ping 以外を -32601 で拒否していたが, 本体側は ID を multi-worktree-server-<uuid> へ書き換えて親のクライアントへ転送し, 返ってきた応答を元の ID に戻して発信元の子プロセスへ届ける. roots/list のようなクライアント側の機能を将来使えるようにするためで, プロトコルのメッセージを黙って落とさない方が安全だという判断である. notifications/cancelled も, 対象の要求を抱えている子プロセスだけへ送る.

もう 1 つはメッセージ長の上限で, 親の標準入力側は 4 MiB のままだが, 子プロセスの標準出力側は 64 MiB まで許している. グラフの応答は素直に大きくなりうるためである.

検証は PR の記載によると ruff, pytest -q (32 件成功), git diff --check. エンドツーエンドのテストは 1 セッションから 2 つの worktree に対して asyncio.gather で同時に構築と問い合わせを投げ, worktree 固有のシンボルが片方にしか現れないこと, 無関係なリポジトリが拒否されることを確認している. 本記事の執筆時点でこの PR は未マージであり, 取り込まれるかは未定である.

制約と今後の課題

明示しておくべき制約が 3 つある.

第一に, 認可範囲は起動時のリポジトリと同じ Git 共通ディレクトリに閉じている. 複数の独立したリポジトリを 1 セッションで横断する用途は想定していない.

第二に, 層の切り分けを混同しないでほしい. 初期 MCP ラッパのハッシュ単位の隔離, 上流 CLI が持つリポジトリ単位のストレージ規則, ラッパ層の dispatcher による同一セッション内の振り分け, そして上流本体の --multi-worktree は, それぞれ別の層の話である. 現在手元で動いているのはラッパ層の構成であり, 上流へは提案した段階で, 取り込まれる保証はない. 仮に取り込まれてもラッパ層が不要になるわけではなく, グラフの鮮度検証と削除処理はラッパ側に残る.

第三に, 並列化の効果について定量的な計測は行っていない. 子プロセスの上限を 32, 子プロセスごとの応答待ち要求の上限を 256 と置いているのは 暴走時の歯止めであって, 実測に基づく調整値ではない.

まとめ

第一に, 隔離を語るときは粒度を先に決めるべきだった. 初期実装にもルートのハッシュ単位の隔離はあったが, それは「1 プロセス = 1 ルート」であり, 求めていた「1 セッション = N worktree」とは別物だった. 問うべきは「隔離されているか」ではなく「何の単位で隔離されているか」である.

第二に, 複数の経路から同じ状態を触るなら, 解決規則そのものを共有する. CLI と MCP で DB パスの導出を二重に実装していたことが不一致の原因であり, 同じ 2 段の規則を両者が使う形にした時点で問題は消えた. DB を 1 つに統合したのではない. この点は何度でも強調しておきたい.

第三に, グラフのような派生データは由来情報を持たないまま返してはならない. repository_rootbuilt_at_sha を DB に持たせ, 現在のルートと HEAD に照らして不一致なら答えない. この単純な規律が, 多重化で最も効いた安全装置であった.

第四に, 手元で組んだ仕組みを上流へ出すときは, 責務を削るほうへ働く. ラッパ層では振り分け, 鮮度検証, 削除処理を 1 つの層に押し込めていたが, 本体へ提案したのは振り分けとプロセス隔離だけである. 誰にでも必要な機構は本体へ, 自分のレビュー運用に固有の規律はラッパへ. この線引きを先にやっておくと, 提案する差分は自然と小さくなる.


活動継続のためのご支援を募集しています