zudo-test-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

ローカルヘビーレーンのアドミッション制御

並行するエージェントセッションが1台の開発マシンを枯渇させ、健全なスイートがredになるとき -- マシン全体のアドミッションゲート、判定(verdict)の分類、そしてロンダリング経路になりえない先送りルール。

このページが埋めるギャップ

実行ティアは、同じマシン上の他のセッションを実測時間のノイズとして扱っています:約10分のレーンが約15分かかる、だからその数値でゲートするな、という話です。これは所要時間の話です。競合が結果を変えてしまうケースは扱っていません。

それが露呈する構成はこうです:複数のAIエージェントセッションが1台の開発マシンで同時に作業している -- 並列のworktree、複数のリポジトリ、2種類のエージェントCLIの併用。各セッションはいずれ自分のヘビーレーンを走らせます:b4push、Playwrightスイート、プロダクションビルド+フルテスト実行。それが2つ3つ重なるとメモリが尽き、スイートがredになります:

  • ブラウザの起動失敗、Target closed

  • 正しくイベントキーで待っているwaitのタイムアウト

  • スイート自身が起動したサーバーへの ECONNREFUSED

  • exit 137 でkillされるプロセス

テスト対象のコードは何も悪くなく、テストも何も悪くありません。

このガイドがすでに掲げている2つの原則が、このredの扱いを厄介にします:

  • テストはマシンスペックから独立であり続ける。 「自分のラップトップが忙しいから」とタイムアウトを延ばしワーカーを減らすのは、スイートを1台のマシンに合わせてチューニングすることです。

  • 「flakyだから仕方ない」は決して受け入れない -- デフレイキングのルール2。リソース枯渇は立派な診断ですが、デフレイキングレシピの修正はどれもテストを編集するもので、ここではテストは健全です。

これらのページを字義どおりにたどる読者は、健全なテストを「修正」し続けるか、redを黙って受け入れるかのどちらかになります。このページは3つのルールからなる第3の選択肢です。

Note

これはランナーサイジングのルール2(「大きなランナーはメモリ律速の失敗を修正しない」)のローカル側の対応物です。CIではマシンは自分専用で、レバーはレーンの形です。ローカルではマシンは共有で、レバーは誰に開始を許すかです。

ルール1:マシンの知識はテストではなくスケジューラーに置く

ヘビーレーンはマシン全体のアドミッションゲートを通過します:

  • スロットセマフォ -- デフォルトではヘビー実行は同時に1つ。マシン上のすべてのセッション、リポジトリ、エージェントCLIをまたいで

  • メモリゲート -- 利用可能メモリが N MB を下回っていれば開始しない

スイート自体には手を触れないので、マシン独立のままです。「このマシンではPlaywrightスイートを2つ同時に走らせられない」と知っている唯一の場所が、それを知ることを許された唯一の場所になります。

これはレーンのオーバーサブスクリプションとは別物です。あちらは1つのレーンがマシンの持ち分以上を要求しており、修正はそのレーン自身のワーカー上限です。こちらでは各レーンはマシンを独占できる前提では正しいサイズです。欠陥は、レーン同士を調停するものが何もないことです。

何がヘビーに該当するか

フルのe2e/統合スイート、ブラウザを起動するものすべて、b4push、プロダクションビルドに続くフルテスト実行、cargo build --workspace。ユニットテスト、型チェック、lint、単一ファイルのテスト実行はヘビーではありません。

ラップするのはヘビーなステップだけにしてください。 キューに並ばされたlintは実時間の無駄であり、安いフィードバックを遅くするゲートはバイパスされます。

実装上の教訓

どれも痛い目を見て学んだものです。ゲートは約350行のシェルスクリプトで、コードよりも設計判断のほうが重要です。

PID入りのロックファイルではなく、fdベースの flock(2) スロットを使う。 エージェントセッションは実行中にしょっちゅう死にます -- APIエラー、トークン上限、killされたターミナル。開いたファイルディスクリプタ上でロックを保持していれば、プロセスが消えた瞬間にカーネルがスロットを解放します。デーモンもハートビートも古いロックのGCも不要で、誰かが「掃除」するものもありません。

exec 200>>"$GATE_DIR/slot-$i.lock"
flock -n 200   # held by the open fd; released by the kernel when this process dies

標準のmacOSには flock(1) がありません。継承したfdに対して flock() を呼ぶ perl のワンライナーが、そのまま代替バックエンドになります。

スイートはロックfdを閉じた状態で、独自のプロセスグループとして起動する。 fdを継承したデタッチ済みのdevサーバーやブラウザは、実行が終わった後もずっとスロットを掴み続けます。プロセスグループがあるからこそ、実行上限はトッププロセスだけでなくツリー全体(pnpm → vitest → ワーカー、playwright → ブラウザ)をkillできます。

setsid "$@" 200>&- &

