3つのエージェントに、同じ Next.js アプリの別々の機能を同時に任せた日のことです。
1つ目が UI コンポーネント、2つ目が API ルート、3つ目がスキーマ移行。Antigravity 2.0 のデスクトップで並列に走らせれば、単純計算で3倍は速くなるはずでした。
結果は逆でした。3つとも localhost:3000 を掴もうとして EADDRINUSE が連発し、移行が同じデータベースで競合してロック待ちになり、片方のエージェントのテストがもう片方のシードデータを消しました。壁時計で測ると、直列で回すより 約1.4倍遅い という数字でした。並列にしたのに遅くなる、という妙な光景です。
原因は計算資源ではありません。共有された状態の副作用 です。ポート、devサーバ、データベース、移行、ファイル監視 — これらは1つしかないのに、3つのエージェントが同時に触ろうとします。真の並列実行は、この「1つしかない資源」を容赦なく表面化させます。
個人開発で複数のアプリを並行して回す中で、私自身がたどり着いた解が「1エージェントに1つの実行環境をリースする」という設計でした。以下、実装とともに整理します。
「速くならない並列」の正体
並列エージェントの失敗は、ほとんどが次の4層のどこかで起きます。
共通する構造は「1つしかない資源を、複数の主体が調停なしに触る」ことです。人間が1人で開発しているときは、そもそも同時に2つのdevサーバを立てないので表面化しません。エージェントを並列にした瞬間、隠れていた前提が崩れます。
だから対策は「エージェントを賢くする」ことではなく、環境の側を分ける ことに尽きます。1エージェントに1つ、他と重ならない実行環境を渡します。これがリースの発想です。
リースとは何を渡すことか
リース(lease)は、エージェントに一定期間貸し出す「重ならない資源の束」です。私は最小構成として次の5つを1つのリースにまとめています。
肝は、これらを エージェント番号から決定的に導く ことです。ランダムに空きポートを取る方式は一見スマートですが、後述の理由で並列運用ではむしろ扱いにくくなります。
実装1: リース割り当て器
まず、エージェント番号を受け取って1つのリースを組み立てる割り当て器です。ポート帯の計算、専用スキーマの作成、環境ファイルの書き出しまでを担います。
// lease.mjs — エージェント1体分の実行環境をリースする
import { writeFileSync } from 'node:fs' ;
import pg from 'pg' ;
// 1エージェントが使うポート数。dev(3000系)/api(4000系)/db-proxy(5000系)を
// まとめて1帯として確保する。10あれば周辺ツールの分も足りる。
const PORTS_PER_AGENT = 10 ;
const BASE = { dev: 3000 , api: 4000 , dbProxy: 5000 };
// エージェント番号(0,1,2,...)から決定的にポート帯を導く。
// 番号さえ固定すれば、再起動しても毎回同じポートに戻る点が重要。
function derivePorts ( agentIndex ) {
const offset = agentIndex * PORTS_PER_AGENT ;
return {
dev: BASE .dev + offset, // 例: agent0=3000, agent1=3010
api: BASE .api + offset,
dbProxy: BASE .dbProxy + offset,
};
}
// 専用スキーマを「移行済みテンプレート」から複製する。
// 毎回 migrate を走らせない。ここが並列化の最大のボトルネックだった。
async function provisionSchema ( client , schema , template = 'tpl_migrated' ) {
// DROP SCHEMA は識別子をプレースホルダにできないため、連結の前に文字種を絞る
for ( const name of [schema, template]) {
if ( ! / ^ [a-z0-9_] +$ / . test (name)) {
throw new Error ( `unsafe schema name: ${ name }` );
}
}
// 既存が残っていれば掃除してから、テンプレートの構造を複製する
await client. query ( `DROP SCHEMA IF EXISTS ${ schema } CASCADE` );
// clone_schema の引数は識別子ではなく値なので、プレースホルダで渡せる
await client. query ( 'SELECT clone_schema($1, $2)' , [template, schema]);
return schema;
}
export async function acquireLease ( agentIndex , agentId ) {
const ports = derivePorts (agentIndex);
const schema = `agent_${ agentIndex }` ;
const client = new pg. Client ({ connectionString: process.env. ADMIN_DATABASE_URL });
await client. connect ();
try {
await provisionSchema (client, schema);
} finally {
await client. end ();
}
// search_path でスキーマを差し込む。アプリ側はテーブル名を変えなくてよい。
const dbUrl =
`${ process . env . APP_DATABASE_URL }?options=-c%20search_path%3D${ schema }` ;
const env = [
`PORT=${ ports . dev }` ,
`API_PORT=${ ports . api }` ,
`DB_PROXY_PORT=${ ports . dbProxy }` ,
`DATABASE_URL=${ dbUrl }` ,
`AGENT_ID=${ agentId }` ,
`LEASE_INDEX=${ agentIndex }` ,
]. join ( ' \n ' );
writeFileSync ( `.env.agent.${ agentIndex }` , env + ' \n ' );
return { agentIndex, agentId, schema, ports, envFile: `.env.agent.${ agentIndex }` };
}
ここで search_path を使うのが要点です。アプリのコードは users や orders といったテーブル名をそのまま書けばよく、接続時に注入したスキーマの中で解決されます。エージェントに渡すコードを一切書き換えずに、データの器だけを分けられます。
実装2: 移行済みスキーマをテンプレートから複製する
clone_schema は、移行を毎回走らせないための仕掛けです。あらかじめ移行を通した tpl_migrated スキーマを1つ用意しておき、エージェントにはその構造の複製 を渡します。
-- 移行済みテンプレートの構造を、別スキーマに複製する関数。
-- search_path を pg_catalog に固定しているのは、既定値や制約の定義が
-- 必ずスキーマ修飾つきで描画されるようにするためです(後段の貼り替えの前提)。
CREATE OR REPLACE FUNCTION clone_schema (src text , dst text )
RETURNS void AS $$
DECLARE
obj record;
BEGIN
EXECUTE format ( 'CREATE SCHEMA %I' , dst);
-- 1) シーケンスを先に用意する。serial 列の既定値をあとでここへ向け直す
FOR obj IN
SELECT sequencename FROM pg_sequences WHERE schemaname = src
LOOP
EXECUTE format ( 'CREATE SEQUENCE %I.%I' , dst, obj . sequencename );
END LOOP ;
-- 2) テーブル構造を複製する。主キー・一意制約・CHECK・索引・既定値はここで入る
FOR obj IN
SELECT tablename FROM pg_tables WHERE schemaname = src
LOOP
EXECUTE format (
'CREATE TABLE %I.%I (LIKE %I.%I INCLUDING ALL)' ,
dst, obj . tablename , src, obj . tablename
);
END LOOP ;
-- 3) serial 列の既定値は src のシーケンスを指したまま複製されるので、
-- nextval の宛先を dst 側へ貼り替える
FOR obj IN
SELECT c . relname AS tbl, a . attname AS col,
pg_get_expr( d . adbin , d . adrelid ) AS def
FROM pg_class c
JOIN pg_namespace n ON n . oid = c . relnamespace
JOIN pg_attribute a ON a . attrelid = c . oid AND a . attnum > 0
JOIN pg_attrdef d ON d . adrelid = c . oid AND d . adnum = a . attnum
WHERE n . nspname = dst
AND c . relkind = 'r'
AND pg_get_expr( d . adbin , d . adrelid ) LIKE 'nextval(%'
LOOP
EXECUTE format (
'ALTER TABLE %I.%I ALTER COLUMN %I SET DEFAULT %s' ,
dst, obj . tbl , obj . col ,
replace ( obj . def , quote_ident(src) || '.' , quote_ident(dst) || '.' )
);
END LOOP ;
-- 4) 外部キーは LIKE では複製されない。src の定義から張り直す
FOR obj IN
SELECT c . relname AS tbl, con . conname AS name ,
pg_get_constraintdef( con . oid ) AS def
FROM pg_constraint con
JOIN pg_class c ON c . oid = con . conrelid
JOIN pg_namespace n ON n . oid = c . relnamespace
WHERE n . nspname = src AND con . contype = 'f'
LOOP
EXECUTE format (
'ALTER TABLE %I.%I ADD CONSTRAINT %I %s' ,
dst, obj . tbl , obj . name ,
replace ( obj . def , quote_ident(src) || '.' , quote_ident(dst) || '.' )
);
END LOOP ;
END ;
$$ LANGUAGE plpgsql SET search_path = pg_catalog;
テンプレート複製が黙って落とすもの
INCLUDING ALL を付ければ、主キー・一意制約・CHECK 制約・既定値・索引までは引き継がれます。ここまでは期待どおりでした。
つまずいたのは、その先です。テンプレート複製に切り替えて数日後、あるエージェントが「参照先のない行を挿入するテストが通ってしまう」実装を平然と提出してきました。テンプレート側なら落ちるはずの挿入です。手元で確かめると、複製したスキーマに 外部キーが1本も存在していませんでした 。
PostgreSQL の公式ドキュメントは、LIKE が何を引き継ぐかを明示しています。INCLUDING CONSTRAINTS で複製されるのは CHECK 制約で、外部キー制約は複製の対象外です。INCLUDING ALL と書いても変わりません。一方で主キー・一意制約・索引は INCLUDING INDEXES の側でしっかり入るため、テーブル一覧を眺めるかぎり「ちゃんと複製できている」ように見えてしまう——ここが厄介でした。
同じ性質の穴が、serial 列にもあります。GENERATED ... AS IDENTITY であれば INCLUDING IDENTITY が複製先に新しいシーケンスを作ります 。けれども serial は「整数列 + nextval の既定値」という糖衣にすぎず、その既定値は INCLUDING DEFAULTS によって式のまま運ばれます。結果として、複製先の列がテンプレート側のシーケンスを共有し続け、エージェントAが採番した ID の続きからエージェントBが始まる、という説明のつかない番号の飛び方になります。
複製できたかどうかは、行数ではなく制約の本数で確かめます。 これが、あの提出物を読んだ夜に引いた線です。リース取得の直後に次の1本を流し、テンプレートと同じ数が返らなければ起動そのものを止めています。
-- テンプレートと複製先で、制約の本数が一致するかを確かめる
SELECT n . nspname AS schema ,
count ( * ) FILTER ( WHERE con . contype = 'f' ) AS foreign_keys,
count ( * ) FILTER ( WHERE con . contype = 'c' ) AS check_constraints
FROM pg_constraint con
JOIN pg_class c ON c . oid = con . conrelid
JOIN pg_namespace n ON n . oid = c . relnamespace
WHERE n . nspname IN ( 'tpl_migrated' , 'agent_0' )
GROUP BY n . nspname
ORDER BY n . nspname ;
ビュー・トリガー・関数も LIKE では運ばれません。テンプレートにそれらを置いている方は、外部キーと同じ要領で pg_views や pg_trigger から張り直す一節を足していただくのが安全です。私と同じ穴に足をかけている環境は、思っているより多いのかもしれません。
複製の速さは、この修正で制約の本数に左右されるようになりました。テーブル複製だけを行っていた頃の私の環境では 平均80ms 、対して createdb で新規データベースを作って移行を流し直すと、小さなスキーマでも 900ms 前後 です。外部キーを張り直す分はここに上乗せされますので、導入時に \timing で一度測っておいていただくのが確実です。それでも、3体・5体と増やすほど、テンプレート複製と移行の再実行の差が起動時間にそのまま乗ってきます。
直感に反した点1: スキーマ分離がデータベース分離より速い
最初は「1エージェント1データベースが一番きれいだろう」と考えました。名前空間が完全に独立し、権限も分けやすいはずでした。ところが実測すると、これが起動のボトルネックになりました。
エージェントは頻繁に環境を作り直します。失敗してやり直すこともあれば、途中で別のタスクへ切り替えることもあります。そのたびに900msかかると、並列の利得を起動コストが食いつぶします。分離は「別データベース」ほど強くなくてよく、「別スキーマ」で十分 というのが、手を動かして分かった結論です。共有DBに agent_id 列を足すだけの案は最速ですが、エージェントの誤った DELETE が全員に波及するので採りませんでした。分離の強さと起動速度の折り合いとして、schema-per-agent が中央値に来ます。
直感に反した点2: ランダムな空きポートは、むしろ運用を難しくする
「使われていない空きポートを動的に取ればいい」と考えるのが自然です。私も最初はそうしました。しかし並列エージェントでは、これが後始末を難しくします。
エージェントはdevサーバを何度も再起動します。前のプロセスがゾンビとして残ると、次の起動が別のランダムポートに逃げ、掃除の対象が毎回変わります。どのポートを誰が使っているかが非決定的 になり、健全性チェックの宛先も定まりません。
決定的ポートなら、後始末が1行で済みます。
# エージェント2の帯(3020)に残ったプロセスを確実に掃除する。
# 決定的ポートだからこそ、掃除の対象が毎回同じで迷わない。
lsof -ti:3020 | xargs -r kill -9
エージェント番号が同じなら、devサーバは毎回同じ3020に戻ります。監視も掃除も宛先が固定されます。「ランダムで衝突を避ける」より「決定的で追跡できる」ほうが自動運用では扱いやすいのだと気づいたのは、ゾンビプロセスを三晩続けて手で追いかけたあとでした。
実装3: リースの回収 — クラッシュした資源を漏らさない
並列運用で地味に効くのが、リースの回収 です。エージェントがクラッシュすると、スキーマとポートが宙に浮きます。放っておくとスキーマが増え続け、ポートが埋まっていきます。
リースIDを起点に、期限切れを掃除する仕組みを1つ用意しておきます。
// reap.mjs — 期限切れのリースを回収する(定期実行 or 起動時に走らせる)
import pg from 'pg' ;
import { execSync } from 'node:child_process' ;
const PORTS_PER_AGENT = 10 ;
const DEV_BASE = 3000 ;
export async function reapLease ( agentIndex ) {
// 1) スキーマを落とす
const client = new pg. Client ({ connectionString: process.env. ADMIN_DATABASE_URL });
await client. connect ();
try {
await client. query ( `DROP SCHEMA IF EXISTS agent_${ agentIndex } CASCADE` );
} finally {
await client. end ();
}
// 2) 帯に残ったプロセスを掃除する(決定的ポートなので対象が確定する)
const devPort = DEV_BASE + agentIndex * PORTS_PER_AGENT ;
try {
execSync ( `lsof -ti:${ devPort } | xargs -r kill -9` , { stdio: 'ignore' });
} catch {
// 掃除対象がなければ何もしない。ここで失敗しても回収は続行する
}
}
リースを「借りたら必ず返す」対象として扱うと、長時間の自動運用でも資源が単調増加しません。ここを最初に設計しておくと、3日連続でエージェントを回しても環境が汚れずに済みます。
実装4: 作業ディレクトリも同じリースに含める
ここまででポートとデータの器は分かれました。残るのはファイルです。同じチェックアウトを複数のエージェントが触っている限り、片方の pnpm install がもう片方の node_modules を入れ替え、片方のビルド生成物がもう片方のテストに紛れ込みます。
私は git worktree をリースの一部として払い出しています。ブランチごとに独立したディレクトリが手に入るうえ、.git の実体は共有されるのでディスク消費が膨らみません。リポジトリをエージェント数だけクローンする方式と違い、fetch が重複しない点も助かっています。
// lease.mjs に追記 — worktree もリースの一部として払い出す
import { execFileSync } from 'node:child_process' ;
import { existsSync } from 'node:fs' ;
// エージェント専用の worktree を用意する。
// ブランチ名も番号から決定的に導き、回収時に対象で迷わないようにする。
function provisionWorktree ( agentIndex , baseRef = 'origin/main' ) {
const dir = `../wt-agent-${ agentIndex }` ;
const branch = `agent/${ agentIndex }` ;
if ( existsSync (dir)) return dir; // 再取得は冪等にしておく
// -B は同名ブランチを作り直す。前回の残骸があっても baseRef の地点に戻る
execFileSync ( 'git' , [ 'worktree' , 'add' , '-B' , branch, dir, baseRef], {
stdio: 'inherit' ,
});
return dir;
}
-b ではなく -B を選んでいるのは、前回のリースが残したブランチをそのまま引き継ぎたくないからです。エージェントは途中で放棄されたコミットを平気で残します。次のリースが同じ番号で始まるなら、出発点は毎回 origin/main に揃っているほうが結果を読みやすくなります。
回収側にも1組の後始末を足します。
// reap.mjs に追記 — worktree を登録ごと外す
execFileSync ( 'git' , [ 'worktree' , 'remove' , '--force' , `../wt-agent-${ agentIndex }` ], {
stdio: 'ignore' ,
});
execFileSync ( 'git' , [ 'worktree' , 'prune' ], { stdio: 'ignore' });
worktree remove を飛ばしてディレクトリだけ rm -rf すると、git 側に幽霊の登録が残ります。次に同じパスへ worktree add した瞬間に拒否され、朝の自動起動が丸ごと止まったことがありました。回収は prune までを1組と考えるのが安全です。
リースを渡したあと — タスクを投げる前の1手間
リースを作った直後にタスクを投入すると、devサーバがまだ待ち受けていない状態でエージェントが動作確認に走ります。接続拒否を「自分の実装の失敗」と受け取って、正しく書けていたコードを直し始めたことが実際にありました。
そこで、リース取得とタスク投入のあいだに受け入れ確認を挟んでいます。
#!/usr/bin/env bash
# ready.sh <agentIndex> — devサーバが応答してからタスクを渡す
set -euo pipefail
IDX = " $1 " ; PORT = $(( 3000 + IDX * 10 )); DEADLINE = $(( SECONDS + 60 ))
until curl -sf "http://127.0.0.1:${ PORT }/api/health" > /dev/null ; do
if (( SECONDS > DEADLINE )); then
echo "agent ${ IDX }: dev server not ready on ${ PORT }" >&2
exit 1
fi
sleep 1
done
echo "agent ${ IDX }: ready on ${ PORT }"
60秒で打ち切っているのは、待ち続けるより早く失敗させたいためです。起動しないなら、たいていリース側の設定ミスであって、時間で解決する種類の問題ではありません。
もうひとつ、エージェントへ渡す指示の冒頭に、リースの値をそのまま書き出しています。
このタスクは .env.agent.2 を読み込んで起動してください。
dev サーバは http://127.0.0.1:3020 のみを使い、他のポートには退避しないでください。
データベースは search_path=agent_2 に固定済みです。
作業ディレクトリは ../wt-agent-2 です。
「他のポートに退避しない」と明示するのが効きます。指定ポートが埋まっていると判断したエージェントは、親切心から 3001 や 3002 へ勝手に逃げます。逃げられた瞬間、資源がリースの外へ漏れ、回収の対象から外れてしまいます。
直列・素朴な並列・リース並列を同じ課題で測る
冒頭の「並列にしたら約1.4倍遅くなった」の続きです。同じ3タスク(UIコンポーネントの追加・APIルートの追加・スキーマ移行)を3方式で回し、壁時計を取りました。M2 Pro・メモリ32GB、Next.js 15 のアプリ、PostgreSQL 16 はローカル、各方式3回ずつの中央値です。
素朴な並列が直列より遅くなる理由は、失敗そのものよりも 失敗したあとにエージェントが原因を調べる時間 にあります。EADDRINUSE を受け取ったエージェントは、設定ファイルを読み、ログを漁り、ときにコードを書き換えます。人間なら3秒で「ポートが被っただけ」と分かる事象に、数分を溶かします。並列度を上げるほど、この調査時間が全体に効いてきます。
リース並列の8分は、3タスクの中で最も重い1本(スキーマ移行)にほぼ張り付いた値です。つまり、ここから先を縮めたければ並列度ではなくタスクの切り方を変えるしかありません。並列化の効果が頭打ちになる地点が数字で見えたのは、私にとって収穫でした。
公式ドキュメントに書かれていない運用の勘所
手を動かして初めて分かった、細かいけれど効く点をいくつか残します。
inotify の監視上限に、思っていたより早く当たります。 devサーバをN個立てると、それぞれが node_modules を監視して inotify のインスタンスを消費します。Linux では既定の上限が低く、3〜4体並列で ENOSPC(ディスクではなく監視上限)に達しました。各エージェントの監視対象を自分の worktree だけに絞るか、fs.inotify.max_user_watches を引き上げると解消します。
移行はリース取得の外で、1回だけ走らせます。 テンプレート tpl_migrated の更新は、エージェントを起動する前に1回だけ。エージェントの中で migrate を呼ばせると、複製した意味がなくなり、しかも移行同士が競合します。移行はリースの前段、複製はリースの中、と役割を分けます。
リースIDは、ログの相関キーとして持たせます。 並列だと、どのエージェントのどの試行のログか分からなくなります。AGENT_ID と LEASE_INDEX を全ログ行に載せておくと、失敗を後から追えます。並列運用のデバッグ可能性は、ここでほぼ決まります。
どこから入れるか
一度に全部を作る必要はありません。衝突の痛みが強い順に、次の順番を推奨します。
まずポート帯の決定的割り当て。EADDRINUSE が消えるだけで、並列の体感が大きく変わります。
次にスキーマ分離とテンプレート複製。データ破壊のリスクが下がり、やり直しが軽くなります。
続けて worktree の分離。ファイル由来の不可解な失敗が消え、失敗の切り分けが一段楽になります。
最後にリース回収と受け入れ確認。長時間の自動運用に踏み込むタイミングで入れれば十分です。
並列エージェントは、賢さより「重ならない環境」で決まる — というのが、遠回りの末に得た実感です。次にエージェントを2体以上同時に走らせるとき、まずポートを固定してみてください。そこが最初の分かれ道になります。
実装の設計判断の一助になれば幸いです。お読みいただき、ありがとうございました。