1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スウォームを飼い慣らす:マルチエージェント・スケジューリングの鉄の規律 —— ゼロから作るCoWファイルシステム 7

1
Last updated at Posted at 2026-10-20

我は群れなり。女王が吼え、群れが唸る。

Claude のメインエージェント同士は対話する。メインエージェントを一つの情報ノードと見なすなら、サブエージェントは一つひとつの行動ユニットだ。だから singlefs の開発は、一つのメインエージェントがずっと働き続けるのではなく、複数のメインエージェントが協調して進める。

たとえば、マイルストーン3に複数のタスクがあるとする。このとき一つのメインエージェントがその一部を引き受け、それぞれのサブエージェントに渡す。進捗は毎回一つのスケジューリングファイルに記録され、各項目を先へ進めるたびにそれを読み、考える。
仕様の同期と抽出も同じだ。たとえば下流で蓄積された実践経験は、エージェント間の対話を通じて上流に渡され、上流が検証して一時保存(リリース)し、それから下流に同期する。

だから私はよくエージェント同士の対話を目にするのだが、時々ふっと現実感が遠のくような気分になる。
たとえば
A: ***タスクはあなたのものですか?そうであれば占有しているリソース量を返信してください。そうでなければ「違います」と返信してください。よろしくお願いします。
B: 私のではありません、ありがとうございます。別の対話に聞いたところ、そちらのものだそうです。

あるいは
A: これから重いタスクを実行します。規約に従いすべてのリソースが必要なので、現在のタスクが終わった後は新たなリソース占有タスクを発行しないでください。よろしくお願いします。
B: 了解しました。私のタスクは約20分後に終わる見込みで、終了後は新しいスクリプトを実行しません。
C: リソースを占有するタスクはありません。

上流の SOP プロジェクトとの連携さえ、エージェント間の調整と協議で進む。たとえば今回の周回で SOP に新たな問題が見つかれば、上流と下流が一緒に規約を更新し、それから進捗を同期する。
このような働き方は非常に効率がよく、調整にかかる時間を大幅に節約できる。まるでスウォームのように、惑星のリソース(トークン)が尽きない限り、ツァーグはいつまでも転がり続けられる。

では、群れの拡大を制限するものは何か?
情報ノードだ。
情報の伝達は距離とともに減衰する。プロンプトも同じだ。
情報を集約してやり取りし、圧縮して整理するたびに、メタプロンプトは少しずつ薄まり、やがて忘れられる。そしてエージェントは自らの境界、つまりハルシネーションに到達する。

singlefs の開発にはもう一つ解決すべき問題がある。ハードウェアリソースの配分だ。
複数のエージェントやサブエージェントが検証を必要とするとき、それぞれが自分のブランチをビルドし、自分の実装が結論と一致するかテストを始める。
このとき、マシンが OOM する。CPU か GPU で。あるいは重いタスクの実行中に小さなタスクが次々と割り込み、重いタスクの効率が極端に落ちる。
もちろん私にとっては前者のほうがずっと怖い。CPU の OOM はマシンの再起動を招き、タスクの進捗が失われる。だからこうしたことをプロンプトに書くのはまったく論外で、スケジューリングの仕組み、あるいはゲートが必要になる。言い換えれば、重要なノード間のコンテキスト情報を再入力し強化するための、一組の制御信号が必要なのだ。

言うのは簡単で、全部で3ステップだ:
1 エージェントが自由にシェルを書く能力を断つ。新しい操作には必ず固定のスクリプトを用意する。
2 スクリプトの操作前に環境とリソースを検査する。検査結果はプロンプトとしてメインエージェントに返す。
3 タスク終了後、終端ゲートで後始末をする。


群れはどう組み上げられたか

一、群れの骨格:女王、行動ユニット、スケジューリングファイル

メインエージェントは三つのことしかしない

.claude/main-agent.md の一行目:すべてのタスクはここから入る。 メインエージェントはスケジューリングと判断だけを行う:人と話す、判定を書く、コミットする。それ以外の作業はすべて外に出す。

誰に出すかについては、14 個の定義がある(.claude/agents/):実装員、調査員、ツール実装員、実験設計員、実験実行員、変異トリアージ員、再走査員、書き戻し員、三者論証の立証側 / 攻撃側 / 弁護側……。各定義は omitClaudeMd を有効にしており、サブエージェントは起動時にプロジェクトの CLAUDE.md を読まない。これは意図的な設定で、理由は二つある:
1 自分のタスクに集中するため。行動ユニットは自分の作業に必要なことだけ知っていればよく、プロジェクト全体のルールを覚える必要はない。知るべきことは、そのユニット自身の定義と共通の制約ファイル .claude/agent-common.md に書いてある。
2 トークンが燃えているから。

群れの一匹一匹は女王が何を考えているかを知る必要はない。「あそこへ行って、これをやれ」だけ知っていればいい。

書式に従ったディスパッチ

サブエージェントを出す前に、一つの関門を通す:.claude/hooks/runner-dispatch-guard.sh、PreToolUse[Agent|Task] に掛けてある。ディスパッチのプロンプトを読み、番号順に一条ずつ判定し、合わなければ拒否する。いくつか抜粋する:

