| 1 | //! Process-wide retry-state surface (#499). |
| 2 | //! |
| 3 | //! The HTTP retry path in `client::send_with_retry` already times its |
| 4 | //! waits and knows the error category. This module gives the TUI a way |
| 5 | //! to observe that state — `start`, `succeeded`, and `failed` flip a |
| 6 | //! global `RetryState` that the footer / status panel reads each frame. |
| 7 | //! |
| 8 | //! Why a process-wide global: the user-facing TUI runs as one engine |
| 9 | //! per process, and the only retry state we want to surface is the one |
| 10 | //! the user is staring at. Sub-agent retries in background tasks |
| 11 | //! deliberately do **not** light up the foreground banner — they're |
| 12 | //! supposed to be invisible. If a future feature ever needs per-engine |
| 13 | //! retry surfaces, swap this for an `Arc<RwLock<...>>` carried on the |
| 14 | //! `EngineHandle`; the public API stays the same. |
| 15 | |
| 16 | use std::sync::{Mutex, OnceLock}; |
| 17 | use std::time::{Duration, Instant}; |
| 18 | |
| 19 | /// One in-flight retry attempt. `deadline` is the wall-clock time the |
| 20 | /// next request will fire — the UI subtracts `Instant::now()` from it |
| 21 | /// to render a live countdown. |
| 22 | #[derive(Debug, Clone)] |
| 23 | pub struct RetryBanner { |
| 24 | /// 1-indexed retry attempt number (the first retry is attempt 1). |
| 25 | pub attempt: u32, |
| 26 | /// Time at which the next request will be sent. |
| 27 | pub deadline: Instant, |
| 28 | /// Short human-readable reason ("rate limited", "server error", …). |
| 29 | pub reason: String, |
| 30 | } |
| 31 | |
| 32 | /// Snapshot of the retry surface for the UI to render. |
| 33 | #[derive(Debug, Clone, Default)] |
| 34 | pub enum RetryState { |
| 35 | /// No retry in flight. Banner hidden. |
| 36 | #[default] |
| 37 | Idle, |
| 38 | /// A request is sleeping before retrying. Show countdown banner. |
| 39 | Active(RetryBanner), |
| 40 | /// All retries exhausted; show failure row until the next turn |
| 41 | /// starts. `since` records when the row was set so a future polish |
| 42 | /// pass can age it out automatically; today the engine clears it on |
| 43 | /// `TurnStarted`. |
| 44 | Failed { |
| 45 | reason: String, |
| 46 | #[allow(dead_code)] |
| 47 | since: Instant, |
| 48 | }, |
| 49 | } |
| 50 | |
| 51 | impl RetryState { |
| 52 | /// Wall-clock seconds remaining on the active banner, or `None` if |
| 53 | /// not active. Saturates at zero — the renderer should treat any |
| 54 | /// negative remaining as "firing now". |
| 55 | #[must_use] |
| 56 | pub fn seconds_remaining(&self) -> Option<u64> { |
| 57 | match self { |
| 58 | Self::Active(banner) => Some( |
| 59 | banner |
| 60 | .deadline |
| 61 | .saturating_duration_since(Instant::now()) |
| 62 | .as_secs(), |
| 63 | ), |
| 64 | _ => None, |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | /// Whether the failure row should still be shown. Mirrors the |
| 69 | /// "until next turn" rule in the issue spec; the engine clears it |
| 70 | /// explicitly via [`clear`] on `TurnStarted`. |
| 71 | #[cfg(test)] |
| 72 | #[must_use] |
| 73 | pub fn is_failed(&self) -> bool { |
| 74 | matches!(self, Self::Failed { .. }) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// Lazy-init the cell on first read so callers don't have to initialize |
| 79 | /// process-wide state at boot. |
| 80 | fn cell() -> &'static Mutex<RetryState> { |
| 81 | static STATE: OnceLock<Mutex<RetryState>> = OnceLock::new(); |
| 82 | STATE.get_or_init(|| Mutex::new(RetryState::Idle)) |
| 83 | } |
| 84 | |
| 85 | /// Public read snapshot for renderers. |
| 86 | #[must_use] |
| 87 | pub fn snapshot() -> RetryState { |
| 88 | cell().lock().map(|s| s.clone()).unwrap_or(RetryState::Idle) |
| 89 | } |
| 90 | |
| 91 | /// Mark an in-flight retry. `attempt` is the number of the *upcoming* |
| 92 | /// retry (1 for the first); `delay` is how long the client will sleep |
| 93 | /// before firing. |
| 94 | pub fn start(attempt: u32, delay: Duration, reason: impl Into<String>) { |
| 95 | let banner = RetryBanner { |
| 96 | attempt, |
| 97 | deadline: Instant::now() + delay, |
| 98 | reason: reason.into(), |
| 99 | }; |
| 100 | if let Ok(mut s) = cell().lock() { |
| 101 | *s = RetryState::Active(banner); |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /// Mark the retry chain as having succeeded. Hides the banner. |
| 106 | pub fn succeeded() { |
| 107 | if let Ok(mut s) = cell().lock() { |
| 108 | *s = RetryState::Idle; |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// Mark the retry chain as having exhausted retries. The renderer keeps |
| 113 | /// the failure row until [`clear`] (typically called on `TurnStarted`). |
| 114 | pub fn failed(reason: impl Into<String>) { |
| 115 | if let Ok(mut s) = cell().lock() { |
| 116 | *s = RetryState::Failed { |
| 117 | reason: reason.into(), |
| 118 | since: Instant::now(), |
| 119 | }; |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /// Reset to idle. Called on `TurnStarted` so the previous turn's |
| 124 | /// failure row doesn't bleed into the next turn. |
| 125 | pub fn clear() { |
| 126 | if let Ok(mut s) = cell().lock() { |
| 127 | *s = RetryState::Idle; |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | /// Test helper: serialize tests that touch the global state so cargo's |
| 132 | /// parallel runner can't observe a torn read. The guard is exported so |
| 133 | /// tests in *other* modules (e.g. footer rendering tests) can hold the |
| 134 | /// same lock as the ones in `retry_status::tests`. |
| 135 | #[cfg(test)] |
| 136 | pub fn test_guard() -> std::sync::MutexGuard<'static, ()> { |
| 137 | static GUARD: Mutex<()> = Mutex::new(()); |
| 138 | GUARD.lock().unwrap_or_else(|e| e.into_inner()) |
| 139 | } |
| 140 | |
| 141 | #[cfg(test)] |
| 142 | mod tests { |
| 143 | use super::*; |
| 144 | |
| 145 | /// Acquire the cross-module test guard from [`super::test_guard`] and |
| 146 | /// reset state to `Idle` before yielding to the test body. |
| 147 | fn setup() -> std::sync::MutexGuard<'static, ()> { |
| 148 | let g = test_guard(); |
| 149 | clear(); |
| 150 | g |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn idle_by_default_after_clear() { |
| 155 | let _g = setup(); |
| 156 | assert!(matches!(snapshot(), RetryState::Idle)); |
| 157 | assert_eq!(snapshot().seconds_remaining(), None); |
| 158 | } |
| 159 | |
| 160 | #[test] |
| 161 | fn start_then_succeeded_returns_to_idle() { |
| 162 | let _g = setup(); |
| 163 | start(1, Duration::from_secs(5), "rate limited"); |
| 164 | let s = snapshot(); |
| 165 | assert!(matches!(s, RetryState::Active(_))); |
| 166 | let remaining = s.seconds_remaining().unwrap(); |
| 167 | assert!(remaining <= 5, "{remaining}"); |
| 168 | succeeded(); |
| 169 | assert!(matches!(snapshot(), RetryState::Idle)); |
| 170 | } |
| 171 | |
| 172 | #[test] |
| 173 | fn failed_persists_until_clear() { |
| 174 | let _g = setup(); |
| 175 | failed("upstream 500"); |
| 176 | let s = snapshot(); |
| 177 | assert!(s.is_failed()); |
| 178 | if let RetryState::Failed { reason, .. } = s { |
| 179 | assert_eq!(reason, "upstream 500"); |
| 180 | } else { |
| 181 | panic!("expected Failed"); |
| 182 | } |
| 183 | clear(); |
| 184 | assert!(matches!(snapshot(), RetryState::Idle)); |
| 185 | } |
| 186 | |
| 187 | #[test] |
| 188 | fn deadline_in_past_yields_zero_remaining() { |
| 189 | let _g = setup(); |
| 190 | // Bypass `start` so we can plant a deadline already in the past. |
| 191 | if let Ok(mut s) = cell().lock() { |
| 192 | *s = RetryState::Active(RetryBanner { |
| 193 | attempt: 2, |
| 194 | deadline: Instant::now() - Duration::from_secs(1), |
| 195 | reason: "test".into(), |
| 196 | }); |
| 197 | } |
| 198 | assert_eq!(snapshot().seconds_remaining(), Some(0)); |
| 199 | clear(); |
| 200 | } |
| 201 | } |
| 202 |