fix(quota): add cooldown + 2-poll confirmation to prevent swap ping-pong
Anthropic HTTP 500 errors surface in the TUI with payloads containing "rate limit" text, which the monitor was matching against quotaPatterns and treating as a real 429 quota hit. With no cooldown and no confirmation, a burst of 500s produced sub-minute ping-pong swaps that tore down user sessions. Two-layer fix: - quota.reactivate_cooldown (already in config, 5m) now gates the monitor too — not just the dispatcher. A completed swap suppresses further detection for the cooldown window. - A hit with no parseable reset time is treated as suspected only on the first poll; a second consecutive poll is required before emitting SwapRequested. Legitimate 429s with "resets in ..." still swap instantly on the first detection. Adds state.RecordSwap / LastSwapInfo for the cooldown, and a forensic log line on every detection: trigger_session, matched pattern, 120-char pane snippet. Tests cover: instant swap with reset, 2-poll confirmation without reset, and suspected-state reset on recovery. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
75b5110748
commit
7c5f8384fa
5 changed files with 246 additions and 25 deletions
|
|
@ -24,6 +24,9 @@ type QuotaState struct {
|
|||
Paused bool `json:"paused"`
|
||||
ActiveAccount string `json:"active_account"`
|
||||
ResumeAt *time.Time `json:"resume_at,omitempty"`
|
||||
LastSwapAt *time.Time `json:"last_swap_at,omitempty"`
|
||||
LastSwapFrom string `json:"last_swap_from,omitempty"`
|
||||
LastSwapTo string `json:"last_swap_to,omitempty"`
|
||||
}
|
||||
|
||||
// State is the thread-safe runtime state persisted to a JSON file.
|
||||
|
|
@ -178,6 +181,31 @@ func (s *State) ActiveAccount() string {
|
|||
return s.Quota.ActiveAccount
|
||||
}
|
||||
|
||||
// RecordSwap stamps the time, source and target of the most recent account swap.
|
||||
// Used by the quota monitor to enforce a cooldown and avoid ping-pong loops
|
||||
// when transient upstream errors (e.g. Anthropic 500s mirrored as "rate limit"
|
||||
// text in the TUI) are misread as quota hits.
|
||||
func (s *State) RecordSwap(from, to string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now().UTC()
|
||||
s.Quota.LastSwapAt = &now
|
||||
s.Quota.LastSwapFrom = from
|
||||
s.Quota.LastSwapTo = to
|
||||
s.touch()
|
||||
}
|
||||
|
||||
// LastSwapInfo returns the time, source and target of the most recent swap,
|
||||
// or a zero time and empty strings if no swap has occurred.
|
||||
func (s *State) LastSwapInfo() (time.Time, string, string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.Quota.LastSwapAt == nil {
|
||||
return time.Time{}, "", ""
|
||||
}
|
||||
return *s.Quota.LastSwapAt, s.Quota.LastSwapFrom, s.Quota.LastSwapTo
|
||||
}
|
||||
|
||||
// SetFailed marks the named session as failed and records the failure timestamp.
|
||||
// The task is preserved for potential requeue by the caller.
|
||||
func (s *State) SetFailed(name string) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue