From 7b1446f7b0a7eea05f4237a406c29bce7dd08348 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 10 Jun 2026 15:47:35 -0700 Subject: [PATCH 1/2] fix(tui): throttle AgentProgress redraws to prevent freeze under subagent load (#3033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When 4+ sub-agents run concurrently, each AgentProgress event triggers a full terminal redraw via received_engine_event → needs_redraw. The render loop saturates, sidebar recomputation dominates the frame budget, and terminal input events (including Ctrl+C) are starved. Limit progress-driven redraws to at most one per 100ms per agent. The status-animation timer (80ms cadence) still guarantees sidebar updates. Agent state is recorded immediately; the sidebar picks it up on the next permitted redraw. Adds last_agent_progress_redraw field to App to track throttle state. --- crates/tui/src/tui/app.rs | 4 ++++ crates/tui/src/tui/ui.rs | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 37bf84c5..0a2a6441 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1445,6 +1445,9 @@ pub struct App { pub pending_subagent_dispatch: Option, /// Animation anchor for status-strip active sub-agent spinner. pub agent_activity_started_at: Option, + /// Last time a sub-agent progress event triggered a redraw. + /// Used to throttle redraws under high sub-agent concurrency (#3033). + pub last_agent_progress_redraw: Option, pub ui_theme: UiTheme, /// Active named theme. Drives the cell-level color remap in /// `tui::color_compat::ColorCompatBackend` so community presets @@ -2174,6 +2177,7 @@ impl App { last_fanout_card_index: None, pending_subagent_dispatch: None, agent_activity_started_at: None, + last_agent_progress_redraw: None, ui_theme, theme_id, onboarding, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3481d43e..3a4dd022 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -2318,6 +2318,24 @@ async fn run_event_loop( app.agent_activity_started_at = Some(Instant::now()); } app.status_message = Some(format!("Sub-agent {id}: {display}")); + // #3033: Throttle redraws from rapid AgentProgress events. + // When 4+ sub-agents are running concurrently, each firing + // progress events, the per-event `needs_redraw = true` saturates + // the render loop and starves terminal input. Limit + // progress-driven repaints to at most one per 100ms; the + // status-animation timer (80ms cadence) provides a guaranteed + // floor for sidebar updates. Data is still recorded immediately; + // the sidebar picks it up on the next permitted redraw. + let now = Instant::now(); + if let Some(last) = app.last_agent_progress_redraw { + if now.duration_since(last) < Duration::from_millis(100) { + received_engine_event = false; + } else { + app.last_agent_progress_redraw = Some(now); + } + } else { + app.last_agent_progress_redraw = Some(now); + } } EngineEvent::AgentComplete { id, result } => { execute_subagent_observer_hook( From df1b35ba0fd7629fbb581db3f24ce52732c26eec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 02:03:17 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(tui):=20#3033=20audit=20fix=20=E2=80=94?= =?UTF-8?q?=20throttled=20AgentProgress=20no=20longer=20cancels=20redraws?= =?UTF-8?q?=20owed=20to=20other=20events=20in=20the=20same=20drain=20batch?= =?UTF-8?q?;=20restore=20pre-event=20accumulator=20value;=20extract=20agen?= =?UTF-8?q?t=5Fprogress=5Fredraw=5Fpermitted=20+=20unit=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude https://claude.ai/code/session_018zaP8vUfTAsrE38L6h6fw5 --- crates/tui/src/tui/ui.rs | 37 ++++++++++++++++----- crates/tui/src/tui/ui/tests.rs | 61 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 3a4dd022..3a89a149 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1430,6 +1430,11 @@ async fn run_event_loop( break; } }; + // #3033: remember whether an EARLIER event in this drain batch + // already requested a redraw. The AgentProgress throttle below + // may opt the current event out of repainting, but it must not + // cancel redraws owed to other events in the same batch. + let redraw_requested_before_event = received_engine_event; received_engine_event = true; if app.suppress_stream_events_until_turn_complete { if matches!(event, EngineEvent::TurnStarted { .. }) { @@ -2326,15 +2331,14 @@ async fn run_event_loop( // status-animation timer (80ms cadence) provides a guaranteed // floor for sidebar updates. Data is still recorded immediately; // the sidebar picks it up on the next permitted redraw. - let now = Instant::now(); - if let Some(last) = app.last_agent_progress_redraw { - if now.duration_since(last) < Duration::from_millis(100) { - received_engine_event = false; - } else { - app.last_agent_progress_redraw = Some(now); - } - } else { - app.last_agent_progress_redraw = Some(now); + if !agent_progress_redraw_permitted( + &mut app.last_agent_progress_redraw, + Instant::now(), + ) { + // Restore the pre-event accumulator value: a + // throttled progress event contributes no redraw of + // its own, but earlier events' redraws survive. + received_engine_event = redraw_requested_before_event; } } EngineEvent::AgentComplete { id, result } => { @@ -4668,6 +4672,21 @@ fn reconcile_turn_liveness(app: &mut App, now: Instant, has_running_agents: bool false } +/// #3033: gate progress-driven repaints to at most one per 100ms. +/// +/// Returns whether the current `AgentProgress` event may request a redraw, +/// updating the last-redraw timestamp when it may. Data updates are never +/// throttled — only the repaint request is. +fn agent_progress_redraw_permitted(last_redraw: &mut Option, now: Instant) -> bool { + match *last_redraw { + Some(last) if now.duration_since(last) < Duration::from_millis(100) => false, + _ => { + *last_redraw = Some(now); + true + } + } +} + fn recover_engine_event_disconnect(app: &mut App) -> bool { let had_live_work = app.is_loading || app.is_compacting diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 832a8f9c..2172e432 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -9303,3 +9303,64 @@ mod work_sidebar_projection_tests { assert_ne!(entry.status, "running"); } } + +// ── #3033: AgentProgress redraw throttle ─────────────────────────────────── + +#[test] +fn agent_progress_redraw_throttle_permits_first_and_spaced_events() { + let mut last_redraw = None; + let t0 = Instant::now(); + + assert!( + agent_progress_redraw_permitted(&mut last_redraw, t0), + "first progress event always repaints" + ); + assert!( + !agent_progress_redraw_permitted(&mut last_redraw, t0 + Duration::from_millis(50)), + "events inside the 100ms window are throttled" + ); + assert!( + !agent_progress_redraw_permitted(&mut last_redraw, t0 + Duration::from_millis(99)), + "throttled events must not advance the window" + ); + assert!( + agent_progress_redraw_permitted(&mut last_redraw, t0 + Duration::from_millis(150)), + "events past the window repaint again" + ); +} + +#[test] +fn throttled_progress_event_does_not_cancel_other_events_redraw() { + // Repro for the #3033 audit finding: `received_engine_event` is a shared + // accumulator for the whole drain batch. A throttled AgentProgress event + // must restore the PRE-EVENT value instead of clearing the flag, so + // redraws owed to other events (AgentSpawned, AgentList, cross-agent + // AgentComplete...) survive. + let t0 = Instant::now(); + let mut last_redraw = Some(t0); + + // Batch: AgentSpawned (requests redraw), then a throttled AgentProgress. + let mut received_engine_event = true; // AgentSpawned drained + let redraw_requested_before_event = received_engine_event; + received_engine_event = true; // AgentProgress drained + if !agent_progress_redraw_permitted(&mut last_redraw, t0 + Duration::from_millis(10)) { + received_engine_event = redraw_requested_before_event; + } + assert!( + received_engine_event, + "redraw owed to AgentSpawned must survive a throttled progress event" + ); + + // Same batch shape but with NO earlier redraw-worthy event: the lone + // throttled progress event contributes nothing. + let mut received_engine_event = false; + let redraw_requested_before_event = received_engine_event; + received_engine_event = true; // AgentProgress drained + if !agent_progress_redraw_permitted(&mut last_redraw, t0 + Duration::from_millis(20)) { + received_engine_event = redraw_requested_before_event; + } + assert!( + !received_engine_event, + "a lone throttled progress event must not trigger a repaint" + ); +}