From 7f2c14ecf8350489d67b7a39d1738487ab66ea9c Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:43:42 -0700 Subject: [PATCH] feat: surface ecc2 worktree pressure --- ecc2/src/main.rs | 9 +- ecc2/src/tui/dashboard.rs | 185 ++++++++++++++++++++++++++++++++++++-- ecc2/src/worktree/mod.rs | 20 +++++ 3 files changed, 203 insertions(+), 11 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index df918c65..2e73f992 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -967,10 +967,11 @@ fn build_worktree_status_report(session: &session::Session, include_patch: bool) None }; let merge_readiness = worktree::merge_readiness(worktree)?; - let (health, check_exit_code) = match merge_readiness.status { - worktree::MergeReadinessStatus::Conflicted => ("conflicted".to_string(), 2), - worktree::MergeReadinessStatus::Ready if file_preview.is_empty() => ("clear".to_string(), 0), - worktree::MergeReadinessStatus::Ready => ("in_progress".to_string(), 1), + let worktree_health = worktree::health(worktree)?; + let (health, check_exit_code) = match worktree_health { + worktree::WorktreeHealth::Conflicted => ("conflicted".to_string(), 2), + worktree::WorktreeHealth::Clear => ("clear".to_string(), 0), + worktree::WorktreeHealth::InProgress => ("in_progress".to_string(), 1), }; Ok(WorktreeStatusReport { diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 955cc0c8..f9661d00 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -43,6 +43,7 @@ pub struct Dashboard { session_output_cache: HashMap>, unread_message_counts: HashMap, handoff_backlog_counts: HashMap, + worktree_health_by_session: HashMap, global_handoff_backlog_leads: usize, global_handoff_backlog_messages: usize, daemon_activity: DaemonActivity, @@ -79,6 +80,8 @@ struct SessionSummary { stopped: usize, unread_messages: usize, inbox_sessions: usize, + conflicted_worktrees: usize, + in_progress_worktrees: usize, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -159,6 +162,7 @@ impl Dashboard { session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), handoff_backlog_counts: HashMap::new(), + worktree_health_by_session: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0, daemon_activity: DaemonActivity::default(), @@ -268,8 +272,12 @@ impl Dashboard { .daemon_activity .stabilized_after_recovery_at() .is_some(); - let summary = - SessionSummary::from_sessions(&self.sessions, &self.handoff_backlog_counts, stabilized); + let summary = SessionSummary::from_sessions( + &self.sessions, + &self.handoff_backlog_counts, + &self.worktree_health_by_session, + stabilized, + ); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Min(3)]) @@ -1240,6 +1248,7 @@ impl Dashboard { } }; self.sync_handoff_backlog_counts(); + self.sync_worktree_health_by_session(); self.sync_global_handoff_backlog(); self.sync_daemon_activity(); self.sync_selection_by_id(selected_id.as_deref()); @@ -1309,6 +1318,28 @@ impl Dashboard { } } + fn sync_worktree_health_by_session(&mut self) { + self.worktree_health_by_session.clear(); + for session in &self.sessions { + let Some(worktree) = session.worktree.as_ref() else { + continue; + }; + + match worktree::health(worktree) { + Ok(health) => { + self.worktree_health_by_session + .insert(session.id.clone(), health); + } + Err(error) => { + tracing::warn!( + "Failed to refresh worktree health for {}: {error}", + session.id + ); + } + } + } + } + fn sync_daemon_activity(&mut self) { self.daemon_activity = match self.db.daemon_activity() { Ok(activity) => activity, @@ -1848,6 +1879,19 @@ impl Dashboard { .is_some(); for session in &self.sessions { + if self + .worktree_health_by_session + .get(&session.id) + .copied() + == Some(worktree::WorktreeHealth::Conflicted) + { + items.push(format!( + "- Conflicted worktree {} | {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 48) + )); + } + let handoff_backlog = self .handoff_backlog_counts .get(&session.id) @@ -2072,6 +2116,7 @@ impl SessionSummary { fn from_sessions( sessions: &[Session], unread_message_counts: &HashMap, + worktree_health_by_session: &HashMap, suppress_inbox_attention: bool, ) -> Self { sessions.iter().fold( @@ -2101,6 +2146,15 @@ impl SessionSummary { SessionState::Failed => summary.failed += 1, SessionState::Stopped => summary.stopped += 1, } + match worktree_health_by_session.get(&session.id).copied() { + Some(worktree::WorktreeHealth::Conflicted) => { + summary.conflicted_worktrees += 1; + } + Some(worktree::WorktreeHealth::InProgress) => { + summary.in_progress_worktrees += 1; + } + Some(worktree::WorktreeHealth::Clear) | None => {} + } summary }, ) @@ -2135,7 +2189,7 @@ fn session_row(session: &Session, unread_messages: usize) -> Row<'static> { } fn summary_line(summary: &SessionSummary) -> Line<'static> { - Line::from(vec![ + let mut spans = vec![ Span::styled( format!("Total {} ", summary.total), Style::default().add_modifier(Modifier::BOLD), @@ -2146,7 +2200,17 @@ fn summary_line(summary: &SessionSummary) -> Line<'static> { summary_span("Failed", summary.failed, Color::Red), summary_span("Stopped", summary.stopped, Color::DarkGray), summary_span("Pending", summary.pending, Color::Reset), - ]) + ]; + + if summary.conflicted_worktrees > 0 { + spans.push(summary_span("Conflicts", summary.conflicted_worktrees, Color::Red)); + } + + if summary.in_progress_worktrees > 0 { + spans.push(summary_span("Worktrees", summary.in_progress_worktrees, Color::Cyan)); + } + + Line::from(spans) } fn summary_span(label: &str, value: usize, color: Color) -> Span<'static> { @@ -2161,6 +2225,7 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta && summary.stopped == 0 && summary.pending == 0 && summary.unread_messages == 0 + && summary.conflicted_worktrees == 0 { return Line::from(vec![ Span::styled( @@ -2177,18 +2242,27 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta ]); } - Line::from(vec![ + let mut spans = vec![ Span::styled( "Attention queue ", Style::default() .fg(Color::Yellow) .add_modifier(Modifier::BOLD), ), + ]; + + if summary.conflicted_worktrees > 0 { + spans.push(summary_span("Conflicts", summary.conflicted_worktrees, Color::Red)); + } + + spans.extend([ summary_span("Backlog", summary.unread_messages, Color::Magenta), summary_span("Failed", summary.failed, Color::Red), summary_span("Stopped", summary.stopped, Color::DarkGray), summary_span("Pending", summary.pending, Color::Yellow), - ]) + ]); + + Line::from(spans) } fn truncate_for_dashboard(value: &str, max_chars: usize) -> String { @@ -2595,7 +2669,7 @@ mod tests { 42, )]; let unread = HashMap::from([(String::from("focus-12345678"), 3usize)]); - let summary = SessionSummary::from_sessions(&sessions, &unread, true); + let summary = SessionSummary::from_sessions(&sessions, &unread, &HashMap::new(), true); let line = attention_queue_line(&summary, true); let rendered = line @@ -2631,6 +2705,102 @@ mod tests { assert!(!text.contains("Backlog focus-12")); } + #[test] + fn summary_line_includes_worktree_health_counts() { + let sessions = vec![ + sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ), + sample_session( + "worker-1234567", + "claude", + SessionState::Idle, + Some("ecc/worker"), + 256, + 21, + ), + ]; + let unread = HashMap::new(); + let worktree_health = HashMap::from([ + ( + String::from("focus-12345678"), + worktree::WorktreeHealth::Conflicted, + ), + ( + String::from("worker-1234567"), + worktree::WorktreeHealth::InProgress, + ), + ]); + + let summary = SessionSummary::from_sessions(&sessions, &unread, &worktree_health, false); + let rendered = summary_line(&summary) + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(rendered.contains("Conflicts 1")); + assert!(rendered.contains("Worktrees 1")); + } + + #[test] + fn attention_queue_keeps_conflicted_worktree_pressure_when_stabilized() { + let now = Utc::now(); + let sessions = vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )]; + let unread = HashMap::from([(String::from("focus-12345678"), 3usize)]); + let worktree_health = HashMap::from([( + String::from("focus-12345678"), + worktree::WorktreeHealth::Conflicted, + )]); + + let summary = SessionSummary::from_sessions(&sessions, &unread, &worktree_health, true); + let rendered = attention_queue_line(&summary, true) + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(rendered.contains("Attention queue")); + assert!(rendered.contains("Conflicts 1")); + assert!(!rendered.contains("Attention queue clear")); + + let mut dashboard = test_dashboard(sessions, 0); + dashboard.unread_message_counts = unread; + dashboard.handoff_backlog_counts = + HashMap::from([(String::from("focus-12345678"), 3usize)]); + dashboard.worktree_health_by_session = worktree_health; + dashboard.daemon_activity = DaemonActivity { + last_dispatch_at: Some(now + chrono::Duration::seconds(2)), + last_dispatch_routed: 2, + last_dispatch_deferred: 0, + last_dispatch_leads: 1, + chronic_saturation_streak: 0, + last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), + last_recovery_dispatch_routed: 1, + last_recovery_dispatch_leads: 1, + last_rebalance_at: Some(now), + last_rebalance_rerouted: 1, + last_rebalance_leads: 1, + }; + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Needs attention:")); + assert!(text.contains("Conflicted worktree focus-12")); + assert!(!text.contains("Backlog focus-12")); + } + #[test] fn route_preview_ignores_non_handoff_inbox_noise() { let lead = sample_session( @@ -3305,6 +3475,7 @@ mod tests { session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), handoff_backlog_counts: HashMap::new(), + worktree_health_by_session: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0, daemon_activity: DaemonActivity::default(), diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 72cefa8b..dac77b6c 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -18,6 +18,13 @@ pub struct MergeReadiness { pub conflicts: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorktreeHealth { + Clear, + InProgress, + Conflicted, +} + /// Create a new git worktree for an agent session. pub fn create_for_session(session_id: &str, cfg: &Config) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve repository root")?; @@ -228,6 +235,19 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result { anyhow::bail!("git merge-tree failed: {stderr}"); } +pub fn health(worktree: &WorktreeInfo) -> Result { + let merge_readiness = merge_readiness(worktree)?; + if merge_readiness.status == MergeReadinessStatus::Conflicted { + return Ok(WorktreeHealth::Conflicted); + } + + if diff_file_preview(worktree, 1)?.is_empty() { + Ok(WorktreeHealth::Clear) + } else { + Ok(WorktreeHealth::InProgress) + } +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command