夜間に走らせているジョブが、ある朝だけ何も残していませんでした。ログには短い一行、「起動できませんでした」。前日まで何百回と回っていた同じ手順が、その日は一歩目でつまずいていました。
原因は、たった一つのファイルでした。~/.gemini/config/ の下に置かれた config.json が、空になっていたのです。中身のない JSON をエージェントが読み込もうとして、そのまま立ち上がれなかった。私の側のコードには、何一つ間違いはありませんでした。
Antigravity 2.3.1(7/16)は、まさにこの不具合を修正しています。「config.json が空または壊れていると起動できなくなる」問題を塞いだ、と変更点にあります。ありがたい修正です。けれど、修正されたのは「壊れた設定で起動できない」という症状の方でした。設定が壊れる原因そのものは、私たちの環境の側に残ります。自動でタスクを回すほど、この一歩目の脆さは他人事ではなくなります。
なぜ一つのファイルが起動を止めるのか
エージェント型のツールは、起動時に自分の設定を読みます。モデルの選択、権限の既定、接続先の情報。それらが config.json のような一つのファイルにまとまっている構造は、扱いやすさと引き換えに、一点への集中というもろさを抱えます。
そのファイルが正しい JSON でありさえすれば、多少内容が古くても起動はできます。問題は、JSON として壊れているときです。閉じ括弧が一つ足りない、途中で切れている、あるいは中身が空。パーサは読み込みに失敗し、設定が無いのではなく「読めない」状態になります。無ければ既定で補える実装もありますが、壊れて読めないと、そこで判断が止まります。
私が経験したのは、この「空になっていた」ケースでした。書き込みの途中で何かが中断され、古い中身が消えたのに新しい中身が入りきらなかった。結果として、サイズ 0 のファイルだけが残りました。
壊れ方を先に知っておく
守りを設計する前に、どう壊れるのかを具体的に見ておきます。原因が分かれば、どこに手を当てるべきかが決まります。
壊れ方 典型的な原因 効く対策
サイズ0・途中で切れる 書き込み中の中断、ディスク満杯 アトミックな書き込み・実行前の検証
不正な JSON 手編集のミス、並行書き込み パース検証・良い状態からの復元
古い状態への巻き戻り クラウド同期の競合解決 世代管理・変更検知
キーの欠落 スキーマ変更・部分的な移行 最小スキーマの照合
個人開発で夜間の自動化を回している私自身の環境では、設定ディレクトリがクラウド同期の対象に入っていた時期があり、二台の端末が別々に書いた設定がぶつかって、同期側が中途半端なファイルを残したことがありました。ディスクが一時的に埋まって、書き込みが最後まで通らなかったこともあります。どれも、ツールのバグではなく環境の側の出来事です。だからこそ、ツールの修正を待つのではなく、実行前に自分で確かめる価値があります。
実行前に、まず読めるかを確かめる
最初の層は、いちばん軽い検査です。スケジュール実行がエージェントを呼ぶ直前に、config.json が JSON としてパースでき、最低限のキーを備えているかだけを見ます。中身の正しさまでは踏み込みません。読めるかどうか、それだけを門にします。
# config_guard.py — 実行前の設定健全性ガード
import json
import os
import sys
CONFIG_PATH = os.path.expanduser( "~/.gemini/config/config.json" )
# 「これが無いと起動できない」最小限のキーだけを宣言する。
# 過剰に縛ると、正当な設定変更を誤って壊れ扱いしてしまう。
REQUIRED_KEYS = ( "model" , "permissions" )
def load_and_validate (path):
"""パースと最小スキーマの照合。壊れていれば理由を返す。"""
if not os.path.exists(path):
return None , "not_found"
if os.path.getsize(path) == 0 :
return None , "empty"
try :
with open (path, "r" , encoding = "utf-8" ) as f:
data = json.load(f)
except json.JSONDecodeError as e:
return None , f "invalid_json: { e } "
if not isinstance (data, dict ):
return None , "not_object"
missing = [k for k in REQUIRED_KEYS if k not in data]
if missing:
return None , f "missing_keys: { missing } "
return data, None
REQUIRED_KEYS を欲張らないことが肝心です。あらゆるキーを必須にすると、ツール側が設定項目を増やしただけで「壊れている」と誤判定します。起動が本当に止まる最小限だけを門番にします。
良い状態を、静かに控えておく
検証を通った設定は、そのまま次に生かせます。二つ目の層は、起動に成功した設定を「良い状態」として控えることです。ただし無制限に貯めるのではなく、sha256 で内容を指紋化し、同じものは二重に持たず、直近の数世代だけを残します。
import hashlib
import shutil
import time
BACKUP_DIR = os.path.expanduser( "~/.gemini/config/.known_good" )
KEEP = 3 # 直近3世代だけ残す
def sha256_of (path):
h = hashlib.sha256()
with open (path, "rb" ) as f:
for chunk in iter ( lambda : f.read( 8192 ), b "" ):
h.update(chunk)
return h.hexdigest()
def snapshot_good (path):
"""検証を通った設定を、内容が新しいときだけ控える。"""
os.makedirs( BACKUP_DIR , exist_ok = True )
digest = sha256_of(path)
existing = sorted (os.listdir( BACKUP_DIR ))
# 直近の控えと同じ内容なら、何もしない。
if existing and existing[ - 1 ].endswith( f " { digest[: 12 ] } .json" ):
return
stamp = time.strftime( "%Y%m %d T%H%M%S" )
dst = os.path.join( BACKUP_DIR , f " { stamp } . { digest[: 12 ] } .json" )
shutil.copy2(path, dst)
# 古い世代を刈る。
keep = sorted (os.listdir( BACKUP_DIR ))[ - KEEP :]
for name in os.listdir( BACKUP_DIR ):
if name not in keep:
os.remove(os.path.join( BACKUP_DIR , name))
指紋を使うのは、同じ設定を何度も控えて世代を無駄に消費しないためです。設定が変わったときにだけ新しい世代が積まれ、古いものから自然に落ちていきます。三世代という数は、私の運用では十分でした。直近の変更が悪かった場合に一つ前へ、それも駄目ならもう一つ前へ、と戻れれば足ります。
壊れていたら、アトミックに戻す
三つ目の層が、復元です。ここで最も避けたいのは、復元の最中にもう一度中断が起きて、config.json を二重に壊すことです。os.replace は、同じファイルシステム上であれば置き換えをアトミックに扱います。一時ファイルに書ききってから、最後の一手で名前を差し替える。この順番を守ります。
def atomic_restore (path, src):
"""src の内容を、一時ファイル経由でアトミックに path へ戻す。"""
tmp = path + ".tmp"
shutil.copy2(src, tmp)
# ここまでで tmp は完全な内容を持つ。最後の一手だけが本番に触れる。
os.replace(tmp, path) # 同一FS上ならアトミック
def recover (path):
"""良い状態が残っていれば新しい順に戻す。戻せたら True。"""
if not os.path.isdir( BACKUP_DIR ):
return False
for name in sorted (os.listdir( BACKUP_DIR ), reverse = True ):
candidate = os.path.join( BACKUP_DIR , name)
data, err = load_and_validate(candidate)
if err is None :
atomic_restore(path, candidate)
return True
return False
復元候補も、戻す前にもう一度検証します。控えたときには良くても、その後に壊れる可能性を否定しないためです。新しい世代から順に試し、最初に検証を通ったものを採用します。
戻せる控えも無いときの、最後の一手
控えが一つも無い初回や、すべての控えが壊れていた場合はどうするか。ここで判断が要ります。何もせずに起動を諦めれば、その朝のジョブは失われます。かといって、勝手に本番の設定を書き換えるのは乱暴です。
私は、起動だけは通す最小の既定を書く道を選びました。ただし、これは「一度きりの緊急避難」として扱い、必ず人間に知らせます。静かに既定へ倒すと、本来やりたかった設定が失われたことに誰も気づかないまま運用が続いてしまうからです。
MINIMAL_DEFAULT = {
"model" : "gemini-3.5-flash" ,
"permissions" : { "mode" : "ask" }, # 迷ったら安全側。自動承認しない。
}
def write_minimal_default (path):
tmp = path + ".tmp"
with open (tmp, "w" , encoding = "utf-8" ) as f:
json.dump( MINIMAL_DEFAULT , f, ensure_ascii = False , indent = 2 )
os.replace(tmp, path)
def guard ():
data, err = load_and_validate( CONFIG_PATH )
if err is None :
snapshot_good( CONFIG_PATH )
print ( "config ok" )
return 0
print ( f "config broken: { err } " , file = sys.stderr)
if recover( CONFIG_PATH ):
print ( "restored from known-good" , file = sys.stderr)
return 0
write_minimal_default( CONFIG_PATH )
# 終了コード 2 は「起動は通したが、要確認」の合図として使う。
print ( "NO known-good; wrote minimal default. REVIEW REQUIRED" , file = sys.stderr)
return 2
if __name__ == "__main__" :
sys.exit(guard())
既定の権限を ask にしているのは意図的です。 迷ったときの既定は、自動承認ではなく安全側の ask を推奨します。迷ったときに自動承認へ倒すのは、最も危うい既定です。1.1.3 でヘッドレス実行の自動承認まわりが見直されたことも、同じ方向を指していると感じます。緊急避難の設定こそ、安全側に振っておきます。
スケジュール実行に、門として組み込む
最後に、この門を実際の実行の前に置きます。ガードの終了コードで枝分かれさせ、2(要確認)のときはジョブは走らせつつ通知だけ上げる、という運用にしました。
#!/usr/bin/env bash
set -euo pipefail
python3 " $HOME /bin/config_guard.py"
rc = $?
if [ " $rc " -eq 2 ]; then
# 起動は通せる状態にしたが、設定が既定に落ちている。人間へ一報。
printf 'config fell back to minimal default at %s\n' "$( date )" \
| mail -s "[antigravity] config review required" you@example.com || true
fi
# rc が 0 でも 2 でも起動は通す。復旧不能なら guard 内で非0(他値)を返す設計。
agy -p "run nightly job"
set -euo pipefail を置いていますが、判定に使う rc はパイプにつながず、直接受け取っています。終了コードをパイプの先へ流すと、途中のコマンドの成否にすり替わってしまうためです。門の判定は、素の終了コードで受ける。ここは崩さないようにしています。
どこまで守り、どこで人に返すか
三層を書いてきましたが、大切なのは、すべてを機械に任せきらないことだと考えています。
設計の指針を三つに絞るなら、次の順で軽くしておきます。
検証は「読めるか」だけに留め、内容の正しさまで踏み込まない
復元は直近の良い状態からに限り、履歴をさかのぼりすぎない
戻せないときの最後の判断だけは、必ず人へ返す
検証は軽く、復元は控えめに、そして最後の判断は人間に返す。設定という、ツールの前提を決めるファイルだからこそ、自動修復が行きすぎると「いつの間にか別の設定で動いていた」という、より気づきにくい問題を生みます。
本番運用でこの門を回していると、設定起因のエラーへの対処が定型化し、朝に慌てる場面が減ります。私の場合、この門を入れてから、config.json 起因で朝のジョブが丸ごと失われることは無くなりました。壊れていれば直近の良い状態へ戻り、戻せなければ最小構成で起動を通したうえで、必ず一通の知らせが届く。完全な自動化ではありません。けれど、一歩目でつまずいて何も残らない朝を、確認できる朝に変えることはできました。
この門にかかる時間は、実行前のわずかな検査だけです。壊れていない朝には、パースが通ったことを一行残して終わります。それでも私はこの一手を外しません。検査の数百ミリ秒と、一歩目でつまずいて丸一日分の生成が消える朝とでは、釣り合いがまるで違うからです。守りの価値は、何も起きない日には見えません。起きた日に、初めて分かります。
自動化を任せるほど、その前提を静かに支える部分に時間を割く。今回の 2.3.1 は、その当たり前を思い出させてくれた修正でした。実装の参考になれば幸いです。お読みいただき、ありがとうございました。