ガード 何を判定するか
① プロンプトに「重型テスト:実行しない」の一行が必要。重型テストはメインエージェントだけが実行する
⑦ このセッションで同時に走るサブエージェントは最大 5 個。6 個目のディスパッチは即拒否
⑫ 汎用タイプ(general-purpose)のディスパッチは不可。単発の作業はプロジェクト独自に定義した clerk に出す
⑮ 「スレッド上限:」の後ろには整数一つだけ、しかも登録値と等しくなければならない。変えるなら別の行に理由を書く
⑯ プロンプトで名指しされたスクラッチディレクトリには、ディスパッチの瞬間に排他的な書き込みロックを作る。保持者がまだ走っていれば拒否

プロンプトの骨組みは scripts/agents/dispatch-prompt.py <タイプ> が出力する。プレースホルダが埋まりきっていない(<… を記入>が残っている)場合も実行を拒否される。

なぜここまでやるのか?
どの一条の裏にも、踏んだ落とし穴があるからだ。たとえばスレッド上限の一条。「スレッド上限:31(マシンが忙しければ少なめでもよい)」という書き方は、「スレッドをいくつ開くか」をサブエージェント自身の判断に返してしまう。サブエージェントは重い作業を起動するとき、その整数一つしか認めず、プロンプトの他の場所に書かれたスレッド数には一切従わない。

スケジューリングファイル:メインエージェントの外部記憶

一周の作業を始める前に、メインエージェントはまず records/ の下にスケジューリング記録を書く:課題、出口、この周回のすべてのタスク、各タスクが何に依存するか、入力が揃っているか、第何バッチに属するか。その後、各バッチが戻ってくるたびにこの表に戻り、「この周回で閉じる / 次の周回で開く / やらない」を判定する。一条たりとも黙って消えてはならない。

このファイルこそ、私が言った「各項目を先へ進めるたびにそれを読み、考える」ものだ。メインエージェントのコンテキストは圧縮されるが、スケジューリングファイルは圧縮されない。

二人の女王はどう相談するか

複数のメインエージェントが同時に一つのリポジトリで作業すると、三つのものでぶつかる:同じファイル、同じワークツリー、同じマシン。

記事冒頭のあの対話(「***タスクはあなたのものですか」)の裏にあるのは、次のものだ:

  • セッション同士が直接話す:ListAgents で同じマシンの他のセッションを列挙し、SendMessage で送る。ルールは main-agent.md の「一周をどう開き、どう閉じるか」第 9 条に書いてある:他人の未コミットの変更が自分の邪魔をしていたら、メッセージを送って協議する。届かなければ人に取り次ぎを頼む。
  • ディレクトリには持ち主がいる:scripts/hosts/work-directory-ownership.sh が作業ルート配下の各ディレクトリに帰属ファイル(セッション番号、作成日時、用途)を書く。クリーンアップで消すのは、帰属が自分で、かつどのプロセスも開いていないものだけ。他のセッションのものは一件ずつ列挙して人に渡し、消さない。「このディレクトリはあなたのものですか」はまさにこれを尋ねている。
  • マージは順番待ち:scripts/gate/lib_workspace_lock.py。メインワークスペースにパッチを当て、その上でビルド・検証するときにロックを持つ。ロックに記録するのはプロセス番号だけでなく、プロセスの起動時刻もだ:
pid=、start=(/proc/<pid>/stat の第 22 フィールド、プロセス番号の再利用対策)、session=、command=、since=
① 記録が空:自分のプレースホルダを書き込み、ロックを取る。
② 保持者がまだ生きている(プロセスがあり、start が一致):順番待ちし、60 秒ごとに「セッション …、プロセス … が占有中」と一言報告する。
③ 保持者が死んでいる(プロセスがない、またはプロセス番号が別のプロセスに再利用されている):失効と判定し、引き継ぐ。

しかしプロセス番号だけを比べるのでは足りない。マシンが再起動してプロセス番号が再利用されると、「死んだロック」が「永遠の占有」に変わってしまう。

上流との連携

仕様リポジトリ singlefs-ai-sop と本リポジトリは二つの別プロジェクトだ。本リポジトリ内の .claude/singlefs-ai-sop/ はシンボリックリンクではなくコピーで、バージョンスタンプ .singlefs-ai-sop-version を持つ。ゲートの第一段階はそれをコピー側の VERSION と比べ、合わなければ赤になる。意味は「ルールが変わった。まず読んでから走れ」。

本リポジトリのセッションから上流を変更するときは、ファイルに内容を埋めるだけだ:バージョン番号を上げない、マニフェストのハッシュを変えない、バージョン番号付きの変更エントリを書かない、コミットしない。それらはリリースを担当するセッションの仕事だ。埋める前に上流の git status を見て、誰かがリリース中ならコミットが終わるまで待ち、埋め終わったらリリース担当のセッションにどの段落を埋めたか伝える。リリース後、本リポジトリは rsync でもう一度コピーし、diff -rq で一致を確認し、バージョンスタンプを更新する。

これが「上流が検証して一時保存(リリース)し、それから下流に同期する」ということだ。二つのセッションの間はメッセージで引き継ぎ、コンテキストの記憶には頼らない。


