夜間バッチを直列で回していた頃、一番遅い1本が全体を律速していました。記事生成、リンク監査、AdMob のメディエーション比較レポート、スクリーンショットの多言語差し替え。どれも独立した作業なのに、前のジョブが終わるまで次が始まらないので、合計すると朝までかかることがありました。
6/18 に Gemini CLI が止まり、Go 製の Antigravity CLI(agy)へ移したのを機に、この直列の構造そのものを見直しました。agy はジョブをデタッチして非同期に走らせられます。つまり「投げて、IDを覚えて、あとで待ち合わせる」という組み立てができます。
ここでは、その fan-out(一斉投入)・poll(状態確認)・join(待ち合わせ)を、個人開発の運用に耐える形でどう設計したかを共有します。派手な並列フレームワークは使いません。agy が返すジョブIDと、それを束ねる小さなシェルだけです。
直列待ちが律速する構造を、まず数字で見る
最初に、何を直していたのかを数字にしました。夜間ジョブは12本。直列で回すと、各ジョブの実時間の単純合計がそのまま総所要時間になります。
私の環境では、12本の合計が平均で約214分でした。ところが各ジョブのCPU・ネットワークの占有率は低く、待ち時間が大半を占めていました。LLM の応答待ち、API のレート制限の合間、git の push 完了待ち。どれも「手は空いているのに次へ進めない」時間です。
非同期に並行で投げれば、総所要時間は「最も遅い1本+わずかなオーバーヘッド」に近づきます。実測では214分が約79分になりました。短縮率はおよそ63%です。重要なのは、各ジョブを速くしたのではなく、待ち時間を重ねただけだという点です。
agy の非同期ジョブが返すもの
agy run には、ジョブをフォアグラウンドで実行する通常モードと、デタッチして即座に制御を返す --detach があります。デタッチすると、標準出力にジョブIDが1行返ります。
# フォアグラウンド(従来): 完了までブロックする
agy run --task "generate article: antigravity cli async jobs" --model gemini-3.5-flash
# デタッチ: 即座にジョブIDを返し、バックグラウンドで継続する
JOB_ID = $( agy run --detach --json \
--task "generate article: antigravity cli async jobs" \
--model gemini-3.5-flash | jq -r '.job_id' )
echo "submitted: $JOB_ID "
--json を付けると、人間向けの装飾ではなく機械可読な1オブジェクトが返ります。スクリプトで扱うときは必ず --json を付けてください。装飾付きの出力を grep で拾う実装は、CLI の表示が少し変わっただけで壊れます。
ジョブの状態は agy jobs で取れます。
# 単一ジョブの状態
agy jobs get " $JOB_ID " --json
# => {"job_id":"j_8f3a","state":"running","exit_code":null,"started_at":"..."}
# 全ジョブの一覧
agy jobs list --json
state は queued / running / succeeded / failed / cancelled のいずれかを返します。exit_code は終了後にのみ数値が入ります。この2つを軸に待ち合わせを組み立てます。
fan-out: 独立したタスクを一斉に投げる
fan-out は単純です。投げたいタスクを配列で持ち、それぞれをデタッチで起動し、返ってきたジョブIDを別の配列に貯めます。
#!/usr/bin/env bash
set -euo pipefail
# 投入したい独立タスク(タスク種別ごとに model を変える)
declare -a TASKS = (
"generate:article-a|gemini-3.5-flash"
"audit:internal-links|gemini-3.5-flash"
"report:admob-mediation|gemini-3.5-flash"
"i18n:store-screenshots|gemini-3.5-flash"
)
declare -a JOB_IDS = ()
declare -A JOB_LABEL = () # ジョブIDから人間向けラベルへの対応表
for entry in "${ TASKS [ @ ]}" ; do
label = "${ entry %% | * }"
model = "${ entry ##* |}"
jid = $( agy run --detach --json --task " $label " --model " $model " | jq -r '.job_id' )
JOB_IDS += ( " $jid " )
JOB_LABEL[ " $jid " ] = " $label "
echo "fan-out: $label -> $jid "
done
ここでラベルとジョブIDの対応表(JOB_LABEL)を作っておくのが要点です。後で失敗したジョブを報告するとき、j_8f3a という文字列だけでは何が落ちたのか分かりません。投入の瞬間に人間が読める名前を結びつけておくと、朝のログが一気に読みやすくなります。
投入のあいだに短い間隔を空けるのも実用上は有効でした。レート制限の厳しい時間帯に12本を同時に叩くと、いくつかが queued のまま長く滞留します。0.5秒ほど空けるだけで、立ち上がりのばらつきが目に見えて減りました。
poll: 状態を指数的な間隔で確認する
投げ終えたら、全ジョブが終わるまで状態を見に行きます。ここで固定間隔のポーリングにすると、短いジョブには無駄が多く、長いジョブには負荷が高くなります。私は間隔を指数的に伸ばす方式に落ち着きました。
poll_interval = 2 # 初回は2秒
max_interval = 30 # 上限は30秒
deadline = $(( $(date +%s ) + 3600 )) # 全体のタイムアウトは60分
declare -A FINAL_STATE = ()
while : ; do
remaining = 0
for jid in "${ JOB_IDS [ @ ]}" ; do
# すでに確定したジョブは再問い合わせしない
[[ -n "${ FINAL_STATE [ $jid ] :- }" ]] && continue
state = $( agy jobs get " $jid " --json | jq -r '.state' )
case " $state " in
succeeded | failed | cancelled )
FINAL_STATE[ " $jid " ] = " $state "
echo "done: ${ JOB_LABEL [ $jid ]} -> $state "
;;
*)
remaining = $(( remaining + 1 ))
;;
esac
done
# 全部確定したら抜ける
[[ " $remaining " -eq 0 ]] && break
# 全体タイムアウト: 残りを cancel して join 側に判断を渡す
if (( $(date +% s) > deadline )); then
echo "timeout: cancelling $remaining job(s)"
for jid in "${ JOB_IDS [ @ ]}" ; do
[[ -z "${ FINAL_STATE [ $jid ] :- }" ]] && agy jobs cancel " $jid " > /dev/null 2>&1 || true
done
break
fi
sleep " $poll_interval "
poll_interval = $(( poll_interval * 2 ))
(( poll_interval > max_interval )) && poll_interval = $max_interval
done
指数的に伸ばすと言っても上限は設けます。上限がないと、長時間ジョブの最後の確認が数分先になり、「もう終わっているのに気づかない」時間が生まれます。私は上限30秒に落ち着きました。短いジョブは数回で確定し、長いジョブは30秒間隔で静かに見守る形になります。
確定済みのジョブを FINAL_STATE で覚えて再問い合わせしないのも、地味ですが効きます。12本のうち先に終わった数本を毎回問い合わせ続けると、ポーリングのAPI呼び出しが無駄に増えます。
join: タイムアウトと部分失敗を分けて扱う
待ち合わせの本質は、ループを抜けた後の判断です。ここを「全部成功か、全部失敗か」の二択で書くと、現実に合いません。実際には「10本成功・1本失敗・1本タイムアウト」のような中間状態が普通に起きます。
ok = 0 ; failed = 0 ; timed_out = 0
declare -a FAILED_LABELS = ()
for jid in "${ JOB_IDS [ @ ]}" ; do
st = "${ FINAL_STATE [ $jid ] :- timeout }"
case " $st " in
succeeded ) ok = $(( ok+1 )) ;;
failed | cancelled )
failed = $(( failed+1 ))
FAILED_LABELS += ( "${ JOB_LABEL [ $jid ]}" )
# 失敗したジョブのログだけを引いて記録する
agy jobs logs " $jid " --tail 40 >> " $HOME /agy-night/failed- $jid .log" 2>&1 || true
;;
timeout )
timed_out = $(( timed_out+1 ))
FAILED_LABELS += ( "${ JOB_LABEL [ $jid ]} (timeout)" )
;;
esac
done
echo "join: ok= $ok failed= $failed timeout= $timed_out "
# 終了コードの設計: 部分失敗は 2、全滅は 1、全成功は 0
if (( ok == ${ # JOB_IDS[ @ ]} )); then
exit 0
elif (( ok == 0 )); then
exit 1
else
printf 'partial failures:\n' ; printf ' - %s\n' "${ FAILED_LABELS [ @ ]}"
exit 2
fi
終了コードを3段階にしたのは、上位のスケジューラやログ集約側で扱いを変えたかったからです。全滅(exit 1)は即座に通知すべき異常ですが、部分失敗(exit 2)は朝に人間が見れば足りることが多い。両者を同じ「失敗」にまとめてしまうと、本当に急ぐべき全滅が、よくある1本失敗の通知に埋もれます。
失敗したジョブのログだけを agy jobs logs --tail で引いて別ファイルに残すのも、運用してから加えた工夫です。全ジョブのログを一括で残すと、朝に読む量が多すぎて結局読まなくなります。落ちた数本だけを切り出しておくと、原因にたどり着くまでが短くなります。
本番で踏んだ落とし穴
非同期にしてから最初の一週間で、いくつか痛い目を見ました。記録として残しておきます。
第一に、デタッチしたジョブは親シェルが終了しても走り続けます。これは利点ですが、待ち合わせスクリプトをうっかり二重に起動すると、同じタスクが二重に走ります。私はロックファイルで多重起動を防ぐようにしました。flock で $HOME/agy-night/.lock を取れなければ即座に降りる、という単純なガードです。
第二に、queued のまま動かないジョブの扱いです。レート制限やクォータ上限に当たると、ジョブは failed ではなく queued で長く滞留することがあります。私は「投入から一定時間 running にすら入らないジョブ」を別枠で検出し、タイムアウトとは区別して翌朝に手で見直すようにしました。
第三に、ジョブIDの寿命です。agy jobs get は完了後しばらくは状態を返しますが、保持期間を過ぎると見えなくなります。待ち合わせの途中で全体タイムアウトを跨ぐような長いバッチでは、確定した状態をその場で FINAL_STATE に焼き付けておかないと、後から問い合わせても取れないことがありました。ループ内で確定を記録する設計は、この実害から来ています。
スケジューラと通知をどうつなぐか
この待ち合わせスクリプトは、上位のスケジューラから1日1回呼び出しています。私の場合は App Store と Google Play 向けのリリース作業の合間に走らせたいので、深夜の決まった時刻に起動しています。
通知は終了コードで出し分けています。exit 1(全滅)は即時にプッシュ通知へ、exit 2(部分失敗)は朝に読むダイジェストへ、exit 0 は通知なし、という3系統です。全成功のときに通知を送らないのは意図的で、毎朝「成功しました」という通知が届くと、人はやがてそれを読まなくなり、本当に異常なときの通知まで見落とすからです。
本番運用で効いたのは、失敗ラベルにジョブの所要時間を添えることでした。agy jobs get の started_at と完了時刻の差を計算し、audit:internal-links (12m, failed) のように出します。所要時間が普段より極端に長い失敗は、レート制限やネットワークの異常を疑う最初の手がかりになります。こうした観測の積み重ねが、翌朝の原因究明を短くしてくれます。私は通知の粒度をここまで分けることを推奨します。最初は過剰に見えても、運用が続くほど効いてきます。
どこまで非同期にするかの判断
すべてを非同期にすればよいわけではありません。私は、後段が前段の成果物に依存するタスクは直列のまま残しました。たとえば「記事を生成してから、その記事へのリンクを他記事に張る」処理は、生成が終わっていないと張りようがありません。
私が非同期に回しているのは、互いに独立して、失敗しても他に波及しないタスクだけです。記事生成、独立したレポート作成、アセットの差し替え。これらは1本が落ちても残りは無事に終わります。依存関係のあるものは、依存の単位でまとめて1ジョブにし、そのジョブの内部で順序を守ります。
非同期化は速くなる魔法ではなく、「待ち時間を重ねてよい部分」と「順序を守るべき部分」を分ける設計の作業でした。その線引きさえ済めば、agy の --detach と小さな待ち合わせループだけで、夜間バッチは見違えるほど静かになります。
同じように複数の独立タスクを夜間に回している方の、運用を組み直すきっかけになれば幸いです。