From 5eca1df72d8f8ff1f573b1bfbf092c6d6b0684f2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 9 Aug 2026 14:01:11 -0700 Subject: [PATCH] [eric] agents: mid-turn provider backoff shows a muted retrying pill instead of a dead card, and transient pills survive session status frames --- .../manager/streaming/note_provider_retry.py | 8 ++++ .../src/app/pages/AgentChat/AgentChat.tsx | 3 +- .../pages/AgentChat/shell/RateLimitPill.tsx | 47 ++++++++++++++++++- frontend/src/shared/state/agentsSlice.ts | 23 +++++++++ frontend/src/shared/ws/WebSocketManager.ts | 12 +++++ 5 files changed, 91 insertions(+), 2 deletions(-) diff --git a/backend/apps/agents/manager/streaming/note_provider_retry.py b/backend/apps/agents/manager/streaming/note_provider_retry.py index 75166a6d..458de59d 100644 --- a/backend/apps/agents/manager/streaming/note_provider_retry.py +++ b/backend/apps/agents/manager/streaming/note_provider_retry.py @@ -32,6 +32,14 @@ def note_provider_retry(session_id: str, raw: object, turn: TurnState) -> None: attempt=data.get("attempt"), delay_ms=delay_ms, ) + # The card sat DEAD through these waits (30s+ with no explanation, ENG-178); a muted pill is honest without reading as an error. + import asyncio + from backend.apps.agents.core.ws_manager import ws_manager + asyncio.get_running_loop().create_task(ws_manager.send_to_session(session_id, "agent:provider_retrying", { + "session_id": session_id, + "attempt": data.get("attempt"), + "delay_ms": delay_ms if isinstance(delay_ms, int) else None, + })) except Exception: pass diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index e3e3ff00..d57a9b82 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -69,7 +69,7 @@ import { isShowUiPair, isAskUiPair, extractPendingAskUi } from './tool-ui/showUi import { composerPlaceholder } from './composerPlaceholder'; import ApprovalBar, { BatchApprovalBar } from './shell/ApprovalBar'; import ForceStopAgentBar from './ForceStopAgentBar'; -import { RateLimitPill } from './shell/RateLimitPill'; +import { ProviderRetryPill, RateLimitPill } from './shell/RateLimitPill'; import { ContextRecoveredPill } from './shell/ContextRecoveredPill'; import ChatInput, { ChatInputHandle } from './ChatInput'; import FollowupChips from './FollowupChips'; @@ -2132,6 +2132,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose )} + {isGlowing ? ( diff --git a/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx b/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx index ce03f5d4..4932a343 100644 --- a/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx +++ b/frontend/src/app/pages/AgentChat/shell/RateLimitPill.tsx @@ -3,10 +3,55 @@ import Box from '@mui/material/Box'; import Fade from '@mui/material/Fade'; import Typography from '@mui/material/Typography'; import ScheduleIcon from '@mui/icons-material/Schedule'; +import AutorenewIcon from '@mui/icons-material/Autorenew'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; -import { clearRateLimited } from '@/shared/state/agentsSlice'; +import { clearProviderRetrying, clearRateLimited } from '@/shared/state/agentsSlice'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +/** Mid-turn CLI backoff pill (ENG-178): the provider 500/429'd and the CLI is silently waiting up + * to tens of seconds; without this the card just sits dead. Auto-clears after the announced delay + * plus slack, and each new retry event refreshes it. Same muted grammar as the rate-limit pill. */ +export const ProviderRetryPill: React.FC<{ sessionId: string }> = ({ sessionId }) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const pr = useAppSelector((s) => s.agents.sessions[sessionId]?.provider_retrying); + + useEffect(() => { + if (!pr) return; + const ms = Math.min(Math.max((pr.delay_ms ?? 15_000) + 15_000, 10_000), 120_000); + const t = setTimeout(() => dispatch(clearProviderRetrying({ sessionId })), ms); + return () => clearTimeout(t); + }, [pr, sessionId, dispatch]); + + const label = pr?.attempt ? `Provider busy, retrying (attempt ${pr.attempt})` : 'Provider busy, retrying'; + const lastLabel = useRef(label); + if (pr) lastLabel.current = label; + + return ( + + + + {lastLabel.current} + + + ); +}; + // Muted, transient pill shown only after a real provider throttle outlasted the silent backoff. No card, no red, no CTA; it fades and auto-clears once the window should have passed. The "why" lives in the hover, not on the surface. export const RateLimitPill: React.FC<{ sessionId: string }> = ({ sessionId }) => { const c = useClaudeTokens(); diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 3e834af2..47c7085d 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -118,6 +118,7 @@ export interface AgentSession { framework_overhead_tokens?: number; context_overflow?: { reason: string; message: string; at: string } | null; rate_limited?: { retry_after_s: number | null; at: string } | null; + provider_retrying?: { attempt: number | null; delay_ms: number | null; at: string } | null; context_recovered?: { at: string } | null; // Set when a view-builder turn installed/changed deps, so the app card does a HARD reload (Vite restart) at turn-finish instead of the soft one. Reset when the next turn starts. app_deps_changed?: boolean; @@ -741,6 +742,9 @@ const agentsSlice = createSlice({ branches: { ...existing?.branches, ...action.payload.branches }, pending_approvals: mergedApprovals, tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta }, + // Renderer-local transient pills; the wire payload never carries them, so a status frame mid-backoff would wipe the "provider busy" pill it exists to explain. + provider_retrying: existing?.provider_retrying ?? null, + rate_limited: existing?.rate_limited ?? null, }; if (action.payload.status === 'running' && !state.trackedNotificationIds.includes(action.payload.id)) { state.trackedNotificationIds.push(action.payload.id); @@ -1003,6 +1007,23 @@ const agentsSlice = createSlice({ } }, + setProviderRetrying( + state, + action: PayloadAction<{ sessionId: string; attempt: number | null; delayMs: number | null }> + ) { + const session = state.sessions[action.payload.sessionId]; + if (session) { + session.provider_retrying = { + attempt: action.payload.attempt, + delay_ms: action.payload.delayMs, + at: new Date().toISOString(), + }; + } + }, + clearProviderRetrying(state, action: PayloadAction<{ sessionId: string }>) { + const session = state.sessions[action.payload.sessionId]; + if (session) session.provider_retrying = null; + }, clearRateLimited(state, action: PayloadAction<{ sessionId: string }>) { const session = state.sessions[action.payload.sessionId]; if (session) session.rate_limited = null; @@ -1489,6 +1510,8 @@ export const { setContextOverflow, setRateLimited, clearRateLimited, + setProviderRetrying, + clearProviderRetrying, setContextRecovered, clearContextRecovered, setAppDepsChanged, diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index ce6fefbd..fe711069 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -13,6 +13,7 @@ import { updateSessionContext, setContextOverflow, setRateLimited, + setProviderRetrying, setContextRecovered, setAppDepsChanged, setMcpSuggestions, @@ -644,6 +645,17 @@ class WebSocketManager { } break; + case 'agent:provider_retrying': + // Mid-turn CLI backoff: the provider 500/429'd and the CLI is silently waiting; show the muted pill so the card doesn't read as dead (ENG-178). + if (session_id) { + store.dispatch(setProviderRetrying({ + sessionId: session_id, + attempt: typeof data.attempt === 'number' ? data.attempt : null, + delayMs: typeof data.delay_ms === 'number' ? data.delay_ms : null, + })); + } + break; + case 'agent:rate_limited': // Provider throttle that outlasted the silent backoff. Transient muted pill, not a card; auto-clears frontend-side. if (session_id) {