見出し画像

二体にレビューさせる設定は書けた。片方の指摘が黙って捨てられる設定も、書けてしまった ptygrid v0.5.7

今日はサンプルを1本足すだけのつもりだった。実装を1体にやらせて、別々のモデル2体に同じ変更を並行レビューさせ、最後にその2本を突き合わせて可否を決める——マルチエージェントでいちばん需要がある形だと思う。それを自前のスクリプトではなく ptygrid.yml に書くだけで回せるところまで持っていく、という作業だ。

ptygrid とは?
複数のAIエージェントCLIを1画面で並行実行・協調させる、軽量ネイティブターミナル「ptygrid」
Claude Code / Codex / Grok をスプリットペインで同時に走らせ、内蔵MCPサーバー「Queen」でエージェント同士が出力を読んだり指示したりできるツールです。
GitHub: https://github.com/zephel01/ptygrid

書けた。ただし途中から、書けるかどうかより「書けてしまうかどうか」のほうが気になりはじめた。設定は通るのに意図どおりには動かない形が、はっきり存在する。今日の収穫はそっちだった。

一本の root から二体が生えて、最後に合流する

段取りそのものは `pattern: supervisor` で素直に書ける。ptygrid の workflow には pipeline / fan-out / supervisor / handoff の4種類があって、「1つの root から複数の子が生えて、最後に合流する」形をそのまま書けるのは supervisor だけだ。

cross-model-review:
  pattern: supervisor
  steps:
    - id: implement
      agent: implementer
      joinOn: reply
      timeoutMs: 1800000
    - id: review-a
      agent: reviewer-a
      dependsOn: [implement]
      joinOn: reply
    - id: review-b
      agent: reviewer-b
      dependsOn: [implement]
      joinOn: reply
    - id: verdict
      agent: judge
      dependsOn: [implement, review-a, review-b]
      joinOn: reply

kickoff——各 step でエージェントに渡す最初の指示文——は長いので落としたが、実物には4つとも書いてある。`joinOn: reply` は kickoff が空だと読み込みの時点で弾かれる。返信すべきスレッドが無ければ、その step は永久に完了しないからだ。

ここで fan-out を使ってはいけない。`fanOut: 2` は同じ step を2枚複製するだけで、2枚とも同じ `agent`、つまり同じ `cmd` になる。違うモデルを並べたいなら、複製ではなく兄弟 step。並列という言葉にいちばん近い名前の機能が、この用途にはいちばん使えなかった。

レビュアーの2体には worktree を付けた。同時にテストを回すので、作業ツリーを共有すると「レビューの指摘」と「相方の踏み荒らし」が区別できなくなる。

  - name: reviewer-a
    cwd: "."
    env:
      PTYGRID_REVIEW_DIR: "/tmp/ptygrid-cross-model-review"
    worktree:
      enabled: true
      base: HEAD
      # setup: "npm ci"
    autostart: false

`base: HEAD` の HEAD は、レビュアーが spawn される瞬間のメインツリーの HEAD だ。だから実装役のほうには worktree を付けていない。付けると変更は乱数付きの名前のブランチに入ってメインの HEAD は動かず、2体とも空の diff をレビューすることになる。

冗長に見える一行を消すと、読み込みごと失敗する

上の YAML で目を引くのは、たぶん `verdict` の依存だろう。`dependsOn: [implement, review-a, review-b]` の先頭が要らなく見える。review-a と review-b が終わっているなら implement は当然終わっているからだ。

外して `[review-a, review-b]` にしてみた。こう言われた。`supervisor step 'verdict' must dependOn root step 'implement'`。読み込みごと失敗する。supervisor には root がちょうど1つ、root 以外は全員 root を `dependsOn` に含めること、という規則がある。冗長ではなく必須。root を2つにすれば `supervisor pattern requires exactly one root step (found 2)`、同じ形を `pipeline` に変えれば `pipeline step 'verdict' has 3 dependencies; pipeline is linear (max 1 dependsOn per step)`。

どれもきちんと落ちるし、エラー文に step の id が入っているので直す場所もすぐわかる。ここまでは親切な設定言語だと思っていた。

落ちてくれるうちは、まだ親切だった

