From 27d7964bb12358687dfd0c619ad25a597a785906 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:11:22 -0700 Subject: [PATCH] feat: add ecc2 worktree auto-merge policy --- ecc2/src/config/mod.rs | 10 +++- ecc2/src/session/daemon.rs | 102 ++++++++++++++++++++++++++++++++++++ ecc2/src/session/manager.rs | 1 + ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 60 +++++++++++++++++++-- 5 files changed, 169 insertions(+), 5 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index d6abb4b3..b989c060 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -31,6 +31,7 @@ pub struct Config { pub default_agent: String, pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_limit_per_session: usize, + pub auto_merge_ready_worktrees: bool, pub cost_budget_usd: f64, pub token_budget: u64, pub theme: Theme, @@ -57,6 +58,7 @@ impl Default for Config { default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, + auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, theme: Theme::Dark, @@ -154,6 +156,10 @@ theme = "Dark" config.auto_dispatch_limit_per_session, defaults.auto_dispatch_limit_per_session ); + assert_eq!( + config.auto_merge_ready_worktrees, + defaults.auto_merge_ready_worktrees + ); } #[test] @@ -174,11 +180,12 @@ theme = "Dark" } #[test] - fn save_round_trips_auto_dispatch_settings() { + fn save_round_trips_automation_settings() { let path = std::env::temp_dir().join(format!("ecc2-config-{}.toml", Uuid::new_v4())); let mut config = Config::default(); config.auto_dispatch_unread_handoffs = true; config.auto_dispatch_limit_per_session = 9; + config.auto_merge_ready_worktrees = true; config.save_to_path(&path).unwrap(); let content = std::fs::read_to_string(&path).unwrap(); @@ -186,6 +193,7 @@ theme = "Dark" assert!(loaded.auto_dispatch_unread_handoffs); assert_eq!(loaded.auto_dispatch_limit_per_session, 9); + assert!(loaded.auto_merge_ready_worktrees); let _ = std::fs::remove_file(path); } diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index e8986db5..e54faea4 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -33,6 +33,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Backlog coordination pass failed: {e}"); } + if let Err(e) = maybe_auto_merge_ready_worktrees(&db, &cfg).await { + tracing::error!("Worktree auto-merge pass failed: {e}"); + } + time::sleep(heartbeat_interval).await; } } @@ -337,6 +341,41 @@ where Ok(rerouted) } +async fn maybe_auto_merge_ready_worktrees(db: &StateStore, cfg: &Config) -> Result { + maybe_auto_merge_ready_worktrees_with(cfg, || manager::merge_ready_worktrees(db, true)).await +} + +async fn maybe_auto_merge_ready_worktrees_with(cfg: &Config, merge: F) -> Result +where + F: Fn() -> Fut, + Fut: Future>, +{ + if !cfg.auto_merge_ready_worktrees { + return Ok(0); + } + + let outcome = merge().await?; + let merged = outcome.merged.len(); + + if merged > 0 { + tracing::info!("Auto-merged {merged} ready worktree(s)"); + } + if !outcome.conflicted_session_ids.is_empty() { + tracing::warn!( + "Skipped {} conflicted worktree(s) during auto-merge", + outcome.conflicted_session_ids.len() + ); + } + if !outcome.dirty_worktree_ids.is_empty() { + tracing::warn!( + "Skipped {} dirty worktree(s) during auto-merge", + outcome.dirty_worktree_ids.len() + ); + } + + Ok(merged) +} + #[cfg(unix)] fn pid_is_alive(pid: u32) -> bool { if pid == 0 { @@ -1039,4 +1078,67 @@ mod tests { let _ = std::fs::remove_file(path); Ok(()) } + + #[tokio::test] + async fn maybe_auto_merge_ready_worktrees_noops_when_disabled() -> Result<()> { + let mut cfg = Config::default(); + cfg.auto_merge_ready_worktrees = false; + + let invoked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let invoked_flag = invoked.clone(); + + let merged = maybe_auto_merge_ready_worktrees_with(&cfg, move || { + let invoked_flag = invoked_flag.clone(); + async move { + invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(manager::WorktreeBulkMergeOutcome { + merged: Vec::new(), + active_with_worktree_ids: Vec::new(), + conflicted_session_ids: Vec::new(), + dirty_worktree_ids: Vec::new(), + failures: Vec::new(), + }) + } + }) + .await?; + + assert_eq!(merged, 0); + assert!(!invoked.load(std::sync::atomic::Ordering::SeqCst)); + Ok(()) + } + + #[tokio::test] + async fn maybe_auto_merge_ready_worktrees_merges_ready_worktrees_when_enabled() -> Result<()> { + let mut cfg = Config::default(); + cfg.auto_merge_ready_worktrees = true; + + let merged = maybe_auto_merge_ready_worktrees_with(&cfg, || async move { + Ok(manager::WorktreeBulkMergeOutcome { + merged: vec![ + manager::WorktreeMergeOutcome { + session_id: "worker-a".to_string(), + branch: "ecc/worker-a".to_string(), + base_branch: "main".to_string(), + already_up_to_date: false, + cleaned_worktree: true, + }, + manager::WorktreeMergeOutcome { + session_id: "worker-b".to_string(), + branch: "ecc/worker-b".to_string(), + base_branch: "main".to_string(), + already_up_to_date: true, + cleaned_worktree: true, + }, + ], + active_with_worktree_ids: vec!["worker-c".to_string()], + conflicted_session_ids: vec!["worker-d".to_string()], + dirty_worktree_ids: vec!["worker-e".to_string()], + failures: Vec::new(), + }) + }) + .await?; + + assert_eq!(merged, 2); + Ok(()) + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 1897f6ad..cebe3651 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1618,6 +1618,7 @@ mod tests { default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, + auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, theme: Theme::Dark, diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 792f4f31..34f3bc17 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -49,6 +49,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), + (_, KeyCode::Char('w')) => dashboard.toggle_auto_merge_policy(), (_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1), (_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1), (_, KeyCode::Char('s')) => dashboard.stop_selected().await, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 40ba2dc3..58aa2d29 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -462,7 +462,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let text = format!( - " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff [m]erge merge ready [M] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", + " [n]ew session [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff [m]erge merge ready [M] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [Tab] switch pane [j/k] scroll [+/-] resize [{}] layout [?] help [q]uit ", self.layout_label() ); let text = if let Some(note) = self.operator_note.as_ref() { @@ -517,6 +517,7 @@ impl Dashboard { " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", " p Toggle daemon auto-dispatch policy and persist config", + " w Toggle daemon auto-merge for ready inactive worktrees", " ,/. Decrease/increase auto-dispatch limit per lead", " s Stop selected session", " u Resume selected session", @@ -1274,6 +1275,27 @@ impl Dashboard { } } + pub fn toggle_auto_merge_policy(&mut self) { + self.cfg.auto_merge_ready_worktrees = !self.cfg.auto_merge_ready_worktrees; + match self.cfg.save() { + Ok(()) => { + let state = if self.cfg.auto_merge_ready_worktrees { + "enabled" + } else { + "disabled" + }; + self.set_operator_note(format!( + "daemon auto-merge {state} | saved to {}", + crate::config::Config::config_path().display() + )); + } + Err(error) => { + self.cfg.auto_merge_ready_worktrees = !self.cfg.auto_merge_ready_worktrees; + self.set_operator_note(format!("failed to persist auto-merge policy: {error}")); + } + } + } + pub fn adjust_auto_dispatch_limit(&mut self, delta: isize) { let next = (self.cfg.auto_dispatch_limit_per_session as isize + delta).clamp(1, 50) as usize; @@ -1749,7 +1771,7 @@ impl Dashboard { } lines.push(format!( - "Global handoff backlog {} lead(s) / {} handoff(s) | Auto-dispatch {} @ {}/lead", + "Global handoff backlog {} lead(s) / {} handoff(s) | Auto-dispatch {} @ {}/lead | Auto-merge {}", self.global_handoff_backlog_leads, self.global_handoff_backlog_messages, if self.cfg.auto_dispatch_unread_handoffs { @@ -1757,7 +1779,12 @@ impl Dashboard { } else { "off" }, - self.cfg.auto_dispatch_limit_per_session + self.cfg.auto_dispatch_limit_per_session, + if self.cfg.auto_merge_ready_worktrees { + "on" + } else { + "off" + } )); let stabilized = self.daemon_activity.stabilized_after_recovery_at(); @@ -2567,12 +2594,36 @@ mod tests { let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Team 3/8 | idle 1 | running 1 | pending 1 | failed 0 | stopped 0")); assert!(text.contains( - "Global handoff backlog 2 lead(s) / 5 handoff(s) | Auto-dispatch off @ 5/lead" + "Global handoff backlog 2 lead(s) / 5 handoff(s) | Auto-dispatch off @ 5/lead | Auto-merge off" )); assert!(text.contains("Coordination mode dispatch-first")); assert!(text.contains("Next route reuse idle worker-1")); } + #[test] + fn selected_session_metrics_text_shows_auto_merge_policy_state() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.cfg.auto_dispatch_unread_handoffs = true; + dashboard.cfg.auto_merge_ready_worktrees = true; + dashboard.global_handoff_backlog_leads = 1; + dashboard.global_handoff_backlog_messages = 2; + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains( + "Global handoff backlog 1 lead(s) / 2 handoff(s) | Auto-dispatch on @ 5/lead | Auto-merge on" + )); + } + #[test] fn selected_session_metrics_text_includes_daemon_activity() { let now = Utc::now(); @@ -3735,6 +3786,7 @@ mod tests { default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, + auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, theme: Theme::Dark,