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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user