問題は突き合わせのほうだった。前の step の本文を指定した step へ運ぶ機構として `handoffTo` がある。素直に考えれば、review-a と review-b の両方に `handoffTo: verdict` と書きたくなる。

書ける。parse は通る。

通るが、動かない。`handoff_bodies` はターゲット1つにつき本文を1本しか持たず、`if bodies.contains_key(target) { continue; }` で2本目を捨てる。設定に先に書いたほうが勝つ、という実装だ。judge の手元には review-a のレビューだけが届き、review-b の指摘は警告もエラーも無しに消える。検証側にこれを止める規則が無いので、気づく手段が無い。

判定は返ってくる。run は緑で終わる。片方のレビューが存在しなかったことだけが、どこにも出ない。

`condition` で「両方が PASS なら進む」と書く手も無かった。`condition` は依存の先頭1本しか見ないため、検証側が「`dependsOn` はちょうど1本」を課している。3本依存の verdict に足すと `step 'verdict' condition requires exactly one dependsOn (found 3)` で落ちる。こちらは落ちてくれるぶん、まだましだ。

迂回路も塞がっていた。`reply_inbox` は返信先を元メッセージの sender に固定するので、workflow への返信は `queen:workflow/<name>/<run_id>` という run 専用の mailbox に戻る。judge は run id を知らないから読めない。

結局サンプルは、レビュー本文をファイルに書かせ、返信は「書き終わった」という合図としてだけ使う形にした。workflow が同期に使うのは「2体とも返信した」という事実だけで、中身の受け渡しはファイル経由。だからこのサンプルには `handoffTo` が一つも書かれていない。

同じ性質の穴がもう一つある。未知のキーを拒否する設定にしていないので、打ち間違えたキーは黙って捨てられる。`close_on_exit` を `closeOnExit` と書いた私の検証用 YAML は読み込みに成功し、値は入らないままだった。しかも綴りの流儀は場所で違う。`agents:` の中は snake_case、`workflows:` の中は camelCase。同じファイルに同居している。

まだ実装していない `onEach: reply` を書いた YAML も、同じ理由で通った。書ける、通る、何も起きない。設定が落ちるのは、いちばん安いエラーだったわけだ。

200ミリ秒の税と、3.4秒の事務手数料

並べること自体の値段も測った。中身が `sleep` だけの合成 workflow で、オーケストレーション層そのものを計った数字だ。

6段の直列 run が31秒、理想値が30秒。依存エッジ5本にオーバーヘッドが1秒ちょうどなので、1エッジあたり0.2秒。実装側の `DRIVER_TICK_MS` は 200。tick 1回ぶんがそのまま乗っている。

同じ6段を3枚に割った版では、step ごとの所要が 5.2 / 5.1 秒のままで直列版と変わらなかった。同時に3枚立てても spawn は重くならない。ただし run 全体の壁時計は未記録なので「30秒が10秒になった」とは書けない。言えるのは、並列化の取り分がオーケストレーション側で目減りしなかった、というところまでだ。

もっと大きいのはエージェント1体の起動コストだ。同じ仕事を cold な1体と warm な2体にやらせて 7.7秒 / 4.1秒 / 4.5秒。warm 平均4.3秒に対して cold start が約3.4秒、warm 同士のばらつきが0.4秒なのでノイズの8倍以上ある。

これは下限だ。計測用のプロンプトは「考えるな、調べるな、ファイルも読むな」を明示的に禁じてあるので、実タスクなら必ず入る CLAUDE.md やリポジトリの読み直しが一切含まれていない。実際の起動はこれより高くつく。

もう一つがペインの空き待ちだ。同時に画面へ出せるのは9枚で、12枚投げた測定では後半の6枚が各8.2秒待たされた。削るべきものの順番が見えてくる。1依存200ミリ秒は誤差で、効くのは起動と待ち時間のほうだ。

「設定を書くだけ」の手前に、手で貼るものがある

正直に書いておく。この形は、設定ファイルだけでは動かない。

kickoff はペインに打ち込まれず、durable な inbox に置かれるだけだ。エージェントがそれを読むには内蔵の MCP サーバー(Queen)が各 CLI に登録されている必要があり、その登録は今日時点で手作業である。ツールバーのバッジからコマンドをコピーして、自分で各 CLI に貼る。代行はまだ実装していない。ここが済んでいないと、1段目が永久に Running のまま止まる。

