同じ記事が2本、ほぼ同じ時刻に公開されているのを見つけたことがあります。
個人開発で複数のサイトを自動運用しているため、コンテンツ生成やレポート集計をスケジュール実行のエージェントに任せています。あるとき生成が普段より重く、1回分の実行が想定の枠を超えて長引きました。終わらないうちに次回のトリガーが来て、2本目のエージェントが起動し、同じトピックでもう1本作って push してしまったのです。
スケジュール実行は「決まった時刻に1回走る」と思いがちですが、実際には「前回が終わっていなくても時刻が来れば走る」ことがあります。私自身、この思い込みのまま組んでいて二重投稿に至りました。重なりと再実行に耐えるエージェントの冪等設計を、運用で踏んだ事故をたどりながら順に組み立てていきます。
二重実行はなぜ起きるのか
原因は大きく2つに分かれます。切り分けないと対策がずれます。
ひとつ目はオーバーラップ(重なり)です。前回の実行が長引き、終わる前に次回のトリガー時刻が来ると、2つの実行が並走します。生成系のエージェントは入力データやモデルの応答時間によって所要時間が大きく振れるため、普段は5分で終わる処理が、たまに20分かかることがあります。固定間隔のスケジュールは、この揺らぎを吸収してくれません。
ふたつ目はリトライによる再実行です。多くの自動実行環境は、失敗したタスクを自動でやり直します。問題は「どこまで進んでから失敗したか」です。記事ファイルを書き終えて push する直前にネットワークで落ちた場合、ファイルは残っているのに「失敗」と判定され、再実行で同じ作業をもう一度やると、成果物が二重になります。
この2つは性質が違います。オーバーラップは「同時に2つ走る」問題で、ロックで防ぎます。リトライは「時間をおいて2回走る」問題で、成果物の冪等化で吸収します。両方が必要です。
オーバーラップは flock で防ぐ
重なりを防ぐ最も確実な方法は、実行の入り口で排他ロックを取ることです。Linux の flock は、ファイルディスクリプタに対するアドバイザリーロックを提供し、ロックが取れなければ即座に諦める、という挙動を簡単に書けます。
#!/usr/bin/env bash
# run_agent_guarded.sh
# 同じタスクの実行が既に走っていたら、今回はスキップする
set -euo pipefail
LOCK="/tmp/agent-content-antigravity.lock"
exec 9>"$LOCK"
if ! flock -n 9; then
echo "⏭ 前回の実行がまだ走っています。今回はスキップします($(date '+%F %T'))"
exit 0
fi
# ここから先は単一実行が保証される
echo "▶ 実行開始 $(date '+%F %T')"
# --- 実際のエージェント作業 ---
generate_and_push_article
echo "■ 実行終了 $(date '+%F %T')"
# 9 番ディスクリプタはプロセス終了時に自動で閉じ、ロックも解放される
flock -n の -n(non-blocking)が肝です。ロックが取れなければ待たずに終了コードを返すので、重なったときは黙って次回に譲ります。待ち行列を作らないことで、実行が雪だるま式に積み上がるのを防げます。ロックファイルは /tmp のようにプロセス間で共有される場所に置き、ディスクリプタはプロセス終了で自動解放されるため、明示的な解放処理は不要です。
これでオーバーラップは止まります。しかしリトライによる二重化は、ロックだけでは防げません。時間をおいて走る2回は、どちらもロックを取れてしまうからです。
リトライは作業キーの冪等化で吸収する
冪等(idempotent)とは、同じ操作を何回行っても結果が変わらない性質です。スケジュール実行は「少なくとも1回(at-least-once)」走ることはあっても「ちょうど1回(exactly-once)」は保証されない、と前提を置きます。そのうえで、2回走っても成果物が1つに収束するように設計します。
要は、生成しようとしている成果物に一意な「作業キー」を与え、push の直前に「そのキーの成果物がもう存在しないか」を確認することです。コンテンツ生成なら、トピックから決まる slug が作業キーになります。
# 冪等ガード: この slug の記事が ja/en に既にあるなら、作業をやめる
SLUG="antigravity-scheduled-agent-idempotency-overlap-guard-design"
JA="content/articles/ja"
EN="content/articles/en"
if find "$JA" "$EN" -name "${SLUG}.mdx" | grep -q .; then
echo "⏭ slug=$SLUG は既に存在します。生成をスキップします。"
exit 0
fi
# ここに来たら、この slug はまだ無い → 生成して良い
write_article "$SLUG"
ポイントは、確認を「実行の最初」ではなく「push の判断と同じ場所」で行うことです。最初に確認しても、生成に時間がかかる間にもう1つの実行が同じ slug を作るかもしれません。push 直前に最終チェックを置くことで、競合の窓を最小にできます。
さらに堅くするなら、ローカルだけでなくリモートの最新状態に対して確認します。スケジュール実行の前段で git pull --rebase を済ませてから slug 存在を検査すれば、別マシンの別実行が先に同じ slug を公開していた場合も弾けます。
push 自体の冪等性も確保する
成果物の重複を弾けても、push の競合が残ります。複数の実行が同じ origin/main に push しようとすると、後発は non-fast-forward で弾かれます。これは失敗として扱い、自動でリベースして1回だけやり直す設計にします。
push_with_rebase_once() {
if git push origin main; then
echo "✅ push 成功"
return 0
fi
echo "↺ 競合を検知。pull --rebase して1回だけ再試行します。"
git pull --rebase origin main || { echo "❌ rebase 失敗。手動確認が必要です。"; return 1; }
# リベース後、冪等ガードを再評価(先に同じ slug が公開されていたら自分の分は捨てる)
git push origin main
}
無限リトライにしないのがコツです。1回リベースして再 push しても通らないなら、それは単純な競合ではなく別の異常(権限・保護ブランチ・大きな乖離)の可能性が高いので、機械の再試行を打ち切ってログに残します。
設計の優先順位
3つの仕掛けは、入れる順番に意味があります。まず flock で同時実行を止め、次に作業キーで成果物の重複を弾き、最後に push をリベース付き1回再試行で冪等にします。この順で重ねると、二重実行が起きても成果物は1つに収束します。
本番運用へ入れる前に、私自身が確認している導入手順を3つにまとめておきます。
- まず flock だけを入れ、1週間ほど「スキップが何回起きたか」のログを観測します。スキップが1日に何度も出るなら、実行間隔が処理時間に対して短すぎる兆候です。
- 次に冪等ガードを push の直前に置き、リモートに対して
git pull --rebase を済ませてから slug 存在を検査します。これで別マシンの実行との競合まで弾けます。
- 最後に push をリベース付き1回再試行にし、それでも通らなければ機械の再試行を打ち切ってログに残します。
アプリ開発で AdMob のレポート集計や App Store 向けの定期処理をエージェントに任せている経験では、この3点を入れてから二重投稿はゼロになり、無音の再実行による手戻りも体感で月に数件から1件未満へ減りました。スケジュール実行は「ちょうど1回」を捨てて「2回走っても壊れない」を前提に置き換えるのが出発点だと考えています。冒頭の二重投稿は、flock ひとつ入れるだけでも防げたものでした。まずはロックから始めてみてください。