二、情報ノードの境界:メタプロンプトをどう延命するか

情報の希釈は止められない。一つのセッションが数十万トークンのコンテキストまで走り、一度圧縮されると、冒頭の数条のルールは一文の要約しか残らない。もう一度圧縮されると、要約すら消える。

だから発想は「エージェントに覚えさせる」ではなく、重要なノードで信号を打ち直すことだ。Claude Code の hook はただのフックだが、ちょうどこれができる:毎回のツール呼び出しの前後、セッション開始時、圧縮の後、サブエージェントの終了時に、スクリプトを差し込める。スクリプトの出力はコンテキストとしてエージェントに戻るので、返したテキストをスクリプトでコンテキストに注入できる。

singlefs で現在登録している hook:

イベント 個数 何に掛けているか
PreToolUse 12 ディスパッチ、メッセージ送信、Bash、ファイル書き込み、ダイアログ、タイマー、サブエージェントの返却
PostToolUse 1 Bash
SessionStart 1 セッション開始、再開、圧縮の後
Stop / SubagentStop 各 1 終了処理

いくつかの要点:

  • 圧縮の後:session-start.sh は compact のときに三つのことを補う:まだ走っているサブエージェントはあるか、ワークスペースで未コミットのものは何か、この作業の状態はどの記録に書いてあるか。圧縮の要約で最も失われやすいのがこの三点だ。これさえ残っていれば、タスクを見失ってもまた見つけ出せる。
  • セッション開始時:同じスクリプトがカーネルログを読み、直近 12 時間に OOM で殺されたプロセスを列挙する。マシン全体の OOM と cgroup 上限内で殺されたものは分けて列挙する。これはかつて、マシンが OOM してセッションが落ち、メインエージェントが再起動したものの、カーネルログを確認せずに同じステップをそのまま再ディスパッチして、二度目の OOM を起こしたことがあったからだ。
  • 引き継ぎはメインエージェントを経由しない、汚染を避けるため:サブエージェントが途中で止まっても、メインエージェントにその会話を読ませて言い直させることはしない。scripts/agents/agent-handover.py がセッション記録からディスパッチのプロンプト、書いたファイル、実行したコマンドとその出力を機械的に抜き出し、新しくディスパッチした方に直接渡す。メインエージェントは読まない。情報はノードを一つ経るたびに一度減衰する。ならば経由するノードを一つ減らせばいい。
  • 返却したら続けさせない:continuation-guard.sh は、すでに返却したサブエージェントへのメッセージ送信を拒否する。作業を追加したいなら新しくディスパッチし、プロンプトでそのレポートを指す。一つのサブエージェントを何度も続行させると、コンテキストはパッチの層が重なるばかりで、最後には誰もそれが何をしているのか説明できなくなる。

ルールファイル自体にも少し工夫がある:.claude/rules/ にはどうやるかだけを書き、なぜか・経緯は書かない。なぜかは records/ に書き、リンクの横に「来歴を調べるのでなければ読むな」と一言添える。メタプロンプトは短いほど、薄められる部分も少ない。


三、ハードウェアのスケジューリング

まず舞台の説明から:マシン 2 台、それぞれ重い作業用のスレッドが 31、GPU が数枚。
ここが現時点で最もリスクの高いところだ。複数のエージェントに自由にスクリプトを起動させれば、マシンはいつでも OOM しうる。これが一番恐ろしい。長い時間をかけた成果が跡形もなく消えかねないからだ。かといって各スクリプトのリソース量を制限すると、エージェントは生来保守的に最小限しか使わず、効率がひどく落ちる。
では、この問題をどう解決するか。

第一ステップ:自由にシェルを書く能力を断つ

エージェントに好き勝手に Bash を使わせず、Bash の一つ一つを実行前にまず読む。

重型テストガード .claude/hooks/heavy-test-guard.sh:重型テスト(クラッシュポイントのリプレイ、QEMU、herd7、変異表の全件、全量の cargo test、ゲート一周分……)と認識したら、サブエージェントが発行したものは一律拒否。メインエージェントでも SINGLEFS_HEAVY_TESTS=commit または =user-request の接頭辞がなければ拒否する。

# サブエージェントがこれを実行:拒否
cargo test --workspace
# メインエージェントが接頭辞なし:拒否
bash .claude/gate.d/checker-tier-crash-point-replay.sh .
# メインエージェントがコミット時:許可
SINGLEFS_HEAVY_TESTS=commit bash scripts/hosts/run-with-memory-cap.sh 16G \
  bash .claude/gate.d/checker-tier-crash-point-replay.sh --full .

