[Bug]: pickFallbackBackend ignores backend health & kind — Settings → LLMs shows "No backend is configured" even when a healthy local backend is registered
#1,174 opened on Jun 5, 2026
Repository metrics
- Stars
- (43 stars)
- PR merge metrics
- (PR metrics pending)
Description
Summary
When there's no valid backend selection (fresh start, or the selected backend was removed), the active backend is chosen by pickFallbackBackend, which returns backends[0] — the first registered backend in insertion order, ignoring both its kind and its recorded health. If index-0 is a cloud backend or a dead local backend, getEffectiveLocalBackend() resolves to null/dead, and every local-protocol call (verified models, LLM profiles, conversation CRUD) throws No backend is configured. — even when a healthy local backend is registered further down the list. A synchronous health store already exists at the point of selection but isn't consulted.
Split out of #1093 (its Docker/local part was handled in #1081).
Environment
macOS · Docker (ghcr.io/openhands/agent-canvas) · 1.0.0-beta.7 · verified against main @ df8a573
Steps to Reproduce
- Register two backends so that index-0 is either a cloud backend or a now-stopped local backend, and a healthy local backend is not index-0.
- Have no valid selection (fresh load, or delete the currently-selected backend).
- Open Settings → LLMs.
Actual Behavior
No backend is configured. toast; empty LLM Provider dropdown; no LLM profiles. The healthy local backend is never tried.
Expected Behavior
Fallback selection resolves to a healthy local backend when one is registered; verified models and profiles load.
Root Cause
pickFallbackBackend (src/api/backend-registry/active-store.ts:36-38):
function pickFallbackBackend(backends: Backend[]): Backend {
return backends[0] ?? NO_BACKEND;
}
Used by computeSnapshot when no selection resolves (active-store.ts:58-62). The chosen backend flows to getEffectiveLocalBackend() (:94-98), which returns null if it isn't a live local backend; local-protocol calls then throw NoBackendAvailableError("No backend is configured.") (agent-server-client-options.ts:22-27, :55-57). Selection consults neither kind nor the health store.
Proposed Fix
A synchronous health store already exists at this exact point (health-store.ts:29 getBackendHealthEntry; fed by the 10s probe in use-backends-health.ts; disabled persists in localStorage). Rank candidates by recorded health (and prefer a local backend, since that's what local-protocol calls need) instead of taking index-0 (illustrative, untested):
import { getBackendHealthEntry } from "./health-store";
function failScore(b: Backend): number {
const h = getBackendHealthEntry(b.id);
if (!h) return 0; // no recorded failures → best
if (h.disabled) return 1000; // probes gave up → worst
return h.consecutiveFailures;
}
function pickFallbackBackend(backends: Backend[]): Backend {
if (backends.length === 0) return NO_BACKEND;
const ranked = [...backends].sort((a, b) => failScore(a) - failScore(b)); // stable: index-0 stays the tiebreak
return ranked[0] ?? NO_BACKEND;
}
Caveats / open questions
- Cold-start ambiguity —
nullhealth can't distinguish "healthy" from "never probed"; before the first probe a stale-but-unflagged backend can still win. localStorage persistence means it self-corrects after the first session. - Should the fallback prefer a local backend over a cloud one? Local-protocol calls need a local backend, but the current design may intentionally allow a cloud fallback — needs a product call.
- Explicit cloud selection still yields
getEffectiveLocalBackend() === nullby design (docstring inactive-store.ts:86-93). That's the literal #1093 repro but appears intentional — if verified models/profiles should work on a cloud backend, that's a separate issue.
Related
#1093 (parent) · #1081 (Docker/local fix)