深夜のスケジュール実行のログを見返していたときのことです。個人開発で運用している複数のアプリ向けに、依存更新・クラッシュレポートのトリアージ・AdMob のレポート集計といった作業を Antigravity のエージェントに定期実行させているのですが、手順書に「週 2 回実行」と書いてあるタスクが、スケジューラ上では毎日動いていました。
頻度を変えたのは数週間前の私自身です。スケジューラの設定は更新しました。けれど手順書は直し忘れていました。タスクは毎晩「成功」のログを出し続けていたので、気づくきっかけがなかったのです。
この食い違い自体に実害はありません。ただ、調べていくうちに、もっと質の悪い乖離が二つ見つかりました。手順書が参照しているデータファイルが、フォルダ改名の影響で空読みになっていたこと。そして、指示書の実体が消えたままスケジュールだけが残った「ゾンビタスク」が一つ、動き続けていたことです。
スケジュール実行のエージェントは、指示書が現実とずれても止まりません。Antigravity 2.0 でスケジュール実行やバックグラウンドエージェントが身近になった今、この「指示書ドリフト」は書き方の工夫ではなく、検知の仕組みを持つ設計の問題として扱う必要があると考えています。
指示書は書いた瞬間から古び始めます
長く運用して見えてきたのは、ドリフトの発生経路はほぼ3つに収束するということです。
経路1: 定義変更の取り残し
スケジューラ側の頻度・時刻・有効/無効を変えたのに、手順書や AGENTS.md に旧仕様の記述が残るパターンです。変更作業の主目的は「動きを変えること」なので、文書の同期は後回しになりがちです。後回しにされた同期は、経験上ほぼ実行されません。
経路2: 参照先の移動・改名
手順書が cat するデータファイルや、サブ手順書へのパスが、リファクタリングで移動するパターンです。怖いのは、多くのシェル手順が空読みでも止まらないことです。cat は存在しないファイルでエラーを返しますが、リダイレクトやパイプの組み方によっては全体が「成功」として流れます。エージェントは欠けた参照を「欠けている」と報告するより、手元にある情報だけで作業を完遂する方向に倒れやすい性質があります。
経路3: 実体の消失
指示書を整理・統合した際に、古いスケジュール定義だけが残るパターンです。逆に、文書はあるのに定義から外れた「孤児文書」も生まれます。どちらも個別には小さな問題ですが、棚卸しされないまま数が増えると、運用全体の信頼性が読めなくなります。
3つに共通するのは、成功ログが整合の証明にならない ことです。エージェントは与えられた状況の中で最善を尽くすので、参照が欠けていても、指示が古くても、それなりの成果物を出してしまいます。「動いているから大丈夫」が通用しないのが、この問題の厄介なところです。
定義・文書・実態 — 三層モデルで乖離を捉える
対策を考えるにあたって、私は運用を3つの層に分けて整理しました。
層1: 定義 — スケジューラが実際に何を・いつ・有効/無効どちらで動かしているか
層2: 文書 — AGENTS.md・手順書・runbook が何と言っているか
層3: 実態 — 実行ログと成果物が何を示しているか
整合性の検査は、この三層の突き合わせ3組(定義×文書、文書×実態、定義×実態)に分解できます。冒頭の「週 2 回 vs 毎日」は定義×文書の乖離、空読みは文書×実態の乖離、ゾンビタスクは定義×文書の欠損です。
突き合わせの前に、ひとつ設計判断が要ります。どの層を正とするか を決めることです。私は「定義(層1)が正、文書は従」と決めています。スケジューラは現実に動いているものなので、嘘をつきません。正本を決めていないと、乖離を見つけても直す方向が定まらず、レビューのたびに議論が発生します。
その上で、定義を機械可読な一箇所に集約します。スケジューラの管理画面だけに頻度が存在する状態だと突き合わせの自動化ができないので、私はリポジトリに tasks.yaml を置き、これを唯一の正本にしています。
# tasks.yaml — スケジュール実行の正本(頻度はここ以外に書かない)
tasks :
- name : nightly-dependency-update
schedule : "0 3 * * *" # 毎日 03:00
doc : docs/agents/nightly-dependency-update.md
refs :
- data/allowlist.json
- docs/shared/update-policy.md
- name : crash-triage
schedule : "0 6 * * 1,4" # 月・木 06:00
doc : docs/agents/crash-triage.md
refs :
- data/crash-thresholds.yaml
ポイントは doc と refs を定義側に持たせることです。タスクが「どの文書に従い、何を参照するか」を定義に書いておくと、後述の検査スクリプトが三層をまとめて辿れるようになります。
参照パスの無音失敗を仕組みで検知する
私が踏んだ中でいちばん発見が遅れたのは、参照ファイルの空読みでした。データフォルダを整理して配下のディレクトリ名を変えたとき、手順書内の cat のパスが全て無効になったのですが、夜間タスクは止まらず、エージェントは参照データなしのまま、それらしいレポートを作り続けました。中身が薄くなっていることに気づいたのは 2 週間後でした。後から分量を測ると、レポートの本文は平常時の 60% ほどに痩せていました。エラーが一度も出ていないのに品質だけが落ちている——これが無音失敗のいちばんの注意点です。
これは指示書に「出力が空なら中断して報告すること」と書くだけでは回避できません。エージェントがその一文を守るかどうかも確率的だからです。参照の読み取り自体を、失敗を記録するラッパーに通します。
#!/usr/bin/env bash
# require_ref.sh — 参照ファイルの欠落・空読みをドリフトログに残す
DRIFT_LOG = "${ DRIFT_LOG :- $HOME / . agent-drift . log }"
require_ref () {
local path = " $1 "
if [ ! -s " $path " ]; then
printf '%s missing-or-empty %s\n' "$( date -Is )" " $path " >> " $DRIFT_LOG "
echo "⚠️ 参照欠落: $path " >&2
return 1
fi
cat " $path "
}
# 手順書内では cat の代わりに必ずこれを通す
require_ref "data/crash-thresholds.yaml" || exit 1
-s を使うのは、「存在しない」と「存在するが空」を一度に弾けるからです。そして失敗を標準エラーに出すだけでなく、必ずログファイルに追記します。スケジュール実行の標準エラーは流れて消えますが、ログファイルは翌朝の検査が拾ってくれます。
定義と文書の突き合わせを自動化する
三層の突き合わせ本体は、tasks.yaml を起点にした 1 本の Python スクリプトにまとめています。検査するのは4点です。タスクの文書が実在するか(ゾンビ検知)、参照ファイルが実在して空でないか、文書内の頻度表現が定義の cron 式と矛盾しないか、そして定義から参照されない孤児文書がないか。
#!/usr/bin/env python3
"""drift_check.py — 定義(tasks.yaml)と文書・参照の乖離を検査する"""
import pathlib
import sys
import yaml
ROOT = pathlib.Path( __file__ ).resolve().parent
# 文書側で使ってよい頻度語彙と、cron 式との整合条件
FREQ_RULES = {
"毎日" : lambda c: c.split()[ 4 ] == "*" ,
"毎週" : lambda c: c.split()[ 4 ] != "*" and "," not in c.split()[ 4 ],
"週 2 回" : lambda c: c.split()[ 4 ].count( "," ) == 1 ,
}
def load_tasks ():
with open ( ROOT / "tasks.yaml" , encoding = "utf-8" ) as f:
return yaml.safe_load(f)[ "tasks" ]
def check (tasks):
problems = []
all_docs = {p.resolve() for p in ( ROOT / "docs" / "agents" ).glob( "*.md" )}
used_docs = set ()
for t in tasks:
doc = ( ROOT / t[ "doc" ]).resolve()
used_docs.add(doc)
if not doc.is_file():
problems.append( f "[zombie] { t[ 'name' ] } : 文書が存在しません -> { t[ 'doc' ] } " )
continue
text = doc.read_text( encoding = "utf-8" )
for word, ok in FREQ_RULES .items():
if word in text and not ok(t[ "schedule" ]):
problems.append(
f "[freq] { t[ 'name' ] } : 文書は「 { word } 」/ 定義は { t[ 'schedule' ] } " )
for ref in t.get( "refs" , []):
rp = ROOT / ref
if not rp.is_file() or rp.stat().st_size == 0 :
problems.append( f "[ref] { t[ 'name' ] } : 参照が欠落または空 -> { ref } " )
for orphan in sorted (all_docs - used_docs):
problems.append( f "[orphan] 定義から参照されない文書: { orphan.name } " )
return problems
if __name__ == "__main__" :
found = check(load_tasks())
for line in found:
print (line)
sys.exit( 1 if found else 0 )
頻度の照合が単純な語彙マッチであることに、物足りなさを感じる方もいらっしゃるかもしれません。ここは意図的にこうしています。文書内の自由な日本語を完全に解釈しようとすると検査器が複雑になりすぎ、検査器自体が保守されなくなるからです。
代わりに執筆規約を一つ添えます。文書に頻度を書くなら FREQ_RULES にある語彙だけを使う。できれば頻度は文書に書かない 、です。頻度の正本は tasks.yaml にあるのだから、文書側は「実行頻度は定義を参照」と書けば十分です。検査可能性は、検査器を賢くするより、書く側の語彙を絞る方が安く手に入ります。
実態の層も突き合わせる — 成果物の鮮度チェック
定義×実態の乖離、つまり「定義上は動いているはずなのに成果物が古い」タスクは、成果物ディレクトリの最終更新で洗い出せます。
# 定義上アクティブなタスクの成果物の鮮度を一覧する
python3 - << 'EOF'
import pathlib, time, yaml
tasks = yaml.safe_load(open("tasks.yaml", encoding="utf-8"))["tasks"]
now = time.time()
for t in tasks:
out = pathlib.Path("outputs") / t["name"]
files = sorted(out.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)
if not files:
print(f"[stale] {t['name']}: 成果物がありません")
continue
age_h = (now - files[0].stat().st_mtime) / 3600
flag = "[stale]" if age_h > 48 else "[ok] "
print(f"{flag} {t['name']}: 最終成果物 {age_h:.0f} 時間前")
EOF
閾値の 48 時間は、私が本番運用しているタスク群での値です。毎日動くタスクなら 2 回連続で成果物が出ていない状態を異常とみなす、という意味になります。週次タスクが混在するなら、tasks.yaml に max_age_hours を持たせて定義側から閾値を引く形に拡張すると、検査の一貫性が保てます。
週次ドリフトレビュー — 機械に列挙させて、人間は差分だけ見る
検査スクリプトが揃ったら、運用は単純です。drift_check.py と鮮度チェックを毎朝スケジューラで回し、結果を日付つきで 1 ファイルに追記します。人間が見るのは週 1 回、しかも前週との差分だけ です。
# 毎朝の検査をログに積む(これ自体もスケジュール実行)
{ date -I ; python3 drift_check.py ; echo "---" ; } >> drift-history.log
レビューで新しい行が出ていたら、直す方向は迷いません。定義が正なので、文書を定義に合わせます。逆に「定義の方が間違っていた」と分かった場合は、定義の修正と文書の修正を必ず同一コミット に入れることをお勧めします。別々のコミットに分けると、その間にスケジュール実行が挟まり、片方だけ直った中途半端な状態でエージェントが動く——ここは私も一度つまずいた落とし穴です。
私の運用では、この仕組みを入れた最初の 1 週間で乖離が 7 件出ました。何ヶ月も前の改名の取り残しや、存在しない参照パスが、静かに積もっていたわけです。その後は週 0〜1 件で安定し、レビューは 10 分かかりません。検知が仕組み化されてからは、「手順書を信じてよいのか」という漠然とした不安がなくなったのが、数字以上に大きな収穫でした。
まずは食い違いを 1 件、見つけるところから
仕組みを全部組む前に、今夜のスケジュール実行が始まる前に一つだけ試していただきたいことがあります。スケジューラの定義一覧と手順書を並べて、頻度・時刻・参照パスのどれかひとつでいいので突き合わせてみてください。ある程度の期間運用しているなら、おそらく 1 件は見つかります。
その 1 件が見つかったら、tasks.yaml への正本の一本化から始めるのが遠回りに見えて最短です。検査は正本がなければ自動化できず、自動化できない検査は続かない、というのがこの数ヶ月の実感です。
同じように夜間タスクをエージェントに任せている方の参考になれば幸いです。