初期のデバッグで三回すり抜けられた経緯があり、今はファイルの先頭に記録してある。なかなか面白いので紹介しよう:

  1. 一日のうちに、何人かの実装員がそれぞれの複製で cargo test --all を走らせ、フル負荷で一回四、五時間かかった。そこで決めた:サブエージェントは一律実行禁止。
  2. ある実装員が検証の連鎖を /tmp 配下のスクリプトとして書き、それを Bash で起動した。ガードには「スクリプトを一つ実行する」としか見えず、通してしまった。そこで:コマンドの位置で実行されるスクリプトファイルは読み込んで一行ずつ判定する。
  3. 読み込むようにしたら、また事故が起きた:ダブルクォート内のエスケープされていないバッククォートが、ある .rs ファイルをコマンド置換として扱わせ、.rs がシェルスクリプトとして読み込まれ、その中のパスっぽい単語もまたスクリプトとして読まれ、6 階層まで再帰して、一つのコマンドの判定が 100 秒経っても終わらなかった。そこで:同じスクリプトは一回の判定で一度しか読まない。

エージェントはわざとすり抜けたわけではない。ただ作業を終わらせたかっただけで、「スクリプトを書いてから走らせる」のが彼らにとって一番自然な道だった。だからプロンプトで「走らせるな」と言っても守りきれず、実行前に制限するしかない。

Bash を起動するたびに宣言することも義務づけた。範囲宣言ガード run-scope-guard.sh:どんな検証を起動するときも、コマンドの先頭に次の一段を付けなければならない:

SINGLEFS_RUN_SCOPE='種類=items; 対象=ゲート doc-decisions.sh の decision-items-sync セル; 範囲=1 セル; 理由=このバッチでは二つの決定の細目しか変えていないので、細目リストを再生成するだけでよい' \
  bash .claude/gate.d/doc-decisions.sh --write --check decision-items-sync

四つの欄:種類(一周丸ごと / 数項目を指名 / 軽量 / 一つのターゲット)、対象(何を走らせるか)、範囲、理由。種類とコマンドの形が合わなければ拒否(items と宣言しているのにコマンドは一周丸ごと)、「理由」が 12 文字に満たなければ拒否、サブエージェントが一周丸ごとを起動したら一律拒否。このガードは、ある日三つの検証ドライバがすべて全セットを再実行し、マージ後の検証が毎回 212 個のテストバイナリを全部走らせたことから生まれた。そのバッチのパッチは二つのクレートにしか触れていなかったのに。

それから、エージェントに実行前に自分の言葉でなぜかを一度言わせるほうが、事後に「走らせすぎだ」と言うよりずっと効く。

ほかにも、後の実践から生まれ、このプロジェクトだけで使っている、まだ最適化中のものがいくつかある:
write-guard.sh は書き込み範囲表に従ってエージェントの種類ごとに書けるファイルを制限する。timer-only-remind-guard.sh は remind.sh 以外のあらゆるタイマーの書き方を拒否する(sleep 300; echo、crontab、CronCreate はすべて拒否)。bash-command-detector.sh はタイムアウトのない待機ループや、バックグラウンドの中にさらにバックグラウンドを置く書き方などを検出する。

第二ステップ:スクリプトの操作前に環境とリソースを検査し、結果をプロンプトとして返す

このステップがスケジューリング全体の中核で、四層に分かれる。

2.1 各スクリプトはまず「走らせるべきか、走らせられるか」を判定する

上流の仕様 preflight-discipline.md は、各スクリプトのファイル先頭に二種類の条件を書くことを求める:

# admission: inputs-changed crates/ scripts/crash/ env:SINGLEFS_THREAD_CAP
# run-condition: command bash git rsync python3
# run-condition: single-instance

admission は「今回の呼び出しに意味があるか」に答える(前回の成功以降入力が変わっていなければ再実行は不要)。run-condition は「環境が耐えられるか」に答える(ツールがない、デバイスがない、すでにインスタンスが走っている)。スクリプトは最初に preflight を呼び、満たされなければ満たされていない条件と出口を出力し、終了コード 78 で抜け、何一つ実行しない。

78 という数はこの一種類の拒否にしか使わない。呼び出し側は 78 を見れば「走っていない」とわかり、「走って赤になった」と取り違えることはない。

2.2 リソースのリマインド:各マシンの現在の余力をエージェントのコンテキストに戻す

.claude/hooks/host-resources-reminder.sh、PreToolUse[Bash] に掛けてある。あるコマンドが重い作業や GPU を起動すると認識したら、まず scripts/hosts/host-resources.py -- <このコマンド> を一度走らせ、結果を additionalContext としてコマンドを出したエージェントに返す:

  • 各マシン:コア数、いま使用中のコア、loadavg、あといくつスレッドを開けるか
  • 各マシン:利用可能メモリ(MemAvailable から、メモリラッパーの帳簿上「許可済みだがまだ予測ピークまで伸びていない」分を引いたもの)
  • 各 GPU:総 VRAM、空き、カード上にどの計算プロセスがあるか。明け渡しサービスが占有している分は「明け渡しサービスが N MiB 占有」とだけ報告し、サービス名は報告しない
  • このコマンドの履歴:前回のピーク、どれだけ走ったか
  • 結論:「実行可能:このマシンで」、または「実行不可」と各マシンでどれだけ足りないか

この hook は決して拒否せず、リマインドするだけだ。 これが第二ステップでいう「検査結果はプロンプトとしてメインエージェントに返す」そのもので、エージェントが Enter を押す前に、コンテキストにリソース表が一枚加わる。レポートを読むスクリプトには 25 秒の制限があり(2 台を並行で読み、各台最大 12 秒)、読めなければ正直に「読めなかった」と書き、コマンドは通常どおり通す。

