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
|
None
|
||||||
};
|
};
|
||||||
let merge_readiness = worktree::merge_readiness(worktree)?;
|
let merge_readiness = worktree::merge_readiness(worktree)?;
|
||||||
let (health, check_exit_code) = match merge_readiness.status {
|
let worktree_health = worktree::health(worktree)?;
|
||||||
worktree::MergeReadinessStatus::Conflicted => ("conflicted".to_string(), 2),
|
let (health, check_exit_code) = match worktree_health {
|
||||||
worktree::MergeReadinessStatus::Ready if file_preview.is_empty() => ("clear".to_string(), 0),
|
worktree::WorktreeHealth::Conflicted => ("conflicted".to_string(), 2),
|
||||||
worktree::MergeReadinessStatus::Ready => ("in_progress".to_string(), 1),
|
worktree::WorktreeHealth::Clear => ("clear".to_string(), 0),
|
||||||
|
worktree::WorktreeHealth::InProgress => ("in_progress".to_string(), 1),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(WorktreeStatusReport {
|
Ok(WorktreeStatusReport {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub struct Dashboard {
|
|||||||
session_output_cache: HashMap<String, Vec<OutputLine>>,
|
session_output_cache: HashMap<String, Vec<OutputLine>>,
|
||||||
unread_message_counts: HashMap<String, usize>,
|
unread_message_counts: HashMap<String, usize>,
|
||||||
handoff_backlog_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_leads: usize,
|
||||||
global_handoff_backlog_messages: usize,
|
global_handoff_backlog_messages: usize,
|
||||||
daemon_activity: DaemonActivity,
|
daemon_activity: DaemonActivity,
|
||||||
@@ -79,6 +80,8 @@ struct SessionSummary {
|
|||||||
stopped: usize,
|
stopped: usize,
|
||||||
unread_messages: usize,
|
unread_messages: usize,
|
||||||
inbox_sessions: usize,
|
inbox_sessions: usize,
|
||||||
|
conflicted_worktrees: usize,
|
||||||
|
in_progress_worktrees: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -159,6 +162,7 @@ impl Dashboard {
|
|||||||
session_output_cache: HashMap::new(),
|
session_output_cache: HashMap::new(),
|
||||||
unread_message_counts: HashMap::new(),
|
unread_message_counts: HashMap::new(),
|
||||||
handoff_backlog_counts: HashMap::new(),
|
handoff_backlog_counts: HashMap::new(),
|
||||||
|
worktree_health_by_session: HashMap::new(),
|
||||||
global_handoff_backlog_leads: 0,
|
global_handoff_backlog_leads: 0,
|
||||||
global_handoff_backlog_messages: 0,
|
global_handoff_backlog_messages: 0,
|
||||||
daemon_activity: DaemonActivity::default(),
|
daemon_activity: DaemonActivity::default(),
|
||||||
@@ -268,8 +272,12 @@ impl Dashboard {
|
|||||||
.daemon_activity
|
.daemon_activity
|
||||||
.stabilized_after_recovery_at()
|
.stabilized_after_recovery_at()
|
||||||
.is_some();
|
.is_some();
|
||||||
let summary =
|
let summary = SessionSummary::from_sessions(
|
||||||
SessionSummary::from_sessions(&self.sessions, &self.handoff_backlog_counts, stabilized);
|
&self.sessions,
|
||||||
|
&self.handoff_backlog_counts,
|
||||||
|
&self.worktree_health_by_session,
|
||||||
|
stabilized,
|
||||||
|
);
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(2), Constraint::Min(3)])
|
.constraints([Constraint::Length(2), Constraint::Min(3)])
|
||||||
@@ -1240,6 +1248,7 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.sync_handoff_backlog_counts();
|
self.sync_handoff_backlog_counts();
|
||||||
|
self.sync_worktree_health_by_session();
|
||||||
self.sync_global_handoff_backlog();
|
self.sync_global_handoff_backlog();
|
||||||
self.sync_daemon_activity();
|
self.sync_daemon_activity();
|
||||||
self.sync_selection_by_id(selected_id.as_deref());
|
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) {
|
fn sync_daemon_activity(&mut self) {
|
||||||
self.daemon_activity = match self.db.daemon_activity() {
|
self.daemon_activity = match self.db.daemon_activity() {
|
||||||
Ok(activity) => activity,
|
Ok(activity) => activity,
|
||||||
@@ -1848,6 +1879,19 @@ impl Dashboard {
|
|||||||
.is_some();
|
.is_some();
|
||||||
|
|
||||||
for session in &self.sessions {
|
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
|
let handoff_backlog = self
|
||||||
.handoff_backlog_counts
|
.handoff_backlog_counts
|
||||||
.get(&session.id)
|
.get(&session.id)
|
||||||
@@ -2072,6 +2116,7 @@ impl SessionSummary {
|
|||||||
fn from_sessions(
|
fn from_sessions(
|
||||||
sessions: &[Session],
|
sessions: &[Session],
|
||||||
unread_message_counts: &HashMap<String, usize>,
|
unread_message_counts: &HashMap<String, usize>,
|
||||||
|
worktree_health_by_session: &HashMap<String, worktree::WorktreeHealth>,
|
||||||
suppress_inbox_attention: bool,
|
suppress_inbox_attention: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
sessions.iter().fold(
|
sessions.iter().fold(
|
||||||
@@ -2101,6 +2146,15 @@ impl SessionSummary {
|
|||||||
SessionState::Failed => summary.failed += 1,
|
SessionState::Failed => summary.failed += 1,
|
||||||
SessionState::Stopped => summary.stopped += 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
|
summary
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2135,7 +2189,7 @@ fn session_row(session: &Session, unread_messages: usize) -> Row<'static> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn summary_line(summary: &SessionSummary) -> Line<'static> {
|
fn summary_line(summary: &SessionSummary) -> Line<'static> {
|
||||||
Line::from(vec![
|
let mut spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("Total {} ", summary.total),
|
format!("Total {} ", summary.total),
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
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("Failed", summary.failed, Color::Red),
|
||||||
summary_span("Stopped", summary.stopped, Color::DarkGray),
|
summary_span("Stopped", summary.stopped, Color::DarkGray),
|
||||||
summary_span("Pending", summary.pending, Color::Reset),
|
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> {
|
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.stopped == 0
|
||||||
&& summary.pending == 0
|
&& summary.pending == 0
|
||||||
&& summary.unread_messages == 0
|
&& summary.unread_messages == 0
|
||||||
|
&& summary.conflicted_worktrees == 0
|
||||||
{
|
{
|
||||||
return Line::from(vec![
|
return Line::from(vec![
|
||||||
Span::styled(
|
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(
|
Span::styled(
|
||||||
"Attention queue ",
|
"Attention queue ",
|
||||||
Style::default()
|
Style::default()
|
||||||
.fg(Color::Yellow)
|
.fg(Color::Yellow)
|
||||||
.add_modifier(Modifier::BOLD),
|
.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("Backlog", summary.unread_messages, Color::Magenta),
|
||||||
summary_span("Failed", summary.failed, Color::Red),
|
summary_span("Failed", summary.failed, Color::Red),
|
||||||
summary_span("Stopped", summary.stopped, Color::DarkGray),
|
summary_span("Stopped", summary.stopped, Color::DarkGray),
|
||||||
summary_span("Pending", summary.pending, Color::Yellow),
|
summary_span("Pending", summary.pending, Color::Yellow),
|
||||||
])
|
]);
|
||||||
|
|
||||||
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn truncate_for_dashboard(value: &str, max_chars: usize) -> String {
|
fn truncate_for_dashboard(value: &str, max_chars: usize) -> String {
|
||||||
@@ -2595,7 +2669,7 @@ mod tests {
|
|||||||
42,
|
42,
|
||||||
)];
|
)];
|
||||||
let unread = HashMap::from([(String::from("focus-12345678"), 3usize)]);
|
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 line = attention_queue_line(&summary, true);
|
||||||
let rendered = line
|
let rendered = line
|
||||||
@@ -2631,6 +2705,102 @@ mod tests {
|
|||||||
assert!(!text.contains("Backlog focus-12"));
|
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]
|
#[test]
|
||||||
fn route_preview_ignores_non_handoff_inbox_noise() {
|
fn route_preview_ignores_non_handoff_inbox_noise() {
|
||||||
let lead = sample_session(
|
let lead = sample_session(
|
||||||
@@ -3305,6 +3475,7 @@ mod tests {
|
|||||||
session_output_cache: HashMap::new(),
|
session_output_cache: HashMap::new(),
|
||||||
unread_message_counts: HashMap::new(),
|
unread_message_counts: HashMap::new(),
|
||||||
handoff_backlog_counts: HashMap::new(),
|
handoff_backlog_counts: HashMap::new(),
|
||||||
|
worktree_health_by_session: HashMap::new(),
|
||||||
global_handoff_backlog_leads: 0,
|
global_handoff_backlog_leads: 0,
|
||||||
global_handoff_backlog_messages: 0,
|
global_handoff_backlog_messages: 0,
|
||||||
daemon_activity: DaemonActivity::default(),
|
daemon_activity: DaemonActivity::default(),
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ pub struct MergeReadiness {
|
|||||||
pub conflicts: Vec<String>,
|
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.
|
/// Create a new git worktree for an agent session.
|
||||||
pub fn create_for_session(session_id: &str, cfg: &Config) -> Result<WorktreeInfo> {
|
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")?;
|
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}");
|
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>> {
|
fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result<Option<String>> {
|
||||||
let mut command = Command::new("git");
|
let mut command = Command::new("git");
|
||||||
command
|
command
|
||||||
|
|||||||
Reference in New Issue
Block a user