Skip to content

fix(chat): retry agents that fail to load instead of a dead mode chip - #13

Merged
doublepi123 merged 4 commits into
mainfrom
fix/agent-chip-retry
Oct 17, 2026
Merged

doublepi123 merged 4 commits into
mainfrom
fix/agent-chip-retry

Conversation

@doublepi123

Copy link
Copy Markdown
Owner

Fixes "the build (agent mode) chip does nothing when tapped" on dp4, with a local OpenCode running oh-my-opencode-slim.

Root cause. A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...) installs them on its first GET /agent after a start. With an empty plugin cache that call took ~25s against OpenCode 1.18.5 and the user's real config, and it can fail. The catalogue took that single answer as final, so the agent list stayed empty and the mode chip kept showing the remembered "build" over an empty menu: tapping it opened nothing visible.

#9 (hidden: null) is in dp4 and correct. dp4 decodes the real /agent output fine; the read itself never succeeded.

Reproduced on an emulator. I put a proxy in front of a real server that fails the first /agent. On dp4 the chip stayed dead for the whole session (60s, background/foreground round trip), and /agent was never asked again.

Fix

  • load() reads agents once and publishes the catalogue. If no agent is selectable, a single background retry (refreshAgents, 4 × 3s) runs outside the refresh lock, so sessions, providers and other refreshes never wait on a slow /agent.
  • A failed or empty agent read no longer blanks agents this runtime already showed (same rule as providers, Opencode crash yuga-hashimoto/and-code#373).
  • The mode chip, tapped with no agents, triggers that retry instead of opening an empty menu. A runtime that never connected gets a full refresh(); one still connecting is left to its load.
  • One retry at a time (synchronized). A retry left from a runtime the user switched away from is cancelled.
  • The "Agents: …" error line is dropped once the retry succeeds. It is flattened to one line so multi-line decoding errors are matched.

Verified

  • Local: ./gradlew detekt spotlessCheck :app:testGithubDebugUnitTest :app:assembleGithubDebug, 1405 unit tests passing (6 new in RuntimeCatalogRepositoryTest).
  • Emulator (debug build, via the failing proxy):
    • First /agent fails: the menu (orchestrator/build/plan) is there within 9s, and no "Agents:" error is left on screen.
    • 12 consecutive failures: 4 fast taps add only one retry (5 calls). After the server recovers, the next tap opens the menu and picking plan sticks.

Follow-ups

  • A loading indicator on the chip while agents are being retried (needs new strings in 8 locales).

Pre-PR review

Reviewed by repo-reviewer in three rounds, each APPROVE, at 06dbac0, 4d680dd and 69a1d3b. Each round's non-blocking suggestions were applied in the next commit. The last commit, 8a6b766, applies round 3's two remaining small suggestions: one-line agents error, and no early poll while connecting. It is verified locally (1405 tests) and was not sent for another round. Round 3's report:

判定: APPROVE

ブロッカー

なし

提案(非ブロッキング)

  • RuntimeCatalogRepository.kt:392:複数行のエラーメッセージだと、エラー行が消えずに残ります。withAgents() は error を行ごとに分け、agentsError と完全に一致する行だけを取り除きます。agentsError が複数行だと、どの行とも一致しないので何も消えません。
    • HTTP エラーは問題ありません。formatHttpError(OpenCodeApiClient.kt:712-718)がスニペットを " " でつないで 1 行にしているからです。
    • 一方、kotlinx.serialization のデコード例外のメッセージは "...\nJSON input: ..." のように複数行になることがあります。fix(opencode): decode agents whose hidden flag is null #9 で直した hidden: null も、まさにこの種類の失敗でした。
    • 再試行が成功しても、エラーカードが残るだけで、agents 自体は正しく入ります。
    • 修正案:agents 以外のエラー行を別に持っておき、error を組み立て直す。または、記録するときに agentsError の改行を空白に置き換える。
  • RuntimeCatalogRepository.kt:304-308:接続確認中(health == null && isRefreshing)にタップすると、refresh() には切り替わらず、再試行 job が起動します。
    • その job は、まだ接続していないサーバーに約 12 秒間 /agent を問い合わせ続けます。
    • load() の最後の refreshAgents() は、同じランタイムの job が動いているので何もしません。そのため実際に再試行するのはこの早すぎた job だけで、これが接続完了より先に失敗し終えると自動回復は起きません(タップすれば回復します)。
    • 修正案:health == null のときは isRefreshing かどうかにかかわらず再試行を起動せず、実行中の load() に任せる(isRefreshing のときは何もせず return)。
  • テストの追加候補:ランタイム切替で古い job を cancel し、新しいランタイム用に起動し直す経路(:310-315)。

チェック済み項目

  • shell が使えなかったので、69a1d3b6 の差分は直接見ていません。RuntimeCatalogRepository.kt の該当箇所(30-89、160-394 行)とテスト(274-353 行)を直接読みました。gradle とエミュレータでの確認結果は、報告されたものをそのまま受け取っています。
  • agentsError の流れを確認しました。
    • load() が最後に書き込むとき、毎回新しい値を設定します(:268)。接続失敗やランタイム切替で state を作り直すときは null に戻ります(:87/179/202)。
    • 同じランタイムで refresh() している間に古い job が成功しても、withAgents が消すのはその時点の agentsError です。他のエラー(sessions/providers/workspaces)は残ります。
    • RuntimeCatalogState(...) を生成しているのはこのファイルの中だけで、新しいフィールドにはデフォルト値があります。外部の呼び出し元への影響はありません。
  • failures() は agentsFailure() を共有しているので、error の中の行と agentsError の文字列が必ず一致します。
  • synchronized は inline なので、:313 の return は non-local return として正しく動きます。scope.launch はブロックしないので、ロックを持ったまま suspend することはありません。
  • 切替時の扱いを確認しました。
    • 別のランタイムの job は cancel してから起動し直します。同じランタイムなら新しくは起動しません。
    • cancel された job は delay か listAgents の途中で止まり、書き込みには届きません。届いた場合も :320-321 の id 確認で弾かれます。
  • 無限ループが無いことを確認しました。refreshAgents → refresh() に進むのは health == null && !isRefreshing のときだけです。load() の最後の refreshAgents()(:273)に届くのは接続に成功した後だけで、そのとき health は null ではありません。
  • 追加テスト an agent retry for a runtime that never connected…(:301)は、/agent の呼び出し回数が増えないことと、エラーが "offline" であることを確かめています。既存テスト(:297)では、再試行の成功後に error == null を確認するようになっています。
  • 最大行長は 140 で、:304 は約 133 文字なので範囲内です。新しいユーザー向け文字列は無く、i18n への影響もありません。DI のコンストラクタも変わっていません。

A local OpenCode with plugins (oh-my-opencode-slim, opencode-mem, ...)
installs them on its first GET /agent after a start: on a fresh cache that
call took ~25s here, and it can fail outright. The catalogue took that one
answer as final, so the agent list stayed empty until something else
refreshed it, and the mode chip kept showing "build" over a menu with
nothing in it - tapping it did nothing visible. Reproduced on an emulator
against a proxy that fails the first /agent: the chip stayed dead for the
whole session, across 60s and a background/foreground round trip.

- RuntimeCatalogRepository retries GET /agent (4 x 3s) while the answer is a
  failure or an empty list
- refreshAgents() re-reads only the agents, keeping the rest of the catalogue
- the mode chip asks for a refresh when tapped with no agents, instead of
  opening an empty menu
Per review: the agent retry ran inside load()'s refresh lock, holding the
whole catalogue (and every other refresh) behind up to several slow
GET /agent calls. load() now reads agents once and publishes, then a
single background retry (agentRetryJob) runs outside the lock.

- a failed or empty agent read no longer blanks agents this runtime
  already showed (same rule as providers, yuga-hashimoto#373)
- repeated taps on an empty chip share one retry instead of stacking polls
- a runtime that never connected gets a full refresh() from the chip
- success means at least one selectable primary agent, matching the chip
Per review: a first GET /agent failure put "Agents: ..." into the catalogue
error, and the background retry only replaced the list, so the workspaces
screen kept showing the failure after the agents had arrived. The agents
line is kept separately and dropped when the retry fills the list.

Also: a retry left over from a runtime the user switched away from is
cancelled rather than blocking the new runtime's, and starting a retry is
synchronized since taps (main) and load (IO) both reach it.
- decoding errors carry the JSON input on a second line, so the agents
  failure never matched a line of the joined error and stayed on screen
  after a successful retry; it is flattened to one line when recorded
- a chip tap while the runtime is still connecting no longer starts its own
  agent poll ahead of the load, which retries the agents once connected
@doublepi123
doublepi123 merged commit 187d26f into main Oct 17, 2026
5 checks passed
@doublepi123
doublepi123 deleted the fix/agent-chip-retry branch October 17, 2026 10:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant