壁紙の分類バッチを一日二回に増やしたつもりでいた朝、処理済みの枚数がどうにも足りないことに気づきました。台帳を開くと、実行の記録はきれいに並んでいます。ステータスはすべて OK です。エラーは一件もありません。
それでも枚数が足りないのです。並んでいる時刻をひとつずつ読んで、ようやく分かりました。朝の枠しかありません。夕方の枠は、二週間ぶん一度も現れていませんでした。
私はそれまで、無人で動かす仕組みの健全性を「失敗が出ていないこと」で測っておりました。失敗が出ないなら大丈夫だと考えていたのです。けれど走らなかった回は、失敗としても記録されません 。台帳には何も書かれず、通知も鳴りません。何も起きなかったという事実だけが、そこに残ります。
以来、私は監視の向きを一つ変えました。起きたことを見るのをやめたわけではありません。それに加えて、起きるはずだった回数を先に数えるようにしたのです。
失敗の通知は、走らなかった回には届きません
理由を書き出すと当たり前でした。失敗通知はプロセスが立ち上がって初めて発火します。起動しなかった回にはプロセスがなく、したがって通知の主体もいないのです。
Antigravity の Remote Control が返してくれるプッシュ通知も、性質は同じです。エージェントがタスクを完了したとき、あるいは追加の入力を求めたときに届きます——つまり、動き始めた実行についてだけ届きます。私はこの通知にかなり助けられておりますが、「今日はそもそも動かなかった」を教えてくれる経路ではありません。
手元の台帳で数えてみます。三日ぶんの記録に対して、失敗だけを拾う経路が捕まえる件数はこうなりました。
照合できた実行: 6
欠落した実行 : 3
failure-only 通知が拾う件数: 0
成功率は 6/6 で 100% です。同じ台帳を、期待した回数の側から見ると 6/9 で 67% になります。同じデータを見ているのに、分母を変えるだけで結論が変わりました。
成功の記録ではなく、起きるはずだった回数を数えます。 この一文を運用メモの先頭に置いてから、私は夜中の処理をようやく信用できるようになりました。
期待の側は、式ではなく表として持ちます
原因を探し始めたとき、私はまず cron 式を疑いました。30 4,16 * * * と書いてあります。読み返しても間違いは見つかりません。式そのものを検証しても、答えは出ませんでした。
そこで、式を読むのをやめて展開してみました。分と時のフィールドを素直に開くだけの、短い関数で足ります。
# expect.py — cron 式から「期待発火時刻」を展開する最小実装
# 日・月・曜日は * の前提。定期実行の監査に必要なのは分と時だけでした。
from datetime import datetime
def parse_field (f, lo, hi):
"""カンマ・ハイフン・スラッシュを展開して値の集合を返します。"""
out = set ()
for part in f.split( "," ):
if part == "*" :
out |= set ( range (lo, hi + 1 ))
continue
if "/" in part: # */6 や 0-23/6 の形
base, step = part.split( "/" )
rng = range (lo, hi + 1 ) if base == "*" else range (
int (base.split( "-" )[ 0 ]), int (base.split( "-" )[ - 1 ]) + 1 )
out |= set (v for v in rng if (v - min (rng)) % int (step) == 0 )
continue
if "-" in part: # 9-17 の形
a, b = part.split( "-" )
out |= set ( range ( int (a), int (b) + 1 ))
continue
out.add( int (part))
return sorted (out)
def expected (expr, day):
"""その日に発火するはずの datetime を昇順で返します。"""
minute_field, hour_field = expr.split()[ 0 ], expr.split()[ 1 ]
minutes = parse_field(minute_field, 0 , 59 )
hours = parse_field(hour_field, 0 , 23 )
return [day.replace( hour = H, minute = M, second = 0 , microsecond = 0 )
for H in hours for M in minutes]
手元で走らせた結果です。
'30 4,16 * * *' -> 2 回/日 ['04:30', '16:30']
'30 4 * * *' -> 1 回/日 ['04:30']
'30 16 * * *' -> 1 回/日 ['16:30']
'0 */6 * * *' -> 4 回/日 ['00:00', '06:00', '12:00', '18:00']
式としては二回です。書き方は正しかったのです。それでも実際に走っていたのは一回でした。実行基盤が複数スロットの表記をどう扱うかは、式の正しさとは別の話だったのだと、このとき初めて腑に落ちました。
一日に二回動かしたい処理は、いまは枠ごとに別のジョブへ分けています。表記をまとめる利点より、片方が黙って落ちる危険のほうが高くつきます。
書き方 式としての期待 取り得る事故 いまの扱い
30 4,16 * * *2 回/日 片方だけ発火しても無音 使いません
30 4 * * * と 30 16 * * *各 1 回/日 片方が止まれば欠落として出ます こちらに分けます
0 */6 * * *4 回/日 間引きに気づきにくい 突合が必須です
実行台帳は、走り始めた時点で一行書きます
突合には、実行された側の記録が要ります。ここで大事なのは、終わったときではなく始まったときに 書くことでした。終了時に書く設計だと、途中でプロセスが落ちた回が非実行と見分けられなくなります。
台帳を書くとき、私は二度ひどい目に遭いました。どちらも記録そのものが壊れる種類の失敗です。
一つ目は終了コードです。判定の結果を head や tail へ通していると、パイプの最後の終了コードだけが残ります。
# 実測: 同じ判定でも、パイプを一本挟むと結果が逆になります
$ grep -q NOPE /etc/hostname | head -1 ; echo $?
0 # 失敗したのに 0
$ set -o pipefail ; grep -q NOPE /etc/hostname | head -1 ; echo $?
1 # pipefail を立てれば拾えます
$ grep -q NOPE /etc/hostname ; echo $?
1 # そもそもパイプを挟まないのが確実です
台帳のステータス欄はこの終了コードで決まります。つまり、パイプ一本で失敗が OK として記録されるということです。判定に使うコマンドは、整形のためであってもパイプへ通さないようにしました。
二つ目はヒアドキュメントです。区切り語をクォートせずに書くと、本文中のバッククォートやドル記号がその場で展開されます。
# 悪い例: <<EOF(クォートなし)
cat << EOF > ledger_bad.txt
id= $RUN_ID status=OK started=` date -u +%s` note=` echo INJECTED`
EOF
# 出力(記録したかった文字列ではなく、実行結果が入ります)
# id=run-2026-09-06 status=OK started=1788657291 note=INJECTED
# 良い例: <<'EOF'(クォートあり)+ 後から置換
cat << 'EOF' > ledger_good.txt
id=__RUN_ID__ status=OK started=__TS__ note=none
EOF
sed -i "s/__RUN_ID__/ $RUN_ID /; s/__TS__/$( date -u +%s)/" ledger_good.txt
# 出力
# id=run-2026-09-06 status=OK started=1757116800 note=none
note の中身が置き換わっているのが分かります。監査のために書いている台帳が、書いた瞬間に別の内容へすり替わっていたわけです——気づいたのは、欠落を調べるためにその台帳を読み返した日でした。
突合は、実行の外側に置きます
準備が整えば、監査そのものは短く書けます。期待発火表と台帳を突き合わせ、許容幅の中に記録が見つからない回を欠落として出すだけです。
# audit.py — 期待発火表と実行台帳を突合し、欠落した実行を出します
from datetime import datetime, timedelta
import json, sys
from expect import expected
GRACE = timedelta( minutes = 20 ) # 起動遅延の許容幅(決め方は次節)
def load_ledger (path):
"""1行1レコードの JSONL を (開始時刻, レコード) の昇順で返します。"""
rows = []
for line in open (path, encoding = "utf-8" ):
line = line.strip()
if not line:
continue
r = json.loads(line)
rows.append((datetime.fromisoformat(r[ "started_at" ]), r))
return sorted (rows)
def audit (schedules, ledger_path, days):
rows = load_ledger(ledger_path)
missing, matched = [], 0
for job, expr in schedules.items():
seen = [t for t, r in rows if r[ "job" ] == job]
for day in days:
for exp in expected(expr, day):
if exp > datetime.now(): # まだ来ていない枠は欠落にしません
continue
if any ( abs (t - exp) <= GRACE for t in seen):
matched += 1
else :
missing.append((job, exp))
return matched, missing
if __name__ == "__main__" :
schedules = {
"wallpaper-category" : "30 4,16 * * *" ,
"reference-refresh" : "0 7 * * *" ,
}
days = [datetime( 2026 , 9 , 4 ), datetime( 2026 , 9 , 5 ), datetime( 2026 , 9 , 6 )]
matched, missing = audit(schedules, "ledger.jsonl" , days)
print ( f "照合できた実行: { matched } " )
print ( f "欠落した実行 : { len (missing) } " )
for job, t in missing:
print ( f " MISSING { job } @ { t: % Y -% m -% d % H: % M } " )
sys.exit( 1 if missing else 0 ) # 欠落があれば非ゼロで終えます
夕方の枠が抜けた台帳に対して走らせると、こう出ます。
照合できた実行: 6
欠落した実行 : 3
MISSING wallpaper-category @ 2026-09-04 16:30
MISSING wallpaper-category @ 2026-09-05 16:30
MISSING wallpaper-category @ 2026-09-06 16:30
exit=1
日付と枠が名指しで出ます。二週間気づけなかったものが、三行で分かるようになりました。
このスクリプトは、監視したいジョブとは別のジョブとして動かします。同じ仕組みの内側に置くと、その仕組みごと止まった日に監査も止まるからです。私は一日一回、朝の早い時間に回しています。
許容幅は、実際の起動遅れから決めます
GRACE を二十分にしているのには理由があります。最初は五分にしていて、正常な回まで欠落として出てしまいました。エージェントの起動には、モデルの初期化や認証の更新でそれなりの揺れがあるのです。
決め方は単純で、いまの台帳から期待時刻とのずれを実際に測ります。手元の記録では 04:30 の枠に対して 04:31:12・04:30:41・04:33:05 で、最大は三分五秒でした。これに余裕を掛けて二十分に置いています。
# 期待時刻とのずれを一覧します(許容幅を決める前に必ず測ります)
python3 - << 'EOF'
import json
from datetime import datetime
for line in open("ledger.jsonl", encoding="utf-8"):
r = json.loads(line)
t = datetime.fromisoformat(r["started_at"])
delay = t.minute * 60 + t.second - 30 * 60 # 04:30 起点の秒数
print(f'{r["job"]:20} {t:%m-%d %H:%M:%S} 遅れ {delay:>4}s')
EOF
広げすぎると隣の枠に食い込んで、欠落が欠落として出なくなります。一日二回の運用なら、間隔の四分の一を上限の目安にしています。
本番で踏んだ落とし穴は、時刻そのものでした
突合を回し始めて最初の朝、全件が欠落として出ました。仕組みが止まったのかと思って調べたところ、原因は時刻の持ち方です。台帳は基盤の都合で UTC のまま書かれており、期待表は手元の日本時間で組んでいました。
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
utc = datetime( 2026 , 9 , 6 , 4 , 31 , 12 , tzinfo = ZoneInfo( "UTC" )) # 台帳の1行
jst = datetime( 2026 , 9 , 6 , 4 , 30 , tzinfo = ZoneInfo( "Asia/Tokyo" )) # 期待表の1枠
print ( abs (utc - jst)) # 9:01:12
print ( abs (utc - jst) <= timedelta( minutes = 20 )) # False — 全件が欠落に見えます
# タイムゾーンを落として比べた場合
print ( abs (utc.replace( tzinfo = None ) - jst.replace( tzinfo = None ))) # 0:01:12
厄介なのは、この二つが逆向きの間違え方をすることです。タイムゾーンを付けたまま比べると九時間ずれて全部が欠落になり、面倒だからと落として比べると一分十二秒に見えて全部が照合できてしまいます。後者は何の異常も見せないぶん、はるかに危ない状態です。
回避の仕方は決めてしまえば単純でした。台帳の started_at はタイムゾーン付きで書き、期待表を組むときも同じタイムゾーンを明示します。私はこの場合、表示だけを日本時間に寄せて、比較は片方に揃えることを推奨します。
# 期待表の側もタイムゾーンを明示して組みます
from zoneinfo import ZoneInfo
TZ = ZoneInfo( "Asia/Tokyo" )
days = [datetime( 2026 , 9 , 4 , tzinfo = TZ ), datetime( 2026 , 9 , 5 , tzinfo = TZ )]
もうひとつ、本番運用に入ってから気づいた注意点があります。日付をまたぐ枠——たとえば 23:50 の枠——は、監査を回す時点の日付だけを見ていると取りこぼします。前日ぶんも必ず対象に含めるようにしました。
通知の宛先を、欠落へ向け直します
最後に鳴らし方です。私は失敗通知を消していません。消さずに、欠落の通知を別の宛先へ足しました。失敗は原因を追う合図で、欠落は仕組みそのものを疑う合図——性質が違うので、同じ場所に混ぜないほうが判断が速くなります。
合図 拾える経路 最初に見る場所
実行が失敗した 終了コードと実行内の例外 その回のログ
実行が途中で消えた 開始行はあるが終了行がない 資源とタイムアウト
実行が起きなかった 期待発火表との突合 スケジュールの定義と基盤側
エージェントの定義や既定値は、更新のたびに静かに変わります。周囲の構成をどこまで引き継ぐかといった既定も同じで、変わったこと自体は通知されません。だからこそ、エージェントに複数リポジトリを触らせる前に置く、作業ルートの番人 のような入口の守りと、この記事の出口側の突合を、両方持っておくのが安心なのだと感じています。設定の綴りが黙って通ってしまう例は、any_of と書いたカスタムツールは、エラーを出さないまま制約を失います にも書き残しました。
明日の一歩
まずは監視したいジョブを一つだけ選んで、開始時に一行だけ台帳へ書き足すところから始めていただければと思います。突合は後から足せます。
私自身、欠落が三行で出るようになってから、夜のあいだに何が起きているかを想像する時間が減りました。読んでくださってありがとうございました。