Catching "Running but Doing Nothing" Antigravity Subagents — A 3-Layer Observability Pattern Across Six Production Apps
Running Antigravity subagents across six production apps surfaced more than ten silent failures every month — exits clean, logs green, but nothing actually happened. Here is the Heartbeat / Output Trace / Decision Log pattern I now use to catch them inside 60 seconds, with code, GCP costs, and four months of running numbers.
At 2 a.m. the Cloud Scheduler dashboard was a wall of green "success" rows, and yet when I opened the App Store in the morning, nothing on the storefront had changed since the night before. During the first month of running Antigravity subagents across six of my apps, this happened eleven times. There were no exceptions in the logs. Exit codes were all zero. The agents had "run" — they just hadn't actually done anything.
I have been running a personal app business since 2014 (a little over 50 million cumulative downloads across six apps), and the silent failure mode that Antigravity's Background Agents and subagents added to my operations was a different animal from the crashes I was used to. Crashes you can see. These ones you cannot. My grandfather, who was a temple carpenter, used to say that a beam with a visible crack is far less scary than a beam that returns the wrong sound when you tap it. I really started thinking about that quote again around month four of agent operations.
This article is the implementation memo of the three-layer Observability stack I rebuilt afterward (Heartbeat / Output Trace / Decision Log), the GCP setup that keeps it under $7 a month, and the numbers from four months of running it across six production apps. It is the design I now use to keep "running but doing nothing" out of production — the failure mode Antigravity's own logs do not surface.
The Three Archetypes of Silent Failure — stale context / no-op loop / silent retry
Before any of the layers, it is worth pulling apart what "silent failure" actually means here. Run Antigravity Background Agents and subagents long enough and you will hit these three flavors.
stale context — treating an old snapshot as ground truth
The agent does exactly what you asked, but the repository snapshot, AdMob cache, or Firestore document it is looking at is hours or even days old. It concludes "nothing has changed" and exits cleanly. The annoying part is that real "nothing changed" days exist too, so you cannot simply equate "zero output" with "failure."
no-op loop — tasks advance but no side effect ever fires
Tool calls happen, but they are all read-only — grep, read_file, that kind of thing. The agent reaches "conclusion: no issues" and terminates normally. The write-side tools you actually wanted to fire — write_file, gh pr create, an update against your backend — were never invoked.
silent retry — internal retries swallow real errors
The Antigravity subagent's own retry policy quietly re-runs an external API call when it sees a 5xx. After three tries, it returns the same stale value the first attempt got, and exits "successfully." The logs say success, but the data you took action on never reflected the API's current state.
Each of the three shows up differently, so the detection logic has to be split out per archetype. My first attempt was a single catch-all alert, and the false-positive rate hit 22% inside a month. Operations was unrunnable. So I broke it apart.
The 3-Layer Picture, and Why the Stacking Order Matters
To catch silent failures you need three independent sources of truth, layered outside the agent itself. The order I now use across six apps is:
Layer 1 Heartbeat — measure, from the outside, whether the agent actually started and finished
Layer 2 Output Trace — hash the artifacts to confirm something actually changed
Layer 3 Decision Log — record tool calls so you can answer "why didn't anything change?"
The order is non-negotiable. If you build Decision Log first, the volume explodes and the data becomes unreadable, and you abandon the whole thing. Heartbeat + Output Trace alone catches about 78% of failures, so my recommendation is to ship those two as a minimal product, then close the remaining 22% with Decision Log.
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 minimal Heartbeat + result-hash setup that catches "clean exit, zero output" failures inside 60 seconds
✦Three silent-failure archetypes (stale context / no-op loop / silent retry) and the rule each one needs to be caught
✦A Cloud Scheduler + BigQuery Streaming Insert + Slack/PagerDuty stack that runs for under $7/month, with retention windows that actually scale
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.
Layer 1 Heartbeat — Cloud Scheduler, the agent's response time, and BigQuery
Layer 1 is straightforward. When Cloud Scheduler triggers the subagent, it issues a freshly minted start_token (a UUIDv4) and passes it through. Just before the agent terminates, it POSTs the start_token, its elapsed time, and its exit_code to BigQuery via a Streaming Insert.
Here is the TypeScript helper I call from inside Antigravity subagents. I keep it on Cloudflare Workers so it is callable from Antigravity as well as from GitHub Actions.
// src/observability/heartbeat.tstype HeartbeatPayload = { start_token: string; // UUIDv4 issued by Cloud Scheduler agent_name: string; // e.g. "wallpaper-nightly-refresh" started_at: string; // ISO8601 finished_at: string; // ISO8601 duration_ms: number; exit_code: 0 | 1 | 2; // 0=ok, 1=app-error, 2=infra-error triggered_by: "scheduler" | "manual" | "retry"; attempt: number; // 1 = first try, 2+ = retry};export async function emitHeartbeat(p: HeartbeatPayload) { // BigQuery Streaming Insert returns inside 500ms, so awaiting is fine const res = await fetch( "https://bigquery.googleapis.com/bigquery/v2/projects/PROJ/datasets/obs/tables/heartbeat/insertAll", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAccessToken()}`, }, body: JSON.stringify({ rows: [{ insertId: p.start_token, json: p }], }), } ); if (!res.ok) { // If this fails, give up. Do not crash the agent itself. console.error("heartbeat insert failed", res.status); }}
The crucial design decision is that Cloud Scheduler issues the start_token, not the agent. If the agent issues its own token, you cannot detect a day when the agent simply never started. With the scheduler issuing the token, you can poll BigQuery five minutes after dispatch and, if no row with that start_token exists, page out as "failed to start." It is a two-stage check.
What Heartbeat catches, and what it cannot
Inside 60 seconds, Heartbeat alone can catch:
The agent never received the Cloud Scheduler trigger (no row)
The agent started but took 3x longer than the rolling average (duration anomaly)
The agent exited with exit_code != 0
What Heartbeat absolutely cannot catch is the opening scenario: "running but doing nothing." exit_code=0, duration_ms is normal, and the output is empty. That is the gap Layer 2 fills.
Layer 2 Output Trace — hashing the artifacts to confirm "did anything change?"
Layer 2 hashes the resources the agent was supposed to touch, before and after the run. For my wallpaper apps' "nightly asset refresh" subagent, the three targets are:
GCS bucket — SHA256 of gsutil ls -l output (zero additions is a no-op candidate)
Firestore releases/latest document — normalize fields and SHA256 (unchanged means we skipped update)
AdMob ad unit floor price — SHA256 of the API response (identical means no update was applied)
I store hashes in BigQuery as before_hash and after_hash. Here is the Python helper that records them.
A gotcha lives here: "changed or not" alone misses stale context. An agent can run against a stale snapshot and, by coincidence, still produce a correct change. So I also keep a separate query that compares "the change category we expected" against "the diff we actually saw."
Telling a healthy no-op day from a sick one
This subagent's expected baseline is "roughly four releases per week on weekdays, around one on weekends." So my rule is: fewer than 3 changed=true events in the last 7 days is abnormal, 3–6 is healthy, and 7+ is overactive (the early symptom of a dependabot-style runaway). The overactive case pages PagerDuty.
-- Count changed=true events in the last 7 daysWITH last7 AS ( SELECT target, COUNTIF(changed) AS change_count, COUNT(*) AS total_runs FROM `PROJ.obs.output_trace` WHERE captured_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY) GROUP BY target)SELECT target, change_count, total_runs, CASE WHEN change_count < 3 THEN 'WARN_underchange' WHEN change_count > 7 THEN 'WARN_overchange' ELSE 'OK' END AS statusFROM last7ORDER BY status DESC;
Cloud Scheduler runs this query once an hour. Any WARN_* row triggers a Slack #ops thread. Underchange fires at under 3% frequency in practice; overchange at 0.4% (averaged over four months).
With Layer 1 + Layer 2 in place, you can detect "something is off." The remaining problem is "why." Antigravity's Background Agent has execution logs, but they are natural-language transcripts that get hard to grep at scale. After four months of operation, the transcript search broke down for me.
So I added an explicit hook on the agent side: emit one JSON line per tool call into BigQuery. Antigravity subagents let you author tool definitions in TypeScript or Python, so it is a thin wrapper around each tool's entry point.
Aggregating result_kind: "empty" gives you the "share of tool calls that returned nothing." A healthy agent runs at 6–12%. On days when the agent silently fails, that number jumps above 40%. I call it the "empty ratio" and I have it posted into Slack every morning at 7:30.
A morning empty-ratio query
SELECT agent_name, DATE(ts, "Asia/Tokyo") AS run_date, COUNT(*) AS total_calls, COUNTIF(result_kind = "empty") AS empty_calls, ROUND(COUNTIF(result_kind = "empty") / COUNT(*) * 100, 1) AS empty_pctFROM `PROJ.obs.decision_log` AS dJOIN `PROJ.obs.heartbeat` AS h USING (start_token)WHERE ts >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY)GROUP BY agent_name, run_dateHAVING empty_pct >= 30ORDER BY empty_pct DESC;
Any agent over 40% gets a same-day context reset (re-clone the repository, drop the cache). That single rule cut stale-context-driven silent failures from 17 per quarter to 3 per quarter.
The Alert Priority Matrix — Slack vs PagerDuty
Once the three layers are emitting signals, the next question is "which ones wake you up at night?" My first attempt routed everything to PagerDuty and I lasted a week. The matrix I run now:
Signal
Slack #ops (low)
Slack #ops-alert (medium)
PagerDuty (high)
Heartbeat: row missing (failed to start)
◯
Heartbeat: duration over 3x rolling avg
◯
Heartbeat: exit_code != 0
◯
Output Trace: 3 days of no-op in a row
◯
Output Trace: overchange (runaway suspicion)
◯
Decision Log: empty ratio 30–60%
◯
Decision Log: empty ratio over 60%
◯
Decision Log: 5 errors in a row
◯
The key principle is that Output Trace no-op alone never pages. No-op days exist, so they go to Slack low, and only get promoted to medium after three consecutive days. That single rule eliminated almost all spurious night pages. In four months across six apps, PagerDuty fired 14 times — 12 of those were real, harmful incidents.
Four Months, Six Apps, Eighteen Agents — the numbers
Here is the before-and-after, measured. The fleet is four wallpaper apps, one healing app, and one manifestation app — six apps. Each runs three Antigravity subagents (nightly refresh / ad KPI monitor / Crashlytics summary), so 18 agents total.
Silent failures shipped to production: 11/month → 1/month (91% reduction)
Mean time from "broke" to "fixed": 9.5 hours → 11 minutes (about 50x faster)
The biggest shift was the visibility of opportunity cost. Silent failures, by definition, do not crash. Before this stack I had been saying "everything looks fine." After Output Trace went in, it became obvious I had been quietly leaving about $1,400 a month on the table. Across the 6-app, 50-million-download portfolio, several titles with lower ARPDAU were losing a few thousand yen each per month — and that adds up to exactly that figure.
Pitfalls — BigQuery cost blowup, PII leakage, and retention
Before you build this, here are the three traps I walked into in the first two weeks.
A 17x BigQuery bill
My initial Layer 3 was streaming result_payload (the raw JSON) into BigQuery. Three days in, Cloud Billing alerted me — I was on a $200/month pace. The fix is to stream only result_size_bytes and result_kind into BigQuery, and store raw payloads in GCS with a 7-day Object Lifecycle rule. That alone gets you to under $7/month.
An AdMob API key in the Decision Log
In an early version I was streaming arguments instead of arguments_hash. Once, an AdMob API request header showed up in the log. I deleted it and rotated the key immediately, but the lesson is permanent: it is very easy to "accidentally" stream PII and secrets into BigQuery. Hash arguments by default, and if you ever do need to keep raw arguments, isolate them in a separate table behind strict IAM.
Empty retention windows quietly accumulating three months of data
I forgot to set partition_expiration_days and BigQuery happily kept everything. My current operational defaults are 30 days for Heartbeat, 90 days for Output Trace, and 14 days for Decision Log. One DDL statement is enough.
ALTER TABLE `PROJ.obs.decision_log`SET OPTIONS ( partition_expiration_days = 14, description = "Decision logs: short retention (raw payloads stored in GCS)");
A Minimal Starting Path — about 60 lines, in the right order
For anyone planning to bolt this onto an existing agent, here is the order I now use whenever I introduce it to a new project. One day, end to end.
Layer 1 Heartbeat only (half a day). One BigQuery table, one emitHeartbeat function, one Cloud Run Job that has Cloud Scheduler issue the start_token. That alone covers "failed to start," "timeout," and "abnormal exit."
One Slack alert, nothing more (30 minutes). Resist building a complex rule. Post exit_code != 0 events to #ops and call it done.
One week of operations, then Layer 2 (half a day). Pick a single target. Trying to instrument every target on day one will sink the project.
Two weeks of operations, then Layer 3 (half a day). Start the empty-ratio threshold loose at 50%, collect data, then tighten to 30%.
A month in, wire up PagerDuty. Connect only the two or three signals that you decided, from Slack-only operations, you absolutely want to be woken up for.
The non-negotiable here is "do not build all three layers in a sprint." When I tried that the first time, the false-positive rate hit 22% and the whole thing collapsed inside a month. Running Heartbeat by itself for a week teaches you what your agents' typical duration and success rate actually look like — and that intuition makes Layer 2 and Layer 3 thresholds easy to set.
My grandfather's line about temple carpentry was that the sounding test matters more than adding more pillars — that you first need to know what the wood is supposed to sound like. Agent observability feels exactly the same to me now: layer by layer, and only after you can hear what your own production sounds like.
I run all of this on a six-app indie portfolio — wallpaper, healing, manifestation — so this stack should transplant cleanly to any indie or small-team operation running AdMob revenue. The same shape is what I deploy on dolice.design client work whenever a Background Agent is going to production. Even just the first layer is enough to start hearing your own agents — once it is in, the next layer is an easy call.
Thank you for reading. May your agents stop quietly breaking in the small hours.
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.