mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-09 10:53:34 +08:00
feat: surface ecc2 worktree pressure
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -43,6 +43,7 @@ pub struct Dashboard {
|
||||
session_output_cache: HashMap<String, Vec<OutputLine>>,
|
||||
unread_message_counts: HashMap<String, usize>,
|
||||
handoff_backlog_counts: HashMap<String, usize>,
|
||||
worktree_health_by_session: HashMap<String, worktree::WorktreeHealth>,
|
||||
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<String, usize>,
|
||||
worktree_health_by_session: &HashMap<String, worktree::WorktreeHealth>,
|
||||
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::<String>();
|
||||
|
||||
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::<String>();
|
||||
|
||||
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(),
|
||||
|
||||
@@ -18,6 +18,13 @@ pub struct MergeReadiness {
|
||||
pub conflicts: Vec<String>,
|
||||
}
|
||||
|
||||
#[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<WorktreeInfo> {
|
||||
let repo_root = std::env::current_dir().context("Failed to resolve repository root")?;
|
||||
@@ -228,6 +235,19 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result<MergeReadiness> {
|
||||
anyhow::bail!("git merge-tree failed: {stderr}");
|
||||
}
|
||||
|
||||
pub fn health(worktree: &WorktreeInfo) -> Result<WorktreeHealth> {
|
||||
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<Option<String>> {
|
||||
let mut command = Command::new("git");
|
||||
command
|
||||
|
||||
Reference in New Issue
Block a user