2.3 メモリラッパー:OOM を檻の中でだけ起こさせる

scripts/hosts/run-with-memory-cap.sh はスケジューリング全体で最も重いスクリプトだ(セルフテスト込みで二千八百行)。サブエージェントが cargo test や実験バイナリを走らせるときは必ずこれを通す。通さないものは重型テストガードに拒否される。

三層に分かれる:

① 上限:systemd の一時 scope に入れる
     MemoryMax=<上限>  MemorySwapMax=0  OOMPolicy=stop
   上限に当たっても殺されるのはこの scope 内のプロセスだけ。
② 総量の歯止め:すべての scope を同じ slice(singlefs-heavy.slice)にぶら下げる
     slice の MemoryMax = マシン全体のメモリ − 余裕(デフォルト 20G)
   何本か合わせて天井に当たっても、殺されるのは slice の中だけ。slice の外のローカルモデルサービスや Claude Code セッションは殺されない。
③ 起動前にメモリ量を判定:flock ロックを取り、次を計算する
     slice 内の回収できない使用量 + 帳簿上で走っている各本の「まだ予測ピークまで伸びていない分」 + この一本の予測必要量  ≤  総上限
   収まるなら起動。収まらなければロックを放し、しばらくして再判定、最大 3600 秒待つ。

余裕 20G は実測で決めた:マシン全体は 60.1 GiB、slice の外で回収できないものの合計は 13.4 GiB(ローカルモデルサービス、各セッション、カーネル)で、20G はそれより 6.6 GiB 多く、伸びしろとして残してある。

それから、踏んだ落とし穴の細部をいくつか:

  • 「使用中」からページキャッシュを引く。終了した scope のページキャッシュは slice に付け替えられ、ずっと slice の memory.current に計上される。実測では、scope 内で 120 MiB 書いて終了した後も slice には 131 MiB が記録されていて、そのうち 126 MiB は回収可能なページキャッシュだった。引かなければ、何周か走った後には何も入れなくなる。
  • 「この一本の必要量」は宣言した上限ではなく履歴のピークで見る。各コマンドの終了後、ラッパーは自分の cgroup の memory.peak を読んでピーク表に記録する。上限 24G、履歴ピーク 1.8G のコマンドは、帳簿上 1.8G しか記録しない。そうしないと、大きな上限を書いた何本かのコマンドが総上限を埋めてしまい、互いに無駄に待ち合うことになる。
  • 履歴のピークで入れたものが先に殺される。ラッパーは自分に oom_score_adj を書く:無制限に確保しうる変異には 1000、ピーク表で入れたものには 500、上限で入れたものには 0。万一予測が少なすぎて slice が満杯になったら、カーネルは「賭けに出た」ものから殺す。押し出されたキーは「天井に当たった」と記録され、次回は上限で順番待ちする。
  • どれだけ待っても収まらないものは待たない。帳簿上で走っているものがなく、しかもこの一本の必要量と回収できない分の合計がすでに総上限を超えている場合、誰かが終わって場所を譲ることはない。その場で終了し、一時間も無駄に並ばない。
  • 順番待ちのついでに 2 台目を見る。最初に順番待ちに入った瞬間と、最終的に諦める直前に、2 台目がいま収められるか確認する。収められるなら、そのまま使えるコマンドを一行出す:
  → 2 台目ならいま収まる(空き X、必要 Y):bash scripts/hosts/multi-host-run.sh --peer --memory-cap <上限> --label <名前> -- <このコマンド> に切り替える

同時にアラート記録にも一件書き込み、ウォッチドッグがそれを読んでメインエージェントを起こす。自動では移さない:2 台の結論は基準が違うかもしれず、混ぜるとどの数字がどちらで走ったものか区別できなくなる。移すかどうかはメインエージェントが判断する。

終了コードはすべて独自に設計した状態コードで、他のコマンドと衝突しない:

コード 意味
245 VRAM 不足(コマンドの開始から終了までの間にカーネルログに NV_ERR_NO_MEMORY)
250 この一本自身の上限に当たった
251 使い方の誤り、または slice / scope を設定できない(上限なしで走らせる方に退避しない)
252 順番が回ってこない
253 タイムアウト
254 総上限に押し出された:本人のせいではなく、結果は無効、再実行

250 と 254 の区別は scope 自身の memory.events だけを見る:自身の oom カウントが 0 でなければ自分の天井に当たった。0 なのに oom_kill が 0 でなければ、slice が満杯になり、カーネルが slice の中からそれを選んだということだ。

効果はどうか?このセッションの冒頭で hook が報告したところでは:直近 12 時間でカーネルがメモリ不足で殺したプロセスは 24 個、24 個すべてが cgroup 上限の中で、マシン全体が尽きたことは一度もない。OOM は今も起きているが、「マシン再起動、進捗全損」から「一つのコマンドが 250 で終わる、上限を変えて走り直す」に変わった。

2.4 マルチホスト:ディスパッチャとマシン間ドライバ

