前回の続きから
前回はDBを中心に据えて、各エージェントを疎結合にするという設計の話だった。ORMも状態マシンもフレームワークも入れず、Postgresのテーブルだけが唯一の交換所になっている、と。
今回はその上で、では各エージェントが実際に何をしているのか。cronで回すのか、ボタンで起こすのか。二度動かないためのガードはどうするのか。そのあたりを、動くコードと、先週までに実際にハマった罠込みで書いていく。

エージェントの動作を「読む·書く·確定する」に還元する
フレームワークを使わないと決めた瞬間、「エージェントとは何か」を自分の言葉で書き下す必要が出てくる。うちの場合、こう還元した。
読む — 特定のテーブル / ビューをクエリする
書く — 別のテーブルに
proposed状態でINSERTする確定する — 人間がUIで承認したら
confirmedにUPDATEする
3動作目は次回のテーマなので今回は触れない。今回はほぼ「読む·書く」の2つと、その周辺の実行制御の話になる。ぶっちゃけ、コミュニティで見かける「自律AIエージェントは結局、意見を持ったcronジョブにすぎない」という言い方は、うちの現場感覚とかなり近い。
cronで回すか、ボタンで起こすか
エージェントの起動方式は、うちでは2種類しかない。
ひとつは cron。毎朝8時に前日のログをまとめる、みたいな定期タスク。Supabaseの場合、pg_cron拡張がそのまま使えるので、外部スケジューラを立てる必要がない。
select cron.schedule(
'daily-brief',
'0 8 * * *',
$$ select run_daily_brief() $$
);もうひとつは オンデマンド。管理画面のボタンをポチると、Next.jsのServer Actionが起動して、そこからNode.jsのサブプロセスとしてclaude CLIをspawnする。「1件だけ再生成したい」「今すぐこの会社を分析してほしい」みたいな用途。
この2つが同じagent_tasksテーブルに書き込むので、UI側は「誰が起こしたか」を気にしなくていい。cronが書いた行も、人間がボタンで起こした行も、同じテーブルにproposed状態で並ぶ。
二度動かないためのガード — INSERT ON CONFLICT
ここが地味に大事なところ。cronのオーバーラップ、ボタンの連打、リトライ。全部「同じタスクが二度INSERTされる」形で襲ってくる。
解決は単純で、テーブル側にUNIQUE制約をかけてON CONFLICTで握りつぶす。
create table agent_tasks (
id uuid primary key default gen_random_uuid(),
task_type text not null,
task_date date not null,
status text not null default 'proposed',
payload jsonb,
created_at timestamptz not null default now(),
unique (task_type, task_date)
);
-- エージェント本体側はこう書く
insert into agent_tasks (task_type, task_date, payload)
values ('daily-brief', current_date, $1)
on conflict (task_type, task_date) do nothing
returning id;returning id は、INSERTが実際に走った場合だけ返る。二回目以降は空。呼び出し側で「idが返ってこなかったらもう誰かがやってる、早期return」と判定できる。
この一発で、cronのオーバーラップも、ボタン連打も、リトライも、全部同じ仕組みで防げる。「タスクは日付+種類で一意」と決めてしまうのが効いた。もっと粒度の細かいタスクなら、task_date を task_key text に置き換えて、呼び出し側で任意の一意キーを組み立てればいい。
pg_cronのオーバーラップと advisory lock
ただしUNIQUE制約でカバーできるのは「重複書き込み」だけで、「重複実行」自体は起きる。cronで15分間隔で回しているのに、1回の実行が20分かかると、次のcronが前の実行の途中で走ってしまう。
これは pg_cron 側の既知の挙動だ。前回の実行が終わるまで次を待ってはくれない。書き込み側でUNIQUE制約が効いていれば大きな事故にはならないが、外部APIを叩くタスクだと二重課金になる。
ここで pg_try_advisory_xact_lock を関数の入口に入れる。ロックが取れなかったら、そのcronはノーオペで終わる。
create or replace function run_daily_brief() returns void
language plpgsql as $$
begin
if not pg_try_advisory_xact_lock(hashtext('daily-brief')) then
raise notice 'skipped: another run is in progress';
return;
end if;
-- 本体処理
perform do_daily_brief();
end;
$$;トランザクションが終われば自動的にロックも解ける(xact_lock版なので)。1行足すだけで、cronの多重実行問題はほぼ消える。cron.job_run_details テーブルを覗くと、skipped が並んでいるのが確認できて安心する。
Node.jsからclaude CLIをspawnする
オンデマンド側は少しややこしい。うちの管理画面はNext.jsのServer Actionから、child_process経由でclaude CLIを叩いている。理由は単純で、CLIの方がAnthropic APIを直で叩くより、ツール呼び出しやプロンプトキャッシュの取り回しが楽だから。
import { spawn } from 'node:child_process';
export async function runAgent(prompt: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('claude', ['-p', prompt, '--output-format', 'json'], {
env: {
...process.env,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
let out = '';
let err = '';
child.stdout.on('data', (d) => { out += d.toString(); });
child.stderr.on('data', (d) => { err += d.toString(); });
const timer = setTimeout(() => {
child.kill('SIGKILL');
reject(new Error('agent timeout'));
}, 30_000);
child.on('close', (code) => {
clearTimeout(timer);
if (code === 0) resolve(out);
else reject(new Error(`claude exited with ${code}: ${err}`));
});
// stdinを閉じないと永遠に待つ
child.stdin.end();
});
}ポイントは3つ。envを明示的に渡すこと、child.stdin.end()を呼ぶこと、そしてタイムアウトを必ず入れること。全部、実際にハマった。
実際にハマった罠
ここからは正直な失敗談。同じ罠を踏む人が5分節約できれば、この記事の目的は達したことにする。
1. 「spawn node ENOENT」で3時間潰した夜
先月、本番相当のDockerイメージに切り替えた夜のこと。手元では普通に動いていたclaude CLIの起動が、コンテナ内では Error: spawn node ENOENT を吐いて即死した。ログを見ながら「あれ、なんで動かない?」を何回言ったかわからない。
結論は、AlpineベースのイメージでnodeバイナリがPATHに入っていなかっただけだった。claude CLIは中でnodeを再起動する構造なので、親側でnodeが動いていても、子プロセスから見えるPATHにnodeがないと落ちる。
spawnのenvオプションにPATHを明示的に含めるか、Dockerfileでnodeを標準パスに置くかで解決する。「動いている環境が動く保証にならない」典型例だった。
2. APIキーが子プロセスに継承されなかった件
spawnのenvを渡すとき、{ ANTHROPIC_API_KEY: '...' }だけを渡すと、親のprocess.envが一切引き継がれない。PATHも、HOMEも、TMPDIRも全部消える。当然claudeは起動できない。
これは{ ...process.env, ANTHROPIC_API_KEY: '...' }のようにspreadで書けば解決する。ドキュメントの一文で終わる話なのだが、「envを渡す=キーだけ渡せばいい」と一瞬でも思うと丸1日消える。子プロセスがさらに子プロセスを作る構造(claude CLI → node)だと、この誤解の代償が大きくなる。
3. stdinを閉じ忘れて子プロセスが永遠に待つ
これは夜中の2時にやった。child.stdin.end()を呼び忘れていて、claude CLIが「入力を待っているのかな」と判断してタイムアウトまで居座り続けた。ログには何も出ない。Node側のPromiseは永久に解決しない。監視ダッシュボードだけが「実行中」で固まる。
1行足すだけで解決したのだが、こういうバグは「発生していることに気づく」までが長い。今は起動関数にデフォルト30秒のタイムアウトを入れて、それを超えたらchild.kill()を呼ぶようにしている。上のコードにsetTimeoutが入っているのはそのため。
4. ビルドキャッシュに古い環境変数が残る
Next.jsのビルドキャッシュに古い環境変数が焼き込まれていて、.envを書き換えても反映されない、という事故もあった。.nextを消して再ビルドすれば直るのだが、これも「なんで動かない?」の時間が長い。エージェント運用の話とは少しズレるが、CI/CDにrm -rf .nextを入れておくと精神衛生上いい。
ここまでで何ができたか
まとめると、うちのエージェント運用は、Supabase側のUNIQUE制約とadvisory lockで「二度動かない」を担保し、Node.js側のspawnで「オンデマンドで一発起こせる」を担保している。フレームワークは相変わらず入っていない。前回書いた「DBを中心に据える」という一点を守るだけで、これぐらいの実装ならなんとかなる。
ただし、ここまでは全部「機械が勝手にやる」部分の話だ。エージェントがproposedまで書いたあと、それを実運用に流すかを最後に決めるのは人間である。次回はそのproposed → confirmedの一線 — human-in-the-loop の設計について書く。ひとりで回している運用が、それでも破綻していない一番の理由は、実はここにある。
筆者は 5years+ で AI/LLM の業務応用を担当している。