The One File That Stops Startup: Guarding config.json Integrity Before Scheduled Runs
Antigravity 2.3.1 fixed a bug where an empty or corrupt config.json blocked startup. The symptom was fixed; the causes of corruption still live in your environment. Here is a three-layer guard — validate, snapshot, restore — that runs before a scheduled job, with working code and notes from running it nightly.
One morning, a job I run overnight left nothing behind. The log held a single line: "could not start." The same steps that had run hundreds of times the day before had tripped on the very first one.
The cause was a single file. The config.json under ~/.gemini/config/ had gone empty. The agent tried to read a JSON file with no contents and simply could not come up. Nothing in my own code was wrong.
Antigravity 2.3.1 (July 16) fixes exactly this: it closes the case where "an empty or corrupt config.json prevents startup." I am grateful for the fix. But what was fixed is the symptom — failing to start on a broken config. The reasons a config gets broken in the first place still live on our side. The more you let tasks run unattended, the less this first-step fragility feels like someone else's problem.
Why one file can stop startup
An agentic tool reads its configuration at launch: the model choice, the default permissions, connection details. Bundling all of that into one file like config.json trades convenience for a single point of fragility.
As long as that file is valid JSON, startup will proceed even if the contents are a little stale. The trouble comes when it is broken as JSON — a missing closing brace, a truncated write, or empty contents. The parser fails, and the state is not "no configuration" but "cannot read the configuration." Some tools fall back to defaults when a value is absent, but when the file is unreadable, judgment stops right there.
What I hit was the "gone empty" case. A write was interrupted partway, the old contents were cleared, and the new contents never fully landed. All that remained was a zero-byte file.
Know the failure modes first
Before designing defenses, it helps to look concretely at how the file breaks. Once you know the causes, you know where to place your hands.
Failure
Typical cause
What helps
Zero bytes / truncated
Interrupted write, disk full
Atomic writes, pre-run validation
Invalid JSON
Hand-edit slip, concurrent writes
Parse check, restore from known-good
Rolled back to old state
Cloud-sync conflict resolution
Generational snapshots, change detection
Missing keys
Schema change, partial migration
Minimal schema check
As an indie developer running jobs overnight, I have felt this firsthand. In my own setup there was a period when the config directory sat inside a cloud-synced folder, and two machines wrote it separately; the sync side once left a half-finished file behind. On another occasion the disk filled briefly and a write never completed. None of these are tool bugs — they are events on the environment side. That is precisely why it is worth checking for yourself before each run, rather than waiting on a tool fix.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A preflight guard that parses config.json and checks a minimal schema before startup, so a broken config never launches the agent
✦A three-layer design: fingerprint known-good states with sha256, keep only the last 3 generations, and restore via an atomic os.replace
✦A fail-safe that writes a minimal default when no good backup remains — plus the line where that one-time fallback must notify a human
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The first layer is the lightest check. Right before the scheduled run hands off to the agent, confirm only that config.json parses as JSON and carries a minimum set of keys. It does not judge whether the contents are correct — only whether they are readable. That alone is the gate.
# config_guard.py — a pre-run configuration health guardimport jsonimport osimport sysCONFIG_PATH = os.path.expanduser("~/.gemini/config/config.json")# Declare only the keys "without which it cannot start."# Over-constraining would wrongly flag legitimate config changes as broken.REQUIRED_KEYS = ("model", "permissions")def load_and_validate(path): """Parse and check the minimal schema. Return a reason if broken.""" 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
The key is not to be greedy with REQUIRED_KEYS. Make every key mandatory and the tool merely adding a new setting will read as "broken." Let the gatekeeper watch only the minimum that truly stops startup.
Quietly keep the good states
A configuration that passes validation can carry straight into the run. The second layer is keeping a config that started successfully as a "known-good" state — not hoarding endlessly, but fingerprinting the contents with sha256, never storing the same one twice, and retaining only the last few generations.
import hashlibimport shutilimport timeBACKUP_DIR = os.path.expanduser("~/.gemini/config/.known_good")KEEP = 3 # keep only the last 3 generationsdef 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): """Keep a validated config only when its contents are new.""" os.makedirs(BACKUP_DIR, exist_ok=True) digest = sha256_of(path) existing = sorted(os.listdir(BACKUP_DIR)) # If it matches the most recent snapshot, do nothing. if existing and existing[-1].endswith(f"{digest[:12]}.json"): return stamp = time.strftime("%Y%m%dT%H%M%S") dst = os.path.join(BACKUP_DIR, f"{stamp}.{digest[:12]}.json") shutil.copy2(path, dst) # Prune the older generations. 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))
The fingerprint exists so that the same config is not snapshotted repeatedly, wasting generations. A new generation is added only when the config actually changes, and older ones fall away on their own. Three generations was enough in my operation: if the latest change was bad, step back one; if that is also bad, one more.
When it is broken, restore atomically
The third layer is recovery. What you most want to avoid here is a second interruption during recovery that breaks config.json twice over. On the same filesystem, os.replace treats the swap atomically. Write the full contents to a temporary file first, then let the final move swap the name. Keep that order.
def atomic_restore(path, src): """Restore src into path atomically, via a temp file.""" tmp = path + ".tmp" shutil.copy2(src, tmp) # By here, tmp holds the complete contents. Only the last move touches the real file. os.replace(tmp, path) # atomic on the same filesystemdef recover(path): """Restore from the newest good state available. True if restored.""" 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
Validate the restore candidate again before restoring it. Good at snapshot time does not rule out breaking afterward. Try from the newest generation down and take the first one that passes.
The last resort when no good backup remains
What about the first run, when there are no snapshots yet, or when every snapshot is broken? This is where judgment is needed. Give up on startup and the morning's job is lost. But rewriting the live config on your own is heavy-handed.
I chose to write a minimal default that at least lets startup proceed — but to treat it strictly as a one-time emergency measure, and always notify a human. Fall silently to a default and no one notices the intended configuration was lost, and operation quietly continues on the wrong footing.
MINIMAL_DEFAULT = { "model": "gemini-3.5-flash", "permissions": {"mode": "ask"}, # when in doubt, the safe side. No auto-approve.}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) # Exit code 2 signals "startup allowed, but review required." print("NO known-good; wrote minimal default. REVIEW REQUIRED", file=sys.stderr) return 2if __name__ == "__main__": sys.exit(guard())
Setting the default permission to ask is deliberate. When in doubt, I recommend the safe ask default over auto-approve. Falling to auto-approve when in doubt is the most dangerous default of all. The way 1.1.3 reworked auto-approval around headless runs points in the same direction, I think. An emergency configuration, above all, should lean to the safe side.
Wire it in as a gate before the scheduled run
Finally, place this gate ahead of the actual run. I branch on the guard's exit code: on 2 (review required) the job still runs, but a notification goes out.
#!/usr/bin/env bashset -euo pipefailpython3 "$HOME/bin/config_guard.py"rc=$?if [ "$rc" -eq 2 ]; then # Startup is possible, but the config has fallen to a default. Tell a human. printf 'config fell back to minimal default at %s\n' "$(date)" \ | mail -s "[antigravity] config review required" you@example.com || truefi# Whether rc is 0 or 2, allow startup. If truly unrecoverable, guard returns non-zero (another value).agy -p "run nightly job"
I keep set -euo pipefail, but the rc used for the decision is taken directly, not through a pipe. Send an exit code down a pipeline and it gets swapped for the success of some intermediate command. Receive the gate's decision on the raw exit code. That is a line I do not cross.
How much to guard, and where to hand back to a human
I have written three layers, but what matters, I believe, is not handing everything to the machine.
If I had to narrow the design to three principles, I keep them light in this order:
Keep validation to "can it be read," not whether the contents are correct
Restore only from a recent good state; do not reach too far back through history
Hand only the final "cannot restore" decision back to a person
Keep validation light, keep restoration modest, and hand the final judgment back to a person. Because a config file sets the tool's premises, over-eager self-repair breeds a subtler problem: "it was quietly running on a different configuration all along."
In my case, since adding this gate, a corrupt config.json no longer costs me an entire morning's job. If it is broken, it returns to a recent good state; if it cannot, it starts on a minimal config and a single notice always arrives. It is not full automation. But it did turn a morning that trips on the first step and leaves nothing into a morning I can review.
This gate costs only a brief check before each run. On mornings when nothing is wrong, it ends by leaving a single line saying the parse passed. Even so, I never drop it. A few hundred milliseconds of checking against a morning where the first step trips and a full day of output vanishes — the two do not balance at all. The value of a defense is invisible on the days nothing happens. You only see it on the day it does.
The more you delegate to automation, the more time you spend on the quiet parts that hold up its premises. For me, 2.3.1 was a fix that brought that obvious truth back to mind. I hope it helps with your own implementation. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.