夜中の 2 時に Cloud Scheduler の通知だけ「success」が並んでいて、朝起きてストアを開いたら昨日と何も変わっていありません。Antigravity のサブエージェントを 6 アプリ並行で回し始めた最初の月、こういう日が 11 回ありました。エラーは出ていません。ログにも例外はありません。ただ、エージェントが「動いてはいたけれど、結局何もしていなかった」のです。
2014 年から累計 5,000 万ダウンロードの個人アプリ事業を運営してきましたが、Antigravity の Background Agent / サブエージェントの導入で増えたサイレント失敗は、これまでの「落ちる障害」とは性質が違いました。落ちないので気付けないのです。打音検査をしていた宮大工の祖父が「見える割れより、たたいて返ってくる音が変な柱の方が怖い」と言っていたのを、エージェント運用 4 ヶ月目くらいで真剣に思い出しました。
この記事は、そのあとに組み直した Observability の 3 層設計(Heartbeat / Output Trace / Decision Log)と、月 $7 以内に収めた GCP 構成、6 アプリ並行で 4 ヶ月運用して見えた数値をまとめた実装メモです。Antigravity 公式のログ画面が見せてくれない「動いているのに何もしていない」状態を、本番に残さないための設計として書きました。
サイレント失敗の 3 類型 — stale context / no-op loop / silent retry
最初に「サイレント失敗」と呼んでいるものの正体を分解しておきます。Antigravity の Background Agent / サブエージェントを長期で運用すると、ほぼ確実に下記 3 つに出会います。
stale context — 古いコンテキストを正としてしまう失敗
エージェントは指示通りに動いていますが、参照しているリポジトリのスナップショットや AdMob レポートのキャッシュが数時間〜数日前のものになっており、「変更点ゼロ」と判断して何もしません。実害がない無風日も含むので、単純な「成果物ゼロ=失敗」とは判定できないのが厄介です。
no-op loop — タスクは進むが副作用が一度も発生しない
ツール呼び出しは行われていて、grep や read_file のような副作用のない関数だけが回ります。最終的に「結論:問題なし」を返してエージェント自身は正常に終了します。本来やってほしい write_file や gh pr create などの書き込み系が一度も呼ばれていません。
silent retry — 内部リトライで例外を握りつぶす
Antigravity サブエージェント側のリトライポリシーが、外部 API の 5xx を黙って再試行します。3 回まで再試行し、最終的に「最初の試行と同じ古い結果」を返して終わるパターンが一定数あります。ログは「success」ですが、実際には API の最新値を反映していません。
この 3 つは出方が違うので、検知ロジックも別々に組まないと取りこぼします。1 つの汎用アラートで全部押さえようとして、最初の 1 ヶ月は誤検知 22% で運用が破綻しました。
3 層 Observability の全体像と「重ねる順番」
サイレント失敗を検知するには、エージェントの外側に「3 種類の真実」を別チャネルで持っておく必要があります。私が現在 6 アプリで採用している構成はこの順番です。
第 1 層 Heartbeat — 「エージェントが起動して、終了したか」を外部から測る
第 2 層 Output Trace — 「成果物が実際に変わったか」をハッシュで照合する
第 3 層 Decision Log — 「なぜそう判断したか」をツール呼び出しレベルで残す
順番が重要です。先に Decision Log から作ると、ログが膨大になって読めなくなり、運用が続きません。Heartbeat → Output Trace の 2 層だけで全体の 78% は捕まえられるので、まず軽い構成を完成させてから、残り 22% を Decision Log で塞ぐのが推奨です。
下記のフロー図はテキスト表現ですが、Cloud Scheduler → サブエージェント → 各層 → BigQuery → アラートと一方向に流れる単純な構成です。
Cloud Scheduler (cron)
↓ HTTP POST (start_token)
Antigravity Subagent (任意の処理)
├─ [Layer 1] Heartbeat probe → BigQuery (latency, exit_code)
├─ [Layer 2] Output hash diff → BigQuery (changed?, bytes)
└─ [Layer 3] Decision log JSON → BigQuery (tool calls, reasoning)
↓
定時クエリ (毎 5 分 Cloud Scheduler)
↓
Slack #ops(軽度)/ PagerDuty(重度)
第 1 層 Heartbeat — Cloud Scheduler とサブエージェントの応答秒数を BigQuery に流す
第 1 層は単純です。Cloud Scheduler からサブエージェントを起動するときに、ランダム生成した start_token を一緒に渡しておきます。エージェントは終了直前にその start_token と「自分が消費した秒数」「exit code」を、HTTP POST で BigQuery Streaming Insert に投げ込みます。
下記は Antigravity のサブエージェントから呼び出す TypeScript のヘルパーです。Cloudflare Workers 上に置いてあるので Antigravity からも GitHub Actions からも叩けます。
// src/observability/heartbeat.ts
type HeartbeatPayload = {
start_token : string ; // Cloud Scheduler が発行した UUIDv4
agent_name : string ; // 例: "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 = 初回, 2+ = リトライ
};
export async function emitHeartbeat ( p : HeartbeatPayload ) {
// BigQuery Streaming Insert は 500 ms 以内に終わるので await で OK
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) {
// ここで失敗したら諦める。エージェント本体は落とさない。
console. error ( "heartbeat insert failed" , res.status);
}
}
start_token を Cloud Scheduler 側で発行するのがコツです。エージェント側で発行してしまうと、「エージェントが起動すらしなかった日」を検知できません。Cloud Scheduler から start_token を発行し、5 分後に BigQuery を覗いてその start_token の行が無ければ「起動失敗」として PagerDuty を鳴らす、という二段構えになります。
Heartbeat だけで捕まえられる失敗・捕まえられない失敗
Heartbeat で 60 秒以内に検知できるのは下記です。
エージェントが Cloud Scheduler の起動を受け取れなかった(行が無い)
エージェントが起動したが、想定の 3 倍以上かかった(duration_ms 異常)
エージェントが exit_code != 0 で終わった
逆に Heartbeat では絶対に捕まえられないのが、冒頭の「動いているのに何もしていない」サイレント失敗です。exit_code=0 で duration_ms も平均通りで、ただ成果物だけがゼロ。ここを第 2 層が拾います。
第 2 層 Output Trace — 生成物のハッシュ照合で「変わったか」を確定する
第 2 層は、エージェントが触ったはずのリソースのハッシュ値を、実行前後で比較します。例えば壁紙アプリの「夜間アセット差分更新」サブエージェントだと、対象は下記の 3 種類です。
対象 ハッシュ取り方 変化の解釈
GCS バケットの新規追加ファイル一覧 gsutil ls -l の出力 SHA256追加 0 件なら no-op 候補
Firestore の releases/latest ドキュメント フィールドを正規化して SHA256 不変なら更新スキップ
AdMob ad unit の eCPM フロアプライス API レスポンスの SHA256 同一ならアップデートなし
ハッシュは BigQuery に before_hash / after_hash の 2 カラムで持っておきます。下記が記録のための Python ヘルパーです。
# scripts/observability/output_trace.py
import hashlib
import json
from datetime import datetime, timezone
from google.cloud import bigquery
bq = bigquery.Client()
def normalize (value):
"""dict / list / str を入力順に依存しない正規化文字列に。"""
return json.dumps(value, ensure_ascii = False , sort_keys = True , separators = ( "," , ":" ))
def hash_value (value) -> str :
return hashlib.sha256(normalize(value).encode( "utf-8" )).hexdigest()
def emit_output_trace (start_token: str , target: str , before, after):
before_h = hash_value(before)
after_h = hash_value(after)
changed = before_h != after_h
bq.insert_rows_json(
"PROJ.obs.output_trace" ,
[{
"start_token" : start_token,
"target" : target,
"before_hash" : before_h,
"after_hash" : after_h,
"changed" : changed,
"delta_bytes" : len (normalize(after)) - len (normalize(before)),
"captured_at" : datetime.now(timezone.utc).isoformat(),
}],
)
return changed
ここで一つ落とし穴があって、「変化したか」だけで判定すると stale context を取り逃します。古いコンテキストのまま動いた結果として、たまたま正しい変更が走ってしまうケースがあるからです。なので運用上は「期待される変更カテゴリ」と「実際の差分内容」を比較するクエリを別途用意しています。
no-op が正常な日と異常な日を分ける判別ルール
このサブエージェントの本来の期待値は「平日は週 4 回前後、週末は週 1 回程度、新規アセットが追加される」でした。なので「直近 7 日で changed=true が 3 回未満」なら異常、3 〜 6 回なら正常、7 回以上なら過剰更新(dependabot 暴走系の兆候)として PagerDuty を鳴らすクエリにしています。
-- 直近 7 日の changed=true 回数を集計
WITH 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 status
FROM last7
ORDER BY status DESC ;
このクエリを 1 時間に 1 度 Cloud Scheduler で叩いて、WARN_* が 1 行でも出たら Slack #ops にスレッド投稿します。アンダーチェンジは 3% 未満、オーバーチェンジは 0.4% という頻度で出ます(4 ヶ月運用平均)。
第 3 層 Decision Log — ツール呼び出し JSON で「なぜ何もしなかったか」を残す
ここまでで Heartbeat と Output Trace の 2 層が組めると、「何かが起きた」までは検知できます。残った最後の課題は「なぜ起きたか」です。Antigravity の Background Agent には実行ログが残りますが、自然言語ベースで grep しづらく、4 ヶ月運用していると検索が破綻しました。
なので、エージェント側で「ツール呼び出しを 1 行 JSON で BigQuery に流す」専用のフックを噛ませています。Antigravity のサブエージェントは TypeScript / Python のツール定義を自分で記述できるので、各ツールのエントリーポイントを 1 段ラップする形です。
// src/observability/decision_log.ts
type DecisionLog = {
start_token : string ;
tool : string ; // 例: "read_file"
arguments_hash : string ; // 引数を JSON.stringify → SHA256
result_kind : "ok" | "empty" | "error" ; // empty が no-op の手がかり
result_size_bytes : number ;
ts : string ;
};
export function withDecisionLog < TArgs , TRet >(
toolName : string ,
startToken : string ,
fn : ( args : TArgs ) => Promise < TRet >
) {
return async ( args : TArgs ) : Promise < TRet > => {
const t0 = performance. now ();
let kind : DecisionLog [ "result_kind" ] = "ok" ;
let result : TRet ;
try {
result = await fn (args);
if (result == null || (Array. isArray (result) && result. length === 0 )) {
kind = "empty" ;
}
} catch (e) {
kind = "error" ;
throw e;
} finally {
const elapsed = performance. now () - t0;
void emitDecisionLog ({
start_token: startToken,
tool: toolName,
arguments_hash: sha256 ( JSON . stringify (args)),
result_kind: kind,
result_size_bytes: JSON . stringify (result ! ?? null ). length ,
ts: new Date (). toISOString (),
});
// 計測ログは別系統へ
console. debug ( `[tool] ${ toolName } ${ kind } ${ elapsed . toFixed ( 1 ) }ms` );
}
return result ! ;
};
}
result_kind: "empty" を集計すると、「呼んだけど中身ゼロで戻ったツールの比率」が出ます。健全なエージェントだとこれは 6 〜 12% で安定するのですが、サイレント失敗を起こす日は 40% を超えます。私はこれを「empty 比率」と呼んで、毎日 7:30 に Slack に投稿しています。
「empty 比率」を毎朝確認するクエリ
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_pct
FROM `PROJ.obs.decision_log` AS d
JOIN `PROJ.obs.heartbeat` AS h USING (start_token)
WHERE ts >= TIMESTAMP_SUB( CURRENT_TIMESTAMP (), INTERVAL 1 DAY )
GROUP BY agent_name, run_date
HAVING empty_pct >= 30
ORDER BY empty_pct DESC ;
40% を超えたエージェントは、その日のうちにコンテキスト初期化(リポジトリの再 clone とキャッシュ削除)を走らせるルールにしました。これだけで stale context 由来のサイレント失敗が 4 ヶ月で 17 回 → 3 回まで減っています。
アラートの優先度マトリクス — Slack に流すか PagerDuty を鳴らすか
3 層のシグナルが出揃うと、次の問題は「どれを夜中に起こすか」です。最初は全部 PagerDuty に流して 1 週間で疲弊しました。現在は下記のマトリクスで運用しています。
シグナル Slack #ops 軽度 Slack #ops-alert 中度 PagerDuty 重度
Heartbeat: 行が無い(起動失敗) ◯
Heartbeat: duration 3 倍超え ◯
Heartbeat: exit_code != 0 ◯
Output Trace: 連続 3 日 no-op ◯
Output Trace: 過剰更新(暴走疑い) ◯
Decision Log: empty 比率 30 〜 60% ◯
Decision Log: empty 比率 60% 超え ◯
Decision Log: error 連続 5 回 ◯
ポイントは「Output Trace の no-op だけでは PagerDuty を鳴らさない」ことです。no-op が正常な日もあるので、最初に Slack 軽度で集約し、3 日連続したら中度に上げます。これで深夜の誤呼び出しがほぼゼロになりました。私の場合 6 アプリ × 4 ヶ月で PagerDuty を鳴らしたのは 14 回、うち 12 回は実害ありの真陽性です。
6 アプリ並行で 4 ヶ月運用した結果 — 数値と判断の変化
導入前と導入後で、運用にかかる時間と障害復旧時間がどう変わったかを数字で残しておきます。アプリは壁紙系 4 本+癒し系 1 本+引き寄せ系 1 本の計 6 本、Antigravity サブエージェントは各アプリに対して「夜間更新」「広告 KPI 監視」「Crashlytics サマリ」の 3 種類が走っているので、合計 18 エージェントです。
サイレント失敗の見逃し件数 : 月 11 件 → 月 1 件(91% 減)
検知から修復までの平均時間 : 9.5 時間 → 11 分(約 50 倍速)
AdMob 売上の機会損失推定 : 月 $1,400 → 月 $120(85% 減・eCPM × 露出量から推計)
私の運用工数 : 1 日 45 分 → 1 日 8 分(朝の Slack 確認のみ)
観測コスト(GCP 請求) : 月 $6.80(BigQuery Streaming $4.10 + ストレージ $1.30 + Cloud Scheduler 0.40 + Cloud Run $1.00)
特に変わったのは「機会損失の認識」です。サイレント失敗は「落ちない」ので、入れる前は「特に問題なく動いているね」と言っていました。Output Trace を入れてから、実は毎月 $1,400 相当の売上を取り逃がしていたことが見えるようになり、これは個人事業として無視できない数字でした。累計 5,000 万ダウンロードのアプリ群でも、ARPDAU が低いカテゴリのアプリは月数千円単位で目減りしていて、6 本合計するとそのくらいになります。
落とし穴 — ログ過多のコスト爆発・PII 漏洩・リテンション設定
ここまで読んで「自分のエージェントにも入れよう」となったときに、私が最初の 2 週間でやらかした 3 つの罠を共有しておきます。
ログ過多で BigQuery 請求が 17 倍になった件
最初は result_size_bytes ではなく result_payload(生の JSON)を全部 BigQuery に流していました。3 日目で Cloud Billing からアラートが来て、月 $200 ペースに突入していました。結論:第 3 層には「サイズと種別」だけ流し、本体は GCS に Object Lifecycle で 7 日保管が推奨です 。これだけで月 $7 以内に収まります。
Decision Log に AdMob レポート API キーが残った件
arguments_hash ではなく arguments をそのまま流していた時期に、AdMob API のリクエストヘッダがログに 1 度だけ載りました。すぐに削除 + キー再発行で済みましたが、本番運用で BigQuery に PII / 秘匿情報を「うっかり」流すリスクは高いです。ハッシュ化を 1 段必ず挟む のと、もし生引数を残すなら専用テーブルに分離して IAM をきつく絞ることをお勧めします。
リテンションが空のままで 3 ヶ月分溜まっていた件
BigQuery の「テーブル有効期限」を設定し忘れていて、3 ヶ月分が無限に蓄積していました。Heartbeat は 30 日、Output Trace は 90 日、Decision Log は 14 日が私の運用デフォルトです。下記のように DDL で一発設定できます。
ALTER TABLE `PROJ.obs.decision_log`
SET OPTIONS (
partition_expiration_days = 14 ,
description = "Decision logs: short retention (raw payloads in GCS)"
);
明日からの最小構成 — まず 60 行で組む順序
長くなったので、最終的に明日から自分のエージェントに入れるための最小手順をまとめておきます。私が新しいプロジェクトに導入するときも、この順番で 1 日で組めます。
第 1 層 Heartbeat だけ作る (半日)。BigQuery テーブル 1 つ + emitHeartbeat 1 関数 + Cloud Scheduler に start_token を発行させる Cloud Run Job 1 本。これで「起動しなかった」「タイムアウト」「異常終了」の 3 つは押さえられます
アラートを Slack に 1 本だけ (30 分)。複雑なルールは作らありません。exit_code != 0 を Slack #ops に投げるだけ
1 週間運用してから第 2 層 (半日)。対象を 1 つに絞って Output Trace を入れる。最初から全部の対象を計測しようとすると挫折します
2 週間運用してから第 3 層 (半日)。empty 比率の閾値は最初は 50% に緩めにし、データを溜めてから 30% まで下げます
1 ヶ月たってから PagerDuty 接続 。Slack 運用で「これは絶対に夜中に起こされたい」と思った 2 〜 3 種類のシグナルだけ接続
ここで重要なのは「3 層を一気に作らない」ことです。私は焦って 1 週間で全部組んだ最初の月、誤検知 22% で運用が回らなくなりました。Heartbeat だけで 1 週間運用すると「自分のエージェントが普段どのくらいの duration で終わるか」「success 率は何 % か」が体感的に分かるので、第 2 層・第 3 層のしきい値設計が一気に楽になります。
宮大工の祖父が「打音検査は柱を増やすより先に、響きを知る方が大事だ」と言っていたのを最近よく思い出します。エージェントの観測も同じで、層を重ねる前にまず一層、自分の現場の「音」を知るところからだと、4 ヶ月運用してきて改めて感じています。
私の個人開発の現場(壁紙系・癒し系・引き寄せ系の 6 アプリ並行)で実証してきた構成なので、AdMob 収益化を伴う個人〜小規模チームの運用にはそのまま転用できるはずです。dolice.design 側で進行中のクライアントワークでも、Background Agent を本番投入する際には必ずこの 3 層から組むようにしています。もし 1 層目だけでも導入してみて、自分のエージェントの「音」が見えてきたら、次の層に進む判断ができるはずです。
お読みいただきありがとうございました。みなさまのエージェント運用が、夜中に静かに崩れていく不安から解放されることを願っています。