# fix(#1011): 失敗した probe が 90 秒ごとに永久再発火する

レート制限ゲージ（#387）の probe が、レポートを得られないまま 90 秒（`PROBE_TIMEOUT_MS`）ごとに
発火し続ける。報告では 30 分で 21 回。probe は Claude セッションを起動するので、**気づかないまま
トークンを消費し続ける**。

## なぜ止まらないか（コードで確認済み）

```
probe 開始 → setProbeInFlight(true)
  ↓ 90 秒たってもレポートが来ない
timer → stop() → onSettled() → setProbeInFlight(false)
  ↓ report() は limits が無いと早期 return するので reportedAt_ms は古いまま
wantsProbe() = 「見ている人がいる」かつ「値が古い」→ 即 true → 次の probe
```

`shouldProbe` の 2 条件のうち、**失敗しても更新されない側が永久に真**になる。試行そのものを
記録していないのが原因。

## 失敗は 4 種類あり、性質が違う

| # | 何が起きているか | いまの挙動 | どう扱うべきか |
| --- | --- | --- | --- |
| 1 | **API キー課金** — `rate_limits` が構造的に存在しない（`statusline.ts` の冒頭コメントに明記） | statusline は届くが `extractRateLimits` が null → 早期 return → 90 秒ごとに永久 | **statusline が届いた事実は成功**。窓が無い環境だと分かるので、長い間隔まで下げて理由を出す |
| 2 | **信頼プロンプト未応答 / ログイン切れ** | レポートなしでタイムアウト → 90 秒ごと | 直るまで待つ。指数バックオフ |
| 3 | **`claude` 未インストール** | spawn が即 throw → `stop()` → **90 秒すら待たず**ポーリング間隔で再試行 | **spawn する前に判定して止める**。バイナリの有無は起動しなくても分かる |
| 4 | 一時的な失敗（起動が遅い等） | 90 秒後に再挑戦 | 妥当。バックオフは成功でリセット |

**1 と 2/3 は区別できる。** statusline が POST してきたかどうかが違う（1 は届く、2/3 は届かない）。
いまは両方「limits が null」に潰れているので、その差を捨てている。

## 直し方

### store — 試行と結果を覚える

- `lastProbeAt_ms` / `consecutiveFailures` / `probeState` を持つ
- `shouldProbe` に **「直近の試行から十分経ったか」** を足す。間隔は失敗回数の指数（90 秒 → 3 分 →
  6 分 …、上限 1 時間）。成功でリセット
- `report(agent, null)` は「窓を持たない環境」として記録する（早期 return をやめる）。ただし
  **ゲージの数値は消さない** — 既存の「absent は 0% ではない」原則は守る

### probe — 起動前に諦める

`claude` が解決できないなら spawn しない。`resolve-bin` で判定できる。probe 機能ごと止め、
理由を持つ（インストールされたら次の判定で復帰する）。

### 画面 — 理由を出す

いまは何も出ない。「出ない」と「出せない」の区別がつかず、#1011 の報告者のように**裏で暴走して
いても気づけない**。ゲージの場所に短い理由を出す:

| 状態 | 表示 |
| --- | --- |
| `claude` が無い | claude が見つからない（gauge は codex だけ） |
| 窓を持たない環境 | API キー課金では取得できない |
| 連続失敗中 | 取得できていない（次回まで待機中） |

## テスト

- `shouldProbe` — 失敗直後は false、バックオフ経過後に true、成功でリセット
- 90 秒ごとの再発火が**再現しないこと**（#1011 の回帰テスト）
- statusline が窓なしで届いた場合と、届かなかった場合が別の状態になること
- `claude` 不在で spawn を呼ばないこと
- 応答に理由が載ること
