廣川政樹(@dolice)です。2014 年から個人開発を続けてきて、累計 5,000 万 DL の壁紙・癒し系アプリ群を運用しています。直近 8 週間、AdMob の eCPM 最適化と Crashlytics トリアージを Antigravity のバックグラウンドエージェントに任せきりにしてみました。最初の 3 週間で気づいたのは、「エージェントは壊れたまま静かに走り続ける」という事実です。プロセスは生きている、ターミナルにも何も出ない、けれど中身は何も進んでいない。
ここでは、その 8 週間で観測した 3 種の停止モードと、最終的に落ち着いた watchdog + heartbeat + 段階的リカバリの設計を残しておきます。一晩中エージェントを走らせるタイプの個人開発者に向けたメモです。
観測された 3 つの停止モード
長時間実行のエージェントが「静かに止まる」とき、内訳はだいたい次の 3 種類に収れんしました。8 週間で合計 142 件の異常終了をカウントし、内訳は次の通りです。
- 無応答型(41%) — LLM 呼び出しのあと応答が来ず、SDK 側でタイムアウトもリトライも発火しない。プロセス的には alive、CPU 使用率 0% で固まる。
- セッション枯渇型(34%) — Antigravity の workspace セッションが内部的に切れ、新しい tool 呼び出しが全て無音で失敗する。エラーは握りつぶされ、エージェントは「次の計画」を考え続ける。
- コンテキスト溢れ型(25%) — 投入コンテキストが上限を超えてエージェントが省略モードに入り、過去の決定を「思い出せない」まま同じステップをループする。
どれも共通して、外形監視だけでは検知できません。プロセスは生きているし、メモリも CPU も平穏です。中の論理状態を見ないと判らない。これが、外形監視に頼ってきた個人開発者がハマる最大の落とし穴でした。
アーキテクチャ全体像
最終的に落ち着いた構成はこうです。
┌────────────────────────────────┐
│ Agent Process (Antigravity) │
│ - business logic │
│ - heartbeat(stage, progress) │──┐
└────────────────────────────────┘ │ writes
▼
┌───────────────────┐
│ heartbeat.json │ (atomic write)
└───────────────────┘
▲
│ reads (5s interval)
┌────────────────────────────────┐ │
│ Supervisor Process │──┘
│ - watchdog timer │
│ - tiered recovery │
│ - escalation log │
└────────────────────────────────┘
エージェント本体は自分の進捗を heartbeat.json に書き込むだけ。判断は全て supervisor 側に寄せます。エージェントが壊れたとき、自分で自分を救えるとは限らない、という前提です。
なぜ自己監視ではなく外部 supervisor か
AI エージェントに「もし自分が止まっていたら復旧してください」と頼むパターンを最初に試したのですが、3 つの停止モードのうち 2 つで失敗しました。無応答型では復旧コードまで辿り着かないし、コンテキスト溢れ型では「自分が止まっている」という事実そのものを忘れます。supervisor は独立した別プロセスで持つ、というのが結論です。
heartbeat プロトコル
heartbeat は「何秒前に生きていたか」だけでは不十分でした。なぜなら無応答型は LLM 呼び出しの直前で固まるので、その瞬間のタイムスタンプだけ更新されてしまうからです。stage(処理段階)と progress(前回からの差分)を両方持つのが大事です。
// agent/heartbeat.ts
import { writeFileSync, renameSync } from "fs";
import { join } from "path";
type Stage =
| "planning"
| "tool_call"
| "llm_response"
| "post_process";
interface Heartbeat {
ts: number; // epoch ms
stage: Stage;
step_idx: number; // 単調増加
context_tokens: number;
session_id: string;
last_tool: string | null;
}
const HB_PATH = join(process.env.STATE_DIR!, "heartbeat.json");
export function beat(h: Omit<Heartbeat, "ts">): void {
const payload: Heartbeat = { ...h, ts: Date.now() };
const tmp = HB_PATH + ".tmp";
writeFileSync(tmp, JSON.stringify(payload));
renameSync(tmp, HB_PATH); // atomic on POSIX
}
ポイントは 2 つあります。第一に step_idx は単調増加カウンタで、進んでいるかどうかの判断はここを見ます。同じ stage に 60 秒留まっていても step_idx が増えていれば正常、増えていなければ無応答型を疑います。第二に書き込みは tmp → rename の atomic 書き込みにすること。supervisor 側が読みかけのファイルを掴むと半壊 JSON で勝手にリカバリが走る、という事故を 1 度起こしています。
watchdog タイマーと判定ロジック
supervisor は 5 秒間隔で heartbeat を読み、停止モードを切り分けます。
// supervisor/watchdog.ts
import { readFileSync } from "fs";
interface Verdict {
state: "healthy" | "stalled" | "session_dead" | "context_bloat";
reason: string;
}
const STALL_MS = 90_000; // 90 秒進捗ゼロで stall
const TOKEN_LIMIT_RATIO = 0.92; // 上限 92% 超え
let lastIdx = -1;
let lastIdxAt = Date.now();
export function evaluate(hbPath: string, tokenLimit: number): Verdict {
const hb = JSON.parse(readFileSync(hbPath, "utf8"));
const now = Date.now();
// (1) ファイル自体が古ければセッション死亡
if (now - hb.ts > 30_000) {
return { state: "session_dead", reason: `hb age ${now - hb.ts}ms` };
}
// (2) step_idx が進まなければ stall
if (hb.step_idx !== lastIdx) {
lastIdx = hb.step_idx;
lastIdxAt = now;
} else if (now - lastIdxAt > STALL_MS) {
return { state: "stalled", reason: `step ${hb.step_idx} for ${now - lastIdxAt}ms` };
}
// (3) コンテキストが上限近くなら溢れリスク
if (hb.context_tokens / tokenLimit > TOKEN_LIMIT_RATIO) {
return {
state: "context_bloat",
reason: `tokens ${hb.context_tokens}/${tokenLimit}`,
};
}
return { state: "healthy", reason: "ok" };
}
90 秒という stall 閾値は、最初は 30 秒で組んでいて誤検知が 1 日 10 件以上出ました。AdMob レポート API の中央値レスポンスが 22 秒、95 パーセンタイルが 71 秒だったので、95 パーセンタイルに余裕を足した値が運用上の現実解です。アプリ収益系の外部 API は遅いと割り切ったうえで、ステージごとに別の閾値を持たせるとさらに精度は上がります。
段階的リカバリ(escalation tiers)
判定だけして手を打たなければ意味がありません。重要なのは「重い手段に飛びつかない」ことで、段階を 3 つに分けるのを強く推奨します。
Tier 1: soft retry(軽量・冪等)
stall を検知したら、まず現在の tool 呼び出しを「進捗ゼロを認識した上で」再投入します。コードはこれだけです。
async function softRetry(agentBus: AgentBus): Promise<boolean> {
await agentBus.send({
type: "supervisor_nudge",
msg: "前回の tool 呼び出しが 90 秒応答なし。同じ操作を再試行するか、別アプローチを検討してください。",
});
return waitForProgress(15_000); // 15 秒以内に step_idx が動けば成功
}
ここで成功するのが 8 週間の集計で 58%。LLM 側の一時的なゆらぎが原因のケースが多く、Tier 1 だけで半分以上が片付きます。
Tier 2: context reset(中量・状態保持)
soft retry で進まなければ context_bloat か session_dead の疑いに切り替えます。具体的には、過去の長い tool 出力を要約に置き換えてからエージェントを再起動します。要約は別の小さな LLM 呼び出しで実行するのがコツで、メインエージェントに要約させると要約自身がループするケースに何度かハマりました。
Tier 2 の成功率は 29%。これで累積 87% が自動リカバリに収まる計算です。
Tier 3: agent rotation(重量・最終手段)
context reset でも復旧しない場合、別の workspace セッションでエージェントを丸ごと立ち上げ、heartbeat と状態スナップショットを引き継ぎます。Tier 3 まで来ると人間に通知すべきサインなので、私の運用では Tier 3 突入時点で Pushover 通知を発火させています。Tier 3 の発生率は全体の 13% で、ここに来てしまうケースは大半が「エージェント側ではなく外部 API(AdMob / Crashlytics)の本番障害」が原因でした。
段階別リカバリ成績(8 週間・142 件)
| Tier | 試行回数 | 成功率 | 累積カバー |
| Tier 1 soft retry | 142 | 58% | 58% |
| Tier 2 context reset | 60 | 49% | 79% |
| Tier 3 agent rotation | 31 | 39% | 87% |
差分の 13% は外部要因(API ダウン・ネットワーク分断)で、人間が介入しないと復旧しません。「100% 自動復旧」を目指すと watchdog のロジックが肥大化して逆にバグるので、87% で線を引いて残りはアラートに任せる、というのが個人開発の現実解だと考えています。
ハマったポイント 3 つ
実装中につまずいた箇所も残しておきます。同じ罠を踏む方が必ず出ると思うので。
(1) heartbeat の atomic 書き込みを忘れる
最初は writeFileSync を直接呼んでいて、supervisor が半壊 JSON を読んで「session_dead」と誤判定する事故を起こしました。tmp → rename の 2 段書き込みを必ず採用してください。Windows と WSL2 で挙動が違う点も注意が必要です。WSL2 上だと rename が稀に EBUSY を返すので、3 回までリトライするコードを supervisor 側に入れています。
(2) Tier 2 のコンテキスト要約をメインエージェントにやらせる
メインエージェントが「自分のコンテキストを要約してください」と頼むと、コンテキスト溢れ状態のままさらに要約タスクを抱えてループ深度が増します。別プロセスの小さな LLM(私の場合は Gemini Flash)に要約専用で投げるのを強く推奨します。
(3) Pushover 通知の reentrancy
Tier 3 を 1 分以内に複数回発火すると、人間側が通知爆弾を浴びます。supervisor 側に「同種アラートは 10 分に 1 回まで」のレートリミットを必ず仕込んでください。私は初日にこれを忘れて 2 時間で 47 通の通知を浴びました。
どのエージェント運用にこの設計を推奨するか
watchdog + 段階的リカバリ全部入りを推奨するのは、次の条件のいずれかに当てはまる場合です。
- 1 回の実行が 30 分以上続く長時間タスクを夜間に流す
- 外部 API(AdMob / App Store Connect / Crashlytics 等)に依存して、相手の API が時々遅延する
- 1 つのエージェントが複数アプリ・複数サイトをまたぐ
- 個人開発で監視を完全自動化したい
逆に、5 分以内に終わる短時間タスクなら supervisor を組むコストの方が高いです。素直に再実行する仕組みだけで十分でしょう。私自身、Antigravity Lab の記事生成(5〜8 分タスク)には watchdog を入れていません。30 分以上の AdMob 最適化ジョブと、Claude in Chrome × Crashlytics の自動トリアージ(平均 90 分)にだけ全力で組み込んでいます。
次の一歩
まず heartbeat.ts だけ既存エージェントに差し込むことから始めるのが現実的です。書き込むだけで判断ロジックを入れなければ、副作用はほぼゼロ。1 週間データを溜めて、自分のエージェントがどの停止モードに偏っているかを見てから supervisor を組むと、無駄な実装を減らせます。
実装の参考になれば嬉しいです。お読みいただきありがとうございました。