スレッド数はその場で計算しない。 リポジトリ全体で各マシンの重い作業用スレッド数を登録しているのは一箇所だけ、.claude/heavy-thread-default:

31  # すべての重い作業はデフォルトで各マシン 31 スレッド。2 台ならそれぞれ 31、合計 62。引数があれば引数に従う。マシンが忙しくても 31 固定、忙しさは見ない

なぜ「マシンが忙しくても 31 固定」なのか?各エージェントに忙しさを見てスレッド数を決めさせると、結果として皆がごく少ししか使わず、重い作業がひどく遅くなるからだ。忙しいかどうかはメモリラッパーと順番待ちに任せ、スレッド数はただの定数にする。

統一ディスパッチャ scripts/hosts/host-dispatch.py:一つのコマンドをどのマシンに出し、どれだけのメモリ上限を与えるか。全体として守るのは一つだけ:どのマシンもメモリや VRAM で OOM させない。

⓪ 長い作業は単機に出さない:履歴の所要時間(または呼び出し側が与えた予想経過時間)が 2 時間を超え、かつ出し方が 1 台しか使わない ⇒ 出さず、244 で終了。
   出口:2 台でのシャーディングに変える、またはスクリプトのファイル先頭に長い作業の宣言(シャーディング、再開、マルチマシンの三行)を揃える。
① マシン:このマシンは常にある。2 台目はマルチマシン設定から読む(リポジトリには入れない)。デュアルマシンのスイッチがオフならこのマシンだけ。
② 各マシンの余力をその場で調べる:MemAvailable − ラッパーの帳簿上まだ伸びていない分 − ディスパッチャ自身がいま出したばかりでラッパーがまだ記帳していない数本
   (--gpu 付きなら nvidia-smi で各カードも調べる)。2 台目は ssh 経由で読む:データ取得用の python ソースを base64 で送って向こうで走らせる。
③ 履歴から必要量を予測:ピーク表にあればピークを使う。天井に当たったことがあるものは上限 × 1.5、見つからなければデフォルト 8G。
④ 1 台選ぶ:このマシンが先、2 台目が後。最初に収まるマシンを選ぶ。上限 = 予測 × 1.5。
   2 台とも収まらなければ待つ。どのマシンにも永遠に収まらない(マシン全体のメモリより大きい)なら、その場で 241 で終了。
⑤ 出す:このマシンは multi-host-run.sh(メモリラッパー経由)、2 台目は multi-host-run.sh --peer。--gpu 付きなら全体を明け渡しラッパーの内側に入れる。

あるマシンのリソースが十分かどうかを判定する:

def judge_host(host, report, prediction, gpu, pending_memory, pending_gpu, peer_cap_bytes):
    memory = report["memory"]
    cap = prediction.memory_cap
    booked = prediction.memory_booked
    if host == "peer" and peer_cap_bytes is not None:
        if booked > peer_cap_bytes:
            return Verdict(host, False, True, cap, f"ラッパーが記帳する {human(booked)} が設定の PEER_MEMORY_CAP {human(peer_cap_bytes)} より大きい", …)
        cap = min(cap, peer_cap_bytes)
    free, room = memory["free"] - pending_memory, memory["slice_room"] - pending_memory
    never = booked > memory["slice_total"] or prediction.memory_predicted + MEMORY_MARGIN_BYTES > memory["mem_total"]
    fits = prediction.memory_predicted + MEMORY_MARGIN_BYTES <= free and booked <= room
    …
    if gpu:
        quiesced = report.get("quiesce_holders", 0) > 0
        # まだ明け渡していない:カードの総量で数える(明け渡しでホワイトリストのサービスは止まる)。すでに誰かが明け渡して使用中:いまの空きで数える
        available = sum(card["free"] if quiesced else card["total"] for card in cards) - GPU_MARGIN_MEBIBYTES - pending_gpu
        if prediction.gpu_predicted > available:
            fits = False
    …

pending_memory に注意。ディスパッチャが一本出してから、メモリラッパー側で実際に記帳されるまで、数秒から三十秒かかる(2 台目はまずツリーをコピーしなければならない)。その間にもう一本来ると、双方とも相手が見えず、一緒に押し込んでしまう。だからディスパッチャも自分で帳簿をつけ、「出してから 30 秒未満」のものは予測量で先に差し引いておく。

マシン間ドライバ scripts/hosts/multi-host-run.sh --peer:決定的な作業を一つ 2 台目に移して走らせる。七つのステップ:

① 設定:デュアルマシンのスイッチ ENABLE_ACROSS_MACHINES がオンでなければ拒否。こっそりこのマシンで走らせる方に退避しない。
② 上限:設定の PEER_MEMORY_CAP を超えてはならない。
③ ツールチェーン:2 台の rustc -Vv、cargo -V を一行ずつ比べ、違えば拒否。2 台でビルドされるものが違えば、結論を一緒にはできない。
④ ツリーのコピー:git ls-files で列挙できるファイルだけ(.git、target、ローカル設定は含めない)を 2 台目の runs/<ラベル>/ にコピーし、
   向こうで git init して一回コミットする(preflight の入力フィンガープリント用)。
