0
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?

各エージェントを「読む·書く·確定する」に還元する — cronとオンデマンドの混在

0
Posted at

前回の続きから

前回はDBを中心に据えて、各エージェントを疎結合にするという設計の話だった。ORMも状態マシンもフレームワークも入れず、Postgresのテーブルだけが唯一の交換所になっている、と。

今回はその上で、では各エージェントが実際に何をしているのか。cronで回すのか、ボタンで起こすのか。二度動かないためのガードはどうするのか。そのあたりを、動くコードと、先週までに実際にハマった罠込みで書いていく。

深夜にターミナルログを追う開発デスクの風景 — 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_datetask_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) =&gt; { out += d.toString(); });
child.stderr.on('data', (d) =&gt; { err += d.toString(); });

const timer = setTimeout(() =&gt; {
  child.kill('SIGKILL');
  reject(new Error('agent timeout'));
}, 30_000);

child.on('close', (code) =&gt; {
  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キーが子プロセスに継承されなかった件

spawnenvを渡すとき、{ 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 の業務応用を担当している。

0
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
0
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?