ハードな実行上限を設ける。 自己解放するロックは、ハングした実行には何もしてくれません -- 保持者が生きているのでスロットは取られたままです。期限切れでグループにTERM、10秒後にKILL、exit 124。

ゲート自身の死に備えたウォッチドッグを入れる。 ゲートがSIGKILLされると、スイートは走り続けているのにロックだけが解放され、次の待機者がその上に重ねて開始します -- ゲートが防ぐはずだった重複そのものです。小さなウォッチドッグのサブシェルがゲートのPIDをポーリングし、ゲートが消えたらスイートのプロセスグループを落とします。

メモリはスロットを保持したまま確認する。 まずスロットの列に並び、それから空きメモリを待ちます。並ぶ前にメモリを確認すると、メモリが空いた瞬間に待機者全員が殺到します。

ネストした呼び出しはno-opにする。 ゲートはラップしたコマンドにマーカー変数をexportし、ゲート済みスクリプトから呼ばれた別のゲート済みスクリプトは直接実行されます。スロットが1つの場合、ネストした取得は待機がタイムアウトするまで自分の親とデッドロックします。

スロット数とメモリ閾値はマシンごとに設定し、呼び出しごとには決して設定しない。 すべての呼び出し元が同じ数値に合意していなければなりません。置き場所はシェルのプロファイルです。通り抜けるために自分だけスロット数を上げる呼び出し元は、単にゲートを無効化しただけです。

終了コードはそのまま通す。 ゲートは結果にラベルを付けるだけで(ルール2)、redをgreenに変換することは決してありません。

フェイルオープンにする。 ロックのバックエンドがない、あるいは状態ディレクトリに書き込めない → 警告を出してコマンドをガードなしで実行します。ゲートがテスト実行の新しい壊れ方になってはいけません。

ゲートはエージェントへの指示からではなく、プロジェクトのスクリプトから呼ぶ

「ヘビーなスイートは必ずゲート経由で実行すること」という指示は、どれか1つのエージェントが忘れるまでは機能します。そして忘れっぽい呼び出し元が1つあれば、他の全員を枯渇させるには十分です。代わりに、呼び出しをプロジェクト自身の b4push スクリプトに入れ、ゲートがない場合はフェイルオープンにします:

heavy() {
  local g="${HEAVY_GATE:-$HOME/.local/bin/heavy-gate}"
  if [ -x "$g" ] && [ -z "${CI:-}" ]; then "$g" -- "$@"; else "$@"; fi
}

heavy pnpm test:e2e

ルールを覚えていなくても、すべての呼び出し元が直列化されます。ゲートを持たない人やCIランナーにとってもリポジトリは使えるままです -- CIではマシンは共有されていないので、ラッパーは身を引きます。

プールは1つか、2つか

短時間のブラウザ自動化作業(エージェントによるスクリーンショット取得、computed styleのチェック)は、競合するリソースのもう1つの種類です。ヘビープールを共有させることも、専用のプールを持たせることもできます。

1プール2プール
単純さキューは1つ、数値も1組2つのキューを考える必要がある
メモリの見積もり正直 -- 他にヘビーなものは走っていないプール同士は互いを知らない
20分のスイート実行中の短いUIチェック20分まるごと待つ進められる

現場の構成は2つです:スロットセマフォだけのブラウザプールと、セマフォ+メモリゲートのヘビープール。20分のスイートの後ろに並ばされた10秒のスクリーンショットは、視覚チェックを飛ばす強い動機になります。それは軽い重複よりも悪い結果です。2つのプールを互いに正直に保つのがメモリゲートです -- ブラウザ作業がマシンを閾値未満に押し下げている間はヘビー実行は開始されず、何か食いしん坊なものと重なって失敗した実行にはルール2でラベルが付きます。まず1プールで始め、短いチェックが目に見えて枯渇しはじめたら分割してください。

ルール2:失敗を読む前に、結果を分類する

ゲートは実行中ずっと利用可能メモリをサンプリングし、最小値を記録します。それと終了コードから、最後に1行を出力します:

gate: verdict=PASS|FAIL|ENV_SUSPECT exit=N secs=N min_mem_mb=N reason=...
判定シグネチャすること
競合(exit 75)キューまたはメモリの待機がタイムアウトした。スイートは一度も走っていない。後でリトライするか、先送りする(ルール3)。ゲートを決してバイパスしない。テスト失敗として決して報告しない。
ENV_SUSPECTexit 137(SIGKILL -- OOM killerの印)、実行上限の期限切れ、または最小メモリが低水位マークを下回った状態での非ゼロ終了1回だけ再実行する -- 単独で走ることはゲートがすでに保証している。green → 枯渇だった、完了。再びred → ルール3。
FAILメモリに余裕がある状態での非ゼロ終了本物の失敗。直す。先送り不可。

失敗出力よりも先に判定行を読んでください。Target closed のスタックトレースは20分間プロダクトのバグに見えますが、min_mem_mb=212 は1秒で枯渇だと読めます。

