Merge PR #3035 from Hmbown: throttle AgentProgress redraws to prevent freeze under subagent load

fix(tui): throttle AgentProgress redraws to prevent freeze under subagent load
This commit is contained in:
Hunter Bown
2026-06-10 22:11:17 -07:00
committed by GitHub
3 changed files with 102 additions and 0 deletions
+4
View File
@@ -1445,6 +1445,9 @@ pub struct App {
pub pending_subagent_dispatch: Option<String>,
/// Animation anchor for status-strip active sub-agent spinner.
pub agent_activity_started_at: Option<Instant>,
/// 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<Instant>,
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,
+37
View File
@@ -1431,6 +1431,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 { .. }) {
@@ -2319,6 +2324,23 @@ 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.
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 } => {
execute_subagent_observer_hook(
@@ -4652,6 +4674,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<Instant>, 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
+61
View File
@@ -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"
);
}