diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-08 23:04:16 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 04:26:19 +0300 |
| commit | 615310e15c6a7b273947dafe0b3fe01290757c96 (patch) | |
| tree | 0b203f16cacd3cb2eab8dd25d17c7b63ab8ac7dd /src | |
| parent | f18bee9f5c43e56ebdcb7e5fad17f3236de4d08e (diff) | |
fix: debounce idle to working agent transitions
Diffstat (limited to 'src')
| -rw-r--r-- | src/pane.rs | 476 |
1 files changed, 468 insertions, 8 deletions
diff --git a/src/pane.rs b/src/pane.rs index e0578b98..c53d99ef 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -124,6 +124,15 @@ const AGENT_STARTUP_GRACE_WINDOW: std::time::Duration = std::time::Duration::fro const AGENT_PENDING_IDLE_RECHECK: std::time::Duration = std::time::Duration::from_millis(100); const AGENT_PENDING_IDLE_CONFIRMATIONS: u8 = 3; const AGENT_PENDING_IDLE_CAP: std::time::Duration = std::time::Duration::from_millis(700); +const AGENT_PENDING_WORKING_FAST_RECHECK: std::time::Duration = + std::time::Duration::from_millis(100); +const AGENT_PENDING_WORKING_SLOW_RECHECK: std::time::Duration = + std::time::Duration::from_millis(250); +const AGENT_PENDING_WORKING_FAST_WINDOW: std::time::Duration = + std::time::Duration::from_millis(500); +const AGENT_PENDING_WORKING_CAP: std::time::Duration = std::time::Duration::from_secs(2); +const AGENT_PENDING_WORKING_CONFIRM_DELAY: std::time::Duration = + std::time::Duration::from_millis(250); #[derive(Debug, Clone, Copy)] struct AgentDetectionPresence { @@ -391,6 +400,103 @@ impl PendingIdleConfirmation { } } +#[derive(Debug, Default)] +struct PendingWorkingConfirmation { + started_at: Option<std::time::Instant>, + last_observed_render_seq: u64, +} + +impl PendingWorkingConfirmation { + fn active(&self) -> bool { + self.started_at.is_some() + } + + fn clear(&mut self) { + self.started_at = None; + self.last_observed_render_seq = 0; + } + + fn recheck_delay(&self, now: std::time::Instant) -> std::time::Duration { + let Some(started_at) = self.started_at else { + return AGENT_PENDING_WORKING_FAST_RECHECK; + }; + if now.duration_since(started_at) < AGENT_PENDING_WORKING_FAST_WINDOW { + AGENT_PENDING_WORKING_FAST_RECHECK + } else { + AGENT_PENDING_WORKING_SLOW_RECHECK + } + } + + fn should_hold_idle_to_working( + &mut self, + previous: DetectionPublishState, + next: DetectionPublishState, + agent_changed: bool, + process_exited: bool, + pty_signal: Option<PtyActivitySignal>, + now: std::time::Instant, + ) -> bool { + let is_idle_to_working = previous.state == AgentState::Idle + && next.state == AgentState::Working + && !next.visible_blocker + && !agent_changed + && !process_exited; + + if !is_idle_to_working { + self.clear(); + return false; + } + + let Some(pty_signal) = pty_signal else { + self.clear(); + return false; + }; + if !pty_signal.active { + self.clear(); + return false; + } + + let Some(started_at) = self.started_at else { + self.started_at = Some(now); + self.last_observed_render_seq = pty_signal.render_seq; + return true; + }; + + if pty_signal.fresh_render && pty_signal.render_seq != self.last_observed_render_seq { + self.last_observed_render_seq = pty_signal.render_seq; + if now.duration_since(started_at) >= AGENT_PENDING_WORKING_CONFIRM_DELAY { + self.clear(); + return false; + } + } + + if now.duration_since(started_at) >= AGENT_PENDING_WORKING_CAP { + self.clear(); + return true; + } + + true + } + + fn should_publish_held_working_before_exit( + &mut self, + previous: DetectionPublishState, + next: DetectionPublishState, + process_exited: bool, + ) -> bool { + if self.started_at.is_none() + || !process_exited + || previous.state != AgentState::Idle + || next.state != AgentState::Idle + { + return false; + } + + self.clear(); + true + } +} + fn pty_working_transition_is_vetoed( agent: Option<Agent>, previous: DetectionPublishState, @@ -466,6 +572,8 @@ struct PtyCausalityTracker { struct PtyActivitySignal { active: bool, tainted: bool, + fresh_render: bool, + render_seq: u64, } fn baseline_pty_causality(tracker: &mut PtyCausalityTracker, pty_render_seq: u64, input_seq: u64) { @@ -489,10 +597,12 @@ fn agent_caused_pty_activity_active( let tainted = tracker.input_tainted_until.is_some_and(|until| now < until); + let mut fresh_render = false; if pty_render_seq != tracker.last_pty_render_seq { tracker.last_pty_render_seq = pty_render_seq; if !tainted { tracker.last_agent_pty_at = Some(now); + fresh_render = true; } } @@ -500,6 +610,8 @@ fn agent_caused_pty_activity_active( return PtyActivitySignal { active: false, tainted: true, + fresh_render: false, + render_seq: tracker.last_pty_render_seq, }; } @@ -509,6 +621,8 @@ fn agent_caused_pty_activity_active( PtyActivitySignal { active, tainted: false, + fresh_render, + render_seq: tracker.last_pty_render_seq, } } @@ -548,9 +662,13 @@ fn spawn_basic_detection_task( let mut pty_causality = PtyCausalityTracker::default(); let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); loop { - let sleep_duration = if pending_idle.active() { + let now_for_sleep = std::time::Instant::now(); + let sleep_duration = if pending_working.active() { + pending_working.recheck_delay(now_for_sleep) + } else if pending_idle.active() { AGENT_PENDING_IDLE_RECHECK } else { std::time::Duration::from_millis(300) @@ -575,6 +693,7 @@ fn spawn_basic_detection_task( pty_causality = PtyCausalityTracker::default(); agent_startup_grace_until = None; pending_idle.clear(); + pending_working.clear(); } } @@ -662,6 +781,7 @@ fn spawn_basic_detection_task( agent_changed = previous_agent != agent; if agent_changed { pending_idle.clear(); + pending_working.clear(); if agent.is_some() { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); baseline_pty_causality( @@ -699,9 +819,11 @@ fn spawn_basic_detection_task( if process_exited { agent_startup_grace_until = None; pending_idle.clear(); + pending_working.clear(); } else { if now < until { pending_idle.clear(); + pending_working.clear(); continue; } baseline_pty_causality( @@ -711,6 +833,7 @@ fn spawn_basic_detection_task( ); agent_startup_grace_until = None; pending_idle.clear(); + pending_working.clear(); continue; } } @@ -720,6 +843,7 @@ fn spawn_basic_detection_task( last_detection_text.clone_from(&content); if !process_exited && crate::detect::should_skip_state_update(agent, &content) { pending_idle.clear(); + pending_working.clear(); continue; } sync_content_change_acquisition( @@ -735,15 +859,21 @@ fn spawn_basic_detection_task( let Some(screen_detection) = detection_update_for_publish(agent, &content, process_exited) else { + pending_idle.clear(); + pending_working.clear(); continue; }; - let pty_signal = if agent.is_some() { - let signal = agent_caused_pty_activity_active( + let pty_activity = if agent.is_some() { + Some(agent_caused_pty_activity_active( pty_render_seq.load(Ordering::Relaxed), input_write_seq.load(Ordering::Relaxed), &mut pty_causality, now, - ); + )) + } else { + None + }; + let pty_signal = if let Some(signal) = pty_activity { Some(crate::agent_detection_policy::PtySignal { active: signal.active, tainted: signal.tainted, @@ -765,6 +895,7 @@ fn spawn_basic_detection_task( } crate::agent_detection_policy::DetectionPolicyDecision::Freeze => { pending_idle.clear(); + pending_working.clear(); continue; } }; @@ -791,6 +922,43 @@ fn spawn_basic_detection_task( if pty_working_transition_is_vetoed(agent, previous_publish, next_publish, &content) { pending_idle.clear(); + pending_working.clear(); + continue; + } + + if pending_working.should_publish_held_working_before_exit( + previous_publish, + next_publish, + process_exited, + ) { + pending_idle.clear(); + state = AgentState::Working; + last_visible_blocker = false; + last_visible_working = false; + last_visible_signal_refresh = None; + publish_state_changed_event( + state_events.clone(), + pane_id, + agent, + AgentState::Working, + false, + false, + false, + now, + ) + .await; + continue; + } + + if pending_working.should_hold_idle_to_working( + previous_publish, + next_publish, + agent_changed, + process_exited, + pty_activity, + now, + ) { + pending_idle.clear(); continue; } @@ -801,6 +969,7 @@ fn spawn_basic_detection_task( process_exited, now, ) { + pending_working.clear(); continue; } @@ -1905,15 +2074,19 @@ impl PaneRuntime { let mut pty_causality = PtyCausalityTracker::default(); let mut agent_startup_grace_until = None; let mut pending_idle = PendingIdleConfirmation::default(); + let mut pending_working = PendingWorkingConfirmation::default(); tokio::time::sleep(Duration::from_millis(50)).await; loop { - let tick = if active_pending_release(&pending_release_for_task, Instant::now()) + let now_for_tick = Instant::now(); + let tick = if active_pending_release(&pending_release_for_task, now_for_tick) .is_some() || terminal.has_transient_default_color_override() { TICK_PENDING_RELEASE + } else if pending_working.active() { + pending_working.recheck_delay(now_for_tick) } else if pending_idle.active() { AGENT_PENDING_IDLE_RECHECK } else if agent_presence.current_agent().is_none() { @@ -1941,6 +2114,7 @@ impl PaneRuntime { pty_causality = PtyCausalityTracker::default(); agent_startup_grace_until = None; pending_idle.clear(); + pending_working.clear(); } } @@ -2035,6 +2209,7 @@ impl PaneRuntime { agent = agent_presence.current_agent(); if agent != previous_agent { pending_idle.clear(); + pending_working.clear(); if agent.is_some() { agent_startup_grace_until = Some(now + AGENT_STARTUP_GRACE_WINDOW); @@ -2102,9 +2277,11 @@ impl PaneRuntime { if process_exited { agent_startup_grace_until = None; pending_idle.clear(); + pending_working.clear(); } else { if now < until { pending_idle.clear(); + pending_working.clear(); continue; } baseline_pty_causality( @@ -2114,6 +2291,7 @@ impl PaneRuntime { ); agent_startup_grace_until = None; pending_idle.clear(); + pending_working.clear(); continue; } } @@ -2123,6 +2301,7 @@ impl PaneRuntime { last_detection_text.clone_from(&content); if detect::should_skip_state_update(agent, &content) { pending_idle.clear(); + pending_working.clear(); continue; } sync_content_change_acquisition( @@ -2138,15 +2317,21 @@ impl PaneRuntime { let Some(screen_detection) = detection_update_for_publish(agent, &content, process_exited) else { + pending_idle.clear(); + pending_working.clear(); continue; }; - let pty_signal = if agent.is_some() { - let signal = agent_caused_pty_activity_active( + let pty_activity = if agent.is_some() { + Some(agent_caused_pty_activity_active( pty_render_seq.load(Ordering::Relaxed), input_write_seq_for_task.load(Ordering::Relaxed), &mut pty_causality, now, - ); + )) + } else { + None + }; + let pty_signal = if let Some(signal) = pty_activity { Some(crate::agent_detection_policy::PtySignal { active: signal.active, tainted: signal.tainted, @@ -2168,6 +2353,7 @@ impl PaneRuntime { ) => detection, crate::agent_detection_policy::DetectionPolicyDecision::Freeze => { pending_idle.clear(); + pending_working.clear(); continue; } }; @@ -2201,6 +2387,43 @@ impl PaneRuntime { &content, ) { pending_idle.clear(); + pending_working.clear(); + continue; + } + + if pending_working.should_publish_held_working_before_exit( + previous_publish, + next_publish, + process_exited, + ) { + pending_idle.clear(); + state = AgentState::Working; + last_visible_blocker = false; + last_visible_working = false; + last_visible_signal_refresh = None; + publish_state_changed_event( + state_events.clone(), + pane_id, + agent, + AgentState::Working, + false, + false, + false, + now, + ) + .await; + continue; + } + + if pending_working.should_hold_idle_to_working( + previous_publish, + next_publish, + agent_changed, + process_exited, + pty_activity, + now, + ) { + pending_idle.clear(); continue; } @@ -2211,6 +2434,7 @@ impl PaneRuntime { process_exited, now, ) { + pending_working.clear(); continue; } @@ -3276,6 +3500,242 @@ mod tests { assert!(!pending.active()); } + fn pty_activity(active: bool, fresh_render: bool, render_seq: u64) -> PtyActivitySignal { + PtyActivitySignal { + active, + tainted: false, + fresh_render, + render_seq, + } + } + + #[test] + fn pending_working_holds_single_twitch_then_clears_when_quiet() { + let now = std::time::Instant::now(); + let idle = DetectionPublishState { + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + }; + let working = DetectionPublishState { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + }; + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(pending.active()); + assert!(!pending.should_hold_idle_to_working( + idle, + idle, + false, + false, + Some(pty_activity(false, false, 10)), + now + AGENT_PTY_ACTIVITY_WINDOW + )); + assert!(!pending.active()); + } + + #[test] + fn pending_working_confirms_after_delayed_render_observation() { + let now = std::time::Instant::now(); + let idle = DetectionPublishState { + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + }; + let working = DetectionPublishState { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + }; + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(!pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 11)), + now + AGENT_PENDING_WORKING_CONFIRM_DELAY + )); + assert!(!pending.active()); + } + + #[test] + fn pending_working_counts_raw_sequence_jump_once() { + let now = std::time::Instant::now(); + let idle = DetectionPublishState { + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + }; + let working = DetectionPublishState { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + }; + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 14)), + now + AGENT_PENDING_WORKING_FAST_RECHECK + )); + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, false, 14)), + now + AGENT_PENDING_WORKING_FAST_RECHECK * 2 + )); + assert!(!pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 15)), + now + AGENT_PENDING_WORKING_CONFIRM_DELAY + )); + } + + #[test] + fn pending_working_cap_suppresses_unconfirmed_activity() { + let now = std::time::Instant::now(); + let idle = DetectionPublishState { + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + }; + let working = DetectionPublishState { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + }; + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 11)), + now + AGENT_PENDING_WORKING_FAST_RECHECK + )); + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, false, 11)), + now + AGENT_PENDING_WORKING_CAP + )); + assert!(!pending.active()); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 20)), + now + )); + assert!(!pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 21)), + now + AGENT_PENDING_WORKING_CONFIRM_DELAY + )); + assert!(!pending.active()); + } + + #[test] + fn pending_working_process_exit_publishes_held_working_first() { + let now = std::time::Instant::now(); + let idle = DetectionPublishState { + state: AgentState::Idle, + visible_blocker: false, + visible_working: false, + }; + let working = DetectionPublishState { + state: AgentState::Working, + visible_blocker: false, + visible_working: false, + }; + let mut pending = PendingWorkingConfirmation::default(); + + assert!(pending.should_hold_idle_to_working( + idle, + working, + false, + false, + Some(pty_activity(true, true, 10)), + now + )); + assert!(pending.should_publish_held_working_before_exit(idle, idle, true)); + assert!(!pending.active()); + } + + #[test] + fn pty_activity_distinguishes_fresh_render_from_active_hold() { + let now = std::time::Instant::now(); + let mut tracker = PtyCausalityTracker::default(); + baseline_pty_causality(&mut tracker, 1, 1); + + let fresh = agent_caused_pty_activity_active(2, 1, &mut tracker, now); + assert!(fresh.active); + assert!(fresh.fresh_render); + assert_eq!(fresh.render_seq, 2); + + let held = agent_caused_pty_activity_active( + 2, + 1, + &mut tracker, + now + AGENT_PENDING_WORKING_FAST_RECHECK, + ); + assert!(held.active); + assert!(!held.fresh_render); + assert_eq!(held.render_seq, 2); + } + #[test] fn claude_recap_veto_only_applies_to_idle_to_working() { let screen = |
