Skip to content

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

Merged
yuga-hashimoto merged 7 commits into
yuga-hashimoto:mainfrom
doublepi123:fix/upstream-agent-retry
Oct 18, 2026
Merged

yuga-hashimoto merged 7 commits into
yuga-hashimoto:mainfrom
doublepi123:fix/upstream-agent-retry

Conversation

@doublepi123

Copy link
Copy Markdown
Contributor

Summary

When a runtime's GET /agent failed or answered with nothing selectable (typically a local OpenCode installing its plugins on the first read after a start), that one answer was final until something else refreshed the catalogue. The mode chip kept showing build over an empty menu, and tapping it did nothing.

  • Retry outside the catalogue lock: RuntimeCatalogRepository.refreshAgents() re-reads the agents in the background (4 retries, 3 s apart) until the runtime offers one to pick. The rest of the catalogue, and every other refresh, no longer waits on a slow GET /agent.
    • Only one retry runs at a time.
    • A retry left over from a runtime the user switched away from is cancelled.
    • A runtime that never connected gets a full refresh() instead of polling a dead server.
  • Never blank a working picker: like the provider list (Opencode crash #373), a failed or empty read keeps the agents this runtime already showed. A kept list carries no failure of its own: the chip shows no error, and no Agents: ... line is left in the catalogue error.
  • Error clears on success: once a retry gets the agents, the load's Agents: ... line is removed from the catalogue error. Decoding errors are collapsed to one line so this removal can match them. The load writes its agents inside the same state update it reads them in, so a retry that succeeds between the load's fetch and its write is not overwritten with an empty list.
  • Chip states:
    • While agents are loading, the chip shows a spinner (with a TalkBack content description) and a disabled "Loading agents…" item.
    • Once retries give up, it shows "Couldn't load agents. Tap to retry."
    • A retry that gives up while a load of the same runtime is still in flight keeps the chip loading.
  • New strings chat_agents_loading and chat_agents_load_failed are added in all 8 locales.

Testing

  • ./gradlew spotlessCheck detekt :app:testGithubDebugUnitTest (JDK 17 + SDK 35): passed.

  • RuntimeCatalogRepositoryTest gets 6 new cases:

    • retry until the runtime answers
    • full refresh for a never-connected runtime
    • fill an empty picker without touching the rest of the catalogue
    • keep the list on screen, with no agents error, when a later read fails
    • a single retry at a time
    • loading → given-up state transitions
  • Last commit f66bb973 applies three of the first review's suggestions:

    • drop the agents error line when a kept list is shown
    • keep a racing load's chip loading
    • stop the chip from requesting a refresh while it is loading

    The report below is the re-review of that HEAD.

Pre-PR review

判定: APPROVE

ブロッカー

なし

提案(非ブロッキング)

  • app/src/test/java/com/yugahashimoto/andcode/data/repository/RuntimeCatalogRepositoryTest.kt — f66bb97 で追加された次の2つの挙動に、直接のテストがありません。現状のコードで正しく動くことはコードを追って確認済みなので、ブロッカーにはしていません。

    • リトライが諦めたとき agentsLoading = it.isRefreshing になり、同じランタイムの load がまだ動いている間は loading を保つ(RuntimeCatalogRepository.kt:360)。
    • 他ランタイムの古いリトライを cancel する分岐(RuntimeCatalogRepository.kt:343-344)。今回は見送りと伺っています。

    どちらも、agentAnswers に suspend する応答を入れ、runCurrent() で途中の状態を確認すれば再現できます。後続の PR で追加することを勧めます。

チェック済み項目

  • 差分全体(/tmp/opencode/review-upstream-agent-retry.diff、7 コミット: 21b8a75〜f66bb973)を読み直し、追加分(.followup.diff)と worktree の HEAD 版ファイル(RuntimeCatalogRepository.kt:258-365)を突き合わせました。差分とファイルの中身は一致しています。
  • 前回提案1(一覧を残したときに error に agents の失敗行が残る件)
    • RuntimeCatalogRepository.kt:275-277 で、agentsError と同じ条件のときは failures() から readFailure を除いています。
    • 除外の判定は、比較対象の文字列が完全に一致する場合だけ効きます。他の失敗行は messages.sessions/providers/workspaces で接頭辞が違うので、誤って消えることはありません。readFailure == null のときも何も除かれません。
    • 既存テスト a refresh whose agent read fails keeps the agents already on screen は、初回 load で build を表示し、次の refresh で agents の取得を全部失敗させます。この経路でちょうど該当コードを通るので、agentsError と error がともに null になるアサーションは有効です。
  • 前回提案2(リトライの失敗が進行中の load の loading 表示を消す件)
    • it.isRefreshing を残す形に直っています。
    • 新しい挙動で loading が残りっぱなしにならないことも確認しました。load の最終書き込みは agentsLoading = !agentsFound で必ず上書きされます。接続失敗時は新しい RuntimeCatalogState(loading は false)で書かれます。ランタイムを切り替えたときは collectLatest が状態をリセットします。
    • 再試行の流れも確認しました。load が一覧を見つけられずに末尾の refreshAgents() を呼ぶ時点では、前のリトライのジョブは既に終わっているので(isActive == false)、新しいリトライが正しく起動します。
  • 前回提案3: ChatHomeScreen.kt:1908 が selectableAgents.isEmpty() && !loading になり、メニュー項目の enabled = !loading と揃っています。
  • 全8ロケール(values / ar / es / fr / ja / pt-rBR / ru / zh-rCN)に chat_agents_loading と chat_agents_load_failed があることを確認しました。追加コミットで文字列の変更はありません。Kotlin にハードコードされた表示文字列もありません。
  • 配線を確認しました。
    • agentsLoading は SettingsViewModel.kt:222 → AndCodeApp.kt:1065 → ChatHomeScreen → ChatComposer → ModeChip と渡っています。
    • onRefreshAgents は AndCodeApp.kt:1133 で渡しています。
    • 新しい引数にはデフォルト値があるので、ChatVoiceInstrumentedTest.kt:34 とプレビュー(ChatHomeScreen.kt:2235)はそのままコンパイルできます。
    • コンストラクタは変わっていないので、Koin(AppModule.kt:205)と AndCodeApplication.kt:507 に影響はありません。
  • 並行性: agentRetry は synchronized(agentRetryLock) 内で参照・起動されます。loadAgents は毎回選択中のランタイムを確認するので、切り替え後は自然に終了します。load() 内の return は inline の withLock を抜ける non-local return で、接続失敗時や切り替え後は末尾の refreshAgents() に到達しません。
  • Claude Code / Antigravity / Codex の listAgents は静的な一覧か PermissionModeChip 経由なので、今回のリトライ処理には入らないことを確認しました。
  • 秘密情報の追加はありません。コメントの方針もこのリポジトリの慣習どおりです。
  • 確認できていない点
    • シェルが使えなかったため、CI ゲート(spotlessCheck / detekt / testGithubDebugUnitTest)は再実行していません。依頼者のローカル全通過の報告を前提にしています。
    • ブランチは .worktree/ 配下にあり、上流 PR なので upstream/main 基点であることは依頼で明示されています。scripts/new-worktree.sh で作ったかどうかまでは検証できていません。

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
A local OpenCode with plugins takes ~25s on its first GET /agent after a
start. During that time the mode chip had no agents and a tap only fired
a silent background refresh, so the chip looked dead on every app start or
runtime restart. Reproduced on an emulator with a proxy delaying the first
/agent by 25s: the first 7 taps (26s) showed nothing.

- RuntimeCatalogState.agentsLoading: true while agents are being read and
  none is on screen (load or the background retry), false once they arrive
  or the retries give up
- the chip swaps its shield for a small spinner while loading
- a tap always opens the menu: a disabled "Loading agents…" while loading,
  "Couldn't load agents. Tap to retry." once retries are over; agents that
  arrive while it is open replace the placeholder
- strings in all 8 locales
- load()'s final write reads the agents inside the update, so a retry that
  succeeded between its fetch and write is no longer overwritten with an
  empty list (which also left the spinner running)
- a retry tap on a never-connected runtime marks loading synchronously, so
  repeated taps cannot queue one full reconnect each
- the spinner carries the loading content description for TalkBack
- zh-CN uses 智能体 like the rest of the locale
… load loading

Apply the pre-PR review on the agent retry:
- a load whose GET /agent fails but keeps the list already on screen no
  longer leaves an "Agents: ..." line in the catalogue error, which
  nothing would clear until the next refresh
- a background retry that gives up while a load of the same runtime is
  still fetching keeps the chip loading instead of offering a retry that
  would race the load
- the chip itself does not ask for a refresh while it is loading
@yuga-hashimoto
yuga-hashimoto merged commit 6268d15 into yuga-hashimoto:main Oct 18, 2026
5 checks passed
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.

2 participants