同じフィールドを台帳ファイルにも追記してください。1週間分のエントリがあれば、「このマシンの閾値は適切か?」に記憶ではなくデータで答えられます。

Warning

壊れた期待値によるオーバーライド。 壊れた期待値を名指しする出力 -- アサーションのdiff、型エラー、lintエラー、モジュールの欠落、スナップショットの不一致 -- は、判定に関わらず本物の失敗です。枯渇は物事を遅くし、不在にし、死なせます。expected 3, received 4 を生み出しはしません。ENV_SUSPECT はどこを最初に見るかのヒントであって、決してpassではありません。

環境由来の形をしたシグネチャは、このページ冒頭に挙げたものです:タイムアウト、ブラウザの起動失敗、Target closed、自分で起動したサーバーへの ECONNREFUSED、killされたプロセス。

再実行は1回。「greenになるまで再実行」ではない。 単独での再実行は統制された実験です:同じコマンドで、競合だけを取り除く。情報のある結果は2通りです。2回目、3回目の再実行は、白衣を着たpass-on-retryです。

ルール3:ロンダリング経路になりえない先送り

ヘビーなステップの中には、今日のローカルでは信頼できる結果を出せないものがあります。それでワークフロー全体をブロックするのは誤りで、greenと呼ぶのはもっと悪い。そのステップを先送り(defer)します -- 判定を、判定を出せるマシンへ移すのです。

3つすべてが成り立つときだけ先送りできる

  1. ローカルの結果が環境的な理由で信頼できない:exit 75、または単独再実行でもredのままだった ENV_SUSPECT、またはそのステップがこのプラットフォームではそもそも実行できない。

  2. 出力に壊れた期待値が見当たらない(ルール2のオーバーライド)。

  3. 他の何かがそのステップそのものを必ず実行する -- それをミラーするCIジョブ、または別のマシン。

(3) が成り立たない -- そのステップをカバーするCIジョブがなく、他のマシンも実行しない -- なら、それは先送り可能ではありません。未検証です。 未検証として報告し、マージの手前で止まってください。この条項こそが、先送りをロンダリング経路にさせないものです:ステップは、どこかへしか先送りできません。

「そのステップそのものをミラーする」は文字どおりの意味です。ユニットスイートを走らせるCIジョブは、先送りされたe2eステップを決着させません。ローカルレーンがCIでは実行できないがゆえのT4であるなら、借りを負うのはCIではなく別のマシンです。

手順

  1. 記録する。 deferred-verification ラベルのissueを立て、正確なコマンド、判定行、出力の末尾、マシン/OS、そしてどのCIジョブまたはマシンが結果を負っているかを記載します。

  2. 続行し、最終報告ではそのステップを先送り(deferred)として挙げます -- 決してpassedとしてではなく。

  3. 決着させる。 そのステップがCIでgreen → issueを閉じる。CIでred → 最初から本物の失敗だった。そのissueの下で直す。別のマシンに借りがあるステップは、そこで実行されるまでopenのままです。

issueは事務作業ではなく、メカニズムそのものです。openな記録のない先送りステップは、1日後にはスキップされたステップと区別がつきません。

プラットフォーム依存のステップも同じルール

WSL/Linuxマシン上のmacOS専用レーン(とその逆)は、シグネチャが違うだけで同じ状況です:ローカルマシンは判定を出せず、別のマシンなら出せる。同じ3条件、同じissueラベル、報告では同じく「先送り、passedではない」。1つのルールが両方をカバーします。これが重要なのは、そうしないとエージェントが「ここでは実行できない」用の、もっと緩い第2の慣習を発明してしまうからです。

デフレイキングのルール2との整合

「CIに先送りする」は、不注意に読むとルール2が禁じていることのように聞こえます。実際は正反対です。flakeを受け入れるとは、redが言い訳で片付けられ、誰も何も負わないことです。先送りとは、redが診断され(判定行、単独再実行、壊れた期待値なし)、記録され(openなissue)、その判定が判定を出せるマシンへ移されることです -- そしてそこでのredには拘束力があります。

決してやってはいけないこと

  • キューが遅いからと、ヘビーなスイートをゲートなしで実行する。

  • 通り抜けるために、1回の呼び出しだけスロットを増やしたりメモリ閾値を下げたりする。

  • exit 75 や ENV_SUSPECT をpassとして扱う、あるいはgreenを期待して2回以上再実行する。

  • このマシンに合わせるためにテストを弱める -- タイムアウトを延ばす、ワーカーを減らす、スペックをスキップする。マシンの限界はスケジューラーの問題です。

  • 被害者に @flaky タグを付ける。隔離は、非決定性がテスト側にあると証明されたもののためのものです。このテストは健全です。

  • 先送りしたステップをpassedとして報告する、あるいは他の誰も実行しないステップを先送りする。

関連ページ

Revision History

作成更新