From 08cb60c6e1607f06888d1150dfcfddf95b3f135c Mon Sep 17 00:00:00 2001 From: shimmyshimmer Date: Thu, 23 Jul 2026 22:03:56 -0700 Subject: [PATCH] Studio autoload: retry the folder's next quant when a load itself fails Fifth round of review follow-ups. A resolved quant that passed validation could still fail /api/inference/load (corrupt file, llama.cpp startup error); the cascade then abandoned the whole row because only validation blocks recorded a skip key. The fallback now marks the failed quant skipped and resolves the folder's next complete quant before moving on, bounded by the existing attempt cap. Single-candidate rows resolve to null once skipped, so the retry loop terminates. Contract test and simulation added for the failure-then-retry path. --- .../src/features/chat/api/chat-adapter.ts | 46 +++++++++++++++---- tests/studio/test_model_picker_contracts.py | 21 +++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 095f74dc36..41d92f2df0 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -1568,8 +1568,12 @@ async function resolveLocalRowCandidate( return null; } } + const candidate = localRowToCandidate(row, isGguf ? rememberedVariant : null); + // Single-candidate rows resolve to null once their candidate is skipped, + // so the retry loop in the fallback terminates instead of re-attempting. + if (isSkippedCandidate?.(candidate)) return null; return { - candidate: localRowToCandidate(row, isGguf ? rememberedVariant : null), + candidate, sizeBytes: sizeOrUnknownBytes(row.size_bytes), }; } @@ -2262,20 +2266,42 @@ export async function autoLoadOnDeviceModel(): Promise<{ continue; } const row = candidate.row; - const localCandidate = candidate.candidate; + let localCandidate: AutoLoadCandidate | null = candidate.candidate; if (isSeen(localCandidate.kind, row.load_id, row.id, row.path)) { continue; } markSeen(localCandidate.kind, row.load_id, row.id, row.path); - if (isSkippedAutoLoadCandidate(localCandidate)) { - continue; - } - try { - if (await loadAutoLoadCandidate(localCandidate)) { - return { loaded: true, blockedByTrustRemoteCode: false }; + // Try the row's quants smallest-first: a failed LOAD (not just a + // blocked validation) marks that quant skipped and the folder's next + // complete quant is resolved and tried, so one corrupt file cannot + // abandon a folder that still holds a loadable quant. The attempt cap + // still bounds total /load calls; single-candidate rows resolve to + // null once skipped, terminating the loop. + while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS) { + if (!isSkippedAutoLoadCandidate(localCandidate)) { + try { + if (await loadAutoLoadCandidate(localCandidate)) { + return { loaded: true, blockedByTrustRemoteCode: false }; + } + } catch { + hadNonTrustFailure = true; + skippedAutoLoadCandidates.add( + autoLoadCandidateKey( + localCandidate.kind, + localCandidate.id, + localCandidate.ggufVariant, + ), + ); + } + } + try { + localCandidate = + (await resolveLocalRowCandidate(row, null, isSkippedAutoLoadCandidate)) + ?.candidate ?? null; + } catch { + hadNonTrustFailure = true; + break; } - } catch { - hadNonTrustFailure = true; } } diff --git a/tests/studio/test_model_picker_contracts.py b/tests/studio/test_model_picker_contracts.py index f1c7037e1a..c5fb84e50f 100644 --- a/tests/studio/test_model_picker_contracts.py +++ b/tests/studio/test_model_picker_contracts.py @@ -708,3 +708,24 @@ def test_local_fallback_orders_by_resolved_quant_size(): "const ggufGroup: FallbackCandidate[]" ) assert "sizeBytes: resolved.sizeBytes" in auto_load + + +def test_cascade_retries_next_quant_after_load_failure(): + """A failed /api/inference/load (not just a blocked validation) must mark + that quant skipped and try the folder's next complete quant before the + row is abandoned; single-candidate rows resolve to null once skipped so + the retry loop terminates, and the attempt cap bounds total loads.""" + src = _read("features/chat/api/chat-adapter.ts") + auto_load = src.split("async function autoLoadOnDeviceModel", 1)[1] + assert ( + "while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS)" in auto_load + ) + # The cascade catch records the failed quant, unlike the old generic flag. + local_loop = auto_load.split( + "while (localCandidate && loadAttempts < MAX_AUTO_LOAD_ATTEMPTS)", 1 + )[1].split("\n }", 1)[0] + assert "skippedAutoLoadCandidates.add(" in local_loop + # Termination guard: a skipped single candidate resolves to null. + resolve_fn = src.split("async function resolveLocalRowCandidate", 1)[1] + resolve_fn = resolve_fn.split("\nfunction ", 1)[0] + assert "if (isSkippedCandidate?.(candidate)) return null;" in resolve_fn