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

@@ -127,17 +127,33 @@ func NewRateLimiter(interval time.Duration) *RateLimiter {
}
// Allow reports whether a checkpoint may run for docID now. When allowed it
// records the time and returns (true, 0); when throttled it returns
// (false, retryAfter) where retryAfter is the wait until the next allowed run.
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration) {
// records the time and returns (true, 0, recordedAt); when throttled it returns
// (false, retryAfter, zero) where retryAfter is the wait until the next allowed
// run. recordedAt lets a caller whose pass fails hand the exact timestamp to
// Release so it rolls back only its own slot.
func (rl *RateLimiter) Allow(docID string) (bool, time.Duration, time.Time) {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
if last, ok := rl.last[docID]; ok {
if elapsed := now.Sub(last); elapsed < rl.interval {
return false, rl.interval - elapsed
return false, rl.interval - elapsed, time.Time{}
}
}
rl.last[docID] = now
return true, 0
return true, 0, now
}
// Release rolls back the slot a prior Allow recorded for docID, but only if no
// newer Allow has since claimed it. Called when an LLM pass fails: Allow runs
// before the model call, so without this a failed checkpoint would hold the
// per-document slot for the full interval and a retry (or the frontend's
// auto-retry) would hit the throttle path and get the stale/empty set back
// instead of re-running. `at` is the time the failed Allow returned.
func (rl *RateLimiter) Release(docID string, at time.Time) {
rl.mu.Lock()
defer rl.mu.Unlock()
if last, ok := rl.last[docID]; ok && last.Equal(at) {
delete(rl.last, docID)
}
}