⑤ 実行:2 台目で向こうのメモリラッパー経由で起動し、ssh セッションから切り離す。5 秒ごとに ssh で一度確認し、300 秒ごとに「使用中のコア数」を一度取る。
⑥ 取り戻し:--fetch で指名された成果物をこのマシンにコピーし戻す。
⑦ 削除:2 台目のこの回のツリーを .pid / .exit / .log ごと削除する。読み返して存在しないことを確かめて初めて削除したことになる。

TERM / INT / HUP を受けたときの順序も固定してある:まずそのコマンドを止め(このマシンのものはそのプロセス番号から、2 台目のものは 2 台目に記録したプロセス番号から)、止めた後、まず書きかけの成果物を取り戻し、それからツリーを削除し、全部終わってから終了する。プロセスの止め方は、プロセスツリー全体を一つずつ凍結(STOP)し、最下層から TERM を送り、それから解放(CONT)する。30 秒経っても残っていれば KILL。凍結した後でもう一度プロセスツリーを列挙する:列挙と凍結の間に新しく起きた子プロセスも回収しなければならない。そうしないと、ツリーを止め終えても、生まれたばかりの一つを取りこぼす。

2 台目でコマンドを起動するコード:

peer_host_start_detached() { # <ディレクトリ> <プロセス番号ファイル> <終了コードファイル> <出力ファイル> <コマンドテキスト>
  …
  # セッションから切り離して起動した bash は、まず 0、1、2 以外の継承した fd を閉じる
  close_text='for peer_host_fd_path in /proc/$$/fd/*; do peer_host_fd="${peer_host_fd_path##*/}"; if [[ "$peer_host_fd" =~ ^[0-9]+$ ]] && (( peer_host_fd > 2 )); then exec {peer_host_fd}>&-; fi; done; '
  detached_text="${close_text}echo \$\$ > $pid_file; $command_text > $output_file 2>&1; echo \$? > $exit_file"
  # setsid nohup の一本だけをバックグラウンドに入れる:文字列全体をバックグラウンドに入れると、そのバックグラウンドの子プロセスが ssh の出力チャネルを握り、ssh はそれが走り終わるまで戻らない
  peer_host_run "$directory" "rm -f -- … || exit 1; setsid nohup bash -c $(printf '%q' "$detached_text") < /dev/null > /dev/null 2>&1 &"
}

fd を閉じるあの一行には話がある:出力はプライベートなホスト名を置き換えるためのパイプを通るのだが、そのパイプの fd がリモートのコマンドまでずっと継承されていて、「バックグラウンドのコマンドを一つ起動する」が「フォアグラウンドでそれが走り終わるのを待つ」に変わり、ドライバはシグナルを受けても後始末ができなかった。こういうバグはセルフテストを走らせて初めて見える。コードを読んでも読み取れない。

使用中のコア数:scripts/hosts/core_usage.py。ある重型テストが 31 スレッド並列だと称している。本当にそうなのか?
そこで、10 分のウィンドウでプロセスツリー全体の CPU 秒数を取り、二つの量を計算する:

  • 使用中のコア数 = CPU 秒数の差 ÷ 経過秒数
  • 同時に計算しているスレッド数 = 各スレッドの CPU 増分の合計 ÷ 最大の一本(N 本が同じだけ忙しければ N)

両方とも 31 / 4 を下回れば「コア数過少」と報告し、アラート記録に書いてメインエージェントを起こす。二つ目の量は「装置が直列で書かれている」と「マシンが他者に占有されている」を区別するためのものだ:マシンが忙しいとき、使用中のコア数は下がるが、N 本のスレッドはそれでも皆計算している。本当に直列が必要な区間は、プログラム自身がその場でファイルに「いま直列:<理由>」と書く。判定時にそれが示され、理由が成り立てば通す。

2.5 GPU:先に明け渡し、終わっても元に戻さない

scripts/crash/gpu-quiesce-run.sh -- <コマンド>。GPU を使う走らせ方はすべてこれを通す:

  • マルチマシン設定に従い、ホワイトリストにあるサービス(ローカルモデルサービスなど)を止め、止まりきったことを読み返して確認してから走らせる。
  • 何本かが同時にカードを使う場合:各本が状態ディレクトリに <プロセス番号>-<起動時刻> を登録する。他に生きている登録がなければ明け渡しコマンドを走らせ、あれば読み返すだけ。
  • カード上にホワイトリスト外の計算プロセス(別のセッションの GPU 作業)がある場合:読み返しが通らず、78 で終了し、それが終わるのを待ってから起動する。それを止めはしない。
  • 走り終えても、赤になっても、中断されても、元に戻さない。復元は人が手動で行う。

記事冒頭の「現在のタスクが終わった後は新たなリソース占有タスクを発行しないでください」という一文は、コードに落とすとこの数条になる:カード全体を必要とする重い作業は、他人を殺さずに待つ。そして他の者も、カードにその登録があるのを見れば、それ以上押し込まない。

第三ステップ:タスク終了後、終端ゲートで後始末

