Checkpoint: auto-retry after a failed pass; release rate-limit slot on failure

A paste fires exactly one grammar checkpoint, and a failed one never retried
until the next keystroke — stranding the writer on "Petal's helper is resting"
after a paste. Long docs make it worse: their 15-25s checks have a wide window
to catch a transient 502 from the shared Ollama (co-tenant apps load other
models and evict the 9B). A failed pass also burned the per-document rate-limit
slot, so a retry within 30s hit the throttle path and got an empty set back.

- llm.RateLimiter.Release rolls back a slot when its pass fails; Allow now
  returns the recorded timestamp so Release only frees its own slot.
- suggestions.runPass releases the slot on LLM failure before returning 502.
- useCheckpoint auto-retries a failed checkpoint with backoff (3/12/35s),
  keeping the breathing dot up and only flagging "resting" once retries exhaust.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
prosolis
2026-06-26 13:09:12 -07:00
parent 1bd38b20e8
commit 46db0a3e16
4 changed files with 80 additions and 14 deletions

View File

@@ -20,17 +20,29 @@ export function useCheckpoint(docId: string | null) {
const [llmDown, setLlmDown] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
// Pending auto-retry timer for a failed checkpoint (see runCheck).
const retryRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const docIdRef = useRef(docId)
docIdRef.current = docId
// Token to discard responses from a doc we've since navigated away from.
const runRef = useRef(0)
const runCheck = useCallback(async () => {
// Backoff schedule for re-running a checkpoint that failed. A paste fires
// exactly ONE checkpoint, and nothing re-fires until the next keystroke — so a
// single transient failure (a momentary stall on the shared inference box)
// would otherwise strand the user on "resting" indefinitely after a paste.
// These delays clear the server's 30s per-doc floor by the later attempts, and
// a failed pass now releases its slot server-side so a retry can truly re-run.
const RETRY_DELAYS_MS = [3000, 12000, 35000]
const runCheck = useCallback(async (attempt = 0) => {
const id = docIdRef.current
if (!id) return
clearTimeout(retryRef.current)
const run = ++runRef.current
setChecking(true)
let retrying = false
try {
const fresh = await api.checkDoc(id)
if (run === runRef.current && id === docIdRef.current) {
@@ -39,9 +51,18 @@ export function useCheckpoint(docId: string | null) {
}
} catch (err) {
console.error('checkpoint failed', err)
if (run === runRef.current) setLlmDown(true)
if (run !== runRef.current) return
if (attempt < RETRY_DELAYS_MS.length) {
// Keep trying quietly — don't flag "resting" until retries are exhausted.
retrying = true
retryRef.current = setTimeout(() => void runCheck(attempt + 1), RETRY_DELAYS_MS[attempt])
} else {
setLlmDown(true)
}
} finally {
if (run === runRef.current) setChecking(false)
// Stay in the "checking" state while a retry is queued so the breathing dot
// keeps reassuring rather than flickering off between attempts.
if (run === runRef.current && !retrying) setChecking(false)
}
}, [])
@@ -51,6 +72,7 @@ export function useCheckpoint(docId: string | null) {
const runVoice = useCallback(async () => {
const id = docIdRef.current
if (!id) return
clearTimeout(retryRef.current) // a voice pass supersedes a queued grammar retry
const run = ++runRef.current
setVoicing(true)
try {
@@ -70,13 +92,15 @@ export function useCheckpoint(docId: string | null) {
// Call on every edit; schedules a check 4s after typing settles.
const schedule = useCallback(() => {
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(runCheck, DEBOUNCE_MS)
clearTimeout(retryRef.current) // a fresh edit supersedes any queued retry
debounceRef.current = setTimeout(() => void runCheck(), DEBOUNCE_MS)
}, [runCheck])
// Load existing pending suggestions whenever the document changes, and cancel
// any in-flight debounce from the previous doc.
useEffect(() => {
clearTimeout(debounceRef.current)
clearTimeout(retryRef.current)
runRef.current++
setSuggestions([])
setChecking(false)
@@ -97,7 +121,13 @@ export function useCheckpoint(docId: string | null) {
}
}, [docId])
useEffect(() => () => clearTimeout(debounceRef.current), [])
useEffect(
() => () => {
clearTimeout(debounceRef.current)
clearTimeout(retryRef.current)
},
[],
)
// Drop one suggestion locally (after accept/dismiss) without a refetch.
const removeSuggestion = useCallback((id: string) => {