`joinOn: reply` も設定側は1行だが、対になる規約はエージェントに教えないといけない。どの mailbox を await して、どのツールをどの引数で呼ぶか。サンプルでは `cmd` のブートストラップと各 kickoff の両方に書いた。最初の指示が流れても直近のメッセージに同じ手順が残るようにするためだ。

返信を1回だけにさせるのも要る。step は最初の返信で完了するので、「了解、見ます」と先に返されると、judge が空のレビューファイルを読みに行く。

だから言えるのは「設定を書くだけで動く」ではない。「一度 MCP を登録して返信の作法を渡してしまえば、段取りのほうは設定ファイルだけで書ける」だ。ここを曖昧にすると、最初に試した人が1段目で詰まる。

埋めるのは三か所だけにした

ここまで書いておいてなんだが、上のサンプルは549行ある。設計判断の理由を全部コメントに書いたからで、初めて触る人が読むものではない。だから短い実用版を別に置いた。`example/review-starter/` のほうだ。

実装 → レビュー → ジャッジの3段で、書き換えるのは kickoff の3か所だけ。`▼▼▼ ここを書き換える (1/3) ▼▼▼` のようなバナーで囲ってある。1が実装役への指示、2がレビュー役のチェック観点、3がジャッジの判定基準。自分の仕事の中身だけを書けばいい。

      - id: review
        agent: reviewer
        dependsOn: [implement]
        joinOn: reply
        timeoutMs: 1800000    # 30分
        # ▼▼▼ ここを書き換える (2/3): レビュー役へのチェック観点 ▼▼▼
        kickoff: >-
          TODO: ここに「何をチェックしてほしいか」を書く。観点を箇条書きで
          並べる(例: 仕様どおりか / エラー処理の抜け / 既存テストの破壊)。
        # ▲▲▲ ここまで書き換える (2/3) ▲▲▲

返信の作法は kickoff ではなく各エージェントの `cmd` に埋めてある。どの mailbox を await して、どのツールをどの引数で呼んで、返信は仕事が終わってから1回だけ——ここがいちばん詰まる場所なのに、3か所を編集していて巻き添えで消せる位置にあってはいけない。だから触らない場所へ隔離した。

3体とも素の `claude` にしてある。モデルで役割を分けるのは見送った。有効なモデル名は時期で変わるし、古い名前が書いてあると起動の時点で固く失敗する。「そのまま使える」の真逆だ。役割はプロンプトで付けて、あとからモデルを分ける手順はコメントに書いた。

通ることは、正しいことではない

今日はブランチに9コミット積んだ。最後がその雛形で、途中に `docs: record that two parallel reviews can run but cannot be reconciled` がある。2体の並行レビューは走らせられるが突き合わせはできない、という記録だ。機能を足したコミットより、これを残せたことのほうが成果だと思っている。

いちばん腑に落ちたのは、設定言語の親切さは書ける機能の数では決まらない、ということだった。決めているのは、書けないことをどれだけ早く、どれだけ大きな音で教えてくれるかのほう。`must dependOn root step` で読み込みごと落ちる規則は親切で、`handoffTo` を2本書けてしまう規則は不親切だ。前者は3秒で直せる。後者は「なぜか片方の指摘が反映されない」を何度か見てからでないと、疑うことすらできない。

設定ファイルやスキーマを他人に渡している方に聞いてみたい。あなたの設定は、間違った書き方をしたときに落ちるだろうか。それとも通ってしまうだろうか。通ってしまう組み合わせを、どうやって見つけただろうか。私は今日、実装を読み直して1つずつ潰す方法しか思いつかなかった。もっと良いやり方があれば、コメントで教えてほしい。

#ptygrid #AIエージェント #マルチエージェント開発 #AI駆動開発 #ClaudeCode #YAML #設定ファイル #Rust #MCP #個人開発 #開発日誌

いいなと思ったら応援しよう!

zephel01 サーバー代とコーヒー代になります☕ 役に立ったら応援よろしくお願いします!