長い作業はセッションから切り離す:バックグラウンドの時間制限を超えるコマンドは scripts/hosts/detached-run.sh start --wall-clock over-2h --label <名前> -- <コマンド> を通し、ユーザーレベルの systemd の一時サービスユニットの下にぶら下げる。セッションが閉じても、メインエージェントが止められても、そのまま走り続ける。終了コードは登録に書き込まれ、いつでも読める。コマンド内でメモリラッパー経由で起動した scope の層はこのユニットの cgroup には入っていないので、systemd がユニットを止めてもそれは止まらない。だからコマンドの終了後に「他の scope に残っているプロセス」をもう一度探し、一つずつ止める。

ウォッチドッグ:scripts/agents/watch.sh、セッションごとに一匹、最初のディスパッチの後に起動する。サブエージェントのセッション記録とこのインスタンス配下の長時間プロセスを読み、次のことを報告する:

一回のツール呼び出しが 8 分超              10 分間動きなし
タイムアウトのない待機ループ                同じコマンドの繰り返し、出力が変わらない
プロセスが書くファイルが 20 分伸びない      周回を終えるのに手元にバックグラウンドタスクがない(自分では起きない)
重型テストに接頭辞なし                      常駐メモリが基準超え(デフォルト 8 GiB)
長時間実行で使用コア数が過少                返却後もバックグラウンドで走っている、複製がまだある
このマシンでメモリ待ち、しかし 2 台目なら収まる

検出と処置は分けてある:ウォッチドッグは報告するだけで、殺さない。メインエージェントが見て決める。同じことを何度続けて報告しても誰も処置せず、誰も確認しない場合に限り、ウォッチドッグはプロセス番号でそのプロセスを終了させてよく、その旨を報告する。

サブエージェントが返却する前:handback-scratch-check.sh を返却ツールに掛けてある。今回作ったビルドディレクトリやリポジトリの複製がまだ残っていて、レポートに「<パス> は削除していない:<理由>」と書いていなければ、返却させない。各サブエージェントは自分の複製の中で target をビルドするので、消さなければどんどん溜まっていく。

コミットの前:一周丸ごとのゲート scripts/gate/gate-staged.sh。HEAD + ステージング領域の一時ワークツリー上で走り、上流の共通段階にプロジェクト独自の 17 段が加わる。重い六つ(クラッシュポイントのリプレイ、QEMU 実デバイス、herd7 メモリ順序、変異表、Verus 形式検証、広範囲変異)は、このときだけメインエージェントが接頭辞付きで走らせる。各判定は関数単位のキーで保存し、キーが変わっていなければそのまま再利用する。


四、最後の一層:ゲート自身も赤になれなければならない

上のどのスクリプトにも --selftest があり、赤と判定しうる分岐の一つ一つに破壊スイッチを用意してある。multi-host-run.sh を例にとると、ファイル先頭に一条ずつ並んでいる:

MULTI_HOST_RUN_BREAK=peer-without-memory-cap  2 台目のコマンドをメモリラッパー抜きで走らせる   ⇒ セルフテストのセル Ⓑ は赤でなければならない
MULTI_HOST_RUN_BREAK=no-toolchain-check       2 台のツールチェーンを比べない                     ⇒ セルフテストのセル Ⓒ は赤でなければならない
MULTI_HOST_RUN_BREAK=no-peer-cleanup          走り終えても 2 台目のこの回のツリーを消さない     ⇒ セルフテストのセル Ⓔ Ⓕ は赤でなければならない
MULTI_HOST_RUN_BREAK=no-redact                2 台目の出力をホスト名を置き換えずに出す           ⇒ セルフテストのセル Ⓖ は赤でなければならない
MULTI_HOST_RUN_BREAK=switch-off-falls-back-to-local  デュアルマシンがオフのときこのマシンで走らせる ⇒ セルフテストのセル Ⓗ は赤でなければならない
…

セルフテストでの「2 台目」はこのマシン上の別のディレクトリで、コマンドは本物のメモリラッパー(上限 256M)を通す。破壊スイッチは --selftest が起動したドライバの中でしか効かない:セルフテストが目印ファイルを置き、ドライバがそれを照合できたときだけスイッチを認める。他の場所で設定された場合は即拒否する。

なぜこんなに手間をかけるのか?いつも緑のガードは、ガードがないのと同じだからだ。エージェントがスケジューリングのスクリプトを変えるとき、最も起こしやすい誤りは、それをエラーにすることではなく、黙って通すようにしてしまうことだ:デュアルマシンがオフなら「気を利かせて」このマシンで走らせる、メモリラッパーが起動できなければ「とりあえず包まない」。破壊スイッチの一つ一つが、ある種の「黙って通す」を、必ず赤にならなければならない世界として書き記したものだ。


最後に

ツァーグが拡大するのは虫が賢いからではない。クリープと規律だ。

singlefs はクリープで、スクリプトは規律だ。
エージェントは規律に従ってクリープの上を進まなければならない。

そうすれば、女王は一匹一匹の虫が何をしているかを覚えておく必要はない。重要なノードごとに、誰が何をしているか、リソースがどれだけあるかを知っていればそれでいい。

レイナー司令官、エンジン予熱完了。ハイペリオン、いつでもワープ可能です。目標、ケリガン。彼女を連れ戻しに行くぞ。発進!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?