mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-12 12:43:32 +08:00
feat: add ecc2 worktree auto-merge policy
This commit is contained in:
@@ -31,6 +31,7 @@ pub struct Config {
|
|||||||
pub default_agent: String,
|
pub default_agent: String,
|
||||||
pub auto_dispatch_unread_handoffs: bool,
|
pub auto_dispatch_unread_handoffs: bool,
|
||||||
pub auto_dispatch_limit_per_session: usize,
|
pub auto_dispatch_limit_per_session: usize,
|
||||||
|
pub auto_merge_ready_worktrees: bool,
|
||||||
pub cost_budget_usd: f64,
|
pub cost_budget_usd: f64,
|
||||||
pub token_budget: u64,
|
pub token_budget: u64,
|
||||||
pub theme: Theme,
|
pub theme: Theme,
|
||||||
@@ -57,6 +58,7 @@ impl Default for Config {
|
|||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
|
auto_merge_ready_worktrees: false,
|
||||||
cost_budget_usd: 10.0,
|
cost_budget_usd: 10.0,
|
||||||
token_budget: 500_000,
|
token_budget: 500_000,
|
||||||
theme: Theme::Dark,
|
theme: Theme::Dark,
|
||||||
@@ -154,6 +156,10 @@ theme = "Dark"
|
|||||||
config.auto_dispatch_limit_per_session,
|
config.auto_dispatch_limit_per_session,
|
||||||
defaults.auto_dispatch_limit_per_session
|
defaults.auto_dispatch_limit_per_session
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
config.auto_merge_ready_worktrees,
|
||||||
|
defaults.auto_merge_ready_worktrees
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -174,11 +180,12 @@ theme = "Dark"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 path = std::env::temp_dir().join(format!("ecc2-config-{}.toml", Uuid::new_v4()));
|
||||||
let mut config = Config::default();
|
let mut config = Config::default();
|
||||||
config.auto_dispatch_unread_handoffs = true;
|
config.auto_dispatch_unread_handoffs = true;
|
||||||
config.auto_dispatch_limit_per_session = 9;
|
config.auto_dispatch_limit_per_session = 9;
|
||||||
|
config.auto_merge_ready_worktrees = true;
|
||||||
|
|
||||||
config.save_to_path(&path).unwrap();
|
config.save_to_path(&path).unwrap();
|
||||||
let content = std::fs::read_to_string(&path).unwrap();
|
let content = std::fs::read_to_string(&path).unwrap();
|
||||||
@@ -186,6 +193,7 @@ theme = "Dark"
|
|||||||
|
|
||||||
assert!(loaded.auto_dispatch_unread_handoffs);
|
assert!(loaded.auto_dispatch_unread_handoffs);
|
||||||
assert_eq!(loaded.auto_dispatch_limit_per_session, 9);
|
assert_eq!(loaded.auto_dispatch_limit_per_session, 9);
|
||||||
|
assert!(loaded.auto_merge_ready_worktrees);
|
||||||
|
|
||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
|||||||
tracing::error!("Backlog coordination pass failed: {e}");
|
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;
|
time::sleep(heartbeat_interval).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,6 +341,41 @@ where
|
|||||||
Ok(rerouted)
|
Ok(rerouted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn maybe_auto_merge_ready_worktrees(db: &StateStore, cfg: &Config) -> Result<usize> {
|
||||||
|
maybe_auto_merge_ready_worktrees_with(cfg, || manager::merge_ready_worktrees(db, true)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn maybe_auto_merge_ready_worktrees_with<F, Fut>(cfg: &Config, merge: F) -> Result<usize>
|
||||||
|
where
|
||||||
|
F: Fn() -> Fut,
|
||||||
|
Fut: Future<Output = Result<manager::WorktreeBulkMergeOutcome>>,
|
||||||
|
{
|
||||||
|
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)]
|
#[cfg(unix)]
|
||||||
fn pid_is_alive(pid: u32) -> bool {
|
fn pid_is_alive(pid: u32) -> bool {
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
@@ -1039,4 +1078,67 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(path);
|
let _ = std::fs::remove_file(path);
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1618,6 +1618,7 @@ mod tests {
|
|||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
|
auto_merge_ready_worktrees: false,
|
||||||
cost_budget_usd: 10.0,
|
cost_budget_usd: 10.0,
|
||||||
token_budget: 500_000,
|
token_budget: 500_000,
|
||||||
theme: Theme::Dark,
|
theme: Theme::Dark,
|
||||||
|
|||||||
@@ -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_selected_worktree().await,
|
||||||
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
|
(_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await,
|
||||||
(_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(),
|
(_, 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('.')) => dashboard.adjust_auto_dispatch_limit(1),
|
(_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1),
|
||||||
(_, KeyCode::Char('s')) => dashboard.stop_selected().await,
|
(_, KeyCode::Char('s')) => dashboard.stop_selected().await,
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ impl Dashboard {
|
|||||||
|
|
||||||
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
fn render_status_bar(&self, frame: &mut Frame, area: Rect) {
|
||||||
let text = format!(
|
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()
|
self.layout_label()
|
||||||
);
|
);
|
||||||
let text = if let Some(note) = self.operator_note.as_ref() {
|
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 selected ready worktree into base and clean it up",
|
||||||
" M Merge all ready inactive worktrees and clean them up",
|
" M Merge all ready inactive worktrees and clean them up",
|
||||||
" p Toggle daemon auto-dispatch policy and persist config",
|
" 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",
|
" ,/. Decrease/increase auto-dispatch limit per lead",
|
||||||
" s Stop selected session",
|
" s Stop selected session",
|
||||||
" u Resume 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) {
|
pub fn adjust_auto_dispatch_limit(&mut self, delta: isize) {
|
||||||
let next =
|
let next =
|
||||||
(self.cfg.auto_dispatch_limit_per_session as isize + delta).clamp(1, 50) as usize;
|
(self.cfg.auto_dispatch_limit_per_session as isize + delta).clamp(1, 50) as usize;
|
||||||
@@ -1749,7 +1771,7 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push(format!(
|
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_leads,
|
||||||
self.global_handoff_backlog_messages,
|
self.global_handoff_backlog_messages,
|
||||||
if self.cfg.auto_dispatch_unread_handoffs {
|
if self.cfg.auto_dispatch_unread_handoffs {
|
||||||
@@ -1757,7 +1779,12 @@ impl Dashboard {
|
|||||||
} else {
|
} else {
|
||||||
"off"
|
"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();
|
let stabilized = self.daemon_activity.stabilized_after_recovery_at();
|
||||||
@@ -2567,12 +2594,36 @@ mod tests {
|
|||||||
let text = dashboard.selected_session_metrics_text();
|
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("Team 3/8 | idle 1 | running 1 | pending 1 | failed 0 | stopped 0"));
|
||||||
assert!(text.contains(
|
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("Coordination mode dispatch-first"));
|
||||||
assert!(text.contains("Next route reuse idle worker-1"));
|
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]
|
#[test]
|
||||||
fn selected_session_metrics_text_includes_daemon_activity() {
|
fn selected_session_metrics_text_includes_daemon_activity() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
@@ -3735,6 +3786,7 @@ mod tests {
|
|||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
|
auto_merge_ready_worktrees: false,
|
||||||
cost_budget_usd: 10.0,
|
cost_budget_usd: 10.0,
|
||||||
token_budget: 500_000,
|
token_budget: 500_000,
|
||||||
theme: Theme::Dark,
|
theme: Theme::Dark,
|
||||||
|
|||||||
Reference in New Issue
Block a user