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.
This commit is contained in:
shimmyshimmer 2026-07-23 22:03:56 -07:00
commit 08cb60c6e1
2 changed files with 57 additions and 10 deletions

View file

@ -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;
}
}

View file

@ -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