From a7bfe82af9ca6a02c90bbed9805c74771267e3a2 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 02:27:28 -0700 Subject: [PATCH 001/459] feat: auto-rebalance ecc2 delegate teams --- ecc2/src/session/daemon.rs | 126 +++++++++++++++++++++++++++++++++++- ecc2/src/session/manager.rs | 41 ++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 842d23d1..099245da 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -26,6 +26,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Auto-dispatch pass failed: {e}"); } + if let Err(e) = maybe_auto_rebalance(&db, &cfg).await { + tracing::error!("Auto-rebalance pass failed: {e}"); + } + time::sleep(heartbeat_interval).await; } } @@ -136,6 +140,53 @@ where Ok(routed) } +async fn maybe_auto_rebalance(db: &StateStore, cfg: &Config) -> Result { + if !cfg.auto_dispatch_unread_handoffs { + return Ok(0); + } + + let outcomes = manager::rebalance_all_teams( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + .await?; + let rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); + + if rerouted > 0 { + tracing::info!( + "Auto-rebalanced {rerouted} task handoff(s) across {} lead session(s)", + outcomes.len() + ); + } + + Ok(rerouted) +} + +async fn maybe_auto_rebalance_with(cfg: &Config, rebalance: F) -> Result +where + F: Fn() -> Fut, + Fut: Future>>, +{ + if !cfg.auto_dispatch_unread_handoffs { + return Ok(0); + } + + let outcomes = rebalance().await?; + let rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); + + if rerouted > 0 { + tracing::info!( + "Auto-rebalanced {rerouted} task handoff(s) across {} lead session(s)", + outcomes.len() + ); + } + + Ok(rerouted) +} + #[cfg(unix)] fn pid_is_alive(pid: u32) -> bool { if pid == 0 { @@ -162,7 +213,10 @@ fn pid_is_alive(_pid: u32) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::session::manager::{AssignmentAction, InboxDrainOutcome, LeadDispatchOutcome}; + use crate::session::manager::{ + AssignmentAction, InboxDrainOutcome, LeadDispatchOutcome, LeadRebalanceOutcome, + RebalanceOutcome, + }; use crate::session::{Session, SessionMetrics, SessionState}; use std::path::PathBuf; @@ -298,4 +352,74 @@ mod tests { let _ = std::fs::remove_file(path); Ok(()) } + + #[tokio::test] + async fn maybe_auto_rebalance_noops_when_disabled() -> Result<()> { + let path = temp_db_path(); + let _store = StateStore::open(&path)?; + let cfg = Config::default(); + let invoked = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let invoked_flag = invoked.clone(); + + let rerouted = maybe_auto_rebalance_with(&cfg, move || { + let invoked_flag = invoked_flag.clone(); + async move { + invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(Vec::new()) + } + }) + .await?; + + assert_eq!(rerouted, 0); + assert!(!invoked.load(std::sync::atomic::Ordering::SeqCst)); + let _ = std::fs::remove_file(path); + Ok(()) + } + + #[tokio::test] + async fn maybe_auto_rebalance_reports_total_rerouted_work() -> Result<()> { + let path = temp_db_path(); + let _store = StateStore::open(&path)?; + let mut cfg = Config::default(); + cfg.auto_dispatch_unread_handoffs = true; + + let rerouted = maybe_auto_rebalance_with(&cfg, || async move { + Ok(vec![ + LeadRebalanceOutcome { + lead_session_id: "lead-a".to_string(), + rerouted: vec![ + RebalanceOutcome { + from_session_id: "worker-a".to_string(), + message_id: 1, + task: "Task A".to_string(), + session_id: "worker-b".to_string(), + action: AssignmentAction::ReusedIdle, + }, + RebalanceOutcome { + from_session_id: "worker-a".to_string(), + message_id: 2, + task: "Task B".to_string(), + session_id: "worker-c".to_string(), + action: AssignmentAction::Spawned, + }, + ], + }, + LeadRebalanceOutcome { + lead_session_id: "lead-b".to_string(), + rerouted: vec![RebalanceOutcome { + from_session_id: "worker-d".to_string(), + message_id: 3, + task: "Task C".to_string(), + session_id: "worker-e".to_string(), + action: AssignmentAction::ReusedActive, + }], + }, + ]) + }) + .await?; + + assert_eq!(rerouted, 3); + let _ = std::fs::remove_file(path); + Ok(()) + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 329d0683..af25a027 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -166,6 +166,42 @@ pub async fn auto_dispatch_backlog( Ok(outcomes) } +pub async fn rebalance_all_teams( + db: &StateStore, + cfg: &Config, + agent_type: &str, + use_worktree: bool, + lead_limit: usize, +) -> Result> { + let sessions = db.list_sessions()?; + let mut outcomes = Vec::new(); + + for session in sessions + .into_iter() + .filter(|session| matches!(session.state, SessionState::Running | SessionState::Pending | SessionState::Idle)) + .take(lead_limit) + { + let rerouted = rebalance_team_backlog( + db, + cfg, + &session.id, + agent_type, + use_worktree, + cfg.auto_dispatch_limit_per_session, + ) + .await?; + + if !rerouted.is_empty() { + outcomes.push(LeadRebalanceOutcome { + lead_session_id: session.id, + rerouted, + }); + } + } + + Ok(outcomes) +} + pub async fn rebalance_team_backlog( db: &StateStore, cfg: &Config, @@ -965,6 +1001,11 @@ pub struct RebalanceOutcome { pub action: AssignmentAction, } +pub struct LeadRebalanceOutcome { + pub lead_session_id: String, + pub rerouted: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssignmentAction { Spawned, From 2709694b7b4b039b50649fbdfe47a9b8d612cfa7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 02:40:18 -0700 Subject: [PATCH 002/459] feat: surface ecc2 daemon activity --- ecc2/src/session/daemon.rs | 168 ++++++++++++++++++++++++++---------- ecc2/src/session/manager.rs | 7 +- ecc2/src/session/store.rs | 106 +++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 74 ++++++++++++++-- 4 files changed, 304 insertions(+), 51 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 099245da..508bc2d9 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -94,34 +94,31 @@ fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> { } async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result { - if !cfg.auto_dispatch_unread_handoffs { - return Ok(0); - } - - let outcomes = manager::auto_dispatch_backlog( - db, - cfg, - &cfg.default_agent, - true, - cfg.max_parallel_sessions, - ) - .await?; - let routed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); - - if routed > 0 { - tracing::info!( - "Auto-dispatched {routed} task handoff(s) across {} lead session(s)", - outcomes.len() - ); - } - - Ok(routed) + maybe_auto_dispatch_with_recorder(cfg, || { + manager::auto_dispatch_backlog( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + }, |routed, leads| db.record_daemon_dispatch_pass(routed, leads)) + .await } async fn maybe_auto_dispatch_with(cfg: &Config, dispatch: F) -> Result where F: Fn() -> Fut, Fut: Future>>, +{ + maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _| Ok(())).await +} + +async fn maybe_auto_dispatch_with_recorder(cfg: &Config, dispatch: F, mut record: R) -> Result +where + F: Fn() -> Fut, + Fut: Future>>, + R: FnMut(usize, usize) -> Result<()>, { if !cfg.auto_dispatch_unread_handoffs { return Ok(0); @@ -129,6 +126,7 @@ where let outcomes = dispatch().await?; let routed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); + record(routed, outcomes.len())?; if routed > 0 { tracing::info!( @@ -141,34 +139,35 @@ where } async fn maybe_auto_rebalance(db: &StateStore, cfg: &Config) -> Result { - if !cfg.auto_dispatch_unread_handoffs { - return Ok(0); - } - - let outcomes = manager::rebalance_all_teams( - db, - cfg, - &cfg.default_agent, - true, - cfg.max_parallel_sessions, - ) - .await?; - let rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); - - if rerouted > 0 { - tracing::info!( - "Auto-rebalanced {rerouted} task handoff(s) across {} lead session(s)", - outcomes.len() - ); - } - - Ok(rerouted) + maybe_auto_rebalance_with_recorder(cfg, || { + manager::rebalance_all_teams( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + }, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads)) + .await } async fn maybe_auto_rebalance_with(cfg: &Config, rebalance: F) -> Result where F: Fn() -> Fut, Fut: Future>>, +{ + maybe_auto_rebalance_with_recorder(cfg, rebalance, |_, _| Ok(())).await +} + +async fn maybe_auto_rebalance_with_recorder( + cfg: &Config, + rebalance: F, + mut record: R, +) -> Result +where + F: Fn() -> Fut, + Fut: Future>>, + R: FnMut(usize, usize) -> Result<()>, { if !cfg.auto_dispatch_unread_handoffs { return Ok(0); @@ -176,6 +175,7 @@ where let outcomes = rebalance().await?; let rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); + record(rerouted, outcomes.len())?; if rerouted > 0 { tracing::info!( @@ -353,6 +353,50 @@ mod tests { Ok(()) } + #[tokio::test] + async fn maybe_auto_dispatch_records_latest_pass() -> Result<()> { + let path = temp_db_path(); + let mut cfg = Config::default(); + cfg.auto_dispatch_unread_handoffs = true; + + let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); + let recorded_clone = recorded.clone(); + + let routed = maybe_auto_dispatch_with_recorder( + &cfg, + || async move { + Ok(vec![LeadDispatchOutcome { + lead_session_id: "lead-a".to_string(), + unread_count: 3, + routed: vec![ + InboxDrainOutcome { + message_id: 1, + task: "task-a".to_string(), + session_id: "worker-a".to_string(), + action: AssignmentAction::Spawned, + }, + InboxDrainOutcome { + message_id: 2, + task: "task-b".to_string(), + session_id: "worker-b".to_string(), + action: AssignmentAction::Spawned, + }, + ], + }]) + }, + move |count, leads| { + *recorded_clone.lock().unwrap() = Some((count, leads)); + Ok(()) + }, + ) + .await?; + + assert_eq!(routed, 2); + assert_eq!(*recorded.lock().unwrap(), Some((2, 1))); + let _ = std::fs::remove_file(path); + Ok(()) + } + #[tokio::test] async fn maybe_auto_rebalance_noops_when_disabled() -> Result<()> { let path = temp_db_path(); @@ -422,4 +466,40 @@ mod tests { let _ = std::fs::remove_file(path); Ok(()) } + + #[tokio::test] + async fn maybe_auto_rebalance_records_latest_pass() -> Result<()> { + let path = temp_db_path(); + let mut cfg = Config::default(); + cfg.auto_dispatch_unread_handoffs = true; + + let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); + let recorded_clone = recorded.clone(); + + let rerouted = maybe_auto_rebalance_with_recorder( + &cfg, + || async move { + Ok(vec![LeadRebalanceOutcome { + lead_session_id: "lead-a".to_string(), + rerouted: vec![RebalanceOutcome { + from_session_id: "worker-a".to_string(), + message_id: 7, + task: "task-a".to_string(), + session_id: "worker-b".to_string(), + action: AssignmentAction::ReusedIdle, + }], + }]) + }, + move |count, leads| { + *recorded_clone.lock().unwrap() = Some((count, leads)); + Ok(()) + }, + ) + .await?; + + assert_eq!(rerouted, 1); + assert_eq!(*recorded.lock().unwrap(), Some((1, 1))); + let _ = std::fs::remove_file(path); + Ok(()) + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index af25a027..bde00d61 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1237,8 +1237,11 @@ mod tests { fn wait_for_file(path: &Path) -> Result { for _ in 0..200 { if path.exists() { - return fs::read_to_string(path) - .with_context(|| format!("failed to read {}", path.display())); + let content = fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + if content.lines().count() >= 2 { + return Ok(content); + } } thread::sleep(StdDuration::from_millis(20)); diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index d8e187e1..8f80e976 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -13,6 +13,16 @@ pub struct StateStore { conn: Connection, } +#[derive(Debug, Clone, Default)] +pub struct DaemonActivity { + pub last_dispatch_at: Option>, + pub last_dispatch_routed: usize, + pub last_dispatch_leads: usize, + pub last_rebalance_at: Option>, + pub last_rebalance_rerouted: usize, + pub last_rebalance_leads: usize, +} + impl StateStore { pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; @@ -74,11 +84,23 @@ impl StateStore { timestamp TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS daemon_activity ( + id INTEGER PRIMARY KEY CHECK(id = 1), + last_dispatch_at TEXT, + last_dispatch_routed INTEGER NOT NULL DEFAULT 0, + last_dispatch_leads INTEGER NOT NULL DEFAULT 0, + last_rebalance_at TEXT, + last_rebalance_rerouted INTEGER NOT NULL DEFAULT 0, + last_rebalance_leads INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state); CREATE INDEX IF NOT EXISTS idx_tool_log_session ON tool_log(session_id); CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session, read); CREATE INDEX IF NOT EXISTS idx_session_output_session ON session_output(session_id, id); + + INSERT OR IGNORE INTO daemon_activity (id) VALUES (1); ", )?; self.ensure_session_columns()?; @@ -488,6 +510,71 @@ impl StateStore { .map_err(Into::into) } + pub fn daemon_activity(&self) -> Result { + self.conn + .query_row( + "SELECT last_dispatch_at, last_dispatch_routed, last_dispatch_leads, + last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads + FROM daemon_activity + WHERE id = 1", + [], + |row| { + let parse_ts = + |value: Option| -> rusqlite::Result>> { + value + .map(|raw| { + chrono::DateTime::parse_from_rfc3339(&raw) + .map(|ts| ts.with_timezone(&chrono::Utc)) + .map_err(|err| { + rusqlite::Error::FromSqlConversionFailure( + 0, + rusqlite::types::Type::Text, + Box::new(err), + ) + }) + }) + .transpose() + }; + + Ok(DaemonActivity { + last_dispatch_at: parse_ts(row.get(0)?)?, + last_dispatch_routed: row.get::<_, i64>(1)? as usize, + last_dispatch_leads: row.get::<_, i64>(2)? as usize, + last_rebalance_at: parse_ts(row.get(3)?)?, + last_rebalance_rerouted: row.get::<_, i64>(4)? as usize, + last_rebalance_leads: row.get::<_, i64>(5)? as usize, + }) + }, + ) + .map_err(Into::into) + } + + pub fn record_daemon_dispatch_pass(&self, routed: usize, leads: usize) -> Result<()> { + self.conn.execute( + "UPDATE daemon_activity + SET last_dispatch_at = ?1, + last_dispatch_routed = ?2, + last_dispatch_leads = ?3 + WHERE id = 1", + rusqlite::params![chrono::Utc::now().to_rfc3339(), routed as i64, leads as i64], + )?; + + Ok(()) + } + + pub fn record_daemon_rebalance_pass(&self, rerouted: usize, leads: usize) -> Result<()> { + self.conn.execute( + "UPDATE daemon_activity + SET last_rebalance_at = ?1, + last_rebalance_rerouted = ?2, + last_rebalance_leads = ?3 + WHERE id = 1", + rusqlite::params![chrono::Utc::now().to_rfc3339(), rerouted as i64, leads as i64], + )?; + + Ok(()) + } + pub fn delegated_children(&self, session_id: &str, limit: usize) -> Result> { let mut stmt = self.conn.prepare( "SELECT to_session @@ -855,4 +942,23 @@ mod tests { Ok(()) } + + #[test] + fn daemon_activity_round_trips_latest_passes() -> Result<()> { + let tempdir = TestDir::new("store-daemon-activity")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + + db.record_daemon_dispatch_pass(4, 2)?; + db.record_daemon_rebalance_pass(3, 1)?; + + let activity = db.daemon_activity()?; + assert_eq!(activity.last_dispatch_routed, 4); + assert_eq!(activity.last_dispatch_leads, 2); + assert_eq!(activity.last_rebalance_rerouted, 3); + assert_eq!(activity.last_rebalance_leads, 1); + assert!(activity.last_dispatch_at.is_some()); + assert!(activity.last_rebalance_at.is_some()); + + Ok(()) + } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 02aec98d..ae9bdf6e 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1,6 +1,4 @@ use std::collections::HashMap; -use std::path::Path; - use ratatui::{ prelude::*, widgets::{ @@ -13,12 +11,19 @@ use super::widgets::{budget_state, format_currency, format_token_count, BudgetSt use crate::comms; use crate::config::{Config, PaneLayout}; use crate::observability::ToolLogEntry; -use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OutputStream, OUTPUT_BUFFER_LIMIT}; +use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; use crate::session::manager; -use crate::session::store::StateStore; -use crate::session::{Session, SessionMessage, SessionMetrics, SessionState, WorktreeInfo}; +use crate::session::store::{DaemonActivity, StateStore}; +use crate::session::{Session, SessionMessage, SessionState}; use crate::worktree; +#[cfg(test)] +use std::path::Path; +#[cfg(test)] +use crate::session::output::OutputStream; +#[cfg(test)] +use crate::session::{SessionMetrics, WorktreeInfo}; + const DEFAULT_PANE_SIZE_PERCENT: u16 = 35; const DEFAULT_GRID_SIZE_PERCENT: u16 = 50; const OUTPUT_PANE_PERCENT: u16 = 70; @@ -37,6 +42,7 @@ pub struct Dashboard { unread_message_counts: HashMap, global_handoff_backlog_leads: usize, global_handoff_backlog_messages: usize, + daemon_activity: DaemonActivity, selected_messages: Vec, selected_parent_session: Option, selected_child_sessions: Vec, @@ -137,6 +143,7 @@ impl Dashboard { unread_message_counts: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0, + daemon_activity: DaemonActivity::default(), selected_messages: Vec::new(), selected_parent_session: None, selected_child_sessions: Vec::new(), @@ -988,6 +995,7 @@ impl Dashboard { } }; self.sync_global_handoff_backlog(); + self.sync_daemon_activity(); self.sync_selection_by_id(selected_id.as_deref()); self.ensure_selected_pane_visible(); self.sync_selected_output(); @@ -1038,6 +1046,16 @@ impl Dashboard { } } + fn sync_daemon_activity(&mut self) { + self.daemon_activity = match self.db.daemon_activity() { + Ok(activity) => activity, + Err(error) => { + tracing::warn!("Failed to refresh daemon activity: {error}"); + DaemonActivity::default() + } + }; + } + fn sync_selected_output(&mut self) { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.output_scroll_offset = 0; @@ -1333,6 +1351,24 @@ impl Dashboard { self.cfg.auto_dispatch_limit_per_session )); + if let Some(last_dispatch_at) = self.daemon_activity.last_dispatch_at.as_ref() { + lines.push(format!( + "Last daemon dispatch {} handoff(s) across {} lead(s) @ {}", + self.daemon_activity.last_dispatch_routed, + self.daemon_activity.last_dispatch_leads, + self.short_timestamp(&last_dispatch_at.to_rfc3339()) + )); + } + + if let Some(last_rebalance_at) = self.daemon_activity.last_rebalance_at.as_ref() { + lines.push(format!( + "Last daemon rebalance {} handoff(s) across {} lead(s) @ {}", + self.daemon_activity.last_rebalance_rerouted, + self.daemon_activity.last_rebalance_leads, + self.short_timestamp(&last_rebalance_at.to_rfc3339()) + )); + } + if let Some(route_preview) = self.selected_route_preview.as_ref() { lines.push(format!("Next route {route_preview}")); } @@ -1932,6 +1968,33 @@ mod tests { assert!(text.contains("Next route reuse idle worker-1")); } + #[test] + fn selected_session_metrics_text_includes_daemon_activity() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.daemon_activity = DaemonActivity { + last_dispatch_at: Some(Utc::now()), + last_dispatch_routed: 4, + last_dispatch_leads: 2, + last_rebalance_at: Some(Utc::now()), + last_rebalance_rerouted: 1, + last_rebalance_leads: 1, + }; + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Last daemon dispatch 4 handoff(s) across 2 lead(s)")); + assert!(text.contains("Last daemon rebalance 1 handoff(s) across 1 lead(s)")); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); @@ -2373,6 +2436,7 @@ mod tests { unread_message_counts: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0, + daemon_activity: DaemonActivity::default(), selected_messages: Vec::new(), selected_parent_session: None, selected_child_sessions: Vec::new(), From 6dc557731958b824b080ceedae62069145fc5765 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 02:43:45 -0700 Subject: [PATCH 003/459] feat: add ecc2 global rebalance controls --- ecc2/src/main.rs | 69 +++++++++++++++++++++++++++++++++ ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 81 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 1 deletion(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 23e4a50b..51e396a1 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -90,6 +90,18 @@ enum Commands { #[arg(long, default_value_t = 10)] lead_limit: usize, }, + /// Rebalance unread handoffs across lead teams with backed-up delegates + RebalanceAll { + /// Agent type for routed delegates + #[arg(short, long, default_value = "claude")] + agent: String, + /// Create a dedicated worktree if new delegates must be spawned + #[arg(short, long, default_value_t = true)] + worktree: bool, + /// Maximum lead sessions to sweep in one pass + #[arg(long, default_value_t = 10)] + lead_limit: usize, + }, /// Rebalance unread handoffs off backed-up delegates onto clearer team capacity RebalanceTeam { /// Lead session ID or alias @@ -337,6 +349,38 @@ async fn main() -> Result<()> { } } } + Some(Commands::RebalanceAll { + agent, + worktree: use_worktree, + lead_limit, + }) => { + let outcomes = session::manager::rebalance_all_teams( + &db, + &cfg, + &agent, + use_worktree, + lead_limit, + ) + .await?; + if outcomes.is_empty() { + println!("No delegate backlog needed global rebalancing"); + } else { + let total_rerouted: usize = + outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); + println!( + "Rebalanced {} task handoff(s) across {} lead session(s)", + total_rerouted, + outcomes.len() + ); + for outcome in outcomes { + println!( + "- {} | rerouted {}", + short_session(&outcome.lead_session_id), + outcome.rerouted.len() + ); + } + } + } Some(Commands::RebalanceTeam { session_id, agent, @@ -747,6 +791,31 @@ mod tests { } } + #[test] + fn cli_parses_rebalance_all_command() { + let cli = Cli::try_parse_from([ + "ecc", + "rebalance-all", + "--agent", + "claude", + "--lead-limit", + "6", + ]) + .expect("rebalance-all should parse"); + + match cli.command { + Some(Commands::RebalanceAll { + agent, + lead_limit, + .. + }) => { + assert_eq!(agent, "claude"); + assert_eq!(lead_limit, 6); + } + _ => panic!("expected rebalance-all subcommand"), + } + } + #[test] fn cli_parses_rebalance_team_command() { let cli = Cli::try_parse_from([ diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 971e382f..207b2964 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -41,6 +41,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('n')) => dashboard.new_session().await, (_, KeyCode::Char('a')) => dashboard.assign_selected().await, (_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await, + (_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await, (_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await, (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index ae9bdf6e..70b86f1a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -407,7 +407,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let text = format!( - " [n]ew session [a]ssign re[b]alance dra[i]n inbox [g]lobal dispatch toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [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 toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [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() { @@ -454,6 +454,7 @@ impl Dashboard { " n New session", " a Assign follow-up work from selected session", " b Rebalance backed-up delegate inboxes for selected lead", + " B Rebalance backed-up delegate inboxes across lead teams", " i Drain unread task handoffs from selected session inbox", " g Auto-dispatch unread handoffs across lead sessions", " p Toggle daemon auto-dispatch policy and persist config", @@ -840,6 +841,52 @@ impl Dashboard { } } + pub async fn rebalance_all_teams(&mut self) { + let agent = self.cfg.default_agent.clone(); + let lead_limit = self.sessions.len().max(1); + + let outcomes = match manager::rebalance_all_teams( + &self.db, + &self.cfg, + &agent, + true, + lead_limit, + ) + .await + { + Ok(outcomes) => outcomes, + Err(error) => { + tracing::warn!("Failed to rebalance teams from dashboard: {error}"); + self.set_operator_note(format!("global rebalance failed: {error}")); + return; + } + }; + + let total_rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); + let selected_session_id = self + .sessions + .get(self.selected_session) + .map(|session| session.id.clone()); + + self.refresh(); + self.sync_selection_by_id(selected_session_id.as_deref()); + self.sync_selected_output(); + self.sync_selected_diff(); + self.sync_selected_messages(); + self.sync_selected_lineage(); + self.refresh_logs(); + + if total_rerouted == 0 { + self.set_operator_note("no delegate backlog needed global rebalancing".to_string()); + } else { + self.set_operator_note(format!( + "rebalanced {} handoff(s) across {} lead session(s)", + total_rerouted, + outcomes.len() + )); + } + } + pub async fn stop_selected(&mut self) { let Some(session) = self.sessions.get(self.selected_session) else { return; @@ -2365,6 +2412,38 @@ mod tests { Ok(()) } + #[tokio::test] + async fn rebalance_all_teams_sets_operator_note_when_clear() -> Result<()> { + let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead-1".to_string(), + task: "coordinate".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let dashboard_store = StateStore::open(&db_path)?; + let mut dashboard = Dashboard::new(dashboard_store, Config::default()); + dashboard.rebalance_all_teams().await; + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("no delegate backlog needed global rebalancing") + ); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } + #[test] fn grid_layout_renders_four_panes() { let mut dashboard = test_dashboard(vec![sample_session("grid-1", "claude", SessionState::Running, None, 1, 1)], 0); From 38f502299a394fd8c08ac43cdccf5547d8799ae7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 02:46:01 -0700 Subject: [PATCH 004/459] feat: add ecc2 global coordination action --- ecc2/src/main.rs | 78 +++++++++++++++++++++++++++++ ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 101 +++++++++++++++++++++++++++++++++++++- 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 51e396a1..4a6dfdff 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -90,6 +90,18 @@ enum Commands { #[arg(long, default_value_t = 10)] lead_limit: usize, }, + /// Dispatch unread handoffs, then rebalance delegate backlog across lead teams + CoordinateBacklog { + /// Agent type for routed delegates + #[arg(short, long, default_value = "claude")] + agent: String, + /// Create a dedicated worktree if new delegates must be spawned + #[arg(short, long, default_value_t = true)] + worktree: bool, + /// Maximum lead sessions to sweep in one pass + #[arg(long, default_value_t = 10)] + lead_limit: usize, + }, /// Rebalance unread handoffs across lead teams with backed-up delegates RebalanceAll { /// Agent type for routed delegates @@ -349,6 +361,47 @@ async fn main() -> Result<()> { } } } + Some(Commands::CoordinateBacklog { + agent, + worktree: use_worktree, + lead_limit, + }) => { + let dispatch_outcomes = session::manager::auto_dispatch_backlog( + &db, + &cfg, + &agent, + use_worktree, + lead_limit, + ) + .await?; + let total_routed: usize = + dispatch_outcomes.iter().map(|outcome| outcome.routed.len()).sum(); + + let rebalance_outcomes = session::manager::rebalance_all_teams( + &db, + &cfg, + &agent, + use_worktree, + lead_limit, + ) + .await?; + let total_rerouted: usize = rebalance_outcomes + .iter() + .map(|outcome| outcome.rerouted.len()) + .sum(); + + if total_routed == 0 && total_rerouted == 0 { + println!("Backlog already clear"); + } else { + println!( + "Coordinated backlog: dispatched {} handoff(s) across {} lead(s); rebalanced {} handoff(s) across {} lead(s)", + total_routed, + dispatch_outcomes.len(), + total_rerouted, + rebalance_outcomes.len() + ); + } + } Some(Commands::RebalanceAll { agent, worktree: use_worktree, @@ -791,6 +844,31 @@ mod tests { } } + #[test] + fn cli_parses_coordinate_backlog_command() { + let cli = Cli::try_parse_from([ + "ecc", + "coordinate-backlog", + "--agent", + "claude", + "--lead-limit", + "7", + ]) + .expect("coordinate-backlog should parse"); + + match cli.command { + Some(Commands::CoordinateBacklog { + agent, + lead_limit, + .. + }) => { + assert_eq!(agent, "claude"); + assert_eq!(lead_limit, 7); + } + _ => panic!("expected coordinate-backlog subcommand"), + } + } + #[test] fn cli_parses_rebalance_all_command() { let cli = Cli::try_parse_from([ diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 207b2964..c9c90fc9 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -44,6 +44,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await, (_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await, (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, + (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), (_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1), (_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 70b86f1a..621121e4 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -407,7 +407,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 toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [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 toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [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() { @@ -457,6 +457,7 @@ impl Dashboard { " B Rebalance backed-up delegate inboxes across lead teams", " i Drain unread task handoffs from selected session inbox", " g Auto-dispatch unread handoffs across lead sessions", + " G Dispatch then rebalance backlog across lead teams", " p Toggle daemon auto-dispatch policy and persist config", " ,/. Decrease/increase auto-dispatch limit per lead", " s Stop selected session", @@ -887,6 +888,75 @@ impl Dashboard { } } + pub async fn coordinate_backlog(&mut self) { + let agent = self.cfg.default_agent.clone(); + let lead_limit = self.sessions.len().max(1); + + let dispatch_outcomes = match manager::auto_dispatch_backlog( + &self.db, + &self.cfg, + &agent, + true, + lead_limit, + ) + .await + { + Ok(outcomes) => outcomes, + Err(error) => { + tracing::warn!("Failed to coordinate backlog dispatch from dashboard: {error}"); + self.set_operator_note(format!("global coordinate failed during dispatch: {error}")); + return; + } + }; + let total_routed: usize = dispatch_outcomes.iter().map(|outcome| outcome.routed.len()).sum(); + + let rebalance_outcomes = match manager::rebalance_all_teams( + &self.db, + &self.cfg, + &agent, + true, + lead_limit, + ) + .await + { + Ok(outcomes) => outcomes, + Err(error) => { + tracing::warn!("Failed to coordinate backlog rebalance from dashboard: {error}"); + self.set_operator_note(format!("global coordinate failed during rebalance: {error}")); + return; + } + }; + let total_rerouted: usize = rebalance_outcomes + .iter() + .map(|outcome| outcome.rerouted.len()) + .sum(); + + let selected_session_id = self + .sessions + .get(self.selected_session) + .map(|session| session.id.clone()); + + self.refresh(); + self.sync_selection_by_id(selected_session_id.as_deref()); + self.sync_selected_output(); + self.sync_selected_diff(); + self.sync_selected_messages(); + self.sync_selected_lineage(); + self.refresh_logs(); + + if total_routed == 0 && total_rerouted == 0 { + self.set_operator_note("backlog already clear".to_string()); + } else { + self.set_operator_note(format!( + "coordinated backlog: dispatched {} handoff(s) across {} lead(s), rebalanced {} handoff(s) across {} lead(s)", + total_routed, + dispatch_outcomes.len(), + total_rerouted, + rebalance_outcomes.len() + )); + } + } + pub async fn stop_selected(&mut self) { let Some(session) = self.sessions.get(self.selected_session) else { return; @@ -2444,6 +2514,35 @@ mod tests { Ok(()) } + #[tokio::test] + async fn coordinate_backlog_sets_operator_note_when_clear() -> Result<()> { + let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead-1".to_string(), + task: "coordinate".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let dashboard_store = StateStore::open(&db_path)?; + let mut dashboard = Dashboard::new(dashboard_store, Config::default()); + dashboard.coordinate_backlog().await; + + assert_eq!(dashboard.operator_note.as_deref(), Some("backlog already clear")); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } + #[test] fn grid_layout_renders_four_panes() { let mut dashboard = test_dashboard(vec![sample_session("grid-1", "claude", SessionState::Running, None, 1, 1)], 0); From 868763dfa900141e52fcf5d97d479ec7805b1556 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 02:50:53 -0700 Subject: [PATCH 005/459] feat: report ecc2 remaining coordination backlog --- ecc2/src/main.rs | 36 ++++++++--------- ecc2/src/session/manager.rs | 80 +++++++++++++++++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 44 ++++++++------------ 3 files changed, 115 insertions(+), 45 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 4a6dfdff..d75344a3 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -366,7 +366,7 @@ async fn main() -> Result<()> { worktree: use_worktree, lead_limit, }) => { - let dispatch_outcomes = session::manager::auto_dispatch_backlog( + let outcome = session::manager::coordinate_backlog( &db, &cfg, &agent, @@ -374,31 +374,31 @@ async fn main() -> Result<()> { lead_limit, ) .await?; - let total_routed: usize = - dispatch_outcomes.iter().map(|outcome| outcome.routed.len()).sum(); - - let rebalance_outcomes = session::manager::rebalance_all_teams( - &db, - &cfg, - &agent, - use_worktree, - lead_limit, - ) - .await?; - let total_rerouted: usize = rebalance_outcomes + let total_routed: usize = outcome + .dispatched .iter() - .map(|outcome| outcome.rerouted.len()) + .map(|dispatch| dispatch.routed.len()) + .sum(); + let total_rerouted: usize = outcome + .rebalanced + .iter() + .map(|rebalance| rebalance.rerouted.len()) .sum(); - if total_routed == 0 && total_rerouted == 0 { + if total_routed == 0 + && total_rerouted == 0 + && outcome.remaining_backlog_sessions == 0 + { println!("Backlog already clear"); } else { println!( - "Coordinated backlog: dispatched {} handoff(s) across {} lead(s); rebalanced {} handoff(s) across {} lead(s)", + "Coordinated backlog: dispatched {} handoff(s) across {} lead(s); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s)", total_routed, - dispatch_outcomes.len(), + outcome.dispatched.len(), total_rerouted, - rebalance_outcomes.len() + outcome.rebalanced.len(), + outcome.remaining_backlog_messages, + outcome.remaining_backlog_sessions ); } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index bde00d61..5f0537a1 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -202,6 +202,30 @@ pub async fn rebalance_all_teams( Ok(outcomes) } +pub async fn coordinate_backlog( + db: &StateStore, + cfg: &Config, + agent_type: &str, + use_worktree: bool, + lead_limit: usize, +) -> Result { + let dispatched = auto_dispatch_backlog(db, cfg, agent_type, use_worktree, lead_limit).await?; + let rebalanced = rebalance_all_teams(db, cfg, agent_type, use_worktree, lead_limit).await?; + let remaining_targets = db.unread_task_handoff_targets(db.list_sessions()?.len().max(1))?; + let remaining_backlog_sessions = remaining_targets.len(); + let remaining_backlog_messages = remaining_targets + .iter() + .map(|(_, unread_count)| *unread_count) + .sum(); + + Ok(CoordinateBacklogOutcome { + dispatched, + rebalanced, + remaining_backlog_sessions, + remaining_backlog_messages, + }) +} + pub async fn rebalance_team_backlog( db: &StateStore, cfg: &Config, @@ -1006,6 +1030,13 @@ pub struct LeadRebalanceOutcome { pub rerouted: Vec, } +pub struct CoordinateBacklogOutcome { + pub dispatched: Vec, + pub rebalanced: Vec, + pub remaining_backlog_sessions: usize, + pub remaining_backlog_messages: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssignmentAction { Spawned, @@ -1899,6 +1930,55 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn coordinate_backlog_reports_remaining_backlog_after_limited_pass() -> Result<()> { + let tempdir = TestDir::new("manager-coordinate-backlog")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.auto_dispatch_limit_per_session = 5; + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + for lead_id in ["lead-a", "lead-b"] { + db.insert_session(&Session { + id: lead_id.to_string(), + task: format!("{lead_id} task"), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + } + + db.send_message( + "planner", + "lead-a", + "{\"task\":\"Review auth\",\"context\":\"Inbound\"}", + "task_handoff", + )?; + db.send_message( + "planner", + "lead-b", + "{\"task\":\"Review billing\",\"context\":\"Inbound\"}", + "task_handoff", + )?; + + let outcome = coordinate_backlog(&db, &cfg, "claude", true, 1).await?; + + assert_eq!(outcome.dispatched.len(), 1); + assert_eq!(outcome.rebalanced.len(), 0); + assert_eq!(outcome.remaining_backlog_sessions, 2); + assert_eq!(outcome.remaining_backlog_messages, 2); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn rebalance_team_backlog_moves_work_off_backed_up_delegate() -> Result<()> { let tempdir = TestDir::new("manager-rebalance-team")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 621121e4..cdf85cfa 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -892,7 +892,7 @@ impl Dashboard { let agent = self.cfg.default_agent.clone(); let lead_limit = self.sessions.len().max(1); - let dispatch_outcomes = match manager::auto_dispatch_backlog( + let outcome = match manager::coordinate_backlog( &self.db, &self.cfg, &agent, @@ -903,32 +903,20 @@ impl Dashboard { { Ok(outcomes) => outcomes, Err(error) => { - tracing::warn!("Failed to coordinate backlog dispatch from dashboard: {error}"); - self.set_operator_note(format!("global coordinate failed during dispatch: {error}")); + tracing::warn!("Failed to coordinate backlog from dashboard: {error}"); + self.set_operator_note(format!("global coordinate failed: {error}")); return; } }; - let total_routed: usize = dispatch_outcomes.iter().map(|outcome| outcome.routed.len()).sum(); - - let rebalance_outcomes = match manager::rebalance_all_teams( - &self.db, - &self.cfg, - &agent, - true, - lead_limit, - ) - .await - { - Ok(outcomes) => outcomes, - Err(error) => { - tracing::warn!("Failed to coordinate backlog rebalance from dashboard: {error}"); - self.set_operator_note(format!("global coordinate failed during rebalance: {error}")); - return; - } - }; - let total_rerouted: usize = rebalance_outcomes + let total_routed: usize = outcome + .dispatched .iter() - .map(|outcome| outcome.rerouted.len()) + .map(|dispatch| dispatch.routed.len()) + .sum(); + let total_rerouted: usize = outcome + .rebalanced + .iter() + .map(|rebalance| rebalance.rerouted.len()) .sum(); let selected_session_id = self @@ -944,15 +932,17 @@ impl Dashboard { self.sync_selected_lineage(); self.refresh_logs(); - if total_routed == 0 && total_rerouted == 0 { + if total_routed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { self.set_operator_note("backlog already clear".to_string()); } else { self.set_operator_note(format!( - "coordinated backlog: dispatched {} handoff(s) across {} lead(s), rebalanced {} handoff(s) across {} lead(s)", + "coordinated backlog: dispatched {} across {} lead(s), rebalanced {} across {} lead(s), remaining {} across {} session(s)", total_routed, - dispatch_outcomes.len(), + outcome.dispatched.len(), total_rerouted, - rebalance_outcomes.len() + outcome.rebalanced.len(), + outcome.remaining_backlog_messages, + outcome.remaining_backlog_sessions )); } } From a3f600e25fcc47833b26c7ad7b3379fd6806d119 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 02:57:26 -0700 Subject: [PATCH 006/459] feat: classify ecc2 remaining coordination pressure --- ecc2/src/main.rs | 6 +- ecc2/src/session/manager.rs | 109 ++++++++++++++++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 6 +- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index d75344a3..2032ab67 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -392,13 +392,15 @@ async fn main() -> Result<()> { println!("Backlog already clear"); } else { println!( - "Coordinated backlog: dispatched {} handoff(s) across {} lead(s); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s)", + "Coordinated backlog: dispatched {} handoff(s) across {} lead(s); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s) [{} absorbable, {} saturated]", total_routed, outcome.dispatched.len(), total_rerouted, outcome.rebalanced.len(), outcome.remaining_backlog_messages, - outcome.remaining_backlog_sessions + outcome.remaining_backlog_sessions, + outcome.remaining_absorbable_sessions, + outcome.remaining_saturated_sessions ); } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 5f0537a1..889e3a87 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -212,6 +212,7 @@ pub async fn coordinate_backlog( let dispatched = auto_dispatch_backlog(db, cfg, agent_type, use_worktree, lead_limit).await?; let rebalanced = rebalance_all_teams(db, cfg, agent_type, use_worktree, lead_limit).await?; let remaining_targets = db.unread_task_handoff_targets(db.list_sessions()?.len().max(1))?; + let pressure = summarize_backlog_pressure(db, cfg, agent_type, &remaining_targets)?; let remaining_backlog_sessions = remaining_targets.len(); let remaining_backlog_messages = remaining_targets .iter() @@ -223,6 +224,8 @@ pub async fn coordinate_backlog( rebalanced, remaining_backlog_sessions, remaining_backlog_messages, + remaining_absorbable_sessions: pressure.absorbable_sessions, + remaining_saturated_sessions: pressure.saturated_sessions, }) } @@ -811,6 +814,33 @@ fn direct_delegate_sessions(db: &StateStore, lead_id: &str, agent_type: &str) -> Ok(sessions) } +fn summarize_backlog_pressure( + db: &StateStore, + cfg: &Config, + agent_type: &str, + targets: &[(String, usize)], +) -> Result { + let unread_counts = db.unread_message_counts()?; + let mut summary = BacklogPressureSummary::default(); + + for (session_id, _) in targets { + let delegates = direct_delegate_sessions(db, session_id, agent_type)?; + let has_clear_idle_delegate = delegates.iter().any(|delegate| { + delegate.state == SessionState::Idle + && unread_counts.get(&delegate.id).copied().unwrap_or(0) == 0 + }); + let has_capacity = delegates.len() < cfg.max_parallel_sessions; + + if has_clear_idle_delegate || has_capacity { + summary.absorbable_sessions += 1; + } else { + summary.saturated_sessions += 1; + } + } + + Ok(summary) +} + fn send_task_handoff( db: &StateStore, from_session: &Session, @@ -1035,6 +1065,8 @@ pub struct CoordinateBacklogOutcome { pub rebalanced: Vec, pub remaining_backlog_sessions: usize, pub remaining_backlog_messages: usize, + pub remaining_absorbable_sessions: usize, + pub remaining_saturated_sessions: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1044,6 +1076,12 @@ pub enum AssignmentAction { ReusedActive, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct BacklogPressureSummary { + absorbable_sessions: usize, + saturated_sessions: usize, +} + struct DelegatedSessionSummary { depth: usize, unread_messages: usize, @@ -1975,6 +2013,77 @@ mod tests { assert_eq!(outcome.rebalanced.len(), 0); assert_eq!(outcome.remaining_backlog_sessions, 2); assert_eq!(outcome.remaining_backlog_messages, 2); + assert_eq!(outcome.remaining_absorbable_sessions, 2); + assert_eq!(outcome.remaining_saturated_sessions, 0); + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn coordinate_backlog_classifies_remaining_saturated_pressure() -> Result<()> { + let tempdir = TestDir::new("manager-coordinate-saturated")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.max_parallel_sessions = 1; + cfg.auto_dispatch_limit_per_session = 1; + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "worker".to_string(), + task: "worker task".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + + db.insert_session(&Session { + id: "worker-child".to_string(), + task: "delegate task".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(43), + worktree: None, + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + + db.send_message( + "worker", + "worker-child", + "{\"task\":\"seed delegate\",\"context\":\"Delegated from worker\"}", + "task_handoff", + )?; + let _ = db.mark_messages_read("worker-child")?; + + db.send_message( + "planner", + "worker", + "{\"task\":\"task-a\",\"context\":\"Inbound\"}", + "task_handoff", + )?; + db.send_message( + "planner", + "worker", + "{\"task\":\"task-b\",\"context\":\"Inbound\"}", + "task_handoff", + )?; + + let outcome = coordinate_backlog(&db, &cfg, "claude", true, 10).await?; + + assert_eq!(outcome.remaining_backlog_sessions, 1); + assert_eq!(outcome.remaining_backlog_messages, 2); + assert_eq!(outcome.remaining_absorbable_sessions, 0); + assert_eq!(outcome.remaining_saturated_sessions, 1); Ok(()) } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index cdf85cfa..c4bdea08 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -936,13 +936,15 @@ impl Dashboard { self.set_operator_note("backlog already clear".to_string()); } else { self.set_operator_note(format!( - "coordinated backlog: dispatched {} across {} lead(s), rebalanced {} across {} lead(s), remaining {} across {} session(s)", + "coordinated backlog: dispatched {} across {} lead(s), rebalanced {} across {} lead(s), remaining {} across {} session(s) [{} absorbable, {} saturated]", total_routed, outcome.dispatched.len(), total_rerouted, outcome.rebalanced.len(), outcome.remaining_backlog_messages, - outcome.remaining_backlog_sessions + outcome.remaining_backlog_sessions, + outcome.remaining_absorbable_sessions, + outcome.remaining_saturated_sessions )); } } From 91e145338fefd0a2403c4da57422fee5a28f7e02 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:06:19 -0700 Subject: [PATCH 007/459] feat: defer ecc2 handoffs on saturated teams --- ecc2/src/main.rs | 95 ++++++++++++++++----- ecc2/src/session/manager.rs | 163 +++++++++++++++++++++++++++++++++--- ecc2/src/tui/dashboard.rs | 44 ++++++++-- 3 files changed, 264 insertions(+), 38 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 2032ab67..b931e27b 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -279,16 +279,25 @@ async fn main() -> Result<()> { use_worktree, ) .await?; - println!( - "Assignment routed: {} -> {} ({})", - short_session(&lead_id), - short_session(&outcome.session_id), - match outcome.action { - session::manager::AssignmentAction::Spawned => "spawned", - session::manager::AssignmentAction::ReusedIdle => "reused-idle", - session::manager::AssignmentAction::ReusedActive => "reused-active", - } - ); + if session::manager::assignment_action_routes_work(outcome.action) { + println!( + "Assignment routed: {} -> {} ({})", + short_session(&lead_id), + short_session(&outcome.session_id), + match outcome.action { + session::manager::AssignmentAction::Spawned => "spawned", + session::manager::AssignmentAction::ReusedIdle => "reused-idle", + session::manager::AssignmentAction::ReusedActive => "reused-active", + session::manager::AssignmentAction::DeferredSaturated => unreachable!(), + } + ); + } else { + println!( + "Assignment deferred: {} is saturated; task stayed in {} inbox", + short_session(&lead_id), + short_session(&lead_id), + ); + } } Some(Commands::DrainInbox { session_id, @@ -309,10 +318,18 @@ async fn main() -> Result<()> { if outcomes.is_empty() { println!("No unread task handoffs for {}", short_session(&lead_id)); } else { + let routed_count = outcomes + .iter() + .filter(|outcome| session::manager::assignment_action_routes_work(outcome.action)) + .count(); + let deferred_count = outcomes.len().saturating_sub(routed_count); println!( - "Routed {} inbox task handoff(s) from {}", + "Processed {} inbox task handoff(s) from {} ({} routed, {} deferred)", outcomes.len(), short_session(&lead_id) + , + routed_count, + deferred_count ); for outcome in outcomes { println!( @@ -323,6 +340,9 @@ async fn main() -> Result<()> { session::manager::AssignmentAction::Spawned => "spawned", session::manager::AssignmentAction::ReusedIdle => "reused-idle", session::manager::AssignmentAction::ReusedActive => "reused-active", + session::manager::AssignmentAction::DeferredSaturated => { + "deferred-saturated" + } }, outcome.task ); @@ -345,18 +365,38 @@ async fn main() -> Result<()> { if outcomes.is_empty() { println!("No unread task handoff backlog found"); } else { - let total_routed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); + let total_processed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); + let total_routed: usize = outcomes + .iter() + .map(|outcome| { + outcome + .routed + .iter() + .filter(|item| session::manager::assignment_action_routes_work(item.action)) + .count() + }) + .sum(); + let total_deferred = total_processed.saturating_sub(total_routed); println!( - "Auto-dispatched {} task handoff(s) across {} lead session(s)", + "Auto-dispatch processed {} task handoff(s) across {} lead session(s) ({} routed, {} deferred)", + total_processed, + outcomes.len(), total_routed, - outcomes.len() + total_deferred ); for outcome in outcomes { + let routed = outcome + .routed + .iter() + .filter(|item| session::manager::assignment_action_routes_work(item.action)) + .count(); + let deferred = outcome.routed.len().saturating_sub(routed); println!( - "- {} | unread {} | routed {}", + "- {} | unread {} | routed {} | deferred {}", short_session(&outcome.lead_session_id), outcome.unread_count, - outcome.routed.len() + routed, + deferred ); } } @@ -374,11 +414,23 @@ async fn main() -> Result<()> { lead_limit, ) .await?; - let total_routed: usize = outcome + let total_processed: usize = outcome .dispatched .iter() .map(|dispatch| dispatch.routed.len()) .sum(); + let total_routed: usize = outcome + .dispatched + .iter() + .map(|dispatch| { + dispatch + .routed + .iter() + .filter(|item| session::manager::assignment_action_routes_work(item.action)) + .count() + }) + .sum(); + let total_deferred = total_processed.saturating_sub(total_routed); let total_rerouted: usize = outcome .rebalanced .iter() @@ -392,9 +444,11 @@ async fn main() -> Result<()> { println!("Backlog already clear"); } else { println!( - "Coordinated backlog: dispatched {} handoff(s) across {} lead(s); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s) [{} absorbable, {} saturated]", - total_routed, + "Coordinated backlog: processed {} handoff(s) across {} lead(s) ({} routed, {} deferred); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s) [{} absorbable, {} saturated]", + total_processed, outcome.dispatched.len(), + total_routed, + total_deferred, total_rerouted, outcome.rebalanced.len(), outcome.remaining_backlog_messages, @@ -470,6 +524,9 @@ async fn main() -> Result<()> { session::manager::AssignmentAction::Spawned => "spawned", session::manager::AssignmentAction::ReusedIdle => "reused-idle", session::manager::AssignmentAction::ReusedActive => "reused-active", + session::manager::AssignmentAction::DeferredSaturated => { + "deferred-saturated" + } }, outcome.task ); diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 889e3a87..20e7db1a 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -121,7 +121,9 @@ pub async fn drain_inbox( ) .await?; - let _ = db.mark_message_read(message.id)?; + if assignment_action_routes_work(outcome.action) { + let _ = db.mark_message_read(message.id)?; + } outcomes.push(InboxDrainOutcome { message_id: message.id, task, @@ -461,7 +463,7 @@ async fn assign_session_in_dir_with_runner_program( }); } - if let Some(idle_delegate) = delegates + if let Some(_idle_delegate) = delegates .iter() .filter(|session| session.state == SessionState::Idle) .min_by_key(|session| { @@ -471,16 +473,9 @@ async fn assign_session_in_dir_with_runner_program( ) }) { - send_task_handoff( - db, - &lead, - &idle_delegate.id, - task, - "reused idle delegate with existing inbox backlog", - )?; return Ok(AssignmentOutcome { - session_id: idle_delegate.id.clone(), - action: AssignmentAction::ReusedIdle, + session_id: lead.id.clone(), + action: AssignmentAction::DeferredSaturated, }); } @@ -494,6 +489,13 @@ async fn assign_session_in_dir_with_runner_program( ) }) { + if unread_counts.get(&active_delegate.id).copied().unwrap_or(0) > 0 { + return Ok(AssignmentOutcome { + session_id: lead.id.clone(), + action: AssignmentAction::DeferredSaturated, + }); + } + send_task_handoff( db, &lead, @@ -1074,6 +1076,11 @@ pub enum AssignmentAction { Spawned, ReusedIdle, ReusedActive, + DeferredSaturated, +} + +pub fn assignment_action_routes_work(action: AssignmentAction) -> bool { + !matches!(action, AssignmentAction::DeferredSaturated) } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -1862,6 +1869,73 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn assign_session_defers_when_team_is_saturated() -> Result<()> { + let tempdir = TestDir::new("manager-assign-defer-saturated")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.max_parallel_sessions = 1; + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "lead task".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "busy-worker".to_string(), + task: "existing work".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(55), + worktree: None, + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.send_message( + "lead", + "busy-worker", + "{\"task\":\"existing work\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + + let (fake_runner, _) = write_fake_claude(tempdir.path())?; + let outcome = assign_session_in_dir_with_runner_program( + &db, + &cfg, + "lead", + "New delegated task", + "claude", + true, + &repo_root, + &fake_runner, + ) + .await?; + + assert_eq!(outcome.action, AssignmentAction::DeferredSaturated); + assert_eq!(outcome.session_id, "lead"); + + let busy_messages = db.list_messages_for_session("busy-worker", 10)?; + assert!(!busy_messages.iter().any(|message| { + message.msg_type == "task_handoff" + && message.content.contains("New delegated task") + })); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn drain_inbox_routes_unread_task_handoffs_and_marks_them_read() -> Result<()> { let tempdir = TestDir::new("manager-drain-inbox")?; @@ -1909,6 +1983,73 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn drain_inbox_leaves_saturated_handoffs_unread() -> Result<()> { + let tempdir = TestDir::new("manager-drain-inbox-defer")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.max_parallel_sessions = 1; + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "lead task".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "busy-worker".to_string(), + task: "existing work".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(55), + worktree: None, + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.send_message( + "lead", + "busy-worker", + "{\"task\":\"existing work\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + db.send_message( + "planner", + "lead", + "{\"task\":\"Review auth changes\",\"context\":\"Inbound request\"}", + "task_handoff", + )?; + + let outcomes = drain_inbox(&db, &cfg, "lead", "claude", true, 5).await?; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].task, "Review auth changes"); + assert_eq!(outcomes[0].action, AssignmentAction::DeferredSaturated); + assert_eq!(outcomes[0].session_id, "lead"); + + let unread = db.unread_message_counts()?; + assert_eq!(unread.get("lead"), Some(&1)); + assert_eq!(unread.get("busy-worker"), Some(&1)); + + let messages = db.list_messages_for_session("busy-worker", 10)?; + assert!(!messages.iter().any(|message| { + message.msg_type == "task_handoff" + && message.content.contains("Review auth changes") + })); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn auto_dispatch_backlog_routes_multiple_lead_inboxes() -> Result<()> { let tempdir = TestDir::new("manager-auto-dispatch")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c4bdea08..5cf95725 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -817,7 +817,18 @@ impl Dashboard { } }; - let total_routed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); + let total_processed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); + let total_routed: usize = outcomes + .iter() + .map(|outcome| { + outcome + .routed + .iter() + .filter(|item| manager::assignment_action_routes_work(item.action)) + .count() + }) + .sum(); + let total_deferred = total_processed.saturating_sub(total_routed); let selected_session_id = self .sessions .get(self.selected_session) @@ -831,13 +842,15 @@ impl Dashboard { self.sync_selected_lineage(); self.refresh_logs(); - if total_routed == 0 { + if total_processed == 0 { self.set_operator_note("no unread handoff backlog found".to_string()); } else { self.set_operator_note(format!( - "auto-dispatched {} handoff(s) across {} lead session(s)", + "auto-dispatch processed {} handoff(s) across {} lead session(s) ({} routed, {} deferred)", + total_processed, + outcomes.len(), total_routed, - outcomes.len() + total_deferred )); } } @@ -908,11 +921,23 @@ impl Dashboard { return; } }; - let total_routed: usize = outcome + let total_processed: usize = outcome .dispatched .iter() .map(|dispatch| dispatch.routed.len()) .sum(); + let total_routed: usize = outcome + .dispatched + .iter() + .map(|dispatch| { + dispatch + .routed + .iter() + .filter(|item| manager::assignment_action_routes_work(item.action)) + .count() + }) + .sum(); + let total_deferred = total_processed.saturating_sub(total_routed); let total_rerouted: usize = outcome .rebalanced .iter() @@ -932,13 +957,15 @@ impl Dashboard { self.sync_selected_lineage(); self.refresh_logs(); - if total_routed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { + if total_processed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { self.set_operator_note("backlog already clear".to_string()); } else { self.set_operator_note(format!( - "coordinated backlog: dispatched {} across {} lead(s), rebalanced {} across {} lead(s), remaining {} across {} session(s) [{} absorbable, {} saturated]", - total_routed, + "coordinated backlog: processed {} across {} lead(s) ({} routed, {} deferred), rebalanced {} across {} lead(s), remaining {} across {} session(s) [{} absorbable, {} saturated]", + total_processed, outcome.dispatched.len(), + total_routed, + total_deferred, total_rerouted, outcome.rebalanced.len(), outcome.remaining_backlog_messages, @@ -1940,6 +1967,7 @@ fn assignment_action_label(action: manager::AssignmentAction) -> &'static str { manager::AssignmentAction::Spawned => "spawned", manager::AssignmentAction::ReusedIdle => "reused idle", manager::AssignmentAction::ReusedActive => "reused active", + manager::AssignmentAction::DeferredSaturated => "deferred saturated", } } From 19ad704216fabae015ffa5ac646a366f601ad5ff Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:09:29 -0700 Subject: [PATCH 008/459] feat: retry deferred ecc2 dispatch after rebalance --- ecc2/src/session/daemon.rs | 202 ++++++++++++++++++++++++++++++++++--- 1 file changed, 186 insertions(+), 16 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 508bc2d9..b58c3932 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -8,6 +8,13 @@ use super::store::StateStore; use super::SessionState; use crate::config::Config; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct DispatchPassSummary { + routed: usize, + deferred: usize, + leads: usize, +} + /// Background daemon that monitors sessions, handles heartbeats, /// and cleans up stale resources. pub async fn run(db: StateStore, cfg: Config) -> Result<()> { @@ -22,12 +29,8 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Session check failed: {e}"); } - if let Err(e) = maybe_auto_dispatch(&db, &cfg).await { - tracing::error!("Auto-dispatch pass failed: {e}"); - } - - if let Err(e) = maybe_auto_rebalance(&db, &cfg).await { - tracing::error!("Auto-rebalance pass failed: {e}"); + if let Err(e) = coordinate_backlog_cycle(&db, &cfg).await { + tracing::error!("Backlog coordination pass failed: {e}"); } time::sleep(heartbeat_interval).await; @@ -94,7 +97,7 @@ fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> { } async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result { - maybe_auto_dispatch_with_recorder(cfg, || { + let summary = maybe_auto_dispatch_with_recorder(cfg, || { manager::auto_dispatch_backlog( db, cfg, @@ -103,7 +106,67 @@ async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result { cfg.max_parallel_sessions, ) }, |routed, leads| db.record_daemon_dispatch_pass(routed, leads)) - .await + .await?; + Ok(summary.routed) +} + +async fn coordinate_backlog_cycle(db: &StateStore, cfg: &Config) -> Result<()> { + coordinate_backlog_cycle_with( + cfg, + || { + maybe_auto_dispatch_with_recorder(cfg, || { + manager::auto_dispatch_backlog( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + }, |routed, leads| db.record_daemon_dispatch_pass(routed, leads)) + }, + || { + maybe_auto_rebalance_with_recorder(cfg, || { + manager::rebalance_all_teams( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + }, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads)) + }, + ) + .await?; + Ok(()) +} + +async fn coordinate_backlog_cycle_with( + _cfg: &Config, + dispatch: DF, + rebalance: RF, +) -> Result<(DispatchPassSummary, usize, DispatchPassSummary)> +where + DF: Fn() -> DFut, + DFut: Future>, + RF: Fn() -> RFut, + RFut: Future>, +{ + let first_dispatch = dispatch().await?; + let rebalanced = rebalance().await?; + let recovery_dispatch = if first_dispatch.deferred > 0 && rebalanced > 0 { + let recovery = dispatch().await?; + if recovery.routed > 0 { + tracing::info!( + "Recovered {} deferred task handoff(s) after rebalancing", + recovery.routed + ); + } + recovery + } else { + DispatchPassSummary::default() + }; + + Ok((first_dispatch, rebalanced, recovery_dispatch)) } async fn maybe_auto_dispatch_with(cfg: &Config, dispatch: F) -> Result @@ -111,31 +174,64 @@ where F: Fn() -> Fut, Fut: Future>>, { - maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _| Ok(())).await + Ok(maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _| Ok(())).await?.routed) } -async fn maybe_auto_dispatch_with_recorder(cfg: &Config, dispatch: F, mut record: R) -> Result +async fn maybe_auto_dispatch_with_recorder( + cfg: &Config, + dispatch: F, + mut record: R, +) -> Result where F: Fn() -> Fut, Fut: Future>>, R: FnMut(usize, usize) -> Result<()>, { if !cfg.auto_dispatch_unread_handoffs { - return Ok(0); + return Ok(DispatchPassSummary::default()); } let outcomes = dispatch().await?; - let routed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); - record(routed, outcomes.len())?; + let routed: usize = outcomes + .iter() + .map(|outcome| { + outcome + .routed + .iter() + .filter(|item| manager::assignment_action_routes_work(item.action)) + .count() + }) + .sum(); + let deferred: usize = outcomes + .iter() + .map(|outcome| { + outcome + .routed + .iter() + .filter(|item| !manager::assignment_action_routes_work(item.action)) + .count() + }) + .sum(); + let leads = outcomes.len(); + record(routed, leads)?; if routed > 0 { tracing::info!( "Auto-dispatched {routed} task handoff(s) across {} lead session(s)", - outcomes.len() + leads + ); + } + if deferred > 0 { + tracing::warn!( + "Deferred {deferred} task handoff(s) because delegate teams were saturated" ); } - Ok(routed) + Ok(DispatchPassSummary { + routed, + deferred, + leads, + }) } async fn maybe_auto_rebalance(db: &StateStore, cfg: &Config) -> Result { @@ -391,12 +487,86 @@ mod tests { ) .await?; - assert_eq!(routed, 2); + assert_eq!(routed.routed, 2); + assert_eq!(routed.deferred, 0); assert_eq!(*recorded.lock().unwrap(), Some((2, 1))); let _ = std::fs::remove_file(path); Ok(()) } + #[tokio::test] + async fn coordinate_backlog_cycle_retries_after_rebalance_when_dispatch_deferred() -> Result<()> { + let cfg = Config { + auto_dispatch_unread_handoffs: true, + ..Config::default() + }; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_clone = calls.clone(); + + let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( + &cfg, + move || { + let calls_clone = calls_clone.clone(); + async move { + let call = calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(match call { + 0 => DispatchPassSummary { + routed: 0, + deferred: 2, + leads: 1, + }, + _ => DispatchPassSummary { + routed: 2, + deferred: 0, + leads: 1, + }, + }) + } + }, + || async move { Ok(1) }, + ) + .await?; + + assert_eq!(first.deferred, 2); + assert_eq!(rebalanced, 1); + assert_eq!(recovery.routed, 2); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2); + Ok(()) + } + + #[tokio::test] + async fn coordinate_backlog_cycle_skips_retry_without_rebalance() -> Result<()> { + let cfg = Config { + auto_dispatch_unread_handoffs: true, + ..Config::default() + }; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_clone = calls.clone(); + + let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( + &cfg, + move || { + let calls_clone = calls_clone.clone(); + async move { + calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(DispatchPassSummary { + routed: 0, + deferred: 2, + leads: 1, + }) + } + }, + || async move { Ok(0) }, + ) + .await?; + + assert_eq!(first.deferred, 2); + assert_eq!(rebalanced, 0); + assert_eq!(recovery, DispatchPassSummary::default()); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + Ok(()) + } + #[tokio::test] async fn maybe_auto_rebalance_noops_when_disabled() -> Result<()> { let path = temp_db_path(); From 08e9d0e28b3ba23267fd1f8938e3e6c5123ef9cf Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:14:20 -0700 Subject: [PATCH 009/459] feat: surface ecc2 daemon recovery pressure --- ecc2/src/session/daemon.rs | 64 +++++++++++++++++++++++--- ecc2/src/session/store.rs | 94 ++++++++++++++++++++++++++++++++++---- ecc2/src/tui/dashboard.rs | 21 ++++++++- 3 files changed, 162 insertions(+), 17 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index b58c3932..089d40c9 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -105,7 +105,7 @@ async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result { true, cfg.max_parallel_sessions, ) - }, |routed, leads| db.record_daemon_dispatch_pass(routed, leads)) + }, |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads)) .await?; Ok(summary.routed) } @@ -122,7 +122,7 @@ async fn coordinate_backlog_cycle(db: &StateStore, cfg: &Config) -> Result<()> { true, cfg.max_parallel_sessions, ) - }, |routed, leads| db.record_daemon_dispatch_pass(routed, leads)) + }, |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads)) }, || { maybe_auto_rebalance_with_recorder(cfg, || { @@ -135,27 +135,31 @@ async fn coordinate_backlog_cycle(db: &StateStore, cfg: &Config) -> Result<()> { ) }, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads)) }, + |routed, leads| db.record_daemon_recovery_dispatch_pass(routed, leads), ) .await?; Ok(()) } -async fn coordinate_backlog_cycle_with( +async fn coordinate_backlog_cycle_with( _cfg: &Config, dispatch: DF, rebalance: RF, + mut record_recovery: Rec, ) -> Result<(DispatchPassSummary, usize, DispatchPassSummary)> where DF: Fn() -> DFut, DFut: Future>, RF: Fn() -> RFut, RFut: Future>, + Rec: FnMut(usize, usize) -> Result<()>, { let first_dispatch = dispatch().await?; let rebalanced = rebalance().await?; let recovery_dispatch = if first_dispatch.deferred > 0 && rebalanced > 0 { let recovery = dispatch().await?; if recovery.routed > 0 { + record_recovery(recovery.routed, recovery.leads)?; tracing::info!( "Recovered {} deferred task handoff(s) after rebalancing", recovery.routed @@ -174,7 +178,7 @@ where F: Fn() -> Fut, Fut: Future>>, { - Ok(maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _| Ok(())).await?.routed) + Ok(maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _, _| Ok(())).await?.routed) } async fn maybe_auto_dispatch_with_recorder( @@ -185,7 +189,7 @@ async fn maybe_auto_dispatch_with_recorder( where F: Fn() -> Fut, Fut: Future>>, - R: FnMut(usize, usize) -> Result<()>, + R: FnMut(usize, usize, usize) -> Result<()>, { if !cfg.auto_dispatch_unread_handoffs { return Ok(DispatchPassSummary::default()); @@ -213,7 +217,7 @@ where }) .sum(); let leads = outcomes.len(); - record(routed, leads)?; + record(routed, deferred, leads)?; if routed > 0 { tracing::info!( @@ -480,7 +484,7 @@ mod tests { ], }]) }, - move |count, leads| { + move |count, _deferred, leads| { *recorded_clone.lock().unwrap() = Some((count, leads)); Ok(()) }, @@ -524,6 +528,7 @@ mod tests { } }, || async move { Ok(1) }, + |_, _| Ok(()), ) .await?; @@ -557,6 +562,7 @@ mod tests { } }, || async move { Ok(0) }, + |_, _| Ok(()), ) .await?; @@ -567,6 +573,50 @@ mod tests { Ok(()) } + #[tokio::test] + async fn coordinate_backlog_cycle_records_recovery_dispatch_when_it_routes_work() -> Result<()> { + let cfg = Config { + auto_dispatch_unread_handoffs: true, + ..Config::default() + }; + let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); + let recorded_clone = recorded.clone(); + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_clone = calls.clone(); + + let (_first, _rebalanced, recovery) = coordinate_backlog_cycle_with( + &cfg, + move || { + let calls_clone = calls_clone.clone(); + async move { + let call = calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(match call { + 0 => DispatchPassSummary { + routed: 0, + deferred: 1, + leads: 1, + }, + _ => DispatchPassSummary { + routed: 2, + deferred: 0, + leads: 1, + }, + }) + } + }, + || async move { Ok(1) }, + move |routed, leads| { + *recorded_clone.lock().unwrap() = Some((routed, leads)); + Ok(()) + }, + ) + .await?; + + assert_eq!(recovery.routed, 2); + assert_eq!(*recorded.lock().unwrap(), Some((2, 1))); + Ok(()) + } + #[tokio::test] async fn maybe_auto_rebalance_noops_when_disabled() -> Result<()> { let path = temp_db_path(); diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 8f80e976..57b182e5 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -17,7 +17,11 @@ pub struct StateStore { pub struct DaemonActivity { pub last_dispatch_at: Option>, pub last_dispatch_routed: usize, + pub last_dispatch_deferred: usize, pub last_dispatch_leads: usize, + pub last_recovery_dispatch_at: Option>, + pub last_recovery_dispatch_routed: usize, + pub last_recovery_dispatch_leads: usize, pub last_rebalance_at: Option>, pub last_rebalance_rerouted: usize, pub last_rebalance_leads: usize, @@ -88,7 +92,11 @@ impl StateStore { id INTEGER PRIMARY KEY CHECK(id = 1), last_dispatch_at TEXT, last_dispatch_routed INTEGER NOT NULL DEFAULT 0, + last_dispatch_deferred INTEGER NOT NULL DEFAULT 0, last_dispatch_leads INTEGER NOT NULL DEFAULT 0, + last_recovery_dispatch_at TEXT, + last_recovery_dispatch_routed INTEGER NOT NULL DEFAULT 0, + last_recovery_dispatch_leads INTEGER NOT NULL DEFAULT 0, last_rebalance_at TEXT, last_rebalance_rerouted INTEGER NOT NULL DEFAULT 0, last_rebalance_leads INTEGER NOT NULL DEFAULT 0 @@ -123,6 +131,42 @@ impl StateStore { .context("Failed to add pid column to sessions table")?; } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_dispatch_deferred INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_dispatch_deferred column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_recovery_dispatch_at")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_recovery_dispatch_at TEXT", + [], + ) + .context("Failed to add last_recovery_dispatch_at column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_recovery_dispatch_routed")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_recovery_dispatch_routed INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_recovery_dispatch_routed column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_recovery_dispatch_leads")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_recovery_dispatch_leads INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_recovery_dispatch_leads column to daemon_activity table")?; + } + Ok(()) } @@ -513,7 +557,8 @@ impl StateStore { pub fn daemon_activity(&self) -> Result { self.conn .query_row( - "SELECT last_dispatch_at, last_dispatch_routed, last_dispatch_leads, + "SELECT last_dispatch_at, last_dispatch_routed, last_dispatch_deferred, last_dispatch_leads, + last_recovery_dispatch_at, last_recovery_dispatch_routed, last_recovery_dispatch_leads, last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads FROM daemon_activity WHERE id = 1", @@ -539,22 +584,50 @@ impl StateStore { Ok(DaemonActivity { last_dispatch_at: parse_ts(row.get(0)?)?, last_dispatch_routed: row.get::<_, i64>(1)? as usize, - last_dispatch_leads: row.get::<_, i64>(2)? as usize, - last_rebalance_at: parse_ts(row.get(3)?)?, - last_rebalance_rerouted: row.get::<_, i64>(4)? as usize, - last_rebalance_leads: row.get::<_, i64>(5)? as usize, + last_dispatch_deferred: row.get::<_, i64>(2)? as usize, + last_dispatch_leads: row.get::<_, i64>(3)? as usize, + last_recovery_dispatch_at: parse_ts(row.get(4)?)?, + last_recovery_dispatch_routed: row.get::<_, i64>(5)? as usize, + last_recovery_dispatch_leads: row.get::<_, i64>(6)? as usize, + last_rebalance_at: parse_ts(row.get(7)?)?, + last_rebalance_rerouted: row.get::<_, i64>(8)? as usize, + last_rebalance_leads: row.get::<_, i64>(9)? as usize, }) }, ) .map_err(Into::into) } - pub fn record_daemon_dispatch_pass(&self, routed: usize, leads: usize) -> Result<()> { + pub fn record_daemon_dispatch_pass( + &self, + routed: usize, + deferred: usize, + leads: usize, + ) -> Result<()> { self.conn.execute( "UPDATE daemon_activity SET last_dispatch_at = ?1, last_dispatch_routed = ?2, - last_dispatch_leads = ?3 + last_dispatch_deferred = ?3, + last_dispatch_leads = ?4 + WHERE id = 1", + rusqlite::params![ + chrono::Utc::now().to_rfc3339(), + routed as i64, + deferred as i64, + leads as i64 + ], + )?; + + Ok(()) + } + + pub fn record_daemon_recovery_dispatch_pass(&self, routed: usize, leads: usize) -> Result<()> { + self.conn.execute( + "UPDATE daemon_activity + SET last_recovery_dispatch_at = ?1, + last_recovery_dispatch_routed = ?2, + last_recovery_dispatch_leads = ?3 WHERE id = 1", rusqlite::params![chrono::Utc::now().to_rfc3339(), routed as i64, leads as i64], )?; @@ -948,15 +1021,20 @@ mod tests { let tempdir = TestDir::new("store-daemon-activity")?; let db = StateStore::open(&tempdir.path().join("state.db"))?; - db.record_daemon_dispatch_pass(4, 2)?; + db.record_daemon_dispatch_pass(4, 1, 2)?; + db.record_daemon_recovery_dispatch_pass(2, 1)?; db.record_daemon_rebalance_pass(3, 1)?; let activity = db.daemon_activity()?; assert_eq!(activity.last_dispatch_routed, 4); + assert_eq!(activity.last_dispatch_deferred, 1); assert_eq!(activity.last_dispatch_leads, 2); + assert_eq!(activity.last_recovery_dispatch_routed, 2); + assert_eq!(activity.last_recovery_dispatch_leads, 1); assert_eq!(activity.last_rebalance_rerouted, 3); assert_eq!(activity.last_rebalance_leads, 1); assert!(activity.last_dispatch_at.is_some()); + assert!(activity.last_recovery_dispatch_at.is_some()); assert!(activity.last_rebalance_at.is_some()); Ok(()) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 5cf95725..e7b9d327 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1489,13 +1489,25 @@ impl Dashboard { if let Some(last_dispatch_at) = self.daemon_activity.last_dispatch_at.as_ref() { lines.push(format!( - "Last daemon dispatch {} handoff(s) across {} lead(s) @ {}", + "Last daemon dispatch {} routed / {} deferred across {} lead(s) @ {}", self.daemon_activity.last_dispatch_routed, + self.daemon_activity.last_dispatch_deferred, self.daemon_activity.last_dispatch_leads, self.short_timestamp(&last_dispatch_at.to_rfc3339()) )); } + if let Some(last_recovery_dispatch_at) = + self.daemon_activity.last_recovery_dispatch_at.as_ref() + { + lines.push(format!( + "Last daemon recovery dispatch {} handoff(s) across {} lead(s) @ {}", + self.daemon_activity.last_recovery_dispatch_routed, + self.daemon_activity.last_recovery_dispatch_leads, + self.short_timestamp(&last_recovery_dispatch_at.to_rfc3339()) + )); + } + if let Some(last_rebalance_at) = self.daemon_activity.last_rebalance_at.as_ref() { lines.push(format!( "Last daemon rebalance {} handoff(s) across {} lead(s) @ {}", @@ -2121,14 +2133,19 @@ mod tests { dashboard.daemon_activity = DaemonActivity { last_dispatch_at: Some(Utc::now()), last_dispatch_routed: 4, + last_dispatch_deferred: 2, last_dispatch_leads: 2, + last_recovery_dispatch_at: Some(Utc::now()), + last_recovery_dispatch_routed: 1, + last_recovery_dispatch_leads: 1, last_rebalance_at: Some(Utc::now()), last_rebalance_rerouted: 1, last_rebalance_leads: 1, }; let text = dashboard.selected_session_metrics_text(); - assert!(text.contains("Last daemon dispatch 4 handoff(s) across 2 lead(s)")); + assert!(text.contains("Last daemon dispatch 4 routed / 2 deferred across 2 lead(s)")); + assert!(text.contains("Last daemon recovery dispatch 1 handoff(s) across 1 lead(s)")); assert!(text.contains("Last daemon rebalance 1 handoff(s) across 1 lead(s)")); } From f498dc09714046a663be901ed7e133a2b1fd22df Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:17:44 -0700 Subject: [PATCH 010/459] feat: prefer ecc2 rebalance after chronic saturation --- ecc2/src/session/daemon.rs | 114 +++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 089d40c9..e166c974 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -111,8 +111,10 @@ async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result { } async fn coordinate_backlog_cycle(db: &StateStore, cfg: &Config) -> Result<()> { + let activity = db.daemon_activity()?; coordinate_backlog_cycle_with( cfg, + &activity, || { maybe_auto_dispatch_with_recorder(cfg, || { manager::auto_dispatch_backlog( @@ -143,6 +145,7 @@ async fn coordinate_backlog_cycle(db: &StateStore, cfg: &Config) -> Result<()> { async fn coordinate_backlog_cycle_with( _cfg: &Config, + prior_activity: &super::store::DaemonActivity, dispatch: DF, rebalance: RF, mut record_recovery: Rec, @@ -154,6 +157,12 @@ where RFut: Future>, Rec: FnMut(usize, usize) -> Result<()>, { + if should_rebalance_first(prior_activity) { + let rebalanced = rebalance().await?; + let first_dispatch = dispatch().await?; + return Ok((first_dispatch, rebalanced, DispatchPassSummary::default())); + } + let first_dispatch = dispatch().await?; let rebalanced = rebalance().await?; let recovery_dispatch = if first_dispatch.deferred > 0 && rebalanced > 0 { @@ -173,6 +182,21 @@ where Ok((first_dispatch, rebalanced, recovery_dispatch)) } +fn should_rebalance_first(activity: &super::store::DaemonActivity) -> bool { + if activity.last_dispatch_deferred == 0 { + return false; + } + + match ( + activity.last_dispatch_at.as_ref(), + activity.last_recovery_dispatch_at.as_ref(), + ) { + (Some(dispatch_at), Some(recovery_at)) => recovery_at < dispatch_at, + (Some(_), None) => true, + _ => false, + } +} + async fn maybe_auto_dispatch_with(cfg: &Config, dispatch: F) -> Result where F: Fn() -> Fut, @@ -317,6 +341,7 @@ mod tests { AssignmentAction, InboxDrainOutcome, LeadDispatchOutcome, LeadRebalanceOutcome, RebalanceOutcome, }; + use crate::session::store::DaemonActivity; use crate::session::{Session, SessionMetrics, SessionState}; use std::path::PathBuf; @@ -504,11 +529,13 @@ mod tests { auto_dispatch_unread_handoffs: true, ..Config::default() }; + let activity = DaemonActivity::default(); let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_clone = calls.clone(); let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( &cfg, + &activity, move || { let calls_clone = calls_clone.clone(); async move { @@ -545,11 +572,13 @@ mod tests { auto_dispatch_unread_handoffs: true, ..Config::default() }; + let activity = DaemonActivity::default(); let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_clone = calls.clone(); let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( &cfg, + &activity, move || { let calls_clone = calls_clone.clone(); async move { @@ -579,6 +608,7 @@ mod tests { auto_dispatch_unread_handoffs: true, ..Config::default() }; + let activity = DaemonActivity::default(); let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); let recorded_clone = recorded.clone(); let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -586,6 +616,7 @@ mod tests { let (_first, _rebalanced, recovery) = coordinate_backlog_cycle_with( &cfg, + &activity, move || { let calls_clone = calls_clone.clone(); async move { @@ -617,6 +648,89 @@ mod tests { Ok(()) } + #[test] + fn should_rebalance_first_only_after_unrecovered_deferred_pressure() { + let now = chrono::Utc::now(); + + assert!(!should_rebalance_first(&DaemonActivity::default())); + + let unresolved = DaemonActivity { + last_dispatch_at: Some(now), + last_dispatch_routed: 0, + last_dispatch_deferred: 2, + last_dispatch_leads: 1, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: None, + last_rebalance_rerouted: 0, + last_rebalance_leads: 0, + }; + assert!(should_rebalance_first(&unresolved)); + + let recovered = DaemonActivity { + last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), + last_recovery_dispatch_routed: 1, + ..unresolved.clone() + }; + assert!(!should_rebalance_first(&recovered)); + } + + #[tokio::test] + async fn coordinate_backlog_cycle_rebalances_first_after_unrecovered_deferred_pressure() -> Result<()> { + let cfg = Config { + auto_dispatch_unread_handoffs: true, + ..Config::default() + }; + let now = chrono::Utc::now(); + let activity = DaemonActivity { + last_dispatch_at: Some(now), + last_dispatch_routed: 0, + last_dispatch_deferred: 2, + last_dispatch_leads: 1, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: None, + last_rebalance_rerouted: 0, + last_rebalance_leads: 0, + }; + let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let dispatch_order = order.clone(); + let rebalance_order = order.clone(); + + let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( + &cfg, + &activity, + move || { + let dispatch_order = dispatch_order.clone(); + async move { + dispatch_order.lock().unwrap().push("dispatch"); + Ok(DispatchPassSummary { + routed: 1, + deferred: 0, + leads: 1, + }) + } + }, + move || { + let rebalance_order = rebalance_order.clone(); + async move { + rebalance_order.lock().unwrap().push("rebalance"); + Ok(1) + } + }, + |_, _| Ok(()), + ) + .await?; + + assert_eq!(*order.lock().unwrap(), vec!["rebalance", "dispatch"]); + assert_eq!(first.routed, 1); + assert_eq!(rebalanced, 1); + assert_eq!(recovery, DispatchPassSummary::default()); + Ok(()) + } + #[tokio::test] async fn maybe_auto_rebalance_noops_when_disabled() -> Result<()> { let path = temp_db_path(); From a6f798e505c643f33e2a1cb843aa8507251f8336 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:20:47 -0700 Subject: [PATCH 011/459] feat: show ecc2 chronic saturation mode --- ecc2/src/session/daemon.rs | 45 +------------------------------------ ecc2/src/session/store.rs | 46 ++++++++++++++++++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 44 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index e166c974..388c6162 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -157,7 +157,7 @@ where RFut: Future>, Rec: FnMut(usize, usize) -> Result<()>, { - if should_rebalance_first(prior_activity) { + if prior_activity.prefers_rebalance_first() { let rebalanced = rebalance().await?; let first_dispatch = dispatch().await?; return Ok((first_dispatch, rebalanced, DispatchPassSummary::default())); @@ -182,21 +182,6 @@ where Ok((first_dispatch, rebalanced, recovery_dispatch)) } -fn should_rebalance_first(activity: &super::store::DaemonActivity) -> bool { - if activity.last_dispatch_deferred == 0 { - return false; - } - - match ( - activity.last_dispatch_at.as_ref(), - activity.last_recovery_dispatch_at.as_ref(), - ) { - (Some(dispatch_at), Some(recovery_at)) => recovery_at < dispatch_at, - (Some(_), None) => true, - _ => false, - } -} - async fn maybe_auto_dispatch_with(cfg: &Config, dispatch: F) -> Result where F: Fn() -> Fut, @@ -648,34 +633,6 @@ mod tests { Ok(()) } - #[test] - fn should_rebalance_first_only_after_unrecovered_deferred_pressure() { - let now = chrono::Utc::now(); - - assert!(!should_rebalance_first(&DaemonActivity::default())); - - let unresolved = DaemonActivity { - last_dispatch_at: Some(now), - last_dispatch_routed: 0, - last_dispatch_deferred: 2, - last_dispatch_leads: 1, - last_recovery_dispatch_at: None, - last_recovery_dispatch_routed: 0, - last_recovery_dispatch_leads: 0, - last_rebalance_at: None, - last_rebalance_rerouted: 0, - last_rebalance_leads: 0, - }; - assert!(should_rebalance_first(&unresolved)); - - let recovered = DaemonActivity { - last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), - last_recovery_dispatch_routed: 1, - ..unresolved.clone() - }; - assert!(!should_rebalance_first(&recovered)); - } - #[tokio::test] async fn coordinate_backlog_cycle_rebalances_first_after_unrecovered_deferred_pressure() -> Result<()> { let cfg = Config { diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 57b182e5..c6a1018a 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -27,6 +27,23 @@ pub struct DaemonActivity { pub last_rebalance_leads: usize, } +impl DaemonActivity { + pub fn prefers_rebalance_first(&self) -> bool { + if self.last_dispatch_deferred == 0 { + return false; + } + + match ( + self.last_dispatch_at.as_ref(), + self.last_recovery_dispatch_at.as_ref(), + ) { + (Some(dispatch_at), Some(recovery_at)) => recovery_at < dispatch_at, + (Some(_), None) => true, + _ => false, + } + } +} + impl StateStore { pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; @@ -1039,4 +1056,33 @@ mod tests { Ok(()) } + + #[test] + fn daemon_activity_detects_rebalance_first_mode() { + let now = chrono::Utc::now(); + + let clear = DaemonActivity::default(); + assert!(!clear.prefers_rebalance_first()); + + let unresolved = DaemonActivity { + last_dispatch_at: Some(now), + last_dispatch_routed: 0, + last_dispatch_deferred: 2, + last_dispatch_leads: 1, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: None, + last_rebalance_rerouted: 0, + last_rebalance_leads: 0, + }; + assert!(unresolved.prefers_rebalance_first()); + + let recovered = DaemonActivity { + last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), + last_recovery_dispatch_routed: 1, + ..unresolved + }; + assert!(!recovered.prefers_rebalance_first()); + } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index e7b9d327..d5c90f3f 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1487,6 +1487,15 @@ impl Dashboard { self.cfg.auto_dispatch_limit_per_session )); + lines.push(format!( + "Coordination mode {}", + if self.daemon_activity.prefers_rebalance_first() { + "rebalance-first (chronic saturation)" + } else { + "dispatch-first" + } + )); + if let Some(last_dispatch_at) = self.daemon_activity.last_dispatch_at.as_ref() { lines.push(format!( "Last daemon dispatch {} routed / {} deferred across {} lead(s) @ {}", @@ -2114,6 +2123,7 @@ 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")); + assert!(text.contains("Coordination mode dispatch-first")); assert!(text.contains("Next route reuse idle worker-1")); } @@ -2144,11 +2154,42 @@ mod tests { }; let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Coordination mode dispatch-first")); assert!(text.contains("Last daemon dispatch 4 routed / 2 deferred across 2 lead(s)")); assert!(text.contains("Last daemon recovery dispatch 1 handoff(s) across 1 lead(s)")); assert!(text.contains("Last daemon rebalance 1 handoff(s) across 1 lead(s)")); } + #[test] + fn selected_session_metrics_text_shows_rebalance_first_mode_when_saturation_is_unrecovered() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.daemon_activity = DaemonActivity { + last_dispatch_at: Some(Utc::now()), + last_dispatch_routed: 0, + last_dispatch_deferred: 3, + last_dispatch_leads: 1, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: Some(Utc::now()), + last_rebalance_rerouted: 1, + last_rebalance_leads: 1, + }; + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Coordination mode rebalance-first (chronic saturation)")); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); From d4cdeca946bc76acc4bcb8642b9d1d5b8614adc7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:28:21 -0700 Subject: [PATCH 012/459] feat: add ecc2 chronic saturation cooloff --- ecc2/src/session/daemon.rs | 54 ++++++++++++++++++++++++++++++++++++++ ecc2/src/session/store.rs | 7 +++++ ecc2/src/tui/dashboard.rs | 36 +++++++++++++++++++++++-- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 388c6162..612945c3 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -159,6 +159,12 @@ where { if prior_activity.prefers_rebalance_first() { let rebalanced = rebalance().await?; + if prior_activity.dispatch_cooloff_active() && rebalanced == 0 { + tracing::warn!( + "Skipping immediate dispatch retry because chronic saturation cooloff is active" + ); + return Ok((DispatchPassSummary::default(), rebalanced, DispatchPassSummary::default())); + } let first_dispatch = dispatch().await?; return Ok((first_dispatch, rebalanced, DispatchPassSummary::default())); } @@ -688,6 +694,54 @@ mod tests { Ok(()) } + #[tokio::test] + async fn coordinate_backlog_cycle_skips_dispatch_during_chronic_cooloff_when_rebalance_does_not_help() -> Result<()> { + let cfg = Config { + auto_dispatch_unread_handoffs: true, + ..Config::default() + }; + let now = chrono::Utc::now(); + let activity = DaemonActivity { + last_dispatch_at: Some(now), + last_dispatch_routed: 0, + last_dispatch_deferred: 3, + last_dispatch_leads: 1, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: Some(now - chrono::Duration::seconds(1)), + last_rebalance_rerouted: 0, + last_rebalance_leads: 1, + }; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_clone = calls.clone(); + + let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( + &cfg, + &activity, + move || { + let calls_clone = calls_clone.clone(); + async move { + calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(DispatchPassSummary { + routed: 1, + deferred: 0, + leads: 1, + }) + } + }, + || async move { Ok(0) }, + |_, _| Ok(()), + ) + .await?; + + assert_eq!(first, DispatchPassSummary::default()); + assert_eq!(rebalanced, 0); + assert_eq!(recovery, DispatchPassSummary::default()); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + Ok(()) + } + #[tokio::test] async fn maybe_auto_rebalance_noops_when_disabled() -> Result<()> { let path = temp_db_path(); diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index c6a1018a..4249e98b 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -42,6 +42,10 @@ impl DaemonActivity { _ => false, } } + + pub fn dispatch_cooloff_active(&self) -> bool { + self.prefers_rebalance_first() && self.last_dispatch_deferred >= 2 + } } impl StateStore { @@ -1063,6 +1067,7 @@ mod tests { let clear = DaemonActivity::default(); assert!(!clear.prefers_rebalance_first()); + assert!(!clear.dispatch_cooloff_active()); let unresolved = DaemonActivity { last_dispatch_at: Some(now), @@ -1077,6 +1082,7 @@ mod tests { last_rebalance_leads: 0, }; assert!(unresolved.prefers_rebalance_first()); + assert!(unresolved.dispatch_cooloff_active()); let recovered = DaemonActivity { last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), @@ -1084,5 +1090,6 @@ mod tests { ..unresolved }; assert!(!recovered.prefers_rebalance_first()); + assert!(!recovered.dispatch_cooloff_active()); } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index d5c90f3f..402a69e9 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1489,7 +1489,9 @@ impl Dashboard { lines.push(format!( "Coordination mode {}", - if self.daemon_activity.prefers_rebalance_first() { + if self.daemon_activity.dispatch_cooloff_active() { + "rebalance-cooloff (chronic saturation)" + } else if self.daemon_activity.prefers_rebalance_first() { "rebalance-first (chronic saturation)" } else { "dispatch-first" @@ -2176,7 +2178,7 @@ mod tests { dashboard.daemon_activity = DaemonActivity { last_dispatch_at: Some(Utc::now()), last_dispatch_routed: 0, - last_dispatch_deferred: 3, + last_dispatch_deferred: 1, last_dispatch_leads: 1, last_recovery_dispatch_at: None, last_recovery_dispatch_routed: 0, @@ -2190,6 +2192,36 @@ mod tests { assert!(text.contains("Coordination mode rebalance-first (chronic saturation)")); } + #[test] + fn selected_session_metrics_text_shows_rebalance_cooloff_mode_when_saturation_is_chronic() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.daemon_activity = DaemonActivity { + last_dispatch_at: Some(Utc::now()), + last_dispatch_routed: 0, + last_dispatch_deferred: 3, + last_dispatch_leads: 1, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: Some(Utc::now()), + last_rebalance_rerouted: 1, + last_rebalance_leads: 1, + }; + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Coordination mode rebalance-cooloff (chronic saturation)")); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); From 9952fcbd7ce6b0b7aaa9d9f350ce83fcaf074cee Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:32:00 -0700 Subject: [PATCH 013/459] feat: clear ecc2 cooloff on recovery --- ecc2/src/session/daemon.rs | 54 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 612945c3..5ddded80 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -166,6 +166,13 @@ where return Ok((DispatchPassSummary::default(), rebalanced, DispatchPassSummary::default())); } let first_dispatch = dispatch().await?; + if first_dispatch.routed > 0 { + record_recovery(first_dispatch.routed, first_dispatch.leads)?; + tracing::info!( + "Recovered {} deferred task handoff(s) after rebalancing", + first_dispatch.routed + ); + } return Ok((first_dispatch, rebalanced, DispatchPassSummary::default())); } @@ -694,6 +701,53 @@ mod tests { Ok(()) } + #[tokio::test] + async fn coordinate_backlog_cycle_records_recovery_when_rebalance_first_dispatch_routes_work() -> Result<()> { + let cfg = Config { + auto_dispatch_unread_handoffs: true, + ..Config::default() + }; + let now = chrono::Utc::now(); + let activity = DaemonActivity { + last_dispatch_at: Some(now), + last_dispatch_routed: 0, + last_dispatch_deferred: 2, + last_dispatch_leads: 1, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: None, + last_rebalance_rerouted: 0, + last_rebalance_leads: 0, + }; + let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); + let recorded_clone = recorded.clone(); + + let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( + &cfg, + &activity, + || async move { + Ok(DispatchPassSummary { + routed: 2, + deferred: 0, + leads: 1, + }) + }, + || async move { Ok(1) }, + move |routed, leads| { + *recorded_clone.lock().unwrap() = Some((routed, leads)); + Ok(()) + }, + ) + .await?; + + assert_eq!(first.routed, 2); + assert_eq!(rebalanced, 1); + assert_eq!(recovery, DispatchPassSummary::default()); + assert_eq!(*recorded.lock().unwrap(), Some((2, 1))); + Ok(()) + } + #[tokio::test] async fn coordinate_backlog_cycle_skips_dispatch_during_chronic_cooloff_when_rebalance_does_not_help() -> Result<()> { let cfg = Config { From 09f6bc3166edc5311f0e12654bec580d59b3b56b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:35:16 -0700 Subject: [PATCH 014/459] feat: surface ecc2 recovery events --- ecc2/src/session/store.rs | 22 ++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 15 ++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 4249e98b..e07b3796 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -46,6 +46,22 @@ impl DaemonActivity { pub fn dispatch_cooloff_active(&self) -> bool { self.prefers_rebalance_first() && self.last_dispatch_deferred >= 2 } + + pub fn chronic_saturation_cleared_at( + &self, + ) -> Option<&chrono::DateTime> { + if self.prefers_rebalance_first() { + return None; + } + + match ( + self.last_dispatch_at.as_ref(), + self.last_recovery_dispatch_at.as_ref(), + ) { + (Some(dispatch_at), Some(recovery_at)) if recovery_at > dispatch_at => Some(recovery_at), + _ => None, + } + } } impl StateStore { @@ -1068,6 +1084,7 @@ mod tests { let clear = DaemonActivity::default(); assert!(!clear.prefers_rebalance_first()); assert!(!clear.dispatch_cooloff_active()); + assert!(clear.chronic_saturation_cleared_at().is_none()); let unresolved = DaemonActivity { last_dispatch_at: Some(now), @@ -1083,6 +1100,7 @@ mod tests { }; assert!(unresolved.prefers_rebalance_first()); assert!(unresolved.dispatch_cooloff_active()); + assert!(unresolved.chronic_saturation_cleared_at().is_none()); let recovered = DaemonActivity { last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), @@ -1091,5 +1109,9 @@ mod tests { }; assert!(!recovered.prefers_rebalance_first()); assert!(!recovered.dispatch_cooloff_active()); + assert_eq!( + recovered.chronic_saturation_cleared_at(), + recovered.last_recovery_dispatch_at.as_ref() + ); } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 402a69e9..af7943ef 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1498,6 +1498,13 @@ impl Dashboard { } )); + if let Some(cleared_at) = self.daemon_activity.chronic_saturation_cleared_at() { + lines.push(format!( + "Chronic saturation cleared @ {}", + self.short_timestamp(&cleared_at.to_rfc3339()) + )); + } + if let Some(last_dispatch_at) = self.daemon_activity.last_dispatch_at.as_ref() { lines.push(format!( "Last daemon dispatch {} routed / {} deferred across {} lead(s) @ {}", @@ -2131,6 +2138,7 @@ mod tests { #[test] fn selected_session_metrics_text_includes_daemon_activity() { + let now = Utc::now(); let mut dashboard = test_dashboard( vec![sample_session( "focus-12345678", @@ -2143,20 +2151,21 @@ mod tests { 0, ); dashboard.daemon_activity = DaemonActivity { - last_dispatch_at: Some(Utc::now()), + last_dispatch_at: Some(now), last_dispatch_routed: 4, last_dispatch_deferred: 2, last_dispatch_leads: 2, - last_recovery_dispatch_at: Some(Utc::now()), + last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), last_recovery_dispatch_routed: 1, last_recovery_dispatch_leads: 1, - last_rebalance_at: Some(Utc::now()), + last_rebalance_at: Some(now + chrono::Duration::seconds(2)), last_rebalance_rerouted: 1, last_rebalance_leads: 1, }; let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Coordination mode dispatch-first")); + assert!(text.contains("Chronic saturation cleared @")); assert!(text.contains("Last daemon dispatch 4 routed / 2 deferred across 2 lead(s)")); assert!(text.contains("Last daemon recovery dispatch 1 handoff(s) across 1 lead(s)")); assert!(text.contains("Last daemon rebalance 1 handoff(s) across 1 lead(s)")); From 40ed9c7f6a80728143dcbe50275d14fa3e5bc1d8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:37:48 -0700 Subject: [PATCH 015/459] feat: surface ecc2 stabilized mode --- ecc2/src/session/store.rs | 34 ++++++++++++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 41 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index e07b3796..15eef24e 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -62,6 +62,22 @@ impl DaemonActivity { _ => None, } } + + pub fn stabilized_after_recovery_at( + &self, + ) -> Option<&chrono::DateTime> { + if self.last_dispatch_deferred != 0 { + return None; + } + + match ( + self.last_dispatch_at.as_ref(), + self.last_recovery_dispatch_at.as_ref(), + ) { + (Some(dispatch_at), Some(recovery_at)) if dispatch_at > recovery_at => Some(dispatch_at), + _ => None, + } + } } impl StateStore { @@ -1085,6 +1101,7 @@ mod tests { assert!(!clear.prefers_rebalance_first()); assert!(!clear.dispatch_cooloff_active()); assert!(clear.chronic_saturation_cleared_at().is_none()); + assert!(clear.stabilized_after_recovery_at().is_none()); let unresolved = DaemonActivity { last_dispatch_at: Some(now), @@ -1101,6 +1118,7 @@ mod tests { assert!(unresolved.prefers_rebalance_first()); assert!(unresolved.dispatch_cooloff_active()); assert!(unresolved.chronic_saturation_cleared_at().is_none()); + assert!(unresolved.stabilized_after_recovery_at().is_none()); let recovered = DaemonActivity { last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), @@ -1113,5 +1131,21 @@ mod tests { recovered.chronic_saturation_cleared_at(), recovered.last_recovery_dispatch_at.as_ref() ); + assert!(recovered.stabilized_after_recovery_at().is_none()); + + let stabilized = DaemonActivity { + last_dispatch_at: Some(now + chrono::Duration::seconds(2)), + last_dispatch_routed: 2, + last_dispatch_deferred: 0, + last_dispatch_leads: 1, + ..recovered + }; + assert!(!stabilized.prefers_rebalance_first()); + assert!(!stabilized.dispatch_cooloff_active()); + assert!(stabilized.chronic_saturation_cleared_at().is_none()); + assert_eq!( + stabilized.stabilized_after_recovery_at(), + stabilized.last_dispatch_at.as_ref() + ); } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index af7943ef..31630fe5 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1493,6 +1493,8 @@ impl Dashboard { "rebalance-cooloff (chronic saturation)" } else if self.daemon_activity.prefers_rebalance_first() { "rebalance-first (chronic saturation)" + } else if self.daemon_activity.stabilized_after_recovery_at().is_some() { + "dispatch-first (stabilized)" } else { "dispatch-first" } @@ -1505,6 +1507,13 @@ impl Dashboard { )); } + if let Some(stabilized_at) = self.daemon_activity.stabilized_after_recovery_at() { + lines.push(format!( + "Recovery stabilized @ {}", + self.short_timestamp(&stabilized_at.to_rfc3339()) + )); + } + if let Some(last_dispatch_at) = self.daemon_activity.last_dispatch_at.as_ref() { lines.push(format!( "Last daemon dispatch {} routed / {} deferred across {} lead(s) @ {}", @@ -2231,6 +2240,38 @@ mod tests { assert!(text.contains("Coordination mode rebalance-cooloff (chronic saturation)")); } + #[test] + fn selected_session_metrics_text_shows_stabilized_dispatch_mode_after_recovery() { + let now = Utc::now(); + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + 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, + 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("Coordination mode dispatch-first (stabilized)")); + assert!(text.contains("Recovery stabilized @")); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); From 051d47eb5f9250ca3a2377e411db88cea406a1fc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:40:26 -0700 Subject: [PATCH 016/459] feat: relax ecc2 stabilized cycles --- ecc2/src/session/daemon.rs | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 5ddded80..c63195e3 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -177,6 +177,12 @@ where } let first_dispatch = dispatch().await?; + if prior_activity.stabilized_after_recovery_at().is_some() && first_dispatch.deferred == 0 { + tracing::info!( + "Skipping rebalance because stabilized dispatch cycle has no deferred handoffs" + ); + return Ok((first_dispatch, 0, DispatchPassSummary::default())); + } let rebalanced = rebalance().await?; let recovery_dispatch = if first_dispatch.deferred > 0 && rebalanced > 0 { let recovery = dispatch().await?; @@ -796,6 +802,56 @@ mod tests { Ok(()) } + #[tokio::test] + async fn coordinate_backlog_cycle_skips_rebalance_when_stabilized_and_dispatch_is_healthy() -> Result<()> { + let cfg = Config { + auto_dispatch_unread_handoffs: true, + ..Config::default() + }; + let now = chrono::Utc::now(); + let activity = DaemonActivity { + last_dispatch_at: Some(now + chrono::Duration::seconds(2)), + last_dispatch_routed: 2, + last_dispatch_deferred: 0, + last_dispatch_leads: 1, + 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 rebalance_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let rebalance_calls_clone = rebalance_calls.clone(); + + let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( + &cfg, + &activity, + || async move { + Ok(DispatchPassSummary { + routed: 1, + deferred: 0, + leads: 1, + }) + }, + move || { + let rebalance_calls_clone = rebalance_calls_clone.clone(); + async move { + rebalance_calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(1) + } + }, + |_, _| Ok(()), + ) + .await?; + + assert_eq!(first.routed, 1); + assert_eq!(rebalanced, 0); + assert_eq!(recovery, DispatchPassSummary::default()); + assert_eq!(rebalance_calls.load(std::sync::atomic::Ordering::SeqCst), 0); + Ok(()) + } + #[tokio::test] async fn maybe_auto_rebalance_noops_when_disabled() -> Result<()> { let path = temp_db_path(); From cf7d3ae5843401e8d936284bb7827eb0453253b8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:41:48 -0700 Subject: [PATCH 017/459] feat: quiet ecc2 stabilized telemetry --- ecc2/src/tui/dashboard.rs | 44 ++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 31630fe5..41b152ae 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1487,13 +1487,15 @@ impl Dashboard { self.cfg.auto_dispatch_limit_per_session )); + let stabilized = self.daemon_activity.stabilized_after_recovery_at(); + lines.push(format!( "Coordination mode {}", if self.daemon_activity.dispatch_cooloff_active() { "rebalance-cooloff (chronic saturation)" } else if self.daemon_activity.prefers_rebalance_first() { "rebalance-first (chronic saturation)" - } else if self.daemon_activity.stabilized_after_recovery_at().is_some() { + } else if stabilized.is_some() { "dispatch-first (stabilized)" } else { "dispatch-first" @@ -1507,7 +1509,7 @@ impl Dashboard { )); } - if let Some(stabilized_at) = self.daemon_activity.stabilized_after_recovery_at() { + if let Some(stabilized_at) = stabilized { lines.push(format!( "Recovery stabilized @ {}", self.short_timestamp(&stabilized_at.to_rfc3339()) @@ -1524,24 +1526,26 @@ impl Dashboard { )); } - if let Some(last_recovery_dispatch_at) = - self.daemon_activity.last_recovery_dispatch_at.as_ref() - { - lines.push(format!( - "Last daemon recovery dispatch {} handoff(s) across {} lead(s) @ {}", - self.daemon_activity.last_recovery_dispatch_routed, - self.daemon_activity.last_recovery_dispatch_leads, - self.short_timestamp(&last_recovery_dispatch_at.to_rfc3339()) - )); - } + if stabilized.is_none() { + if let Some(last_recovery_dispatch_at) = + self.daemon_activity.last_recovery_dispatch_at.as_ref() + { + lines.push(format!( + "Last daemon recovery dispatch {} handoff(s) across {} lead(s) @ {}", + self.daemon_activity.last_recovery_dispatch_routed, + self.daemon_activity.last_recovery_dispatch_leads, + self.short_timestamp(&last_recovery_dispatch_at.to_rfc3339()) + )); + } - if let Some(last_rebalance_at) = self.daemon_activity.last_rebalance_at.as_ref() { - lines.push(format!( - "Last daemon rebalance {} handoff(s) across {} lead(s) @ {}", - self.daemon_activity.last_rebalance_rerouted, - self.daemon_activity.last_rebalance_leads, - self.short_timestamp(&last_rebalance_at.to_rfc3339()) - )); + if let Some(last_rebalance_at) = self.daemon_activity.last_rebalance_at.as_ref() { + lines.push(format!( + "Last daemon rebalance {} handoff(s) across {} lead(s) @ {}", + self.daemon_activity.last_rebalance_rerouted, + self.daemon_activity.last_rebalance_leads, + self.short_timestamp(&last_rebalance_at.to_rfc3339()) + )); + } } if let Some(route_preview) = self.selected_route_preview.as_ref() { @@ -2270,6 +2274,8 @@ mod tests { let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Coordination mode dispatch-first (stabilized)")); assert!(text.contains("Recovery stabilized @")); + assert!(!text.contains("Last daemon recovery dispatch")); + assert!(!text.contains("Last daemon rebalance")); } #[test] From 478466168a0d6a0da5b637b1e3c9bae95993b784 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:43:46 -0700 Subject: [PATCH 018/459] feat: calm ecc2 stabilized attention --- ecc2/src/tui/dashboard.rs | 83 +++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 41b152ae..faa69262 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -241,14 +241,19 @@ impl Dashboard { return; } - let summary = SessionSummary::from_sessions(&self.sessions, &self.unread_message_counts); + let stabilized = self.daemon_activity.stabilized_after_recovery_at().is_some(); + let summary = + SessionSummary::from_sessions(&self.sessions, &self.unread_message_counts, stabilized); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Min(3)]) .split(inner_area); frame.render_widget( - Paragraph::new(vec![summary_line(&summary), attention_queue_line(&summary)]), + Paragraph::new(vec![ + summary_line(&summary), + attention_queue_line(&summary, stabilized), + ]), chunks[0], ); @@ -1657,6 +1662,7 @@ impl Dashboard { fn attention_queue_items(&self, limit: usize) -> Vec { let mut items = Vec::new(); + let suppress_inbox_attention = self.daemon_activity.stabilized_after_recovery_at().is_some(); for session in &self.sessions { let unread = self @@ -1664,7 +1670,7 @@ impl Dashboard { .get(&session.id) .copied() .unwrap_or(0); - if unread > 0 { + if unread > 0 && !suppress_inbox_attention { items.push(format!( "- Inbox {} | {} unread | {}", format_session_id(&session.id), @@ -1870,12 +1876,24 @@ impl Pane { } impl SessionSummary { - fn from_sessions(sessions: &[Session], unread_message_counts: &HashMap) -> Self { + fn from_sessions( + sessions: &[Session], + unread_message_counts: &HashMap, + suppress_inbox_attention: bool, + ) -> Self { sessions.iter().fold( Self { total: sessions.len(), - unread_messages: unread_message_counts.values().sum(), - inbox_sessions: unread_message_counts.values().filter(|count| **count > 0).count(), + unread_messages: if suppress_inbox_attention { + 0 + } else { + unread_message_counts.values().sum() + }, + inbox_sessions: if suppress_inbox_attention { + 0 + } else { + unread_message_counts.values().filter(|count| **count > 0).count() + }, ..Self::default() }, |mut summary, session| { @@ -1942,7 +1960,7 @@ fn summary_span(label: &str, value: usize, color: Color) -> Span<'static> { ) } -fn attention_queue_line(summary: &SessionSummary) -> Line<'static> { +fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'static> { if summary.failed == 0 && summary.stopped == 0 && summary.pending == 0 @@ -1953,7 +1971,11 @@ fn attention_queue_line(summary: &SessionSummary) -> Line<'static> { "Attention queue clear", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), ), - Span::raw(" no failed, stopped, or pending sessions"), + Span::raw(if stabilized { + " stabilized backlog absorbed" + } else { + " no failed, stopped, or pending sessions" + }), ]); } @@ -2278,6 +2300,51 @@ mod tests { assert!(!text.contains("Last daemon rebalance")); } + #[test] + fn attention_queue_suppresses_inbox_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 summary = SessionSummary::from_sessions(&sessions, &unread, true); + + let line = attention_queue_line(&summary, true); + let rendered = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert!(rendered.contains("Attention queue clear")); + assert!(rendered.contains("stabilized backlog absorbed")); + + let mut dashboard = test_dashboard(sessions, 0); + dashboard.unread_message_counts = unread; + 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, + 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("Attention queue clear")); + assert!(!text.contains("Needs attention:")); + assert!(!text.contains("Inbox focus-12")); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); From 3199120abe59697d3e0a200544c703c5f3ed61f3 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:47:11 -0700 Subject: [PATCH 019/459] feat: route ecc2 by handoff backlog --- ecc2/src/session/manager.rs | 99 +++++++++++++++++++++++++++++++++++-- ecc2/src/session/store.rs | 13 +++++ ecc2/src/tui/dashboard.rs | 64 +++++++++++++++++++++++- 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 20e7db1a..a2c1a741 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -428,13 +428,23 @@ async fn assign_session_in_dir_with_runner_program( ) -> Result { let lead = resolve_session(db, lead_id)?; let delegates = direct_delegate_sessions(db, &lead.id, agent_type)?; - let unread_counts = db.unread_message_counts()?; + let delegate_handoff_backlog = delegates + .iter() + .map(|session| { + db.unread_task_handoff_count(&session.id) + .map(|count| (session.id.clone(), count)) + }) + .collect::>>()?; if let Some(idle_delegate) = delegates .iter() .filter(|session| { session.state == SessionState::Idle - && unread_counts.get(&session.id).copied().unwrap_or(0) == 0 + && delegate_handoff_backlog + .get(&session.id) + .copied() + .unwrap_or(0) + == 0 }) .min_by_key(|session| session.updated_at) { @@ -468,7 +478,10 @@ async fn assign_session_in_dir_with_runner_program( .filter(|session| session.state == SessionState::Idle) .min_by_key(|session| { ( - unread_counts.get(&session.id).copied().unwrap_or(0), + delegate_handoff_backlog + .get(&session.id) + .copied() + .unwrap_or(0), session.updated_at, ) }) @@ -484,12 +497,20 @@ async fn assign_session_in_dir_with_runner_program( .filter(|session| matches!(session.state, SessionState::Running | SessionState::Pending)) .min_by_key(|session| { ( - unread_counts.get(&session.id).copied().unwrap_or(0), + delegate_handoff_backlog + .get(&session.id) + .copied() + .unwrap_or(0), session.updated_at, ) }) { - if unread_counts.get(&active_delegate.id).copied().unwrap_or(0) > 0 { + if delegate_handoff_backlog + .get(&active_delegate.id) + .copied() + .unwrap_or(0) + > 0 + { return Ok(AssignmentOutcome { session_id: lead.id.clone(), action: AssignmentAction::DeferredSaturated, @@ -1798,6 +1819,74 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn assign_session_reuses_idle_delegate_when_only_non_handoff_messages_are_unread() -> Result<()> { + let tempdir = TestDir::new("manager-assign-reuse-idle-info-inbox")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "lead task".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "idle-worker".to_string(), + task: "old worker task".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Idle, + pid: Some(99), + worktree: None, + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.send_message( + "lead", + "idle-worker", + "{\"task\":\"old worker task\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + db.mark_messages_read("idle-worker")?; + db.send_message("lead", "idle-worker", "FYI status update", "info")?; + + let (fake_runner, _) = write_fake_claude(tempdir.path())?; + let outcome = assign_session_in_dir_with_runner_program( + &db, + &cfg, + "lead", + "Fresh delegated task", + "claude", + true, + &repo_root, + &fake_runner, + ) + .await?; + + assert_eq!(outcome.action, AssignmentAction::ReusedIdle); + assert_eq!(outcome.session_id, "idle-worker"); + + let idle_messages = db.list_messages_for_session("idle-worker", 10)?; + assert!(idle_messages.iter().any(|message| { + message.msg_type == "task_handoff" + && message.content.contains("Fresh delegated task") + })); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn assign_session_spawns_when_team_has_capacity() -> Result<()> { let tempdir = TestDir::new("manager-assign-spawn")?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 15eef24e..793fa6d3 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -555,6 +555,19 @@ impl StateStore { .map_err(Into::into) } + pub fn unread_task_handoff_count(&self, session_id: &str) -> Result { + self.conn + .query_row( + "SELECT COUNT(*) + FROM messages + WHERE to_session = ?1 AND msg_type = 'task_handoff' AND read = 0", + rusqlite::params![session_id], + |row| row.get::<_, i64>(0), + ) + .map(|count| count as usize) + .map_err(Into::into) + } + pub fn unread_task_handoff_targets(&self, limit: usize) -> Result> { let mut stmt = self.conn.prepare( "SELECT to_session, COUNT(*) as unread_count diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index faa69262..63d5526a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1285,6 +1285,16 @@ impl Dashboard { .get(&child_id) .copied() .unwrap_or(0); + let handoff_backlog = match self.db.unread_task_handoff_count(&child_id) { + Ok(count) => count, + Err(error) => { + tracing::warn!( + "Failed to load delegated child handoff backlog {}: {error}", + child_id + ); + 0 + } + }; let state = session.state.clone(); match state { SessionState::Idle => team.idle += 1, @@ -1296,7 +1306,7 @@ impl Dashboard { } route_candidates.push(DelegatedChildSummary { - unread_messages, + unread_messages: handoff_backlog, state: state.clone(), session_id: child_id.clone(), }); @@ -2345,6 +2355,58 @@ mod tests { assert!(!text.contains("Inbox focus-12")); } + #[test] + fn route_preview_ignores_non_handoff_inbox_noise() { + let lead = sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ); + let idle_worker = sample_session( + "idle-worker", + "planner", + SessionState::Idle, + Some("ecc/idle"), + 128, + 12, + ); + + let mut dashboard = test_dashboard(vec![lead.clone(), idle_worker.clone()], 0); + dashboard.db.insert_session(&lead).unwrap(); + dashboard.db.insert_session(&idle_worker).unwrap(); + dashboard + .db + .send_message("lead-12345678", "idle-worker", "FYI status update", "info") + .unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "idle-worker", + "{\"task\":\"Delegated work\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + dashboard.db.mark_messages_read("idle-worker").unwrap(); + dashboard + .db + .send_message("lead-12345678", "idle-worker", "FYI status update", "info") + .unwrap(); + + dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap(); + dashboard.sync_selected_lineage(); + + assert_eq!( + dashboard.selected_route_preview.as_deref(), + Some("reuse idle idle-wor") + ); + assert_eq!(dashboard.selected_child_sessions.len(), 1); + assert_eq!(dashboard.selected_child_sessions[0].unread_messages, 1); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); From 63c437b986d5ac440bdd54717c63a24b35a3d62f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:51:17 -0700 Subject: [PATCH 020/459] feat: align ecc2 backlog surfaces --- ecc2/src/tui/dashboard.rs | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 63d5526a..11133ba6 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -40,6 +40,7 @@ pub struct Dashboard { sessions: Vec, session_output_cache: HashMap>, unread_message_counts: HashMap, + handoff_backlog_counts: HashMap, global_handoff_backlog_leads: usize, global_handoff_backlog_messages: usize, daemon_activity: DaemonActivity, @@ -141,6 +142,7 @@ impl Dashboard { sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), + handoff_backlog_counts: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0, daemon_activity: DaemonActivity::default(), @@ -162,6 +164,7 @@ impl Dashboard { session_table_state, }; dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); + dashboard.sync_handoff_backlog_counts(); dashboard.sync_global_handoff_backlog(); dashboard.sync_selected_output(); dashboard.sync_selected_diff(); @@ -243,7 +246,7 @@ impl Dashboard { let stabilized = self.daemon_activity.stabilized_after_recovery_at().is_some(); let summary = - SessionSummary::from_sessions(&self.sessions, &self.unread_message_counts, stabilized); + SessionSummary::from_sessions(&self.sessions, &self.handoff_backlog_counts, stabilized); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Min(3)]) @@ -260,13 +263,13 @@ impl Dashboard { let rows = self.sessions.iter().map(|session| { session_row( session, - self.unread_message_counts + self.handoff_backlog_counts .get(&session.id) .copied() .unwrap_or(0), ) }); - let header = Row::new(["ID", "Agent", "State", "Branch", "Inbox", "Tokens", "Duration"]) + let header = Row::new(["ID", "Agent", "State", "Branch", "Backlog", "Tokens", "Duration"]) .style(Style::default().add_modifier(Modifier::BOLD)); let widths = [ Constraint::Length(8), @@ -1135,6 +1138,7 @@ impl Dashboard { HashMap::new() } }; + self.sync_handoff_backlog_counts(); self.sync_global_handoff_backlog(); self.sync_daemon_activity(); self.sync_selection_by_id(selected_id.as_deref()); @@ -1187,6 +1191,19 @@ impl Dashboard { } } + fn sync_handoff_backlog_counts(&mut self) { + let limit = self.sessions.len().max(1); + self.handoff_backlog_counts.clear(); + match self.db.unread_task_handoff_targets(limit) { + Ok(targets) => { + self.handoff_backlog_counts.extend(targets); + } + Err(error) => { + tracing::warn!("Failed to refresh handoff backlog counts: {error}"); + } + } + } + fn sync_daemon_activity(&mut self) { self.daemon_activity = match self.db.daemon_activity() { Ok(activity) => activity, @@ -1675,16 +1692,16 @@ impl Dashboard { let suppress_inbox_attention = self.daemon_activity.stabilized_after_recovery_at().is_some(); for session in &self.sessions { - let unread = self - .unread_message_counts + let handoff_backlog = self + .handoff_backlog_counts .get(&session.id) .copied() .unwrap_or(0); - if unread > 0 && !suppress_inbox_attention { + if handoff_backlog > 0 && !suppress_inbox_attention { items.push(format!( - "- Inbox {} | {} unread | {}", + "- Backlog {} | {} handoff(s) | {}", format_session_id(&session.id), - unread, + handoff_backlog, truncate_for_dashboard(&session.task, 40) )); } @@ -1994,7 +2011,7 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta "Attention queue ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), ), - summary_span("Inbox", summary.unread_messages, Color::Magenta), + 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), @@ -2336,6 +2353,7 @@ mod tests { let mut dashboard = test_dashboard(sessions, 0); dashboard.unread_message_counts = unread; + dashboard.handoff_backlog_counts = HashMap::from([(String::from("focus-12345678"), 3usize)]); dashboard.daemon_activity = DaemonActivity { last_dispatch_at: Some(now + chrono::Duration::seconds(2)), last_dispatch_routed: 2, @@ -2907,6 +2925,7 @@ mod tests { sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), + handoff_backlog_counts: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0, daemon_activity: DaemonActivity::default(), From 2fba71fcdbb46414a0fbae59a62994c7fde8ff2b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:55:03 -0700 Subject: [PATCH 021/459] feat: align ecc2 delegate backlog semantics --- ecc2/src/session/manager.rs | 85 +++++++++++++++++++++++++++++++------ ecc2/src/tui/dashboard.rs | 31 ++++++-------- 2 files changed, 84 insertions(+), 32 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index a2c1a741..ee210c41 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -42,7 +42,10 @@ pub fn get_status(db: &StateStore, id: &str) -> Result { pub fn get_team_status(db: &StateStore, id: &str, depth: usize) -> Result { let root = resolve_session(db, id)?; - let unread_counts = db.unread_message_counts()?; + let handoff_backlog = db + .unread_task_handoff_targets(db.list_sessions()?.len().max(1))? + .into_iter() + .collect(); let mut visited = HashSet::new(); visited.insert(root.id.clone()); @@ -52,14 +55,14 @@ pub fn get_team_status(db: &StateStore, id: &str, depth: usize) -> Result, + handoff_backlog: &std::collections::HashMap, visited: &mut HashSet, descendants: &mut Vec, ) -> Result<()> { @@ -571,7 +574,7 @@ fn collect_delegation_descendants( descendants.push(DelegatedSessionSummary { depth: current_depth, - unread_messages: unread_counts.get(&child_id).copied().unwrap_or(0), + handoff_backlog: handoff_backlog.get(&child_id).copied().unwrap_or(0), session, }); @@ -580,7 +583,7 @@ fn collect_delegation_descendants( &child_id, remaining_depth.saturating_sub(1), current_depth + 1, - unread_counts, + handoff_backlog, visited, descendants, )?; @@ -843,14 +846,13 @@ fn summarize_backlog_pressure( agent_type: &str, targets: &[(String, usize)], ) -> Result { - let unread_counts = db.unread_message_counts()?; let mut summary = BacklogPressureSummary::default(); for (session_id, _) in targets { let delegates = direct_delegate_sessions(db, session_id, agent_type)?; let has_clear_idle_delegate = delegates.iter().any(|delegate| { delegate.state == SessionState::Idle - && unread_counts.get(&delegate.id).copied().unwrap_or(0) == 0 + && db.unread_task_handoff_count(&delegate.id).unwrap_or(0) == 0 }); let has_capacity = delegates.len() < cfg.max_parallel_sessions; @@ -1048,7 +1050,7 @@ pub struct SessionStatus { pub struct TeamStatus { root: Session, - unread_messages: std::collections::HashMap, + handoff_backlog: std::collections::HashMap, descendants: Vec, } @@ -1112,7 +1114,7 @@ struct BacklogPressureSummary { struct DelegatedSessionSummary { depth: usize, - unread_messages: usize, + handoff_backlog: usize, session: Session, } @@ -1154,8 +1156,8 @@ impl fmt::Display for TeamStatus { writeln!(f, "Branch: {}", worktree.branch)?; } - let lead_unread = self.unread_messages.get(&self.root.id).copied().unwrap_or(0); - writeln!(f, "Inbox: {}", lead_unread)?; + let lead_handoff_backlog = self.handoff_backlog.get(&self.root.id).copied().unwrap_or(0); + writeln!(f, "Backlog: {}", lead_handoff_backlog)?; if self.descendants.is_empty() { return write!(f, "Board: no delegated sessions"); @@ -1185,11 +1187,11 @@ impl fmt::Display for TeamStatus { for item in items { writeln!( f, - " - {}{} [{}] | inbox {} | {}", + " - {}{} [{}] | backlog {} handoff(s) | {}", " ".repeat(item.depth.saturating_sub(1)), item.session.id, item.session.agent_type, - item.unread_messages, + item.handoff_backlog, item.session.task )?; } @@ -2404,4 +2406,59 @@ mod tests { Ok(()) } + + #[test] + fn team_status_reports_handoff_backlog_not_generic_inbox_noise() -> Result<()> { + let tempdir = TestDir::new("manager-team-status-backlog")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "lead task".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(4), + updated_at: now - Duration::minutes(4), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "worker".to_string(), + task: "delegate task".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root, + state: SessionState::Idle, + pid: None, + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + + db.send_message("lead", "worker", "FYI status update", "info")?; + db.send_message( + "lead", + "worker", + "{\"task\":\"Delegated work\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + let _ = db.mark_messages_read("worker")?; + db.send_message("lead", "worker", "FYI reminder", "info")?; + + let status = get_team_status(&db, "lead", 3)?; + let rendered = format!("{status}"); + + assert!(rendered.contains("Backlog: 0")); + assert!(rendered.contains("| backlog 0 handoff(s) |")); + assert!(!rendered.contains("Inbox:")); + + Ok(()) + } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 11133ba6..dfdba905 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -104,7 +104,7 @@ struct AggregateUsage { struct DelegatedChildSummary { session_id: String, state: SessionState, - unread_messages: usize, + handoff_backlog: usize, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -1297,11 +1297,6 @@ impl Dashboard { match self.db.get_session(&child_id) { Ok(Some(session)) => { team.total += 1; - let unread_messages = self - .unread_message_counts - .get(&child_id) - .copied() - .unwrap_or(0); let handoff_backlog = match self.db.unread_task_handoff_count(&child_id) { Ok(count) => count, Err(error) => { @@ -1323,12 +1318,12 @@ impl Dashboard { } route_candidates.push(DelegatedChildSummary { - unread_messages: handoff_backlog, + handoff_backlog, state: state.clone(), session_id: child_id.clone(), }); delegated.push(DelegatedChildSummary { - unread_messages, + handoff_backlog, state, session_id: child_id, }); @@ -1365,7 +1360,7 @@ impl Dashboard { ) -> Option { if let Some(idle_clear) = delegates .iter() - .filter(|delegate| delegate.state == SessionState::Idle && delegate.unread_messages == 0) + .filter(|delegate| delegate.state == SessionState::Idle && delegate.handoff_backlog == 0) .min_by_key(|delegate| delegate.session_id.as_str()) { return Some(format!( @@ -1381,24 +1376,24 @@ impl Dashboard { if let Some(idle_backed_up) = delegates .iter() .filter(|delegate| delegate.state == SessionState::Idle) - .min_by_key(|delegate| (delegate.unread_messages, delegate.session_id.as_str())) + .min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str())) { return Some(format!( - "reuse idle {} with inbox {}", + "reuse idle {} with backlog {}", format_session_id(&idle_backed_up.session_id), - idle_backed_up.unread_messages + idle_backed_up.handoff_backlog )); } if let Some(active_delegate) = delegates .iter() .filter(|delegate| matches!(delegate.state, SessionState::Running | SessionState::Pending)) - .min_by_key(|delegate| (delegate.unread_messages, delegate.session_id.as_str())) + .min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str())) { return Some(format!( - "reuse active {} with inbox {}", + "reuse active {} with backlog {}", format_session_id(&active_delegate.session_id), - active_delegate.unread_messages + active_delegate.handoff_backlog )); } @@ -1588,10 +1583,10 @@ impl Dashboard { lines.push("Delegates".to_string()); for child in &self.selected_child_sessions { lines.push(format!( - "- {} [{}] | inbox {}", + "- {} [{}] | backlog {}", format_session_id(&child.session_id), session_state_label(&child.state), - child.unread_messages + child.handoff_backlog )); } } @@ -2422,7 +2417,7 @@ mod tests { Some("reuse idle idle-wor") ); assert_eq!(dashboard.selected_child_sessions.len(), 1); - assert_eq!(dashboard.selected_child_sessions[0].unread_messages, 1); + assert_eq!(dashboard.selected_child_sessions[0].handoff_backlog, 0); } #[test] From 9d766af0258959d2fe9a74d42568f3b92a655075 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 03:56:40 -0700 Subject: [PATCH 022/459] docs: align ecc2 operator backlog language --- ecc2/src/tui/dashboard.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index dfdba905..36ce08e6 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -461,9 +461,9 @@ impl Dashboard { "", " n New session", " a Assign follow-up work from selected session", - " b Rebalance backed-up delegate inboxes for selected lead", - " B Rebalance backed-up delegate inboxes across lead teams", - " i Drain unread task handoffs from selected session inbox", + " b Rebalance backed-up delegate handoff backlog for selected lead", + " B Rebalance backed-up delegate handoff backlog across lead teams", + " i Drain unread task handoffs from selected lead", " g Auto-dispatch unread handoffs across lead sessions", " G Dispatch then rebalance backlog across lead teams", " p Toggle daemon auto-dispatch policy and persist config", @@ -1622,7 +1622,7 @@ impl Dashboard { lines.push(String::new()); if self.selected_messages.is_empty() { - lines.push("Inbox clear".to_string()); + lines.push("Message inbox clear".to_string()); } else { lines.push("Recent messages:".to_string()); let recent = self @@ -2365,7 +2365,7 @@ mod tests { let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Attention queue clear")); assert!(!text.contains("Needs attention:")); - assert!(!text.contains("Inbox focus-12")); + assert!(!text.contains("Backlog focus-12")); } #[test] From 10e34aa47a5250289c4d5bb05f4a31c07b352e8f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 12:36:32 -0700 Subject: [PATCH 023/459] feat: track ecc2 chronic saturation streak --- ecc2/src/session/daemon.rs | 176 +++++++++++++++++------- ecc2/src/session/store.rs | 122 ++++++++++++----- ecc2/src/tui/dashboard.rs | 271 ++++++++++++++++++++++++------------- 3 files changed, 394 insertions(+), 175 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index c63195e3..e8986db5 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -97,15 +97,19 @@ fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> { } async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result { - let summary = maybe_auto_dispatch_with_recorder(cfg, || { - manager::auto_dispatch_backlog( - db, - cfg, - &cfg.default_agent, - true, - cfg.max_parallel_sessions, - ) - }, |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads)) + let summary = maybe_auto_dispatch_with_recorder( + cfg, + || { + manager::auto_dispatch_backlog( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + }, + |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads), + ) .await?; Ok(summary.routed) } @@ -116,26 +120,34 @@ async fn coordinate_backlog_cycle(db: &StateStore, cfg: &Config) -> Result<()> { cfg, &activity, || { - maybe_auto_dispatch_with_recorder(cfg, || { - manager::auto_dispatch_backlog( - db, - cfg, - &cfg.default_agent, - true, - cfg.max_parallel_sessions, - ) - }, |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads)) + maybe_auto_dispatch_with_recorder( + cfg, + || { + manager::auto_dispatch_backlog( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + }, + |routed, deferred, leads| db.record_daemon_dispatch_pass(routed, deferred, leads), + ) }, || { - maybe_auto_rebalance_with_recorder(cfg, || { - manager::rebalance_all_teams( - db, - cfg, - &cfg.default_agent, - true, - cfg.max_parallel_sessions, - ) - }, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads)) + maybe_auto_rebalance_with_recorder( + cfg, + || { + manager::rebalance_all_teams( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + }, + |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads), + ) }, |routed, leads| db.record_daemon_recovery_dispatch_pass(routed, leads), ) @@ -163,7 +175,11 @@ where tracing::warn!( "Skipping immediate dispatch retry because chronic saturation cooloff is active" ); - return Ok((DispatchPassSummary::default(), rebalanced, DispatchPassSummary::default())); + return Ok(( + DispatchPassSummary::default(), + rebalanced, + DispatchPassSummary::default(), + )); } let first_dispatch = dispatch().await?; if first_dispatch.routed > 0 { @@ -206,7 +222,11 @@ where F: Fn() -> Fut, Fut: Future>>, { - Ok(maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _, _| Ok(())).await?.routed) + Ok( + maybe_auto_dispatch_with_recorder(cfg, dispatch, |_, _, _| Ok(())) + .await? + .routed, + ) } async fn maybe_auto_dispatch_with_recorder( @@ -254,9 +274,7 @@ where ); } if deferred > 0 { - tracing::warn!( - "Deferred {deferred} task handoff(s) because delegate teams were saturated" - ); + tracing::warn!("Deferred {deferred} task handoff(s) because delegate teams were saturated"); } Ok(DispatchPassSummary { @@ -267,15 +285,19 @@ where } async fn maybe_auto_rebalance(db: &StateStore, cfg: &Config) -> Result { - maybe_auto_rebalance_with_recorder(cfg, || { - manager::rebalance_all_teams( - db, - cfg, - &cfg.default_agent, - true, - cfg.max_parallel_sessions, - ) - }, |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads)) + maybe_auto_rebalance_with_recorder( + cfg, + || { + manager::rebalance_all_teams( + db, + cfg, + &cfg.default_agent, + true, + cfg.max_parallel_sessions, + ) + }, + |rerouted, leads| db.record_daemon_rebalance_pass(rerouted, leads), + ) .await } @@ -528,7 +550,8 @@ mod tests { } #[tokio::test] - async fn coordinate_backlog_cycle_retries_after_rebalance_when_dispatch_deferred() -> Result<()> { + async fn coordinate_backlog_cycle_retries_after_rebalance_when_dispatch_deferred() -> Result<()> + { let cfg = Config { auto_dispatch_unread_handoffs: true, ..Config::default() @@ -607,7 +630,8 @@ mod tests { } #[tokio::test] - async fn coordinate_backlog_cycle_records_recovery_dispatch_when_it_routes_work() -> Result<()> { + async fn coordinate_backlog_cycle_records_recovery_dispatch_when_it_routes_work() -> Result<()> + { let cfg = Config { auto_dispatch_unread_handoffs: true, ..Config::default() @@ -653,7 +677,8 @@ mod tests { } #[tokio::test] - async fn coordinate_backlog_cycle_rebalances_first_after_unrecovered_deferred_pressure() -> Result<()> { + async fn coordinate_backlog_cycle_rebalances_first_after_unrecovered_deferred_pressure( + ) -> Result<()> { let cfg = Config { auto_dispatch_unread_handoffs: true, ..Config::default() @@ -664,6 +689,7 @@ mod tests { last_dispatch_routed: 0, last_dispatch_deferred: 2, last_dispatch_leads: 1, + chronic_saturation_streak: 1, last_recovery_dispatch_at: None, last_recovery_dispatch_routed: 0, last_recovery_dispatch_leads: 0, @@ -708,7 +734,8 @@ mod tests { } #[tokio::test] - async fn coordinate_backlog_cycle_records_recovery_when_rebalance_first_dispatch_routes_work() -> Result<()> { + async fn coordinate_backlog_cycle_records_recovery_when_rebalance_first_dispatch_routes_work( + ) -> Result<()> { let cfg = Config { auto_dispatch_unread_handoffs: true, ..Config::default() @@ -719,6 +746,7 @@ mod tests { last_dispatch_routed: 0, last_dispatch_deferred: 2, last_dispatch_leads: 1, + chronic_saturation_streak: 1, last_recovery_dispatch_at: None, last_recovery_dispatch_routed: 0, last_recovery_dispatch_leads: 0, @@ -755,7 +783,8 @@ mod tests { } #[tokio::test] - async fn coordinate_backlog_cycle_skips_dispatch_during_chronic_cooloff_when_rebalance_does_not_help() -> Result<()> { + async fn coordinate_backlog_cycle_skips_dispatch_during_chronic_cooloff_when_rebalance_does_not_help( + ) -> Result<()> { let cfg = Config { auto_dispatch_unread_handoffs: true, ..Config::default() @@ -766,6 +795,7 @@ mod tests { last_dispatch_routed: 0, last_dispatch_deferred: 3, last_dispatch_leads: 1, + chronic_saturation_streak: 1, last_recovery_dispatch_at: None, last_recovery_dispatch_routed: 0, last_recovery_dispatch_leads: 0, @@ -803,7 +833,58 @@ mod tests { } #[tokio::test] - async fn coordinate_backlog_cycle_skips_rebalance_when_stabilized_and_dispatch_is_healthy() -> Result<()> { + async fn coordinate_backlog_cycle_skips_dispatch_when_persistent_saturation_streak_hits_cooloff( + ) -> Result<()> { + let cfg = Config { + auto_dispatch_unread_handoffs: true, + ..Config::default() + }; + let now = chrono::Utc::now(); + let activity = DaemonActivity { + last_dispatch_at: Some(now), + last_dispatch_routed: 0, + last_dispatch_deferred: 1, + last_dispatch_leads: 1, + chronic_saturation_streak: 3, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: Some(now - chrono::Duration::seconds(1)), + last_rebalance_rerouted: 0, + last_rebalance_leads: 1, + }; + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_clone = calls.clone(); + + let (first, rebalanced, recovery) = coordinate_backlog_cycle_with( + &cfg, + &activity, + move || { + let calls_clone = calls_clone.clone(); + async move { + calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(DispatchPassSummary { + routed: 1, + deferred: 0, + leads: 1, + }) + } + }, + || async move { Ok(0) }, + |_, _| Ok(()), + ) + .await?; + + assert_eq!(first, DispatchPassSummary::default()); + assert_eq!(rebalanced, 0); + assert_eq!(recovery, DispatchPassSummary::default()); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0); + Ok(()) + } + + #[tokio::test] + async fn coordinate_backlog_cycle_skips_rebalance_when_stabilized_and_dispatch_is_healthy( + ) -> Result<()> { let cfg = Config { auto_dispatch_unread_handoffs: true, ..Config::default() @@ -814,6 +895,7 @@ mod tests { 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, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 793fa6d3..096ca52a 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -19,6 +19,7 @@ pub struct DaemonActivity { pub last_dispatch_routed: usize, pub last_dispatch_deferred: usize, pub last_dispatch_leads: usize, + pub chronic_saturation_streak: usize, pub last_recovery_dispatch_at: Option>, pub last_recovery_dispatch_routed: usize, pub last_recovery_dispatch_leads: usize, @@ -44,12 +45,11 @@ impl DaemonActivity { } pub fn dispatch_cooloff_active(&self) -> bool { - self.prefers_rebalance_first() && self.last_dispatch_deferred >= 2 + self.prefers_rebalance_first() + && (self.last_dispatch_deferred >= 2 || self.chronic_saturation_streak >= 3) } - pub fn chronic_saturation_cleared_at( - &self, - ) -> Option<&chrono::DateTime> { + pub fn chronic_saturation_cleared_at(&self) -> Option<&chrono::DateTime> { if self.prefers_rebalance_first() { return None; } @@ -58,14 +58,14 @@ impl DaemonActivity { self.last_dispatch_at.as_ref(), self.last_recovery_dispatch_at.as_ref(), ) { - (Some(dispatch_at), Some(recovery_at)) if recovery_at > dispatch_at => Some(recovery_at), + (Some(dispatch_at), Some(recovery_at)) if recovery_at > dispatch_at => { + Some(recovery_at) + } _ => None, } } - pub fn stabilized_after_recovery_at( - &self, - ) -> Option<&chrono::DateTime> { + pub fn stabilized_after_recovery_at(&self) -> Option<&chrono::DateTime> { if self.last_dispatch_deferred != 0 { return None; } @@ -74,7 +74,9 @@ impl DaemonActivity { self.last_dispatch_at.as_ref(), self.last_recovery_dispatch_at.as_ref(), ) { - (Some(dispatch_at), Some(recovery_at)) if dispatch_at > recovery_at => Some(dispatch_at), + (Some(dispatch_at), Some(recovery_at)) if dispatch_at > recovery_at => { + Some(dispatch_at) + } _ => None, } } @@ -147,6 +149,7 @@ impl StateStore { last_dispatch_routed INTEGER NOT NULL DEFAULT 0, last_dispatch_deferred INTEGER NOT NULL DEFAULT 0, last_dispatch_leads INTEGER NOT NULL DEFAULT 0, + chronic_saturation_streak INTEGER NOT NULL DEFAULT 0, last_recovery_dispatch_at TEXT, last_recovery_dispatch_routed INTEGER NOT NULL DEFAULT 0, last_recovery_dispatch_leads INTEGER NOT NULL DEFAULT 0, @@ -199,7 +202,9 @@ impl StateStore { "ALTER TABLE daemon_activity ADD COLUMN last_recovery_dispatch_at TEXT", [], ) - .context("Failed to add last_recovery_dispatch_at column to daemon_activity table")?; + .context( + "Failed to add last_recovery_dispatch_at column to daemon_activity table", + )?; } if !self.has_column("daemon_activity", "last_recovery_dispatch_routed")? { @@ -220,6 +225,15 @@ impl StateStore { .context("Failed to add last_recovery_dispatch_leads column to daemon_activity table")?; } + if !self.has_column("daemon_activity", "chronic_saturation_streak")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN chronic_saturation_streak INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add chronic_saturation_streak column to daemon_activity table")?; + } + Ok(()) } @@ -550,9 +564,7 @@ impl StateStore { }) })?; - messages - .collect::, _>>() - .map_err(Into::into) + messages.collect::, _>>().map_err(Into::into) } pub fn unread_task_handoff_count(&self, session_id: &str) -> Result { @@ -582,9 +594,7 @@ impl StateStore { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)) })?; - targets - .collect::, _>>() - .map_err(Into::into) + targets.collect::, _>>().map_err(Into::into) } pub fn mark_messages_read(&self, session_id: &str) -> Result { @@ -624,6 +634,7 @@ impl StateStore { self.conn .query_row( "SELECT last_dispatch_at, last_dispatch_routed, last_dispatch_deferred, last_dispatch_leads, + chronic_saturation_streak, last_recovery_dispatch_at, last_recovery_dispatch_routed, last_recovery_dispatch_leads, last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads FROM daemon_activity @@ -652,12 +663,13 @@ impl StateStore { last_dispatch_routed: row.get::<_, i64>(1)? as usize, last_dispatch_deferred: row.get::<_, i64>(2)? as usize, last_dispatch_leads: row.get::<_, i64>(3)? as usize, - last_recovery_dispatch_at: parse_ts(row.get(4)?)?, - last_recovery_dispatch_routed: row.get::<_, i64>(5)? as usize, - last_recovery_dispatch_leads: row.get::<_, i64>(6)? as usize, - last_rebalance_at: parse_ts(row.get(7)?)?, - last_rebalance_rerouted: row.get::<_, i64>(8)? as usize, - last_rebalance_leads: row.get::<_, i64>(9)? as usize, + chronic_saturation_streak: row.get::<_, i64>(4)? as usize, + last_recovery_dispatch_at: parse_ts(row.get(5)?)?, + last_recovery_dispatch_routed: row.get::<_, i64>(6)? as usize, + last_recovery_dispatch_leads: row.get::<_, i64>(7)? as usize, + last_rebalance_at: parse_ts(row.get(8)?)?, + last_rebalance_rerouted: row.get::<_, i64>(9)? as usize, + last_rebalance_leads: row.get::<_, i64>(10)? as usize, }) }, ) @@ -675,7 +687,11 @@ impl StateStore { SET last_dispatch_at = ?1, last_dispatch_routed = ?2, last_dispatch_deferred = ?3, - last_dispatch_leads = ?4 + last_dispatch_leads = ?4, + chronic_saturation_streak = CASE + WHEN ?3 > 0 THEN chronic_saturation_streak + 1 + ELSE 0 + END WHERE id = 1", rusqlite::params![ chrono::Utc::now().to_rfc3339(), @@ -693,7 +709,8 @@ impl StateStore { "UPDATE daemon_activity SET last_recovery_dispatch_at = ?1, last_recovery_dispatch_routed = ?2, - last_recovery_dispatch_leads = ?3 + last_recovery_dispatch_leads = ?3, + chronic_saturation_streak = 0 WHERE id = 1", rusqlite::params![chrono::Utc::now().to_rfc3339(), routed as i64, leads as i64], )?; @@ -708,7 +725,11 @@ impl StateStore { last_rebalance_rerouted = ?2, last_rebalance_leads = ?3 WHERE id = 1", - rusqlite::params![chrono::Utc::now().to_rfc3339(), rerouted as i64, leads as i64], + rusqlite::params![ + chrono::Utc::now().to_rfc3339(), + rerouted as i64, + leads as i64 + ], )?; Ok(()) @@ -1023,7 +1044,12 @@ mod tests { db.insert_session(&build_session("planner", SessionState::Running))?; db.insert_session(&build_session("worker", SessionState::Pending))?; - db.send_message("planner", "worker", "{\"question\":\"Need context\"}", "query")?; + db.send_message( + "planner", + "worker", + "{\"question\":\"Need context\"}", + "query", + )?; db.send_message( "worker", "planner", @@ -1066,17 +1092,11 @@ mod tests { ); assert_eq!( db.delegated_children("planner", 10)?, - vec![ - "worker-3".to_string(), - "worker-2".to_string(), - ] + vec!["worker-3".to_string(), "worker-2".to_string(),] ); assert_eq!( db.unread_task_handoff_targets(10)?, - vec![ - ("worker-2".to_string(), 1), - ("worker-3".to_string(), 1), - ] + vec![("worker-2".to_string(), 1), ("worker-3".to_string(), 1),] ); Ok(()) @@ -1095,6 +1115,7 @@ mod tests { assert_eq!(activity.last_dispatch_routed, 4); assert_eq!(activity.last_dispatch_deferred, 1); assert_eq!(activity.last_dispatch_leads, 2); + assert_eq!(activity.chronic_saturation_streak, 0); assert_eq!(activity.last_recovery_dispatch_routed, 2); assert_eq!(activity.last_recovery_dispatch_leads, 1); assert_eq!(activity.last_rebalance_rerouted, 3); @@ -1121,6 +1142,7 @@ mod tests { last_dispatch_routed: 0, last_dispatch_deferred: 2, last_dispatch_leads: 1, + chronic_saturation_streak: 1, last_recovery_dispatch_at: None, last_recovery_dispatch_routed: 0, last_recovery_dispatch_leads: 0, @@ -1133,9 +1155,18 @@ mod tests { assert!(unresolved.chronic_saturation_cleared_at().is_none()); assert!(unresolved.stabilized_after_recovery_at().is_none()); + let persistent = DaemonActivity { + last_dispatch_deferred: 1, + chronic_saturation_streak: 3, + ..unresolved.clone() + }; + assert!(persistent.prefers_rebalance_first()); + assert!(persistent.dispatch_cooloff_active()); + let recovered = DaemonActivity { last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), last_recovery_dispatch_routed: 1, + chronic_saturation_streak: 0, ..unresolved }; assert!(!recovered.prefers_rebalance_first()); @@ -1161,4 +1192,27 @@ mod tests { stabilized.last_dispatch_at.as_ref() ); } + + #[test] + fn daemon_activity_tracks_chronic_saturation_streak() -> Result<()> { + let tempdir = TestDir::new("store-daemon-streak")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + + db.record_daemon_dispatch_pass(0, 1, 1)?; + db.record_daemon_dispatch_pass(0, 1, 1)?; + let saturated = db.daemon_activity()?; + assert_eq!(saturated.chronic_saturation_streak, 2); + assert!(!saturated.dispatch_cooloff_active()); + + db.record_daemon_dispatch_pass(0, 1, 1)?; + let chronic = db.daemon_activity()?; + assert_eq!(chronic.chronic_saturation_streak, 3); + assert!(chronic.dispatch_cooloff_active()); + + db.record_daemon_recovery_dispatch_pass(1, 1)?; + let recovered = db.daemon_activity()?; + assert_eq!(recovered.chronic_saturation_streak, 0); + + Ok(()) + } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 36ce08e6..18d28320 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1,28 +1,28 @@ -use std::collections::HashMap; use ratatui::{ prelude::*, widgets::{ Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap, }, }; +use std::collections::HashMap; use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; use crate::config::{Config, PaneLayout}; use crate::observability::ToolLogEntry; -use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; use crate::session::manager; +use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; use crate::session::store::{DaemonActivity, StateStore}; use crate::session::{Session, SessionMessage, SessionState}; use crate::worktree; -#[cfg(test)] -use std::path::Path; #[cfg(test)] use crate::session::output::OutputStream; #[cfg(test)] use crate::session::{SessionMetrics, WorktreeInfo}; +#[cfg(test)] +use std::path::Path; const DEFAULT_PANE_SIZE_PERCENT: u16 = 35; const DEFAULT_GRID_SIZE_PERCENT: u16 = 50; @@ -122,7 +122,11 @@ impl Dashboard { Self::with_output_store(db, cfg, SessionOutputStore::default()) } - pub fn with_output_store(db: StateStore, cfg: Config, output_store: SessionOutputStore) -> Self { + pub fn with_output_store( + db: StateStore, + cfg: Config, + output_store: SessionOutputStore, + ) -> Self { let pane_size_percent = match cfg.pane_layout { PaneLayout::Grid => DEFAULT_GRID_SIZE_PERCENT, PaneLayout::Horizontal | PaneLayout::Vertical => DEFAULT_PANE_SIZE_PERCENT, @@ -221,13 +225,13 @@ impl Dashboard { .map(|pane| pane.title()) .collect::>(), ) - .block(Block::default().borders(Borders::ALL).title(title)) - .select(self.selected_pane_index()) - .highlight_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ); + .block(Block::default().borders(Borders::ALL).title(title)) + .select(self.selected_pane_index()) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); frame.render_widget(tabs, area); } @@ -244,7 +248,10 @@ impl Dashboard { return; } - let stabilized = self.daemon_activity.stabilized_after_recovery_at().is_some(); + let stabilized = self + .daemon_activity + .stabilized_after_recovery_at() + .is_some(); let summary = SessionSummary::from_sessions(&self.sessions, &self.handoff_backlog_counts, stabilized); let chunks = Layout::default() @@ -269,8 +276,10 @@ impl Dashboard { .unwrap_or(0), ) }); - let header = Row::new(["ID", "Agent", "State", "Branch", "Backlog", "Tokens", "Duration"]) - .style(Style::default().add_modifier(Modifier::BOLD)); + let header = Row::new([ + "ID", "Agent", "State", "Branch", "Backlog", "Tokens", "Duration", + ]) + .style(Style::default().add_modifier(Modifier::BOLD)); let widths = [ Constraint::Length(8), Constraint::Length(10), @@ -600,14 +609,15 @@ impl Dashboard { let task = self.new_session_task(); let agent = self.cfg.default_agent.clone(); - let session_id = match manager::create_session(&self.db, &self.cfg, &task, &agent, true).await { - Ok(session_id) => session_id, - Err(error) => { - tracing::warn!("Failed to create new session from dashboard: {error}"); - self.set_operator_note(format!("new session failed: {error}")); - return; - } - }; + let session_id = + match manager::create_session(&self.db, &self.cfg, &task, &agent, true).await { + Ok(session_id) => session_id, + Err(error) => { + tracing::warn!("Failed to create new session from dashboard: {error}"); + self.set_operator_note(format!("new session failed: {error}")); + return; + } + }; if let Some(source_session) = self.sessions.get(self.selected_session) { let context = format!( @@ -644,7 +654,10 @@ impl Dashboard { self.refresh(); self.sync_selection_by_id(Some(&session_id)); - self.set_operator_note(format!("spawned session {}", format_session_id(&session_id))); + self.set_operator_note(format!( + "spawned session {}", + format_session_id(&session_id) + )); self.reset_output_view(); self.sync_selected_output(); self.sync_selected_diff(); @@ -808,22 +821,17 @@ impl Dashboard { let agent = self.cfg.default_agent.clone(); let lead_limit = self.sessions.len().max(1); - let outcomes = match manager::auto_dispatch_backlog( - &self.db, - &self.cfg, - &agent, - true, - lead_limit, - ) - .await - { - Ok(outcomes) => outcomes, - Err(error) => { - tracing::warn!("Failed to auto-dispatch backlog from dashboard: {error}"); - self.set_operator_note(format!("global auto-dispatch failed: {error}")); - return; - } - }; + let outcomes = + match manager::auto_dispatch_backlog(&self.db, &self.cfg, &agent, true, lead_limit) + .await + { + Ok(outcomes) => outcomes, + Err(error) => { + tracing::warn!("Failed to auto-dispatch backlog from dashboard: {error}"); + self.set_operator_note(format!("global auto-dispatch failed: {error}")); + return; + } + }; let total_processed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); let total_routed: usize = outcomes @@ -867,22 +875,16 @@ impl Dashboard { let agent = self.cfg.default_agent.clone(); let lead_limit = self.sessions.len().max(1); - let outcomes = match manager::rebalance_all_teams( - &self.db, - &self.cfg, - &agent, - true, - lead_limit, - ) - .await - { - Ok(outcomes) => outcomes, - Err(error) => { - tracing::warn!("Failed to rebalance teams from dashboard: {error}"); - self.set_operator_note(format!("global rebalance failed: {error}")); - return; - } - }; + let outcomes = + match manager::rebalance_all_teams(&self.db, &self.cfg, &agent, true, lead_limit).await + { + Ok(outcomes) => outcomes, + Err(error) => { + tracing::warn!("Failed to rebalance teams from dashboard: {error}"); + self.set_operator_note(format!("global rebalance failed: {error}")); + return; + } + }; let total_rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); let selected_session_id = self @@ -914,11 +916,7 @@ impl Dashboard { let lead_limit = self.sessions.len().max(1); let outcome = match manager::coordinate_backlog( - &self.db, - &self.cfg, - &agent, - true, - lead_limit, + &self.db, &self.cfg, &agent, true, lead_limit, ) .await { @@ -992,12 +990,18 @@ impl Dashboard { let session_id = session.id.clone(); if let Err(error) = manager::stop_session(&self.db, &session_id).await { tracing::warn!("Failed to stop session {}: {error}", session.id); - self.set_operator_note(format!("stop failed for {}: {error}", format_session_id(&session_id))); + self.set_operator_note(format!( + "stop failed for {}: {error}", + format_session_id(&session_id) + )); return; } self.refresh(); - self.set_operator_note(format!("stopped session {}", format_session_id(&session_id))); + self.set_operator_note(format!( + "stopped session {}", + format_session_id(&session_id) + )); } pub async fn resume_selected(&mut self) { @@ -1008,12 +1012,18 @@ impl Dashboard { let session_id = session.id.clone(); if let Err(error) = manager::resume_session(&self.db, &self.cfg, &session_id).await { tracing::warn!("Failed to resume session {}: {error}", session.id); - self.set_operator_note(format!("resume failed for {}: {error}", format_session_id(&session_id))); + self.set_operator_note(format!( + "resume failed for {}: {error}", + format_session_id(&session_id) + )); return; } self.refresh(); - self.set_operator_note(format!("resumed session {}", format_session_id(&session_id))); + self.set_operator_note(format!( + "resumed session {}", + format_session_id(&session_id) + )); } pub async fn cleanup_selected_worktree(&mut self) { @@ -1036,7 +1046,10 @@ impl Dashboard { } self.refresh(); - self.set_operator_note(format!("cleaned worktree for {}", format_session_id(&session_id))); + self.set_operator_note(format!( + "cleaned worktree for {}", + format_session_id(&session_id) + )); } pub async fn delete_selected_session(&mut self) { @@ -1047,12 +1060,18 @@ impl Dashboard { let session_id = session.id.clone(); if let Err(error) = manager::delete_session(&self.db, &session_id).await { tracing::warn!("Failed to delete session {}: {error}", session.id); - self.set_operator_note(format!("delete failed for {}: {error}", format_session_id(&session_id))); + self.set_operator_note(format!( + "delete failed for {}: {error}", + format_session_id(&session_id) + )); return; } self.refresh(); - self.set_operator_note(format!("deleted session {}", format_session_id(&session_id))); + self.set_operator_note(format!( + "deleted session {}", + format_session_id(&session_id) + )); } pub fn refresh(&mut self) { @@ -1085,7 +1104,8 @@ impl Dashboard { } 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; + let next = + (self.cfg.auto_dispatch_limit_per_session as isize + delta).clamp(1, 50) as usize; if next == self.cfg.auto_dispatch_limit_per_session { self.set_operator_note(format!( "auto-dispatch limit unchanged at {} handoff(s) per lead", @@ -1162,7 +1182,11 @@ impl Dashboard { fn sync_selection_by_id(&mut self, selected_id: Option<&str>) { if let Some(selected_id) = selected_id { - if let Some(index) = self.sessions.iter().position(|session| session.id == selected_id) { + if let Some(index) = self + .sessions + .iter() + .position(|session| session.id == selected_id) + { self.selected_session = index; } } @@ -1246,7 +1270,11 @@ impl Dashboard { return; }; - let unread_count = self.unread_message_counts.get(&session_id).copied().unwrap_or(0); + let unread_count = self + .unread_message_counts + .get(&session_id) + .copied() + .unwrap_or(0); if unread_count > 0 { match self.db.mark_messages_read(&session_id) { Ok(_) => { @@ -1297,7 +1325,8 @@ impl Dashboard { match self.db.get_session(&child_id) { Ok(Some(session)) => { team.total += 1; - let handoff_backlog = match self.db.unread_task_handoff_count(&child_id) { + let handoff_backlog = match self.db.unread_task_handoff_count(&child_id) + { Ok(count) => count, Err(error) => { tracing::warn!( @@ -1360,7 +1389,9 @@ impl Dashboard { ) -> Option { if let Some(idle_clear) = delegates .iter() - .filter(|delegate| delegate.state == SessionState::Idle && delegate.handoff_backlog == 0) + .filter(|delegate| { + delegate.state == SessionState::Idle && delegate.handoff_backlog == 0 + }) .min_by_key(|delegate| delegate.session_id.as_str()) { return Some(format!( @@ -1387,7 +1418,12 @@ impl Dashboard { if let Some(active_delegate) = delegates .iter() - .filter(|delegate| matches!(delegate.state, SessionState::Running | SessionState::Pending)) + .filter(|delegate| { + matches!( + delegate.state, + SessionState::Running | SessionState::Pending + ) + }) .min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str())) { return Some(format!( @@ -1510,7 +1546,11 @@ impl Dashboard { "Global handoff backlog {} lead(s) / {} handoff(s) | Auto-dispatch {} @ {}/lead", self.global_handoff_backlog_leads, self.global_handoff_backlog_messages, - if self.cfg.auto_dispatch_unread_handoffs { "on" } else { "off" }, + if self.cfg.auto_dispatch_unread_handoffs { + "on" + } else { + "off" + }, self.cfg.auto_dispatch_limit_per_session )); @@ -1529,6 +1569,13 @@ impl Dashboard { } )); + if self.daemon_activity.chronic_saturation_streak > 0 { + lines.push(format!( + "Chronic saturation streak {} cycle(s)", + self.daemon_activity.chronic_saturation_streak + )); + } + if let Some(cleared_at) = self.daemon_activity.chronic_saturation_cleared_at() { lines.push(format!( "Chronic saturation cleared @ {}", @@ -1684,7 +1731,10 @@ impl Dashboard { fn attention_queue_items(&self, limit: usize) -> Vec { let mut items = Vec::new(); - let suppress_inbox_attention = self.daemon_activity.stabilized_after_recovery_at().is_some(); + let suppress_inbox_attention = self + .daemon_activity + .stabilized_after_recovery_at() + .is_some(); for session in &self.sessions { let handoff_backlog = self @@ -1914,7 +1964,10 @@ impl SessionSummary { inbox_sessions: if suppress_inbox_attention { 0 } else { - unread_message_counts.values().filter(|count| **count > 0).count() + unread_message_counts + .values() + .filter(|count| **count > 0) + .count() }, ..Self::default() }, @@ -1991,7 +2044,9 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta return Line::from(vec![ Span::styled( "Attention queue clear", - Style::default().fg(Color::Green).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), ), Span::raw(if stabilized { " stabilized backlog absorbed" @@ -2004,7 +2059,9 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta Line::from(vec![ Span::styled( "Attention queue ", - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), ), summary_span("Backlog", summary.unread_messages, Color::Magenta), summary_span("Failed", summary.failed, Color::Red), @@ -2141,15 +2198,13 @@ mod tests { ], 0, ); - dashboard - .session_output_cache - .insert( - "focus-12345678".to_string(), - vec![OutputLine { - stream: OutputStream::Stdout, - text: "last useful output".to_string(), - }], - ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![OutputLine { + stream: OutputStream::Stdout, + text: "last useful output".to_string(), + }], + ); dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string()); let text = dashboard.selected_session_metrics_text(); @@ -2188,7 +2243,9 @@ 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")); + assert!(text.contains( + "Global handoff backlog 2 lead(s) / 5 handoff(s) | Auto-dispatch off @ 5/lead" + )); assert!(text.contains("Coordination mode dispatch-first")); assert!(text.contains("Next route reuse idle worker-1")); } @@ -2212,6 +2269,7 @@ mod tests { last_dispatch_routed: 4, last_dispatch_deferred: 2, last_dispatch_leads: 2, + chronic_saturation_streak: 0, last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), last_recovery_dispatch_routed: 1, last_recovery_dispatch_leads: 1, @@ -2246,6 +2304,7 @@ mod tests { last_dispatch_routed: 0, last_dispatch_deferred: 1, last_dispatch_leads: 1, + chronic_saturation_streak: 1, last_recovery_dispatch_at: None, last_recovery_dispatch_routed: 0, last_recovery_dispatch_leads: 0, @@ -2276,6 +2335,7 @@ mod tests { last_dispatch_routed: 0, last_dispatch_deferred: 3, last_dispatch_leads: 1, + chronic_saturation_streak: 3, last_recovery_dispatch_at: None, last_recovery_dispatch_routed: 0, last_recovery_dispatch_leads: 0, @@ -2286,6 +2346,7 @@ mod tests { let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Coordination mode rebalance-cooloff (chronic saturation)")); + assert!(text.contains("Chronic saturation streak 3 cycle(s)")); } #[test] @@ -2307,6 +2368,7 @@ mod tests { 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, @@ -2348,12 +2410,14 @@ mod tests { let mut dashboard = test_dashboard(sessions, 0); dashboard.unread_message_counts = unread; - dashboard.handoff_backlog_counts = HashMap::from([(String::from("focus-12345678"), 3usize)]); + dashboard.handoff_backlog_counts = + HashMap::from([(String::from("focus-12345678"), 3usize)]); 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, @@ -2690,7 +2754,10 @@ mod tests { let session = db .get_session("stopped-1")? .expect("session should exist after cleanup"); - assert!(session.worktree.is_none(), "worktree metadata should be cleared"); + assert!( + session.worktree.is_none(), + "worktree metadata should be cleared" + ); let _ = std::fs::remove_dir_all(worktree_path); let _ = std::fs::remove_file(db_path); @@ -2720,7 +2787,10 @@ mod tests { let mut dashboard = Dashboard::new(dashboard_store, Config::default()); dashboard.delete_selected_session().await; - assert!(db.get_session("done-1")?.is_none(), "session should be deleted"); + assert!( + db.get_session("done-1")?.is_none(), + "session should be deleted" + ); let _ = std::fs::remove_file(db_path); Ok(()) @@ -2845,7 +2915,10 @@ mod tests { let mut dashboard = Dashboard::new(dashboard_store, Config::default()); dashboard.coordinate_backlog().await; - assert_eq!(dashboard.operator_note.as_deref(), Some("backlog already clear")); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("backlog already clear") + ); let _ = std::fs::remove_file(db_path); Ok(()) @@ -2853,7 +2926,17 @@ mod tests { #[test] fn grid_layout_renders_four_panes() { - let mut dashboard = test_dashboard(vec![sample_session("grid-1", "claude", SessionState::Running, None, 1, 1)], 0); + let mut dashboard = test_dashboard( + vec![sample_session( + "grid-1", + "claude", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); dashboard.cfg.pane_layout = PaneLayout::Grid; dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; From 1bc9b9c5850584fc3ab649a8ae094e71e60943c6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 12:39:34 -0700 Subject: [PATCH 024/459] feat: escalate ecc2 chronic saturation --- ecc2/src/session/store.rs | 14 ++++++++++++++ ecc2/src/tui/dashboard.rs | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 096ca52a..ecbf5081 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -80,6 +80,12 @@ impl DaemonActivity { _ => None, } } + + pub fn operator_escalation_required(&self) -> bool { + self.dispatch_cooloff_active() + && self.chronic_saturation_streak >= 5 + && self.last_rebalance_rerouted == 0 + } } impl StateStore { @@ -1162,6 +1168,14 @@ mod tests { }; assert!(persistent.prefers_rebalance_first()); assert!(persistent.dispatch_cooloff_active()); + assert!(!persistent.operator_escalation_required()); + + let escalated = DaemonActivity { + chronic_saturation_streak: 5, + last_rebalance_rerouted: 0, + ..persistent.clone() + }; + assert!(escalated.operator_escalation_required()); let recovered = DaemonActivity { last_recovery_dispatch_at: Some(now + chrono::Duration::seconds(1)), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 18d28320..c2a2eaf2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1576,6 +1576,12 @@ impl Dashboard { )); } + if self.daemon_activity.operator_escalation_required() { + lines.push( + "Operator escalation recommended: chronic saturation is not clearing".into(), + ); + } + if let Some(cleared_at) = self.daemon_activity.chronic_saturation_cleared_at() { lines.push(format!( "Chronic saturation cleared @ {}", @@ -2349,6 +2355,37 @@ mod tests { assert!(text.contains("Chronic saturation streak 3 cycle(s)")); } + #[test] + fn selected_session_metrics_text_recommends_operator_escalation_when_chronic_saturation_is_stuck() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.daemon_activity = DaemonActivity { + last_dispatch_at: Some(Utc::now()), + last_dispatch_routed: 0, + last_dispatch_deferred: 2, + last_dispatch_leads: 1, + chronic_saturation_streak: 5, + last_recovery_dispatch_at: None, + last_recovery_dispatch_routed: 0, + last_recovery_dispatch_leads: 0, + last_rebalance_at: Some(Utc::now()), + last_rebalance_rerouted: 0, + last_rebalance_leads: 1, + }; + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Operator escalation recommended: chronic saturation is not clearing")); + } + #[test] fn selected_session_metrics_text_shows_stabilized_dispatch_mode_after_recovery() { let now = Utc::now(); From 0ff58108e40df57ef6be83e9cf15241a42f65b18 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 12:58:02 -0700 Subject: [PATCH 025/459] fix: restore agent yaml command export --- WORKING-CONTEXT.md | 6 ++- agent.yaml | 80 +++++++++++++++++++++++++++++ tests/ci/agent-yaml-surface.test.js | 79 ++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 tests/ci/agent-yaml-surface.test.js diff --git a/WORKING-CONTEXT.md b/WORKING-CONTEXT.md index 66a70ef0..62fa3450 100644 --- a/WORKING-CONTEXT.md +++ b/WORKING-CONTEXT.md @@ -1,6 +1,6 @@ # Working Context -Last updated: 2026-04-05 +Last updated: 2026-04-08 ## Purpose @@ -10,7 +10,7 @@ Public ECC plugin repo for agents, skills, commands, hooks, rules, install surfa - Default branch: `main` - Public release surface is aligned at `v1.10.0` -- Public catalog truth is `39` agents, `73` commands, and `179` skills +- Public catalog truth is `47` agents, `79` commands, and `181` skills - Public plugin slug is now `ecc`; legacy `everything-claude-code` install paths remain supported for compatibility - Release discussion: `#1272` - ECC 2.0 exists in-tree and builds, but it is still alpha rather than GA @@ -36,6 +36,7 @@ Public ECC plugin repo for agents, skills, commands, hooks, rules, install surfa - control plane primitives - operator surface - self-improving skills + - keep `agent.yaml` export parity with the shipped `commands/` and `skills/` directories so modern install surfaces do not silently lose command registration - Skill quality: - rewrite content-facing skills to use source-backed voice modeling - remove generic LLM rhetoric, canned CTA patterns, and forced platform stereotypes @@ -175,3 +176,4 @@ Keep this file detailed for only the current sprint, blockers, and next actions. - `skills/oura-health` and `skills/pmx-guidelines` are user- or project-specific, not canonical ECC surfaces - `docs/releases/2.0.0-preview/*` is premature collateral and should be rebuilt from current product truth later - nested `skills/hermes-generated/*` is superseded by the top-level ECC-native operator skills already ported to `main` +- 2026-04-08: Fixed the command-export regression reported in `#1327` by restoring a canonical `commands:` section in `agent.yaml` and adding `tests/ci/agent-yaml-surface.test.js` to enforce exact parity between the YAML export surface and the real `commands/` directory. Verified with the full repo test sweep: `1764/1764` passing. diff --git a/agent.yaml b/agent.yaml index cdf22d1f..8241c085 100644 --- a/agent.yaml +++ b/agent.yaml @@ -143,6 +143,86 @@ skills: - videodb - visa-doc-translate - x-api +commands: + - agent-sort + - aside + - build-fix + - checkpoint + - claw + - code-review + - context-budget + - cpp-build + - cpp-review + - cpp-test + - devfleet + - docs + - e2e + - eval + - evolve + - feature-dev + - flutter-build + - flutter-review + - flutter-test + - gan-build + - gan-design + - go-build + - go-review + - go-test + - gradle-build + - harness-audit + - hookify + - hookify-configure + - hookify-help + - hookify-list + - instinct-export + - instinct-import + - instinct-status + - jira + - kotlin-build + - kotlin-review + - kotlin-test + - learn + - learn-eval + - loop-start + - loop-status + - model-route + - multi-backend + - multi-execute + - multi-frontend + - multi-plan + - multi-workflow + - orchestrate + - plan + - pm2 + - projects + - promote + - prompt-optimize + - prp-commit + - prp-implement + - prp-plan + - prp-pr + - prp-prd + - prune + - python-review + - quality-gate + - refactor-clean + - resume-session + - review-pr + - rules-distill + - rust-build + - rust-review + - rust-test + - santa-loop + - save-session + - sessions + - setup-pm + - skill-create + - skill-health + - tdd + - test-coverage + - update-codemaps + - update-docs + - verify tags: - agent-harness - developer-tools diff --git a/tests/ci/agent-yaml-surface.test.js b/tests/ci/agent-yaml-surface.test.js new file mode 100644 index 00000000..6e9d1333 --- /dev/null +++ b/tests/ci/agent-yaml-surface.test.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * Validate agent.yaml exports the legacy command shim surface. + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); +const AGENT_YAML_PATH = path.join(REPO_ROOT, 'agent.yaml'); +const COMMANDS_DIR = path.join(REPO_ROOT, 'commands'); + +function extractTopLevelList(yamlSource, key) { + const lines = yamlSource.replace(/^\uFEFF/, '').split(/\r?\n/); + const results = []; + let collecting = false; + + for (const line of lines) { + if (!collecting) { + if (line.trim() === `${key}:`) { + collecting = true; + } + continue; + } + + if (/^[A-Za-z0-9_-]+:\s*/.test(line)) { + break; + } + + const match = line.match(/^\s*-\s+(.+?)\s*$/); + if (match) { + results.push(match[1]); + } + } + + return results; +} + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function run() { + console.log('\n=== Testing agent.yaml export surface ===\n'); + + let passed = 0; + let failed = 0; + + const yamlSource = fs.readFileSync(AGENT_YAML_PATH, 'utf8'); + const declaredCommands = extractTopLevelList(yamlSource, 'commands').sort(); + const actualCommands = fs.readdirSync(COMMANDS_DIR) + .filter(file => file.endsWith('.md')) + .map(file => path.basename(file, '.md')) + .sort(); + + if (test('agent.yaml declares commands export surface', () => { + assert.ok(declaredCommands.length > 0, 'Expected non-empty commands list in agent.yaml'); + })) passed++; else failed++; + + if (test('agent.yaml commands stay in sync with commands/ directory', () => { + assert.deepStrictEqual(declaredCommands, actualCommands); + })) passed++; else failed++; + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + + process.exit(failed > 0 ? 1 : 0); +} + +run(); From cd948783749be3847c6235c9069522ea32dafcc0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:13:46 -0700 Subject: [PATCH 026/459] feat: add ecc2 coordination status command --- ecc2/src/main.rs | 17 +++ ecc2/src/session/manager.rs | 225 ++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index b931e27b..70f469b4 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -102,6 +102,8 @@ enum Commands { #[arg(long, default_value_t = 10)] lead_limit: usize, }, + /// Show global coordination, backlog, and daemon policy status + CoordinationStatus, /// Rebalance unread handoffs across lead teams with backed-up delegates RebalanceAll { /// Agent type for routed delegates @@ -458,6 +460,10 @@ async fn main() -> Result<()> { ); } } + Some(Commands::CoordinationStatus) => { + let status = session::manager::get_coordination_status(&db, &cfg)?; + println!("{status}"); + } Some(Commands::RebalanceAll { agent, worktree: use_worktree, @@ -953,6 +959,17 @@ mod tests { } } + #[test] + fn cli_parses_coordination_status_command() { + let cli = Cli::try_parse_from(["ecc", "coordination-status"]) + .expect("coordination-status should parse"); + + match cli.command { + Some(Commands::CoordinationStatus) => {} + _ => panic!("expected coordination-status subcommand"), + } + } + #[test] fn cli_parses_rebalance_team_command() { let cli = Cli::try_parse_from([ diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index ee210c41..2d214141 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1094,6 +1094,16 @@ pub struct CoordinateBacklogOutcome { pub remaining_saturated_sessions: usize, } +pub struct CoordinationStatus { + pub backlog_leads: usize, + pub backlog_messages: usize, + pub absorbable_sessions: usize, + pub saturated_sessions: usize, + pub auto_dispatch_enabled: bool, + pub auto_dispatch_limit_per_session: usize, + pub daemon_activity: super::store::DaemonActivity, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssignmentAction { Spawned, @@ -1106,6 +1116,25 @@ pub fn assignment_action_routes_work(action: AssignmentAction) -> bool { !matches!(action, AssignmentAction::DeferredSaturated) } +pub fn get_coordination_status(db: &StateStore, cfg: &Config) -> Result { + let targets = db.unread_task_handoff_targets(db.list_sessions()?.len().max(1))?; + let pressure = summarize_backlog_pressure(db, cfg, &cfg.default_agent, &targets)?; + let backlog_messages = targets + .iter() + .map(|(_, unread_count)| *unread_count) + .sum::(); + + Ok(CoordinationStatus { + backlog_leads: targets.len(), + backlog_messages, + absorbable_sessions: pressure.absorbable_sessions, + saturated_sessions: pressure.saturated_sessions, + auto_dispatch_enabled: cfg.auto_dispatch_unread_handoffs, + auto_dispatch_limit_per_session: cfg.auto_dispatch_limit_per_session, + daemon_activity: db.daemon_activity()?, + }) +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct BacklogPressureSummary { absorbable_sessions: usize, @@ -1201,6 +1230,105 @@ impl fmt::Display for TeamStatus { } } +impl fmt::Display for CoordinationStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let stabilized = self.daemon_activity.stabilized_after_recovery_at(); + let mode = if self.daemon_activity.dispatch_cooloff_active() { + "rebalance-cooloff (chronic saturation)" + } else if self.daemon_activity.prefers_rebalance_first() { + "rebalance-first (chronic saturation)" + } else if stabilized.is_some() { + "dispatch-first (stabilized)" + } else { + "dispatch-first" + }; + + writeln!( + f, + "Global handoff backlog: {} lead(s) / {} handoff(s) [{} absorbable, {} saturated]", + self.backlog_leads, + self.backlog_messages, + self.absorbable_sessions, + self.saturated_sessions + )?; + writeln!( + f, + "Auto-dispatch: {} @ {}/lead", + if self.auto_dispatch_enabled { + "on" + } else { + "off" + }, + self.auto_dispatch_limit_per_session + )?; + writeln!(f, "Coordination mode: {mode}")?; + + if self.daemon_activity.chronic_saturation_streak > 0 { + writeln!( + f, + "Chronic saturation streak: {} cycle(s)", + self.daemon_activity.chronic_saturation_streak + )?; + } + + if self.daemon_activity.operator_escalation_required() { + writeln!( + f, + "Operator escalation: chronic saturation is not clearing" + )?; + } + + if let Some(cleared_at) = self.daemon_activity.chronic_saturation_cleared_at() { + writeln!( + f, + "Chronic saturation cleared: {}", + cleared_at.to_rfc3339() + )?; + } + + if let Some(stabilized_at) = stabilized { + writeln!(f, "Recovery stabilized: {}", stabilized_at.to_rfc3339())?; + } + + if let Some(last_dispatch_at) = self.daemon_activity.last_dispatch_at.as_ref() { + writeln!( + f, + "Last daemon dispatch: {} routed / {} deferred across {} lead(s) @ {}", + self.daemon_activity.last_dispatch_routed, + self.daemon_activity.last_dispatch_deferred, + self.daemon_activity.last_dispatch_leads, + last_dispatch_at.to_rfc3339() + )?; + } + + if stabilized.is_none() { + if let Some(last_recovery_dispatch_at) = + self.daemon_activity.last_recovery_dispatch_at.as_ref() + { + writeln!( + f, + "Last daemon recovery dispatch: {} handoff(s) across {} lead(s) @ {}", + self.daemon_activity.last_recovery_dispatch_routed, + self.daemon_activity.last_recovery_dispatch_leads, + last_recovery_dispatch_at.to_rfc3339() + )?; + } + + if let Some(last_rebalance_at) = self.daemon_activity.last_rebalance_at.as_ref() { + writeln!( + f, + "Last daemon rebalance: {} handoff(s) across {} lead(s) @ {}", + self.daemon_activity.last_rebalance_rerouted, + self.daemon_activity.last_rebalance_leads, + last_rebalance_at.to_rfc3339() + )?; + } + } + + Ok(()) + } +} + fn session_state_label(state: &SessionState) -> &'static str { match state { SessionState::Pending => "Pending", @@ -1283,6 +1411,23 @@ mod tests { } } + fn build_daemon_activity() -> super::super::store::DaemonActivity { + let now = Utc::now(); + super::super::store::DaemonActivity { + last_dispatch_at: Some(now), + last_dispatch_routed: 3, + last_dispatch_deferred: 1, + last_dispatch_leads: 2, + chronic_saturation_streak: 2, + last_recovery_dispatch_at: Some(now - Duration::seconds(5)), + last_recovery_dispatch_routed: 2, + last_recovery_dispatch_leads: 1, + last_rebalance_at: Some(now - Duration::seconds(2)), + last_rebalance_rerouted: 0, + last_rebalance_leads: 1, + } + } + fn init_git_repo(path: &Path) -> Result<()> { fs::create_dir_all(path)?; run_git(path, ["init", "-q"])?; @@ -2461,4 +2606,84 @@ mod tests { Ok(()) } + + #[test] + fn coordination_status_display_surfaces_mode_and_activity() { + let status = CoordinationStatus { + backlog_leads: 2, + backlog_messages: 5, + absorbable_sessions: 1, + saturated_sessions: 1, + auto_dispatch_enabled: true, + auto_dispatch_limit_per_session: 4, + daemon_activity: build_daemon_activity(), + }; + + let rendered = status.to_string(); + assert!( + rendered.contains( + "Global handoff backlog: 2 lead(s) / 5 handoff(s) [1 absorbable, 1 saturated]" + ) + ); + assert!(rendered.contains("Auto-dispatch: on @ 4/lead")); + assert!(rendered.contains("Coordination mode: rebalance-first (chronic saturation)")); + assert!(rendered.contains("Chronic saturation streak: 2 cycle(s)")); + assert!(rendered.contains("Last daemon dispatch: 3 routed / 1 deferred across 2 lead(s)")); + assert!(rendered.contains("Last daemon recovery dispatch: 2 handoff(s) across 1 lead(s)")); + assert!(rendered.contains("Last daemon rebalance: 0 handoff(s) across 1 lead(s)")); + } + + #[test] + fn coordination_status_summarizes_real_handoff_backlog() -> Result<()> { + let tempdir = TestDir::new("manager-coordination-status")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = Config { + max_parallel_sessions: 1, + ..build_config(tempdir.path()) + }; + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&build_session("source", SessionState::Running, now))?; + db.insert_session(&build_session("lead-a", SessionState::Running, now))?; + db.insert_session(&build_session("lead-b", SessionState::Running, now))?; + db.insert_session(&build_session( + "delegate-b", + SessionState::Idle, + now - Duration::seconds(1), + ))?; + + db.send_message( + "source", + "lead-a", + "{\"task\":\"clear docs\",\"context\":\"incoming\"}", + "task_handoff", + )?; + db.send_message( + "source", + "lead-b", + "{\"task\":\"review queue\",\"context\":\"incoming\"}", + "task_handoff", + )?; + db.send_message( + "lead-b", + "delegate-b", + "{\"task\":\"delegate queue\",\"context\":\"routed\"}", + "task_handoff", + )?; + + db.record_daemon_dispatch_pass(1, 1, 2)?; + + let status = get_coordination_status(&db, &cfg)?; + assert_eq!(status.backlog_leads, 3); + assert_eq!(status.backlog_messages, 3); + assert_eq!(status.absorbable_sessions, 2); + assert_eq!(status.saturated_sessions, 1); + assert_eq!(status.daemon_activity.last_dispatch_routed, 1); + assert_eq!(status.daemon_activity.last_dispatch_deferred, 1); + + Ok(()) + } } From 53d8cee6f87a02100d9cffb1297600cb46c0a2fd Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:15:21 -0700 Subject: [PATCH 027/459] feat: add ecc2 coordination status json output --- ecc2/src/main.rs | 60 ++++++++++++++++++++++++++++++++++--- ecc2/src/session/manager.rs | 2 ++ ecc2/src/session/store.rs | 3 +- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 70f469b4..e6a36e76 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -103,7 +103,11 @@ enum Commands { lead_limit: usize, }, /// Show global coordination, backlog, and daemon policy status - CoordinationStatus, + CoordinationStatus { + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Rebalance unread handoffs across lead teams with backed-up delegates RebalanceAll { /// Agent type for routed delegates @@ -460,9 +464,9 @@ async fn main() -> Result<()> { ); } } - Some(Commands::CoordinationStatus) => { + Some(Commands::CoordinationStatus { json }) => { let status = session::manager::get_coordination_status(&db, &cfg)?; - println!("{status}"); + println!("{}", format_coordination_status(&status, json)?); } Some(Commands::RebalanceAll { agent, @@ -670,6 +674,17 @@ fn short_session(session_id: &str) -> String { session_id.chars().take(8).collect() } +fn format_coordination_status( + status: &session::manager::CoordinationStatus, + json: bool, +) -> Result { + if json { + return Ok(serde_json::to_string_pretty(status)?); + } + + Ok(status.to_string()) +} + fn send_handoff_message( db: &session::store::StateStore, from_id: &str, @@ -965,11 +980,48 @@ mod tests { .expect("coordination-status should parse"); match cli.command { - Some(Commands::CoordinationStatus) => {} + Some(Commands::CoordinationStatus { json }) => assert!(!json), _ => panic!("expected coordination-status subcommand"), } } + #[test] + fn cli_parses_coordination_status_json_flag() { + let cli = Cli::try_parse_from(["ecc", "coordination-status", "--json"]) + .expect("coordination-status --json should parse"); + + match cli.command { + Some(Commands::CoordinationStatus { json }) => assert!(json), + _ => panic!("expected coordination-status subcommand"), + } + } + + #[test] + fn format_coordination_status_emits_json() { + let status = session::manager::CoordinationStatus { + backlog_leads: 2, + backlog_messages: 5, + absorbable_sessions: 1, + saturated_sessions: 1, + auto_dispatch_enabled: true, + auto_dispatch_limit_per_session: 4, + daemon_activity: session::store::DaemonActivity { + last_dispatch_routed: 3, + last_dispatch_deferred: 1, + last_dispatch_leads: 2, + ..Default::default() + }, + }; + + let rendered = + format_coordination_status(&status, true).expect("json formatting should succeed"); + let value: serde_json::Value = + serde_json::from_str(&rendered).expect("valid json should be emitted"); + assert_eq!(value["backlog_leads"], 2); + assert_eq!(value["backlog_messages"], 5); + assert_eq!(value["daemon_activity"]["last_dispatch_routed"], 3); + } + #[test] fn cli_parses_rebalance_team_command() { let cli = Cli::try_parse_from([ diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 2d214141..90f924e7 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use serde::Serialize; use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::path::{Path, PathBuf}; @@ -1094,6 +1095,7 @@ pub struct CoordinateBacklogOutcome { pub remaining_saturated_sessions: usize, } +#[derive(Debug, Clone, Serialize)] pub struct CoordinationStatus { pub backlog_leads: usize, pub backlog_messages: usize, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index ecbf5081..cc1a73ff 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; +use serde::Serialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -13,7 +14,7 @@ pub struct StateStore { conn: Connection, } -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, Serialize)] pub struct DaemonActivity { pub last_dispatch_at: Option>, pub last_dispatch_routed: usize, From da4c7791fefb40569ca72a833f7f20475ba6ba58 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:16:45 -0700 Subject: [PATCH 028/459] feat: add ecc2 coordination status health checks --- ecc2/src/main.rs | 70 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index e6a36e76..2f17ea27 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -107,6 +107,9 @@ enum Commands { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, + /// Return a non-zero exit code when backlog or saturation needs attention + #[arg(long)] + check: bool, }, /// Rebalance unread handoffs across lead teams with backed-up delegates RebalanceAll { @@ -464,9 +467,12 @@ async fn main() -> Result<()> { ); } } - Some(Commands::CoordinationStatus { json }) => { + Some(Commands::CoordinationStatus { json, check }) => { let status = session::manager::get_coordination_status(&db, &cfg)?; println!("{}", format_coordination_status(&status, json)?); + if check { + std::process::exit(coordination_status_exit_code(&status)); + } } Some(Commands::RebalanceAll { agent, @@ -685,6 +691,16 @@ fn format_coordination_status( Ok(status.to_string()) } +fn coordination_status_exit_code(status: &session::manager::CoordinationStatus) -> i32 { + if status.daemon_activity.operator_escalation_required() || status.saturated_sessions > 0 { + 2 + } else if status.backlog_messages > 0 { + 1 + } else { + 0 + } +} + fn send_handoff_message( db: &session::store::StateStore, from_id: &str, @@ -980,7 +996,10 @@ mod tests { .expect("coordination-status should parse"); match cli.command { - Some(Commands::CoordinationStatus { json }) => assert!(!json), + Some(Commands::CoordinationStatus { json, check }) => { + assert!(!json); + assert!(!check); + } _ => panic!("expected coordination-status subcommand"), } } @@ -991,7 +1010,24 @@ mod tests { .expect("coordination-status --json should parse"); match cli.command { - Some(Commands::CoordinationStatus { json }) => assert!(json), + Some(Commands::CoordinationStatus { json, check }) => { + assert!(json); + assert!(!check); + } + _ => panic!("expected coordination-status subcommand"), + } + } + + #[test] + fn cli_parses_coordination_status_check_flag() { + let cli = Cli::try_parse_from(["ecc", "coordination-status", "--check"]) + .expect("coordination-status --check should parse"); + + match cli.command { + Some(Commands::CoordinationStatus { json, check }) => { + assert!(!json); + assert!(check); + } _ => panic!("expected coordination-status subcommand"), } } @@ -1022,6 +1058,34 @@ mod tests { assert_eq!(value["daemon_activity"]["last_dispatch_routed"], 3); } + #[test] + fn coordination_status_exit_codes_reflect_pressure() { + let clear = session::manager::CoordinationStatus { + backlog_leads: 0, + backlog_messages: 0, + absorbable_sessions: 0, + saturated_sessions: 0, + auto_dispatch_enabled: false, + auto_dispatch_limit_per_session: 5, + daemon_activity: Default::default(), + }; + assert_eq!(coordination_status_exit_code(&clear), 0); + + let absorbable = session::manager::CoordinationStatus { + backlog_messages: 2, + backlog_leads: 1, + absorbable_sessions: 1, + ..clear.clone() + }; + assert_eq!(coordination_status_exit_code(&absorbable), 1); + + let saturated = session::manager::CoordinationStatus { + saturated_sessions: 1, + ..absorbable + }; + assert_eq!(coordination_status_exit_code(&saturated), 2); + } + #[test] fn cli_parses_rebalance_team_command() { let cli = Cli::try_parse_from([ From bcf8d0617e558b92a51546a982fc471b8320ddc6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:19:24 -0700 Subject: [PATCH 029/459] feat: add ecc2 coordination status health metadata --- ecc2/src/main.rs | 19 ++++++--- ecc2/src/session/manager.rs | 84 ++++++++++++++++++++++++++++++++----- 2 files changed, 87 insertions(+), 16 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 2f17ea27..aef8bd09 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -692,12 +692,11 @@ fn format_coordination_status( } fn coordination_status_exit_code(status: &session::manager::CoordinationStatus) -> i32 { - if status.daemon_activity.operator_escalation_required() || status.saturated_sessions > 0 { - 2 - } else if status.backlog_messages > 0 { - 1 - } else { - 0 + match status.health { + session::manager::CoordinationHealth::Healthy => 0, + session::manager::CoordinationHealth::BacklogAbsorbable => 1, + session::manager::CoordinationHealth::Saturated + | session::manager::CoordinationHealth::EscalationRequired => 2, } } @@ -1039,6 +1038,9 @@ mod tests { backlog_messages: 5, absorbable_sessions: 1, saturated_sessions: 1, + mode: session::manager::CoordinationMode::RebalanceFirstChronicSaturation, + health: session::manager::CoordinationHealth::Saturated, + operator_escalation_required: false, auto_dispatch_enabled: true, auto_dispatch_limit_per_session: 4, daemon_activity: session::store::DaemonActivity { @@ -1065,6 +1067,9 @@ mod tests { backlog_messages: 0, absorbable_sessions: 0, saturated_sessions: 0, + mode: session::manager::CoordinationMode::DispatchFirst, + health: session::manager::CoordinationHealth::Healthy, + operator_escalation_required: false, auto_dispatch_enabled: false, auto_dispatch_limit_per_session: 5, daemon_activity: Default::default(), @@ -1075,12 +1080,14 @@ mod tests { backlog_messages: 2, backlog_leads: 1, absorbable_sessions: 1, + health: session::manager::CoordinationHealth::BacklogAbsorbable, ..clear.clone() }; assert_eq!(coordination_status_exit_code(&absorbable), 1); let saturated = session::manager::CoordinationStatus { saturated_sessions: 1, + health: session::manager::CoordinationHealth::Saturated, ..absorbable }; assert_eq!(coordination_status_exit_code(&saturated), 2); diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 90f924e7..f8e62131 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1101,11 +1101,32 @@ pub struct CoordinationStatus { pub backlog_messages: usize, pub absorbable_sessions: usize, pub saturated_sessions: usize, + pub mode: CoordinationMode, + pub health: CoordinationHealth, + pub operator_escalation_required: bool, pub auto_dispatch_enabled: bool, pub auto_dispatch_limit_per_session: usize, pub daemon_activity: super::store::DaemonActivity, } +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CoordinationMode { + DispatchFirst, + DispatchFirstStabilized, + RebalanceFirstChronicSaturation, + RebalanceCooloffChronicSaturation, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CoordinationHealth { + Healthy, + BacklogAbsorbable, + Saturated, + EscalationRequired, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssignmentAction { Spawned, @@ -1118,6 +1139,34 @@ pub fn assignment_action_routes_work(action: AssignmentAction) -> bool { !matches!(action, AssignmentAction::DeferredSaturated) } +fn coordination_mode(activity: &super::store::DaemonActivity) -> CoordinationMode { + if activity.dispatch_cooloff_active() { + CoordinationMode::RebalanceCooloffChronicSaturation + } else if activity.prefers_rebalance_first() { + CoordinationMode::RebalanceFirstChronicSaturation + } else if activity.stabilized_after_recovery_at().is_some() { + CoordinationMode::DispatchFirstStabilized + } else { + CoordinationMode::DispatchFirst + } +} + +fn coordination_health( + backlog_messages: usize, + saturated_sessions: usize, + activity: &super::store::DaemonActivity, +) -> CoordinationHealth { + if activity.operator_escalation_required() { + CoordinationHealth::EscalationRequired + } else if saturated_sessions > 0 { + CoordinationHealth::Saturated + } else if backlog_messages > 0 { + CoordinationHealth::BacklogAbsorbable + } else { + CoordinationHealth::Healthy + } +} + pub fn get_coordination_status(db: &StateStore, cfg: &Config) -> Result { let targets = db.unread_task_handoff_targets(db.list_sessions()?.len().max(1))?; let pressure = summarize_backlog_pressure(db, cfg, &cfg.default_agent, &targets)?; @@ -1125,15 +1174,23 @@ pub fn get_coordination_status(db: &StateStore, cfg: &Config) -> Result(); + let daemon_activity = db.daemon_activity()?; Ok(CoordinationStatus { backlog_leads: targets.len(), backlog_messages, absorbable_sessions: pressure.absorbable_sessions, saturated_sessions: pressure.saturated_sessions, + mode: coordination_mode(&daemon_activity), + health: coordination_health( + backlog_messages, + pressure.saturated_sessions, + &daemon_activity, + ), + operator_escalation_required: daemon_activity.operator_escalation_required(), auto_dispatch_enabled: cfg.auto_dispatch_unread_handoffs, auto_dispatch_limit_per_session: cfg.auto_dispatch_limit_per_session, - daemon_activity: db.daemon_activity()?, + daemon_activity, }) } @@ -1235,14 +1292,15 @@ impl fmt::Display for TeamStatus { impl fmt::Display for CoordinationStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let stabilized = self.daemon_activity.stabilized_after_recovery_at(); - let mode = if self.daemon_activity.dispatch_cooloff_active() { - "rebalance-cooloff (chronic saturation)" - } else if self.daemon_activity.prefers_rebalance_first() { - "rebalance-first (chronic saturation)" - } else if stabilized.is_some() { - "dispatch-first (stabilized)" - } else { - "dispatch-first" + let mode = match self.mode { + CoordinationMode::DispatchFirst => "dispatch-first", + CoordinationMode::DispatchFirstStabilized => "dispatch-first (stabilized)", + CoordinationMode::RebalanceFirstChronicSaturation => { + "rebalance-first (chronic saturation)" + } + CoordinationMode::RebalanceCooloffChronicSaturation => { + "rebalance-cooloff (chronic saturation)" + } }; writeln!( @@ -1273,7 +1331,7 @@ impl fmt::Display for CoordinationStatus { )?; } - if self.daemon_activity.operator_escalation_required() { + if self.operator_escalation_required { writeln!( f, "Operator escalation: chronic saturation is not clearing" @@ -2616,6 +2674,9 @@ mod tests { backlog_messages: 5, absorbable_sessions: 1, saturated_sessions: 1, + mode: CoordinationMode::RebalanceFirstChronicSaturation, + health: CoordinationHealth::Saturated, + operator_escalation_required: false, auto_dispatch_enabled: true, auto_dispatch_limit_per_session: 4, daemon_activity: build_daemon_activity(), @@ -2683,6 +2744,9 @@ mod tests { assert_eq!(status.backlog_messages, 3); assert_eq!(status.absorbable_sessions, 2); assert_eq!(status.saturated_sessions, 1); + assert_eq!(status.mode, CoordinationMode::RebalanceFirstChronicSaturation); + assert_eq!(status.health, CoordinationHealth::Saturated); + assert!(!status.operator_escalation_required); assert_eq!(status.daemon_activity.last_dispatch_routed, 1); assert_eq!(status.daemon_activity.last_dispatch_deferred, 1); From d738089e3ea1d7770b6ef707025af1dc9c37a6e0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:22:02 -0700 Subject: [PATCH 030/459] feat: add ecc2 looping backlog coordination --- ecc2/src/main.rs | 187 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 139 insertions(+), 48 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index aef8bd09..f568abf7 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -101,6 +101,12 @@ enum Commands { /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, + /// Keep coordinating until the backlog is healthy, saturated, or max passes is reached + #[arg(long)] + until_healthy: bool, + /// Maximum coordination passes when using --until-healthy + #[arg(long, default_value_t = 5)] + max_passes: usize, }, /// Show global coordination, backlog, and daemon policy status CoordinationStatus { @@ -414,57 +420,57 @@ async fn main() -> Result<()> { agent, worktree: use_worktree, lead_limit, + until_healthy, + max_passes, }) => { - let outcome = session::manager::coordinate_backlog( - &db, - &cfg, - &agent, - use_worktree, - lead_limit, - ) - .await?; - let total_processed: usize = outcome - .dispatched - .iter() - .map(|dispatch| dispatch.routed.len()) - .sum(); - let total_routed: usize = outcome - .dispatched - .iter() - .map(|dispatch| { - dispatch - .routed - .iter() - .filter(|item| session::manager::assignment_action_routes_work(item.action)) - .count() - }) - .sum(); - let total_deferred = total_processed.saturating_sub(total_routed); - let total_rerouted: usize = outcome - .rebalanced - .iter() - .map(|rebalance| rebalance.rerouted.len()) - .sum(); - - if total_routed == 0 - && total_rerouted == 0 - && outcome.remaining_backlog_sessions == 0 - { - println!("Backlog already clear"); + let pass_budget = if until_healthy { + max_passes.max(1) } else { - println!( - "Coordinated backlog: processed {} handoff(s) across {} lead(s) ({} routed, {} deferred); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s) [{} absorbable, {} saturated]", - total_processed, - outcome.dispatched.len(), - total_routed, - total_deferred, - total_rerouted, - outcome.rebalanced.len(), - outcome.remaining_backlog_messages, - outcome.remaining_backlog_sessions, - outcome.remaining_absorbable_sessions, - outcome.remaining_saturated_sessions + 1 + }; + let mut final_status = None; + + for pass in 1..=pass_budget { + let outcome = session::manager::coordinate_backlog( + &db, + &cfg, + &agent, + use_worktree, + lead_limit, + ) + .await?; + let summary = summarize_coordinate_backlog(&outcome); + + if pass_budget > 1 { + println!("Pass {pass}/{pass_budget}: {summary}"); + } else { + println!("{summary}"); + } + + let status = session::manager::get_coordination_status(&db, &cfg)?; + let should_stop = matches!( + status.health, + session::manager::CoordinationHealth::Healthy + | session::manager::CoordinationHealth::Saturated + | session::manager::CoordinationHealth::EscalationRequired ); + final_status = Some(status); + + if should_stop { + break; + } + } + + if pass_budget > 1 { + if let Some(status) = final_status { + println!( + "Final coordination health: {:?} | mode {:?} | backlog {} handoff(s) across {} lead(s)", + status.health, + status.mode, + status.backlog_messages, + status.backlog_leads + ); + } } } Some(Commands::CoordinationStatus { json, check }) => { @@ -691,6 +697,49 @@ fn format_coordination_status( Ok(status.to_string()) } +fn summarize_coordinate_backlog(outcome: &session::manager::CoordinateBacklogOutcome) -> String { + let total_processed: usize = outcome + .dispatched + .iter() + .map(|dispatch| dispatch.routed.len()) + .sum(); + let total_routed: usize = outcome + .dispatched + .iter() + .map(|dispatch| { + dispatch + .routed + .iter() + .filter(|item| session::manager::assignment_action_routes_work(item.action)) + .count() + }) + .sum(); + let total_deferred = total_processed.saturating_sub(total_routed); + let total_rerouted: usize = outcome + .rebalanced + .iter() + .map(|rebalance| rebalance.rerouted.len()) + .sum(); + + if total_routed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { + "Backlog already clear".to_string() + } else { + format!( + "Coordinated backlog: processed {} handoff(s) across {} lead(s) ({} routed, {} deferred); rebalanced {} handoff(s) across {} lead(s); remaining {} handoff(s) across {} session(s) [{} absorbable, {} saturated]", + total_processed, + outcome.dispatched.len(), + total_routed, + total_deferred, + total_rerouted, + outcome.rebalanced.len(), + outcome.remaining_backlog_messages, + outcome.remaining_backlog_sessions, + outcome.remaining_absorbable_sessions, + outcome.remaining_saturated_sessions + ) + } +} + fn coordination_status_exit_code(status: &session::manager::CoordinationStatus) -> i32 { match status.health { session::manager::CoordinationHealth::Healthy => 0, @@ -955,10 +1004,38 @@ mod tests { Some(Commands::CoordinateBacklog { agent, lead_limit, + until_healthy, + max_passes, .. }) => { assert_eq!(agent, "claude"); assert_eq!(lead_limit, 7); + assert!(!until_healthy); + assert_eq!(max_passes, 5); + } + _ => panic!("expected coordinate-backlog subcommand"), + } + } + + #[test] + fn cli_parses_coordinate_backlog_until_healthy_flags() { + let cli = Cli::try_parse_from([ + "ecc", + "coordinate-backlog", + "--until-healthy", + "--max-passes", + "3", + ]) + .expect("coordinate-backlog looping flags should parse"); + + match cli.command { + Some(Commands::CoordinateBacklog { + until_healthy, + max_passes, + .. + }) => { + assert!(until_healthy); + assert_eq!(max_passes, 3); } _ => panic!("expected coordinate-backlog subcommand"), } @@ -1093,6 +1170,20 @@ mod tests { assert_eq!(coordination_status_exit_code(&saturated), 2); } + #[test] + fn summarize_coordinate_backlog_reports_clear_state() { + let summary = summarize_coordinate_backlog(&session::manager::CoordinateBacklogOutcome { + dispatched: Vec::new(), + rebalanced: Vec::new(), + remaining_backlog_sessions: 0, + remaining_backlog_messages: 0, + remaining_absorbable_sessions: 0, + remaining_saturated_sessions: 0, + }); + + assert_eq!(summary, "Backlog already clear"); + } + #[test] fn cli_parses_rebalance_team_command() { let cli = Cli::try_parse_from([ From 2b7b71766474d5baf48ab2a50bd9ccaa33dc23da Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:24:32 -0700 Subject: [PATCH 031/459] feat: add ecc2 coordinate backlog json output --- ecc2/src/main.rs | 145 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 136 insertions(+), 9 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index f568abf7..eef5caca 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -7,6 +7,7 @@ mod worktree; use anyhow::Result; use clap::Parser; +use serde::Serialize; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -101,6 +102,9 @@ enum Commands { /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, /// Keep coordinating until the backlog is healthy, saturated, or max passes is reached #[arg(long)] until_healthy: bool, @@ -420,6 +424,7 @@ async fn main() -> Result<()> { agent, worktree: use_worktree, lead_limit, + json, until_healthy, max_passes, }) => { @@ -429,6 +434,7 @@ async fn main() -> Result<()> { 1 }; let mut final_status = None; + let mut pass_summaries = Vec::new(); for pass in 1..=pass_budget { let outcome = session::manager::coordinate_backlog( @@ -439,12 +445,16 @@ async fn main() -> Result<()> { lead_limit, ) .await?; - let summary = summarize_coordinate_backlog(&outcome); + let mut summary = summarize_coordinate_backlog(&outcome); + summary.pass = pass; + pass_summaries.push(summary.clone()); - if pass_budget > 1 { - println!("Pass {pass}/{pass_budget}: {summary}"); - } else { - println!("{summary}"); + if !json { + if pass_budget > 1 { + println!("Pass {pass}/{pass_budget}: {}", summary.message); + } else { + println!("{}", summary.message); + } } let status = session::manager::get_coordination_status(&db, &cfg)?; @@ -461,7 +471,14 @@ async fn main() -> Result<()> { } } - if pass_budget > 1 { + if json { + let payload = CoordinateBacklogRun { + pass_budget, + passes: pass_summaries, + final_status, + }; + println!("{}", serde_json::to_string_pretty(&payload)?); + } else if pass_budget > 1 { if let Some(status) = final_status { println!( "Final coordination health: {:?} | mode {:?} | backlog {} handoff(s) across {} lead(s)", @@ -697,7 +714,32 @@ fn format_coordination_status( Ok(status.to_string()) } -fn summarize_coordinate_backlog(outcome: &session::manager::CoordinateBacklogOutcome) -> String { +#[derive(Debug, Clone, Serialize)] +struct CoordinateBacklogPassSummary { + pass: usize, + processed: usize, + routed: usize, + deferred: usize, + rerouted: usize, + dispatched_leads: usize, + rebalanced_leads: usize, + remaining_backlog_sessions: usize, + remaining_backlog_messages: usize, + remaining_absorbable_sessions: usize, + remaining_saturated_sessions: usize, + message: String, +} + +#[derive(Debug, Clone, Serialize)] +struct CoordinateBacklogRun { + pass_budget: usize, + passes: Vec, + final_status: Option, +} + +fn summarize_coordinate_backlog( + outcome: &session::manager::CoordinateBacklogOutcome, +) -> CoordinateBacklogPassSummary { let total_processed: usize = outcome .dispatched .iter() @@ -721,7 +763,7 @@ fn summarize_coordinate_backlog(outcome: &session::manager::CoordinateBacklogOut .map(|rebalance| rebalance.rerouted.len()) .sum(); - if total_routed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { + let message = if total_routed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { "Backlog already clear".to_string() } else { format!( @@ -737,6 +779,21 @@ fn summarize_coordinate_backlog(outcome: &session::manager::CoordinateBacklogOut outcome.remaining_absorbable_sessions, outcome.remaining_saturated_sessions ) + }; + + CoordinateBacklogPassSummary { + pass: 0, + processed: total_processed, + routed: total_routed, + deferred: total_deferred, + rerouted: total_rerouted, + dispatched_leads: outcome.dispatched.len(), + rebalanced_leads: outcome.rebalanced.len(), + remaining_backlog_sessions: outcome.remaining_backlog_sessions, + remaining_backlog_messages: outcome.remaining_backlog_messages, + remaining_absorbable_sessions: outcome.remaining_absorbable_sessions, + remaining_saturated_sessions: outcome.remaining_saturated_sessions, + message, } } @@ -1030,10 +1087,12 @@ mod tests { match cli.command { Some(Commands::CoordinateBacklog { + json, until_healthy, max_passes, .. }) => { + assert!(!json); assert!(until_healthy); assert_eq!(max_passes, 3); } @@ -1041,6 +1100,26 @@ mod tests { } } + #[test] + fn cli_parses_coordinate_backlog_json_flag() { + let cli = Cli::try_parse_from(["ecc", "coordinate-backlog", "--json"]) + .expect("coordinate-backlog --json should parse"); + + match cli.command { + Some(Commands::CoordinateBacklog { + json, + until_healthy, + max_passes, + .. + }) => { + assert!(json); + assert!(!until_healthy); + assert_eq!(max_passes, 5); + } + _ => panic!("expected coordinate-backlog subcommand"), + } + } + #[test] fn cli_parses_rebalance_all_command() { let cli = Cli::try_parse_from([ @@ -1181,7 +1260,55 @@ mod tests { remaining_saturated_sessions: 0, }); - assert_eq!(summary, "Backlog already clear"); + assert_eq!(summary.message, "Backlog already clear"); + assert_eq!(summary.processed, 0); + assert_eq!(summary.rerouted, 0); + } + + #[test] + fn summarize_coordinate_backlog_structures_counts() { + let summary = summarize_coordinate_backlog(&session::manager::CoordinateBacklogOutcome { + dispatched: vec![session::manager::LeadDispatchOutcome { + lead_session_id: "lead".into(), + unread_count: 2, + routed: vec![ + session::manager::InboxDrainOutcome { + message_id: 1, + task: "one".into(), + session_id: "a".into(), + action: session::manager::AssignmentAction::Spawned, + }, + session::manager::InboxDrainOutcome { + message_id: 2, + task: "two".into(), + session_id: "lead".into(), + action: session::manager::AssignmentAction::DeferredSaturated, + }, + ], + }], + rebalanced: vec![session::manager::LeadRebalanceOutcome { + lead_session_id: "lead".into(), + rerouted: vec![session::manager::RebalanceOutcome { + from_session_id: "a".into(), + message_id: 3, + task: "three".into(), + session_id: "b".into(), + action: session::manager::AssignmentAction::ReusedIdle, + }], + }], + remaining_backlog_sessions: 1, + remaining_backlog_messages: 2, + remaining_absorbable_sessions: 1, + remaining_saturated_sessions: 0, + }); + + assert_eq!(summary.processed, 2); + assert_eq!(summary.routed, 1); + assert_eq!(summary.deferred, 1); + assert_eq!(summary.rerouted, 1); + assert_eq!(summary.dispatched_leads, 1); + assert_eq!(summary.rebalanced_leads, 1); + assert_eq!(summary.remaining_backlog_messages, 2); } #[test] From dc12e902b1c81060e445c700847d1372934e93f4 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:26:45 -0700 Subject: [PATCH 032/459] feat: add ecc2 coordinate backlog health checks --- ecc2/src/main.rs | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index eef5caca..400f42f8 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -105,6 +105,9 @@ enum Commands { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, + /// Return a non-zero exit code from the final coordination health + #[arg(long)] + check: bool, /// Keep coordinating until the backlog is healthy, saturated, or max passes is reached #[arg(long)] until_healthy: bool, @@ -425,6 +428,7 @@ async fn main() -> Result<()> { worktree: use_worktree, lead_limit, json, + check, until_healthy, max_passes, }) => { @@ -475,11 +479,11 @@ async fn main() -> Result<()> { let payload = CoordinateBacklogRun { pass_budget, passes: pass_summaries, - final_status, + final_status: final_status.clone(), }; println!("{}", serde_json::to_string_pretty(&payload)?); } else if pass_budget > 1 { - if let Some(status) = final_status { + if let Some(status) = final_status.as_ref() { println!( "Final coordination health: {:?} | mode {:?} | backlog {} handoff(s) across {} lead(s)", status.health, @@ -489,6 +493,14 @@ async fn main() -> Result<()> { ); } } + + if check { + let exit_code = final_status + .as_ref() + .map(coordination_status_exit_code) + .unwrap_or(0); + std::process::exit(exit_code); + } } Some(Commands::CoordinationStatus { json, check }) => { let status = session::manager::get_coordination_status(&db, &cfg)?; @@ -1061,12 +1073,14 @@ mod tests { Some(Commands::CoordinateBacklog { agent, lead_limit, + check, until_healthy, max_passes, .. }) => { assert_eq!(agent, "claude"); assert_eq!(lead_limit, 7); + assert!(!check); assert!(!until_healthy); assert_eq!(max_passes, 5); } @@ -1108,11 +1122,35 @@ mod tests { match cli.command { Some(Commands::CoordinateBacklog { json, + check, until_healthy, max_passes, .. }) => { assert!(json); + assert!(!check); + assert!(!until_healthy); + assert_eq!(max_passes, 5); + } + _ => panic!("expected coordinate-backlog subcommand"), + } + } + + #[test] + fn cli_parses_coordinate_backlog_check_flag() { + let cli = Cli::try_parse_from(["ecc", "coordinate-backlog", "--check"]) + .expect("coordinate-backlog --check should parse"); + + match cli.command { + Some(Commands::CoordinateBacklog { + json, + check, + until_healthy, + max_passes, + .. + }) => { + assert!(!json); + assert!(check); assert!(!until_healthy); assert_eq!(max_passes, 5); } From afb97961e3552bb48db70fa9f98eb718490cb8cf Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:31:11 -0700 Subject: [PATCH 033/459] feat: add ecc2 maintain coordination command --- ecc2/src/main.rs | 266 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 212 insertions(+), 54 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 400f42f8..71150622 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -124,6 +124,27 @@ enum Commands { #[arg(long)] check: bool, }, + /// Coordinate only when backlog pressure actually needs work + MaintainCoordination { + /// Agent type for routed delegates + #[arg(short, long, default_value = "claude")] + agent: String, + /// Create a dedicated worktree if new delegates must be spawned + #[arg(short, long, default_value_t = true)] + worktree: bool, + /// Maximum lead sessions to sweep in one pass + #[arg(long, default_value_t = 10)] + lead_limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + /// Return a non-zero exit code from the final coordination health + #[arg(long)] + check: bool, + /// Maximum coordination passes when maintenance is needed + #[arg(long, default_value_t = 5)] + max_passes: usize, + }, /// Rebalance unread handoffs across lead teams with backed-up delegates RebalanceAll { /// Agent type for routed delegates @@ -437,65 +458,24 @@ async fn main() -> Result<()> { } else { 1 }; - let mut final_status = None; - let mut pass_summaries = Vec::new(); - - for pass in 1..=pass_budget { - let outcome = session::manager::coordinate_backlog( - &db, - &cfg, - &agent, - use_worktree, - lead_limit, - ) - .await?; - let mut summary = summarize_coordinate_backlog(&outcome); - summary.pass = pass; - pass_summaries.push(summary.clone()); - - if !json { - if pass_budget > 1 { - println!("Pass {pass}/{pass_budget}: {}", summary.message); - } else { - println!("{}", summary.message); - } - } - - let status = session::manager::get_coordination_status(&db, &cfg)?; - let should_stop = matches!( - status.health, - session::manager::CoordinationHealth::Healthy - | session::manager::CoordinationHealth::Saturated - | session::manager::CoordinationHealth::EscalationRequired - ); - final_status = Some(status); - - if should_stop { - break; - } - } + let run = run_coordination_loop( + &db, + &cfg, + &agent, + use_worktree, + lead_limit, + pass_budget, + !json, + ) + .await?; if json { - let payload = CoordinateBacklogRun { - pass_budget, - passes: pass_summaries, - final_status: final_status.clone(), - }; - println!("{}", serde_json::to_string_pretty(&payload)?); - } else if pass_budget > 1 { - if let Some(status) = final_status.as_ref() { - println!( - "Final coordination health: {:?} | mode {:?} | backlog {} handoff(s) across {} lead(s)", - status.health, - status.mode, - status.backlog_messages, - status.backlog_leads - ); - } + println!("{}", serde_json::to_string_pretty(&run)?); } if check { - let exit_code = final_status + let exit_code = run + .final_status .as_ref() .map(coordination_status_exit_code) .unwrap_or(0); @@ -509,6 +489,55 @@ async fn main() -> Result<()> { std::process::exit(coordination_status_exit_code(&status)); } } + Some(Commands::MaintainCoordination { + agent, + worktree: use_worktree, + lead_limit, + json, + check, + max_passes, + }) => { + let initial_status = session::manager::get_coordination_status(&db, &cfg)?; + let run = if matches!( + initial_status.health, + session::manager::CoordinationHealth::Healthy + ) { + None + } else { + Some( + run_coordination_loop( + &db, + &cfg, + &agent, + use_worktree, + lead_limit, + max_passes.max(1), + !json, + ) + .await?, + ) + }; + let final_status = run + .as_ref() + .and_then(|run| run.final_status.clone()) + .unwrap_or_else(|| initial_status.clone()); + + if json { + let payload = MaintainCoordinationRun { + skipped: run.is_none(), + initial_status, + run, + final_status: final_status.clone(), + }; + println!("{}", serde_json::to_string_pretty(&payload)?); + } else if run.is_none() { + println!("Coordination already healthy"); + } + + if check { + std::process::exit(coordination_status_exit_code(&final_status)); + } + } Some(Commands::RebalanceAll { agent, worktree: use_worktree, @@ -726,6 +755,65 @@ fn format_coordination_status( Ok(status.to_string()) } +async fn run_coordination_loop( + db: &session::store::StateStore, + cfg: &config::Config, + agent: &str, + use_worktree: bool, + lead_limit: usize, + pass_budget: usize, + emit_progress: bool, +) -> Result { + let mut final_status = None; + let mut pass_summaries = Vec::new(); + + for pass in 1..=pass_budget.max(1) { + let outcome = + session::manager::coordinate_backlog(db, cfg, agent, use_worktree, lead_limit).await?; + let mut summary = summarize_coordinate_backlog(&outcome); + summary.pass = pass; + pass_summaries.push(summary.clone()); + + if emit_progress { + if pass_budget > 1 { + println!("Pass {pass}/{pass_budget}: {}", summary.message); + } else { + println!("{}", summary.message); + } + } + + let status = session::manager::get_coordination_status(db, cfg)?; + let should_stop = matches!( + status.health, + session::manager::CoordinationHealth::Healthy + | session::manager::CoordinationHealth::Saturated + | session::manager::CoordinationHealth::EscalationRequired + ); + final_status = Some(status); + + if should_stop { + break; + } + } + + let run = CoordinateBacklogRun { + pass_budget, + passes: pass_summaries, + final_status, + }; + + if emit_progress && pass_budget > 1 { + if let Some(status) = run.final_status.as_ref() { + println!( + "Final coordination health: {:?} | mode {:?} | backlog {} handoff(s) across {} lead(s)", + status.health, status.mode, status.backlog_messages, status.backlog_leads + ); + } + } + + Ok(run) +} + #[derive(Debug, Clone, Serialize)] struct CoordinateBacklogPassSummary { pass: usize, @@ -749,6 +837,14 @@ struct CoordinateBacklogRun { final_status: Option, } +#[derive(Debug, Clone, Serialize)] +struct MaintainCoordinationRun { + skipped: bool, + initial_status: session::manager::CoordinationStatus, + run: Option, + final_status: session::manager::CoordinationStatus, +} + fn summarize_coordinate_backlog( outcome: &session::manager::CoordinateBacklogOutcome, ) -> CoordinateBacklogPassSummary { @@ -1225,6 +1321,68 @@ mod tests { } } + #[test] + fn cli_parses_maintain_coordination_command() { + let cli = Cli::try_parse_from(["ecc", "maintain-coordination"]) + .expect("maintain-coordination should parse"); + + match cli.command { + Some(Commands::MaintainCoordination { + agent, + json, + check, + max_passes, + .. + }) => { + assert_eq!(agent, "claude"); + assert!(!json); + assert!(!check); + assert_eq!(max_passes, 5); + } + _ => panic!("expected maintain-coordination subcommand"), + } + } + + #[test] + fn cli_parses_maintain_coordination_json_flag() { + let cli = Cli::try_parse_from(["ecc", "maintain-coordination", "--json"]) + .expect("maintain-coordination --json should parse"); + + match cli.command { + Some(Commands::MaintainCoordination { + json, + check, + max_passes, + .. + }) => { + assert!(json); + assert!(!check); + assert_eq!(max_passes, 5); + } + _ => panic!("expected maintain-coordination subcommand"), + } + } + + #[test] + fn cli_parses_maintain_coordination_check_flag() { + let cli = Cli::try_parse_from(["ecc", "maintain-coordination", "--check"]) + .expect("maintain-coordination --check should parse"); + + match cli.command { + Some(Commands::MaintainCoordination { + json, + check, + max_passes, + .. + }) => { + assert!(!json); + assert!(check); + assert_eq!(max_passes, 5); + } + _ => panic!("expected maintain-coordination subcommand"), + } + } + #[test] fn format_coordination_status_emits_json() { let status = session::manager::CoordinationStatus { From 5070b2d785bc7b7136a221399e1218afd9876b17 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:45:32 -0700 Subject: [PATCH 034/459] feat: add ecc2 worktree file previews --- ecc2/src/tui/dashboard.rs | 47 ++++++++---- ecc2/src/worktree/mod.rs | 153 +++++++++++++++++++++++++++++++++++++- 2 files changed, 185 insertions(+), 15 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c2a2eaf2..9257528b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -7,12 +7,12 @@ use ratatui::{ use std::collections::HashMap; use tokio::sync::broadcast; -use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; +use super::widgets::{BudgetState, TokenMeter, budget_state, format_currency, format_token_count}; use crate::comms; use crate::config::{Config, PaneLayout}; use crate::observability::ToolLogEntry; use crate::session::manager; -use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; +use crate::session::output::{OUTPUT_BUFFER_LIMIT, OutputEvent, OutputLine, SessionOutputStore}; use crate::session::store::{DaemonActivity, StateStore}; use crate::session::{Session, SessionMessage, SessionState}; use crate::worktree; @@ -31,6 +31,7 @@ const MIN_PANE_SIZE_PERCENT: u16 = 20; const MAX_PANE_SIZE_PERCENT: u16 = 80; const PANE_RESIZE_STEP_PERCENT: u16 = 5; const MAX_LOG_ENTRIES: u64 = 12; +const MAX_DIFF_PREVIEW_LINES: usize = 6; pub struct Dashboard { db: StateStore, @@ -51,6 +52,7 @@ pub struct Dashboard { selected_route_preview: Option, logs: Vec, selected_diff_summary: Option, + selected_diff_preview: Vec, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -157,6 +159,7 @@ impl Dashboard { selected_route_preview: None, logs: Vec::new(), selected_diff_summary: None, + selected_diff_preview: Vec::new(), selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -1257,11 +1260,16 @@ impl Dashboard { } fn sync_selected_diff(&mut self) { - self.selected_diff_summary = self + let worktree = self .sessions .get(self.selected_session) - .and_then(|session| session.worktree.as_ref()) - .and_then(|worktree| worktree::diff_summary(worktree).ok().flatten()); + .and_then(|session| session.worktree.as_ref()); + + self.selected_diff_summary = + worktree.and_then(|worktree| worktree::diff_summary(worktree).ok().flatten()); + self.selected_diff_preview = worktree + .and_then(|worktree| worktree::diff_file_preview(worktree, MAX_DIFF_PREVIEW_LINES).ok()) + .unwrap_or_default(); } fn sync_selected_messages(&mut self) { @@ -1653,6 +1661,12 @@ impl Dashboard { if let Some(diff_summary) = self.selected_diff_summary.as_ref() { lines.push(format!("Diff {diff_summary}")); } + if !self.selected_diff_preview.is_empty() { + lines.push("Changed files".to_string()); + for entry in &self.selected_diff_preview { + lines.push(format!("- {entry}")); + } + } } lines.push(format!( @@ -1914,11 +1928,7 @@ impl Dashboard { fn log_field<'a>(&self, value: &'a str) -> &'a str { let trimmed = value.trim(); - if trimmed.is_empty() { - "n/a" - } else { - trimmed - } + if trimmed.is_empty() { "n/a" } else { trimmed } } fn short_timestamp(&self, timestamp: &str) -> String { @@ -2140,7 +2150,7 @@ fn format_duration(duration_secs: u64) -> String { mod tests { use anyhow::Result; use chrono::Utc; - use ratatui::{backend::TestBackend, Terminal}; + use ratatui::{Terminal, backend::TestBackend}; use std::path::PathBuf; use uuid::Uuid; @@ -2212,11 +2222,18 @@ mod tests { }], ); dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string()); + dashboard.selected_diff_preview = vec![ + "Branch M src/main.rs".to_string(), + "Working ?? notes.txt".to_string(), + ]; let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Branch ecc/focus | Base main")); assert!(text.contains("Worktree /tmp/ecc/focus")); assert!(text.contains("Diff 1 file changed, 2 insertions(+)")); + assert!(text.contains("Changed files")); + assert!(text.contains("- Branch M src/main.rs")); + assert!(text.contains("- Working ?? notes.txt")); assert!(text.contains("Last output last useful output")); assert!(text.contains("Needs attention:")); assert!(text.contains("Failed failed-8 | Render dashboard rows")); @@ -2356,7 +2373,8 @@ mod tests { } #[test] - fn selected_session_metrics_text_recommends_operator_escalation_when_chronic_saturation_is_stuck() { + fn selected_session_metrics_text_recommends_operator_escalation_when_chronic_saturation_is_stuck() + { let mut dashboard = test_dashboard( vec![sample_session( "focus-12345678", @@ -2383,7 +2401,9 @@ mod tests { }; let text = dashboard.selected_session_metrics_text(); - assert!(text.contains("Operator escalation recommended: chronic saturation is not clearing")); + assert!( + text.contains("Operator escalation recommended: chronic saturation is not clearing") + ); } #[test] @@ -3051,6 +3071,7 @@ mod tests { selected_route_preview: None, logs: Vec::new(), selected_diff_summary: None, + selected_diff_preview: Vec::new(), selected_pane: Pane::Sessions, selected_session, show_help: false, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 61896666..5b77b07b 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -107,6 +107,35 @@ pub fn diff_summary(worktree: &WorktreeInfo) -> Result> { } } +pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result> { + let mut preview = Vec::new(); + let base_ref = format!("{}...HEAD", worktree.base_branch); + + let committed = git_diff_name_status(&worktree.path, &[&base_ref])?; + if !committed.is_empty() { + preview.extend( + committed + .into_iter() + .map(|entry| format!("Branch {entry}")) + .take(limit.saturating_sub(preview.len())), + ); + } + + if preview.len() < limit { + let working = git_status_short(&worktree.path)?; + if !working.is_empty() { + preview.extend( + working + .into_iter() + .map(|entry| format!("Working {entry}")) + .take(limit.saturating_sub(preview.len())), + ); + } + } + + Ok(preview) +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -137,6 +166,60 @@ fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result Result> { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .arg("--name-status"); + command.args(extra_args); + + let output = command + .output() + .context("Failed to generate worktree diff file preview")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Worktree diff file preview warning for {}: {stderr}", + worktree_path.display() + ); + return Ok(Vec::new()); + } + + Ok(parse_nonempty_lines(&output.stdout)) +} + +fn git_status_short(worktree_path: &Path) -> Result> { + let output = Command::new("git") + .arg("-C") + .arg(worktree_path) + .args(["status", "--short"]) + .output() + .context("Failed to generate worktree status preview")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Worktree status preview warning for {}: {stderr}", + worktree_path.display() + ); + return Ok(Vec::new()); + } + + Ok(parse_nonempty_lines(&output.stdout)) +} + +fn parse_nonempty_lines(stdout: &[u8]) -> Vec { + String::from_utf8_lossy(stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + fn get_current_branch(repo_root: &Path) -> Result { let output = Command::new("git") .arg("-C") @@ -157,7 +240,11 @@ mod tests { use uuid::Uuid; fn run_git(repo: &Path, args: &[&str]) -> Result<()> { - let output = Command::new("git").arg("-C").arg(repo).args(args).output()?; + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output()?; if !output.status.success() { anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)); } @@ -196,7 +283,10 @@ mod tests { base_branch: "main".to_string(), }; - assert_eq!(diff_summary(&info)?, Some("Clean relative to main".to_string())); + assert_eq!( + diff_summary(&info)?, + Some("Clean relative to main".to_string()) + ); fs::write(worktree_dir.join("README.md"), "hello\nmore\n")?; let dirty = diff_summary(&info)?.expect("dirty summary"); @@ -212,4 +302,63 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn diff_file_preview_reports_branch_and_working_tree_files() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-preview-{}", Uuid::new_v4())); + let repo = root.join("repo"); + fs::create_dir_all(&repo)?; + + run_git(&repo, &["init", "-b", "main"])?; + run_git(&repo, &["config", "user.email", "ecc@example.com"])?; + run_git(&repo, &["config", "user.name", "ECC"])?; + fs::write(repo.join("README.md"), "hello\n")?; + run_git(&repo, &["add", "README.md"])?; + run_git(&repo, &["commit", "-m", "init"])?; + + let worktree_dir = root.join("wt-1"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/test", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("src.txt"), "branch\n")?; + run_git(&worktree_dir, &["add", "src.txt"])?; + run_git(&worktree_dir, &["commit", "-m", "branch file"])?; + fs::write(worktree_dir.join("README.md"), "hello\nworking\n")?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/test".to_string(), + base_branch: "main".to_string(), + }; + + let preview = diff_file_preview(&info, 6)?; + assert!( + preview + .iter() + .any(|line| line.contains("Branch A") && line.contains("src.txt")) + ); + assert!( + preview + .iter() + .any(|line| line.contains("Working M") && line.contains("README.md")) + ); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } } From 87d520f0b1290f398ea9fee319801f209a4da552 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:49:35 -0700 Subject: [PATCH 035/459] feat: add ecc2 diff viewer mode --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 128 +++++++++++++++++++++++++++++++++----- ecc2/src/worktree/mod.rs | 113 +++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 14 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index c9c90fc9..7495bd89 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -45,6 +45,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await, (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, + (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), (_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1), (_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 9257528b..05540ae2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -32,6 +32,7 @@ const MAX_PANE_SIZE_PERCENT: u16 = 80; const PANE_RESIZE_STEP_PERCENT: u16 = 5; const MAX_LOG_ENTRIES: u64 = 12; const MAX_DIFF_PREVIEW_LINES: usize = 6; +const MAX_DIFF_PATCH_LINES: usize = 80; pub struct Dashboard { db: StateStore, @@ -53,6 +54,8 @@ pub struct Dashboard { logs: Vec, selected_diff_summary: Option, selected_diff_preview: Vec, + selected_diff_patch: Option, + output_mode: OutputMode, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -85,6 +88,12 @@ enum Pane { Log, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputMode { + SessionOutput, + WorktreeDiff, +} + #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, @@ -160,6 +169,8 @@ impl Dashboard { logs: Vec::new(), selected_diff_summary: None, selected_diff_preview: Vec::new(), + selected_diff_patch: None, + output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -319,27 +330,43 @@ impl Dashboard { fn render_output(&mut self, frame: &mut Frame, area: Rect) { self.sync_output_scroll(area.height.saturating_sub(2) as usize); - let content = if self.sessions.get(self.selected_session).is_some() { - let lines = self.selected_output_lines(); - - if lines.is_empty() { - "Waiting for session output...".to_string() - } else { - lines - .iter() - .map(|line| line.text.as_str()) - .collect::>() - .join("\n") + let (title, content) = if self.sessions.get(self.selected_session).is_some() { + match self.output_mode { + OutputMode::SessionOutput => { + let lines = self.selected_output_lines(); + let content = if lines.is_empty() { + "Waiting for session output...".to_string() + } else { + lines.iter().map(|line| line.text.as_str()).collect::>().join("\n") + }; + (" Output ", content) + } + OutputMode::WorktreeDiff => { + let content = self + .selected_diff_patch + .clone() + .or_else(|| { + self.selected_diff_summary.as_ref().map(|summary| { + format!( + "{summary}\n\nNo patch content to preview yet. The worktree may be clean or only have summary-level changes." + ) + }) + }) + .unwrap_or_else(|| { + "No worktree diff available for the selected session.".to_string() + }); + (" Diff ", content) + } } } else { - "No sessions. Press 'n' to start one.".to_string() + (" Output ", "No sessions. Press 'n' to start one.".to_string()) }; let paragraph = Paragraph::new(content) .block( Block::default() .borders(Borders::ALL) - .title(" Output ") + .title(title) .border_style(self.pane_border_style(Pane::Output)), ) .scroll((self.output_scroll_offset as u16, 0)); @@ -427,7 +454,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 toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [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 toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [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() { @@ -478,6 +505,7 @@ impl Dashboard { " i Drain unread task handoffs from selected lead", " g Auto-dispatch unread handoffs across lead sessions", " G Dispatch then rebalance backlog across lead teams", + " v Toggle selected worktree diff in output pane", " p Toggle daemon auto-dispatch policy and persist config", " ,/. Decrease/increase auto-dispatch limit per lead", " s Stop selected session", @@ -669,6 +697,27 @@ impl Dashboard { self.refresh_logs(); } + pub fn toggle_output_mode(&mut self) { + match self.output_mode { + OutputMode::SessionOutput => { + if self.selected_diff_patch.is_some() || self.selected_diff_summary.is_some() { + self.output_mode = OutputMode::WorktreeDiff; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = 0; + self.set_operator_note("showing selected worktree diff".to_string()); + } else { + self.set_operator_note("no worktree diff for selected session".to_string()); + } + } + OutputMode::WorktreeDiff => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + } + } + pub async fn assign_selected(&mut self) { let Some(source_session) = self.sessions.get(self.selected_session) else { return; @@ -1270,6 +1319,11 @@ impl Dashboard { self.selected_diff_preview = worktree .and_then(|worktree| worktree::diff_file_preview(worktree, MAX_DIFF_PREVIEW_LINES).ok()) .unwrap_or_default(); + self.selected_diff_patch = worktree + .and_then(|worktree| worktree::diff_patch_preview(worktree, MAX_DIFF_PATCH_LINES).ok().flatten()); + if self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_none() { + self.output_mode = OutputMode::SessionOutput; + } } fn sync_selected_messages(&mut self) { @@ -1950,6 +2004,20 @@ impl Dashboard { .collect::>() .join("\n") } + + #[cfg(test)] + fn rendered_output_text(&mut self, width: u16, height: u16) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + terminal.draw(|frame| self.render(frame)).expect("draw"); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::() + } } impl Pane { @@ -2239,6 +2307,36 @@ mod tests { assert!(text.contains("Failed failed-8 | Render dashboard rows")); } + #[test] + fn toggle_output_mode_switches_to_worktree_diff_preview() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.selected_diff_summary = Some("1 file changed".to_string()); + dashboard.selected_diff_patch = Some( + "--- Branch diff vs main ---\ndiff --git a/src/lib.rs b/src/lib.rs\n+hello".to_string(), + ); + + dashboard.toggle_output_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::WorktreeDiff); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected worktree diff") + ); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Diff")); + assert!(rendered.contains("diff --git a/src/lib.rs b/src/lib.rs")); + } + #[test] fn selected_session_metrics_text_includes_team_capacity_summary() { let mut dashboard = test_dashboard( @@ -3072,6 +3170,8 @@ mod tests { logs: Vec::new(), selected_diff_summary: None, selected_diff_preview: Vec::new(), + selected_diff_patch: None, + output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, selected_session, show_help: false, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 5b77b07b..de7edf4a 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -136,6 +136,34 @@ pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result Result> { + let mut remaining = max_lines.max(1); + let mut sections = Vec::new(); + let base_ref = format!("{}...HEAD", worktree.base_branch); + + let committed = git_diff_patch_lines(&worktree.path, &[&base_ref])?; + if !committed.is_empty() && remaining > 0 { + let taken = take_preview_lines(&committed, &mut remaining); + sections.push(format!( + "--- Branch diff vs {} ---\n{}", + worktree.base_branch, + taken.join("\n") + )); + } + + let working = git_diff_patch_lines(&worktree.path, &[])?; + if !working.is_empty() && remaining > 0 { + let taken = take_preview_lines(&working, &mut remaining); + sections.push(format!("--- Working tree diff ---\n{}", taken.join("\n"))); + } + + if sections.is_empty() { + Ok(None) + } else { + Ok(Some(sections.join("\n\n"))) + } +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -191,6 +219,31 @@ fn git_diff_name_status(worktree_path: &Path, extra_args: &[&str]) -> Result Result> { + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .args(["--stat", "--patch", "--find-renames"]); + command.args(extra_args); + + let output = command + .output() + .context("Failed to generate worktree patch preview")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Worktree patch preview warning for {}: {stderr}", + worktree_path.display() + ); + return Ok(Vec::new()); + } + + Ok(parse_nonempty_lines(&output.stdout)) +} + fn git_status_short(worktree_path: &Path) -> Result> { let output = Command::new("git") .arg("-C") @@ -220,6 +273,13 @@ fn parse_nonempty_lines(stdout: &[u8]) -> Vec { .collect() } +fn take_preview_lines(lines: &[String], remaining: &mut usize) -> Vec { + let count = (*remaining).min(lines.len()); + let taken = lines.iter().take(count).cloned().collect::>(); + *remaining = remaining.saturating_sub(count); + taken +} + fn get_current_branch(repo_root: &Path) -> Result { let output = Command::new("git") .arg("-C") @@ -361,4 +421,57 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn diff_patch_preview_reports_branch_and_working_tree_sections() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-patch-{}", Uuid::new_v4())); + let repo = root.join("repo"); + fs::create_dir_all(&repo)?; + + run_git(&repo, &["init", "-b", "main"])?; + run_git(&repo, &["config", "user.email", "ecc@example.com"])?; + run_git(&repo, &["config", "user.name", "ECC"])?; + fs::write(repo.join("README.md"), "hello\n")?; + run_git(&repo, &["add", "README.md"])?; + run_git(&repo, &["commit", "-m", "init"])?; + + let worktree_dir = root.join("wt-1"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/test", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("src.txt"), "branch\n")?; + run_git(&worktree_dir, &["add", "src.txt"])?; + run_git(&worktree_dir, &["commit", "-m", "branch file"])?; + fs::write(worktree_dir.join("README.md"), "hello\nworking\n")?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/test".to_string(), + base_branch: "main".to_string(), + }; + + let preview = diff_patch_preview(&info, 40)?.expect("patch preview"); + assert!(preview.contains("--- Branch diff vs main ---")); + assert!(preview.contains("--- Working tree diff ---")); + assert!(preview.contains("src.txt")); + assert!(preview.contains("README.md")); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } } From dd14888f5fd9c3f6d75d586790b90d652162ac67 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 13:54:31 -0700 Subject: [PATCH 036/459] feat: add ecc2 worktree merge readiness --- ecc2/src/tui/dashboard.rs | 18 ++++ ecc2/src/worktree/mod.rs | 179 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 05540ae2..826a4ac2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -55,6 +55,7 @@ pub struct Dashboard { selected_diff_summary: Option, selected_diff_preview: Vec, selected_diff_patch: Option, + selected_merge_readiness: Option, output_mode: OutputMode, selected_pane: Pane, selected_session: usize, @@ -170,6 +171,7 @@ impl Dashboard { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, selected_session: 0, @@ -1321,6 +1323,8 @@ impl Dashboard { .unwrap_or_default(); self.selected_diff_patch = worktree .and_then(|worktree| worktree::diff_patch_preview(worktree, MAX_DIFF_PATCH_LINES).ok().flatten()); + self.selected_merge_readiness = worktree + .and_then(|worktree| worktree::merge_readiness(worktree).ok()); if self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_none() { self.output_mode = OutputMode::SessionOutput; } @@ -1721,6 +1725,12 @@ impl Dashboard { lines.push(format!("- {entry}")); } } + if let Some(merge_readiness) = self.selected_merge_readiness.as_ref() { + lines.push(merge_readiness.summary.clone()); + for conflict in merge_readiness.conflicts.iter().take(3) { + lines.push(format!("- conflict {conflict}")); + } + } } lines.push(format!( @@ -2294,6 +2304,11 @@ mod tests { "Branch M src/main.rs".to_string(), "Working ?? notes.txt".to_string(), ]; + dashboard.selected_merge_readiness = Some(worktree::MergeReadiness { + status: worktree::MergeReadinessStatus::Conflicted, + summary: "Merge blocked by 1 conflict(s): src/main.rs".to_string(), + conflicts: vec!["src/main.rs".to_string()], + }); let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Branch ecc/focus | Base main")); @@ -2302,6 +2317,8 @@ mod tests { assert!(text.contains("Changed files")); assert!(text.contains("- Branch M src/main.rs")); assert!(text.contains("- Working ?? notes.txt")); + assert!(text.contains("Merge blocked by 1 conflict(s): src/main.rs")); + assert!(text.contains("- conflict src/main.rs")); assert!(text.contains("Last output last useful output")); assert!(text.contains("Needs attention:")); assert!(text.contains("Failed failed-8 | Render dashboard rows")); @@ -3171,6 +3188,7 @@ mod tests { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, selected_session, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index de7edf4a..72cefa8b 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -5,6 +5,19 @@ use std::process::Command; use crate::config::Config; use crate::session::WorktreeInfo; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MergeReadinessStatus { + Ready, + Conflicted, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergeReadiness { + pub status: MergeReadinessStatus, + pub summary: String, + pub conflicts: Vec, +} + /// 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")?; @@ -164,6 +177,57 @@ pub fn diff_patch_preview(worktree: &WorktreeInfo, max_lines: usize) -> Result Result { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["merge-tree", "--write-tree", &worktree.base_branch, &worktree.branch]) + .output() + .context("Failed to generate merge readiness preview")?; + + let merged_output = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let conflicts = merged_output + .lines() + .filter_map(parse_merge_conflict_path) + .collect::>(); + + if output.status.success() { + return Ok(MergeReadiness { + status: MergeReadinessStatus::Ready, + summary: format!("Merge ready into {}", worktree.base_branch), + conflicts: Vec::new(), + }); + } + + if !conflicts.is_empty() { + let conflict_summary = conflicts + .iter() + .take(3) + .cloned() + .collect::>() + .join(", "); + let overflow = conflicts.len().saturating_sub(3); + let detail = if overflow > 0 { + format!("{conflict_summary}, +{overflow} more") + } else { + conflict_summary + }; + + return Ok(MergeReadiness { + status: MergeReadinessStatus::Conflicted, + summary: format!("Merge blocked by {} conflict(s): {detail}", conflicts.len()), + conflicts, + }); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git merge-tree failed: {stderr}"); +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -280,6 +344,18 @@ fn take_preview_lines(lines: &[String], remaining: &mut usize) -> Vec { taken } +fn parse_merge_conflict_path(line: &str) -> Option { + if !line.contains("CONFLICT") { + return None; + } + + line.split(" in ") + .nth(1) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) +} + fn get_current_branch(repo_root: &Path) -> Result { let output = Command::new("git") .arg("-C") @@ -474,4 +550,107 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn merge_readiness_reports_ready_worktree() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-merge-ready-{}", Uuid::new_v4())); + let repo = root.join("repo"); + fs::create_dir_all(&repo)?; + + run_git(&repo, &["init", "-b", "main"])?; + run_git(&repo, &["config", "user.email", "ecc@example.com"])?; + run_git(&repo, &["config", "user.name", "ECC"])?; + fs::write(repo.join("README.md"), "hello\n")?; + run_git(&repo, &["add", "README.md"])?; + run_git(&repo, &["commit", "-m", "init"])?; + + let worktree_dir = root.join("wt-1"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/test", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("src.txt"), "branch only\n")?; + run_git(&worktree_dir, &["add", "src.txt"])?; + run_git(&worktree_dir, &["commit", "-m", "branch file"])?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/test".to_string(), + base_branch: "main".to_string(), + }; + + let readiness = merge_readiness(&info)?; + assert_eq!(readiness.status, MergeReadinessStatus::Ready); + assert!(readiness.summary.contains("Merge ready into main")); + assert!(readiness.conflicts.is_empty()); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn merge_readiness_reports_conflicted_worktree() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-merge-conflict-{}", Uuid::new_v4())); + let repo = root.join("repo"); + fs::create_dir_all(&repo)?; + + run_git(&repo, &["init", "-b", "main"])?; + run_git(&repo, &["config", "user.email", "ecc@example.com"])?; + run_git(&repo, &["config", "user.name", "ECC"])?; + fs::write(repo.join("README.md"), "hello\n")?; + run_git(&repo, &["add", "README.md"])?; + run_git(&repo, &["commit", "-m", "init"])?; + + let worktree_dir = root.join("wt-1"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/test", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("README.md"), "hello\nbranch\n")?; + run_git(&worktree_dir, &["commit", "-am", "branch change"])?; + fs::write(repo.join("README.md"), "hello\nmain\n")?; + run_git(&repo, &["commit", "-am", "main change"])?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/test".to_string(), + base_branch: "main".to_string(), + }; + + let readiness = merge_readiness(&info)?; + assert_eq!(readiness.status, MergeReadinessStatus::Conflicted); + assert!(readiness.summary.contains("Merge blocked by 1 conflict")); + assert_eq!(readiness.conflicts, vec!["README.md".to_string()]); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } } From 10b8471e3c7968d262bbc6ea3a7360f4382640d6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:02:01 -0700 Subject: [PATCH 037/459] feat: add ecc2 worktree status command --- ecc2/src/main.rs | 197 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 71150622..4be2fb96 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -186,6 +186,14 @@ enum Commands { #[arg(long, default_value_t = 2)] depth: usize, }, + /// Show worktree diff and merge-readiness details for a session + WorktreeStatus { + /// Session ID or alias + session_id: Option, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Stop a running session Stop { /// Session ID or alias @@ -629,6 +637,19 @@ async fn main() -> Result<()> { let team = session::manager::get_team_status(&db, &id, depth)?; println!("{team}"); } + Some(Commands::WorktreeStatus { session_id, json }) => { + let id = session_id.unwrap_or_else(|| "latest".to_string()); + let resolved_id = resolve_session_id(&db, &id)?; + let session = db + .get_session(&resolved_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {resolved_id}"))?; + let report = build_worktree_status_report(&session)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_worktree_status_human(&report)); + } + } Some(Commands::Stop { session_id }) => { session::manager::stop_session(&db, &session_id).await?; println!("Session stopped: {session_id}"); @@ -845,6 +866,106 @@ struct MaintainCoordinationRun { final_status: session::manager::CoordinationStatus, } +#[derive(Debug, Clone, Serialize)] +struct WorktreeMergeReadinessReport { + status: String, + summary: String, + conflicts: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct WorktreeStatusReport { + session_id: String, + task: String, + session_state: String, + attached: bool, + path: Option, + branch: Option, + base_branch: Option, + diff_summary: Option, + file_preview: Vec, + merge_readiness: Option, +} + +fn build_worktree_status_report(session: &session::Session) -> Result { + let Some(worktree) = session.worktree.as_ref() else { + return Ok(WorktreeStatusReport { + session_id: session.id.clone(), + task: session.task.clone(), + session_state: session.state.to_string(), + attached: false, + path: None, + branch: None, + base_branch: None, + diff_summary: None, + file_preview: Vec::new(), + merge_readiness: None, + }); + }; + + let diff_summary = worktree::diff_summary(worktree)?; + let file_preview = worktree::diff_file_preview(worktree, 8)?; + let merge_readiness = worktree::merge_readiness(worktree)?; + + Ok(WorktreeStatusReport { + session_id: session.id.clone(), + task: session.task.clone(), + session_state: session.state.to_string(), + attached: true, + path: Some(worktree.path.display().to_string()), + branch: Some(worktree.branch.clone()), + base_branch: Some(worktree.base_branch.clone()), + diff_summary, + file_preview, + merge_readiness: Some(WorktreeMergeReadinessReport { + status: match merge_readiness.status { + worktree::MergeReadinessStatus::Ready => "ready".to_string(), + worktree::MergeReadinessStatus::Conflicted => "conflicted".to_string(), + }, + summary: merge_readiness.summary, + conflicts: merge_readiness.conflicts, + }), + }) +} + +fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { + let mut lines = vec![format!( + "Worktree status for {} [{}]", + short_session(&report.session_id), + report.session_state + )]; + lines.push(format!("Task {}", report.task)); + + if !report.attached { + lines.push("No worktree attached".to_string()); + return lines.join("\n"); + } + + if let Some(path) = report.path.as_ref() { + lines.push(format!("Path {path}")); + } + if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) { + lines.push(format!("Branch {branch} (base {base_branch})")); + } + if let Some(diff_summary) = report.diff_summary.as_ref() { + lines.push(diff_summary.clone()); + } + if !report.file_preview.is_empty() { + lines.push("Files".to_string()); + for entry in &report.file_preview { + lines.push(format!("- {entry}")); + } + } + if let Some(merge_readiness) = report.merge_readiness.as_ref() { + lines.push(merge_readiness.summary.clone()); + for conflict in merge_readiness.conflicts.iter().take(5) { + lines.push(format!("- conflict {conflict}")); + } + } + + lines.join("\n") +} + fn summarize_coordinate_backlog( outcome: &session::manager::CoordinateBacklogOutcome, ) -> CoordinateBacklogPassSummary { @@ -1072,6 +1193,82 @@ mod tests { } } + #[test] + fn cli_parses_worktree_status_command() { + let cli = Cli::try_parse_from(["ecc", "worktree-status", "planner"]) + .expect("worktree-status should parse"); + + match cli.command { + Some(Commands::WorktreeStatus { session_id, json }) => { + assert_eq!(session_id.as_deref(), Some("planner")); + assert!(!json); + } + _ => panic!("expected worktree-status subcommand"), + } + } + + #[test] + fn cli_parses_worktree_status_json_flag() { + let cli = Cli::try_parse_from(["ecc", "worktree-status", "--json"]) + .expect("worktree-status --json should parse"); + + match cli.command { + Some(Commands::WorktreeStatus { session_id, json }) => { + assert_eq!(session_id, None); + assert!(json); + } + _ => panic!("expected worktree-status subcommand"), + } + } + + #[test] + fn format_worktree_status_human_includes_readiness_and_conflicts() { + let report = WorktreeStatusReport { + session_id: "deadbeefcafefeed".to_string(), + task: "Review merge readiness".to_string(), + session_state: "running".to_string(), + attached: true, + path: Some("/tmp/ecc/wt-1".to_string()), + branch: Some("ecc/deadbeefcafefeed".to_string()), + base_branch: Some("main".to_string()), + diff_summary: Some("Branch 1 file changed, 2 insertions(+)".to_string()), + file_preview: vec!["Branch M README.md".to_string()], + merge_readiness: Some(WorktreeMergeReadinessReport { + status: "conflicted".to_string(), + summary: "Merge blocked by 1 conflict(s): README.md".to_string(), + conflicts: vec!["README.md".to_string()], + }), + }; + + let text = format_worktree_status_human(&report); + assert!(text.contains("Worktree status for deadbeef [running]")); + assert!(text.contains("Branch ecc/deadbeefcafefeed (base main)")); + assert!(text.contains("Branch M README.md")); + assert!(text.contains("Merge blocked by 1 conflict(s): README.md")); + assert!(text.contains("- conflict README.md")); + } + + #[test] + fn format_worktree_status_human_handles_missing_worktree() { + let report = WorktreeStatusReport { + session_id: "deadbeefcafefeed".to_string(), + task: "No worktree here".to_string(), + session_state: "stopped".to_string(), + attached: false, + path: None, + branch: None, + base_branch: None, + diff_summary: None, + file_preview: Vec::new(), + merge_readiness: None, + }; + + let text = format_worktree_status_human(&report); + assert!(text.contains("Worktree status for deadbeef [stopped]")); + assert!(text.contains("Task No worktree here")); + assert!(text.contains("No worktree attached")); + } + #[test] fn cli_parses_assign_command() { let cli = Cli::try_parse_from([ From e7be2ddf8d8c060885da8f31c6c47f2ea85ec7e6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:04:55 -0700 Subject: [PATCH 038/459] feat: add ecc2 worktree status checks --- ecc2/src/main.rs | 125 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 122 insertions(+), 3 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 4be2fb96..6dd04d79 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -193,6 +193,9 @@ enum Commands { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, + /// Return a non-zero exit code when the worktree needs attention + #[arg(long)] + check: bool, }, /// Stop a running session Stop { @@ -637,7 +640,11 @@ async fn main() -> Result<()> { let team = session::manager::get_team_status(&db, &id, depth)?; println!("{team}"); } - Some(Commands::WorktreeStatus { session_id, json }) => { + Some(Commands::WorktreeStatus { + session_id, + json, + check, + }) => { let id = session_id.unwrap_or_else(|| "latest".to_string()); let resolved_id = resolve_session_id(&db, &id)?; let session = db @@ -649,6 +656,9 @@ async fn main() -> Result<()> { } else { println!("{}", format_worktree_status_human(&report)); } + if check { + std::process::exit(worktree_status_exit_code(&report)); + } } Some(Commands::Stop { session_id }) => { session::manager::stop_session(&db, &session_id).await?; @@ -878,6 +888,8 @@ struct WorktreeStatusReport { session_id: String, task: String, session_state: String, + health: String, + check_exit_code: i32, attached: bool, path: Option, branch: Option, @@ -893,6 +905,8 @@ fn build_worktree_status_report(session: &session::Session) -> Result Result ("conflicted".to_string(), 2), + worktree::MergeReadinessStatus::Ready if file_preview.is_empty() => ("clear".to_string(), 0), + worktree::MergeReadinessStatus::Ready => ("in_progress".to_string(), 1), + }; Ok(WorktreeStatusReport { session_id: session.id.clone(), task: session.task.clone(), session_state: session.state.to_string(), + health, + check_exit_code, attached: true, path: Some(worktree.path.display().to_string()), branch: Some(worktree.branch.clone()), @@ -935,6 +956,7 @@ fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { report.session_state )]; lines.push(format!("Task {}", report.task)); + lines.push(format!("Health {}", report.health)); if !report.attached { lines.push("No worktree attached".to_string()); @@ -966,6 +988,10 @@ fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { lines.join("\n") } +fn worktree_status_exit_code(report: &WorktreeStatusReport) -> i32 { + report.check_exit_code +} + fn summarize_coordinate_backlog( outcome: &session::manager::CoordinateBacklogOutcome, ) -> CoordinateBacklogPassSummary { @@ -1199,9 +1225,14 @@ mod tests { .expect("worktree-status should parse"); match cli.command { - Some(Commands::WorktreeStatus { session_id, json }) => { + Some(Commands::WorktreeStatus { + session_id, + json, + check, + }) => { assert_eq!(session_id.as_deref(), Some("planner")); assert!(!json); + assert!(!check); } _ => panic!("expected worktree-status subcommand"), } @@ -1213,9 +1244,33 @@ mod tests { .expect("worktree-status --json should parse"); match cli.command { - Some(Commands::WorktreeStatus { session_id, json }) => { + Some(Commands::WorktreeStatus { + session_id, + json, + check, + }) => { assert_eq!(session_id, None); assert!(json); + assert!(!check); + } + _ => panic!("expected worktree-status subcommand"), + } + } + + #[test] + fn cli_parses_worktree_status_check_flag() { + let cli = Cli::try_parse_from(["ecc", "worktree-status", "--check"]) + .expect("worktree-status --check should parse"); + + match cli.command { + Some(Commands::WorktreeStatus { + session_id, + json, + check, + }) => { + assert_eq!(session_id, None); + assert!(!json); + assert!(check); } _ => panic!("expected worktree-status subcommand"), } @@ -1227,6 +1282,8 @@ mod tests { session_id: "deadbeefcafefeed".to_string(), task: "Review merge readiness".to_string(), session_state: "running".to_string(), + health: "conflicted".to_string(), + check_exit_code: 2, attached: true, path: Some("/tmp/ecc/wt-1".to_string()), branch: Some("ecc/deadbeefcafefeed".to_string()), @@ -1243,6 +1300,7 @@ mod tests { let text = format_worktree_status_human(&report); assert!(text.contains("Worktree status for deadbeef [running]")); assert!(text.contains("Branch ecc/deadbeefcafefeed (base main)")); + assert!(text.contains("Health conflicted")); assert!(text.contains("Branch M README.md")); assert!(text.contains("Merge blocked by 1 conflict(s): README.md")); assert!(text.contains("- conflict README.md")); @@ -1254,6 +1312,8 @@ mod tests { session_id: "deadbeefcafefeed".to_string(), task: "No worktree here".to_string(), session_state: "stopped".to_string(), + health: "clear".to_string(), + check_exit_code: 0, attached: false, path: None, branch: None, @@ -1266,9 +1326,68 @@ mod tests { let text = format_worktree_status_human(&report); assert!(text.contains("Worktree status for deadbeef [stopped]")); assert!(text.contains("Task No worktree here")); + assert!(text.contains("Health clear")); assert!(text.contains("No worktree attached")); } + #[test] + fn worktree_status_exit_code_tracks_health() { + let clear = WorktreeStatusReport { + session_id: "a".to_string(), + task: "clear".to_string(), + session_state: "idle".to_string(), + health: "clear".to_string(), + check_exit_code: 0, + attached: false, + path: None, + branch: None, + base_branch: None, + diff_summary: None, + file_preview: Vec::new(), + merge_readiness: None, + }; + let in_progress = WorktreeStatusReport { + session_id: "b".to_string(), + task: "progress".to_string(), + session_state: "running".to_string(), + health: "in_progress".to_string(), + check_exit_code: 1, + attached: true, + path: Some("/tmp/ecc/wt-2".to_string()), + branch: Some("ecc/b".to_string()), + base_branch: Some("main".to_string()), + diff_summary: Some("Branch 1 file changed".to_string()), + file_preview: vec!["Branch M README.md".to_string()], + merge_readiness: Some(WorktreeMergeReadinessReport { + status: "ready".to_string(), + summary: "Merge ready into main".to_string(), + conflicts: Vec::new(), + }), + }; + let conflicted = WorktreeStatusReport { + session_id: "c".to_string(), + task: "conflict".to_string(), + session_state: "running".to_string(), + health: "conflicted".to_string(), + check_exit_code: 2, + attached: true, + path: Some("/tmp/ecc/wt-3".to_string()), + branch: Some("ecc/c".to_string()), + base_branch: Some("main".to_string()), + diff_summary: Some("Branch 1 file changed".to_string()), + file_preview: vec!["Branch M README.md".to_string()], + merge_readiness: Some(WorktreeMergeReadinessReport { + status: "conflicted".to_string(), + summary: "Merge blocked by 1 conflict(s): README.md".to_string(), + conflicts: vec!["README.md".to_string()], + }), + }; + + assert_eq!(worktree_status_exit_code(&clear), 0); + assert_eq!(worktree_status_exit_code(&in_progress), 1); + assert_eq!(worktree_status_exit_code(&conflicted), 2); + } + #[test] fn cli_parses_assign_command() { let cli = Cli::try_parse_from([ From 2dee4072a30e9456ec4727a10113c28e1213f614 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:10:24 -0700 Subject: [PATCH 039/459] feat: add ecc2 worktree patch previews --- ecc2/src/main.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 6dd04d79..dee546b2 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -193,6 +193,9 @@ enum Commands { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, + /// Include a bounded patch preview when a worktree is attached + #[arg(long)] + patch: bool, /// Return a non-zero exit code when the worktree needs attention #[arg(long)] check: bool, @@ -643,6 +646,7 @@ async fn main() -> Result<()> { Some(Commands::WorktreeStatus { session_id, json, + patch, check, }) => { let id = session_id.unwrap_or_else(|| "latest".to_string()); @@ -650,7 +654,7 @@ async fn main() -> Result<()> { let session = db .get_session(&resolved_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {resolved_id}"))?; - let report = build_worktree_status_report(&session)?; + let report = build_worktree_status_report(&session, patch)?; if json { println!("{}", serde_json::to_string_pretty(&report)?); } else { @@ -890,16 +894,18 @@ struct WorktreeStatusReport { session_state: String, health: String, check_exit_code: i32, + patch_included: bool, attached: bool, path: Option, branch: Option, base_branch: Option, diff_summary: Option, file_preview: Vec, + patch_preview: Option, merge_readiness: Option, } -fn build_worktree_status_report(session: &session::Session) -> Result { +fn build_worktree_status_report(session: &session::Session, include_patch: bool) -> Result { let Some(worktree) = session.worktree.as_ref() else { return Ok(WorktreeStatusReport { session_id: session.id.clone(), @@ -907,18 +913,25 @@ fn build_worktree_status_report(session: &session::Session) -> Result ("conflicted".to_string(), 2), @@ -932,12 +945,14 @@ fn build_worktree_status_report(session: &session::Session) -> Result "ready".to_string(), @@ -984,6 +999,14 @@ fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { lines.push(format!("- conflict {conflict}")); } } + if report.patch_included { + if let Some(patch_preview) = report.patch_preview.as_ref() { + lines.push("Patch preview".to_string()); + lines.push(patch_preview.clone()); + } else { + lines.push("Patch preview unavailable".to_string()); + } + } lines.join("\n") } @@ -1228,10 +1251,12 @@ mod tests { Some(Commands::WorktreeStatus { session_id, json, + patch, check, }) => { assert_eq!(session_id.as_deref(), Some("planner")); assert!(!json); + assert!(!patch); assert!(!check); } _ => panic!("expected worktree-status subcommand"), @@ -1247,10 +1272,33 @@ mod tests { Some(Commands::WorktreeStatus { session_id, json, + patch, check, }) => { assert_eq!(session_id, None); assert!(json); + assert!(!patch); + assert!(!check); + } + _ => panic!("expected worktree-status subcommand"), + } + } + + #[test] + fn cli_parses_worktree_status_patch_flag() { + let cli = Cli::try_parse_from(["ecc", "worktree-status", "--patch"]) + .expect("worktree-status --patch should parse"); + + match cli.command { + Some(Commands::WorktreeStatus { + session_id, + json, + patch, + check, + }) => { + assert_eq!(session_id, None); + assert!(!json); + assert!(patch); assert!(!check); } _ => panic!("expected worktree-status subcommand"), @@ -1266,10 +1314,12 @@ mod tests { Some(Commands::WorktreeStatus { session_id, json, + patch, check, }) => { assert_eq!(session_id, None); assert!(!json); + assert!(!patch); assert!(check); } _ => panic!("expected worktree-status subcommand"), @@ -1284,12 +1334,14 @@ mod tests { session_state: "running".to_string(), health: "conflicted".to_string(), check_exit_code: 2, + patch_included: true, attached: true, path: Some("/tmp/ecc/wt-1".to_string()), branch: Some("ecc/deadbeefcafefeed".to_string()), base_branch: Some("main".to_string()), diff_summary: Some("Branch 1 file changed, 2 insertions(+)".to_string()), file_preview: vec!["Branch M README.md".to_string()], + patch_preview: Some("--- Branch diff vs main ---\n+hello".to_string()), merge_readiness: Some(WorktreeMergeReadinessReport { status: "conflicted".to_string(), summary: "Merge blocked by 1 conflict(s): README.md".to_string(), @@ -1304,6 +1356,8 @@ mod tests { assert!(text.contains("Branch M README.md")); assert!(text.contains("Merge blocked by 1 conflict(s): README.md")); assert!(text.contains("- conflict README.md")); + assert!(text.contains("Patch preview")); + assert!(text.contains("--- Branch diff vs main ---")); } #[test] @@ -1314,12 +1368,14 @@ mod tests { session_state: "stopped".to_string(), health: "clear".to_string(), check_exit_code: 0, + patch_included: true, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), + patch_preview: None, merge_readiness: None, }; @@ -1338,12 +1394,14 @@ mod tests { session_state: "idle".to_string(), health: "clear".to_string(), check_exit_code: 0, + patch_included: false, attached: false, path: None, branch: None, base_branch: None, diff_summary: None, file_preview: Vec::new(), + patch_preview: None, merge_readiness: None, }; let in_progress = WorktreeStatusReport { @@ -1352,12 +1410,14 @@ mod tests { session_state: "running".to_string(), health: "in_progress".to_string(), check_exit_code: 1, + patch_included: false, attached: true, path: Some("/tmp/ecc/wt-2".to_string()), branch: Some("ecc/b".to_string()), base_branch: Some("main".to_string()), diff_summary: Some("Branch 1 file changed".to_string()), file_preview: vec!["Branch M README.md".to_string()], + patch_preview: None, merge_readiness: Some(WorktreeMergeReadinessReport { status: "ready".to_string(), summary: "Merge ready into main".to_string(), @@ -1370,12 +1430,14 @@ mod tests { session_state: "running".to_string(), health: "conflicted".to_string(), check_exit_code: 2, + patch_included: false, attached: true, path: Some("/tmp/ecc/wt-3".to_string()), branch: Some("ecc/c".to_string()), base_branch: Some("main".to_string()), diff_summary: Some("Branch 1 file changed".to_string()), file_preview: vec!["Branch M README.md".to_string()], + patch_preview: None, merge_readiness: Some(WorktreeMergeReadinessReport { status: "conflicted".to_string(), summary: "Merge blocked by 1 conflict(s): README.md".to_string(), From 4834b63b3543fc6759e57fb57740220208c7cba8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:13:26 -0700 Subject: [PATCH 040/459] feat: add ecc2 global worktree status --- ecc2/src/main.rs | 205 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 195 insertions(+), 10 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index dee546b2..d1094609 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -190,6 +190,9 @@ enum Commands { WorktreeStatus { /// Session ID or alias session_id: Option, + /// Show worktree status for all sessions + #[arg(long)] + all: bool, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, @@ -645,23 +648,40 @@ async fn main() -> Result<()> { } Some(Commands::WorktreeStatus { session_id, + all, json, patch, check, }) => { - let id = session_id.unwrap_or_else(|| "latest".to_string()); - let resolved_id = resolve_session_id(&db, &id)?; - let session = db - .get_session(&resolved_id)? - .ok_or_else(|| anyhow::anyhow!("Session not found: {resolved_id}"))?; - let report = build_worktree_status_report(&session, patch)?; - if json { - println!("{}", serde_json::to_string_pretty(&report)?); + if all && session_id.is_some() { + return Err(anyhow::anyhow!( + "worktree-status does not accept a session ID when --all is set" + )); + } + let reports = if all { + session::manager::list_sessions(&db)? + .into_iter() + .map(|session| build_worktree_status_report(&session, patch)) + .collect::>>()? } else { - println!("{}", format_worktree_status_human(&report)); + let id = session_id.unwrap_or_else(|| "latest".to_string()); + let resolved_id = resolve_session_id(&db, &id)?; + let session = db + .get_session(&resolved_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {resolved_id}"))?; + vec![build_worktree_status_report(&session, patch)?] + }; + if json { + if all { + println!("{}", serde_json::to_string_pretty(&reports)?); + } else { + println!("{}", serde_json::to_string_pretty(&reports[0])?); + } + } else { + println!("{}", format_worktree_status_reports_human(&reports)); } if check { - std::process::exit(worktree_status_exit_code(&report)); + std::process::exit(worktree_status_reports_exit_code(&reports)); } } Some(Commands::Stop { session_id }) => { @@ -1011,10 +1031,26 @@ fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { lines.join("\n") } +fn format_worktree_status_reports_human(reports: &[WorktreeStatusReport]) -> String { + reports + .iter() + .map(format_worktree_status_human) + .collect::>() + .join("\n\n") +} + fn worktree_status_exit_code(report: &WorktreeStatusReport) -> i32 { report.check_exit_code } +fn worktree_status_reports_exit_code(reports: &[WorktreeStatusReport]) -> i32 { + reports + .iter() + .map(worktree_status_exit_code) + .max() + .unwrap_or(0) +} + fn summarize_coordinate_backlog( outcome: &session::manager::CoordinateBacklogOutcome, ) -> CoordinateBacklogPassSummary { @@ -1250,11 +1286,13 @@ mod tests { match cli.command { Some(Commands::WorktreeStatus { session_id, + all, json, patch, check, }) => { assert_eq!(session_id.as_deref(), Some("planner")); + assert!(!all); assert!(!json); assert!(!patch); assert!(!check); @@ -1271,11 +1309,13 @@ mod tests { match cli.command { Some(Commands::WorktreeStatus { session_id, + all, json, patch, check, }) => { assert_eq!(session_id, None); + assert!(!all); assert!(json); assert!(!patch); assert!(!check); @@ -1284,6 +1324,91 @@ mod tests { } } + #[test] + fn cli_parses_worktree_status_all_flag() { + let cli = Cli::try_parse_from(["ecc", "worktree-status", "--all"]) + .expect("worktree-status --all should parse"); + + match cli.command { + Some(Commands::WorktreeStatus { + session_id, + all, + json, + patch, + check, + }) => { + assert_eq!(session_id, None); + assert!(all); + assert!(!json); + assert!(!patch); + assert!(!check); + } + _ => panic!("expected worktree-status subcommand"), + } + } + + #[test] + fn cli_parses_worktree_status_session_id_with_all_flag() { + let err = Cli::try_parse_from(["ecc", "worktree-status", "planner", "--all"]) + .expect("worktree-status planner --all should parse"); + + let command = err.command.expect("expected command"); + let Commands::WorktreeStatus { + session_id, + all, + .. + } = command + else { + panic!("expected worktree-status subcommand"); + }; + + assert_eq!(session_id.as_deref(), Some("planner")); + assert!(all); + } + + #[test] + fn format_worktree_status_reports_human_joins_multiple_reports() { + let reports = vec![ + WorktreeStatusReport { + session_id: "sess-a".to_string(), + task: "first".to_string(), + session_state: "running".to_string(), + health: "in_progress".to_string(), + check_exit_code: 1, + patch_included: false, + attached: false, + path: None, + branch: None, + base_branch: None, + diff_summary: None, + file_preview: Vec::new(), + patch_preview: None, + merge_readiness: None, + }, + WorktreeStatusReport { + session_id: "sess-b".to_string(), + task: "second".to_string(), + session_state: "stopped".to_string(), + health: "clear".to_string(), + check_exit_code: 0, + patch_included: false, + attached: false, + path: None, + branch: None, + base_branch: None, + diff_summary: None, + file_preview: Vec::new(), + patch_preview: None, + merge_readiness: None, + }, + ]; + + let text = format_worktree_status_reports_human(&reports); + assert!(text.contains("Worktree status for sess-a [running]")); + assert!(text.contains("Worktree status for sess-b [stopped]")); + assert!(text.contains("\n\nWorktree status for sess-b [stopped]")); + } + #[test] fn cli_parses_worktree_status_patch_flag() { let cli = Cli::try_parse_from(["ecc", "worktree-status", "--patch"]) @@ -1292,11 +1417,13 @@ mod tests { match cli.command { Some(Commands::WorktreeStatus { session_id, + all, json, patch, check, }) => { assert_eq!(session_id, None); + assert!(!all); assert!(!json); assert!(patch); assert!(!check); @@ -1313,11 +1440,13 @@ mod tests { match cli.command { Some(Commands::WorktreeStatus { session_id, + all, json, patch, check, }) => { assert_eq!(session_id, None); + assert!(!all); assert!(!json); assert!(!patch); assert!(check); @@ -1450,6 +1579,62 @@ mod tests { assert_eq!(worktree_status_exit_code(&conflicted), 2); } + #[test] + fn worktree_status_reports_exit_code_uses_highest_severity() { + let reports = vec![ + WorktreeStatusReport { + session_id: "sess-a".to_string(), + task: "first".to_string(), + session_state: "running".to_string(), + health: "clear".to_string(), + check_exit_code: 0, + patch_included: false, + attached: false, + path: None, + branch: None, + base_branch: None, + diff_summary: None, + file_preview: Vec::new(), + patch_preview: None, + merge_readiness: None, + }, + WorktreeStatusReport { + session_id: "sess-b".to_string(), + task: "second".to_string(), + session_state: "running".to_string(), + health: "in_progress".to_string(), + check_exit_code: 1, + patch_included: false, + attached: false, + path: None, + branch: None, + base_branch: None, + diff_summary: None, + file_preview: Vec::new(), + patch_preview: None, + merge_readiness: None, + }, + WorktreeStatusReport { + session_id: "sess-c".to_string(), + task: "third".to_string(), + session_state: "running".to_string(), + health: "conflicted".to_string(), + check_exit_code: 2, + patch_included: false, + attached: false, + path: None, + branch: None, + base_branch: None, + diff_summary: None, + file_preview: Vec::new(), + patch_preview: None, + merge_readiness: None, + }, + ]; + + assert_eq!(worktree_status_reports_exit_code(&reports), 2); + } + #[test] fn cli_parses_assign_command() { let cli = Cli::try_parse_from([ From 689235af169fdfe8c890a46a6aae9cebcc2013d7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:30:08 -0700 Subject: [PATCH 041/459] feat: add ecc2 worktree pruning command --- ecc2/src/main.rs | 70 +++++++++++++++++++++++ ecc2/src/session/manager.rs | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index d1094609..df918c65 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -203,6 +203,12 @@ enum Commands { #[arg(long)] check: bool, }, + /// Prune worktrees for inactive sessions and report any active sessions still holding one + PruneWorktrees { + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Stop a running session Stop { /// Session ID or alias @@ -684,6 +690,14 @@ async fn main() -> Result<()> { std::process::exit(worktree_status_reports_exit_code(&reports)); } } + Some(Commands::PruneWorktrees { json }) => { + let outcome = session::manager::prune_inactive_worktrees(&db).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_prune_worktrees_human(&outcome)); + } + } Some(Commands::Stop { session_id }) => { session::manager::stop_session(&db, &session_id).await?; println!("Session stopped: {session_id}"); @@ -1051,6 +1065,36 @@ fn worktree_status_reports_exit_code(reports: &[WorktreeStatusReport]) -> i32 { .unwrap_or(0) } +fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome) -> String { + let mut lines = Vec::new(); + + if outcome.cleaned_session_ids.is_empty() { + lines.push("Pruned 0 inactive worktree(s)".to_string()); + } else { + lines.push(format!( + "Pruned {} inactive worktree(s)", + outcome.cleaned_session_ids.len() + )); + for session_id in &outcome.cleaned_session_ids { + lines.push(format!("- cleaned {}", short_session(session_id))); + } + } + + if outcome.active_with_worktree_ids.is_empty() { + lines.push("No active sessions are holding worktrees".to_string()); + } else { + lines.push(format!( + "Skipped {} active session(s) still holding worktrees", + outcome.active_with_worktree_ids.len() + )); + for session_id in &outcome.active_with_worktree_ids { + lines.push(format!("- active {}", short_session(session_id))); + } + } + + lines.join("\n") +} + fn summarize_coordinate_backlog( outcome: &session::manager::CoordinateBacklogOutcome, ) -> CoordinateBacklogPassSummary { @@ -1455,6 +1499,19 @@ mod tests { } } + #[test] + fn cli_parses_prune_worktrees_json_flag() { + let cli = Cli::try_parse_from(["ecc", "prune-worktrees", "--json"]) + .expect("prune-worktrees --json should parse"); + + match cli.command { + Some(Commands::PruneWorktrees { json }) => { + assert!(json); + } + _ => panic!("expected prune-worktrees subcommand"), + } + } + #[test] fn format_worktree_status_human_includes_readiness_and_conflicts() { let report = WorktreeStatusReport { @@ -1489,6 +1546,19 @@ mod tests { assert!(text.contains("--- Branch diff vs main ---")); } + #[test] + fn format_prune_worktrees_human_reports_cleaned_and_active_sessions() { + let text = format_prune_worktrees_human(&session::manager::WorktreePruneOutcome { + cleaned_session_ids: vec!["deadbeefcafefeed".to_string()], + active_with_worktree_ids: vec!["facefeed12345678".to_string()], + }); + + assert!(text.contains("Pruned 1 inactive worktree(s)")); + assert!(text.contains("- cleaned deadbeef")); + assert!(text.contains("Skipped 1 active session(s) still holding worktrees")); + assert!(text.contains("- active facefeed")); + } + #[test] fn format_worktree_status_human_handles_missing_worktree() { let report = WorktreeStatusReport { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index f8e62131..4ec104a6 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -610,6 +610,40 @@ pub async fn cleanup_session_worktree(db: &StateStore, id: &str) -> Result<()> { Ok(()) } +#[derive(Debug, Clone, Serialize)] +pub struct WorktreePruneOutcome { + pub cleaned_session_ids: Vec, + pub active_with_worktree_ids: Vec, +} + +pub async fn prune_inactive_worktrees(db: &StateStore) -> Result { + let sessions = db.list_sessions()?; + let mut cleaned_session_ids = Vec::new(); + let mut active_with_worktree_ids = Vec::new(); + + for session in sessions { + let Some(_) = session.worktree.as_ref() else { + continue; + }; + + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) { + active_with_worktree_ids.push(session.id); + continue; + } + + cleanup_session_worktree(db, &session.id).await?; + cleaned_session_ids.push(session.id); + } + + Ok(WorktreePruneOutcome { + cleaned_session_ids, + active_with_worktree_ids, + }) +} + pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { let session = resolve_session(db, id)?; @@ -1745,6 +1779,83 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn prune_inactive_worktrees_cleans_stopped_sessions_only() -> Result<()> { + let tempdir = TestDir::new("manager-prune-worktrees")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let (fake_claude, _) = write_fake_claude(tempdir.path())?; + + let active_id = create_session_in_dir( + &db, + &cfg, + "active worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + let stopped_id = create_session_in_dir( + &db, + &cfg, + "stopped worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + + stop_session_with_options(&db, &stopped_id, false).await?; + + let active_before = db + .get_session(&active_id)? + .context("active session should exist")?; + let active_path = active_before + .worktree + .clone() + .context("active session worktree missing")? + .path; + + let stopped_before = db + .get_session(&stopped_id)? + .context("stopped session should exist")?; + let stopped_path = stopped_before + .worktree + .clone() + .context("stopped session worktree missing")? + .path; + + let outcome = prune_inactive_worktrees(&db).await?; + + assert_eq!(outcome.cleaned_session_ids, vec![stopped_id.clone()]); + assert_eq!(outcome.active_with_worktree_ids, vec![active_id.clone()]); + assert!(active_path.exists(), "active worktree should remain"); + assert!(!stopped_path.exists(), "stopped worktree should be removed"); + + let active_after = db + .get_session(&active_id)? + .context("active session should still exist")?; + assert!( + active_after.worktree.is_some(), + "active session should keep worktree metadata" + ); + + let stopped_after = db + .get_session(&stopped_id)? + .context("stopped session should still exist")?; + assert!( + stopped_after.worktree.is_none(), + "stopped session worktree metadata should be cleared" + ); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn delete_session_removes_inactive_session_and_worktree() -> Result<()> { let tempdir = TestDir::new("manager-delete-session")?; From 027d77468efd80a7b80b9b91acc194ce40f2c9cf Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:33:30 -0700 Subject: [PATCH 042/459] feat: add ecc2 dashboard worktree pruning --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 131 +++++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 7495bd89..aaad30b2 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -52,6 +52,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('s')) => dashboard.stop_selected().await, (_, KeyCode::Char('u')) => dashboard.resume_selected().await, (_, KeyCode::Char('x')) => dashboard.cleanup_selected_worktree().await, + (_, KeyCode::Char('X')) => dashboard.prune_inactive_worktrees().await, (_, KeyCode::Char('d')) => dashboard.delete_selected_session().await, (_, KeyCode::Char('r')) => dashboard.refresh(), (_, KeyCode::Char('?')) => dashboard.toggle_help(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 826a4ac2..955cc0c8 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -456,7 +456,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 toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup [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 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() { @@ -513,6 +513,7 @@ impl Dashboard { " s Stop selected session", " u Resume selected session", " x Cleanup selected worktree", + " X Prune inactive worktrees globally", " d Delete selected inactive session", " Tab Next pane", " S-Tab Previous pane", @@ -1106,6 +1107,32 @@ impl Dashboard { )); } + pub async fn prune_inactive_worktrees(&mut self) { + match manager::prune_inactive_worktrees(&self.db).await { + Ok(outcome) => { + self.refresh(); + if outcome.cleaned_session_ids.is_empty() { + self.set_operator_note("no inactive worktrees to prune".to_string()); + } else if outcome.active_with_worktree_ids.is_empty() { + self.set_operator_note(format!( + "pruned {} inactive worktree(s)", + outcome.cleaned_session_ids.len() + )); + } else { + self.set_operator_note(format!( + "pruned {} inactive worktree(s); skipped {} active session(s)", + outcome.cleaned_session_ids.len(), + outcome.active_with_worktree_ids.len() + )); + } + } + Err(error) => { + tracing::warn!("Failed to prune inactive worktrees: {error}"); + self.set_operator_note(format!("prune inactive worktrees failed: {error}")); + } + } + } + pub async fn delete_selected_session(&mut self) { let Some(session) = self.sessions.get(self.selected_session) else { return; @@ -2936,6 +2963,108 @@ mod tests { Ok(()) } + #[tokio::test] + async fn prune_inactive_worktrees_sets_operator_note_when_clear() -> Result<()> { + let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "running-1".to_string(), + task: "keep alive".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let dashboard_store = StateStore::open(&db_path)?; + let mut dashboard = Dashboard::new(dashboard_store, Config::default()); + dashboard.prune_inactive_worktrees().await; + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("no inactive worktrees to prune") + ); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } + + #[tokio::test] + async fn prune_inactive_worktrees_reports_pruned_and_skipped_counts() -> Result<()> { + let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let now = Utc::now(); + let active_path = std::env::temp_dir().join(format!("ecc2-active-{}", Uuid::new_v4())); + let stopped_path = std::env::temp_dir().join(format!("ecc2-stopped-{}", Uuid::new_v4())); + std::fs::create_dir_all(&active_path)?; + std::fs::create_dir_all(&stopped_path)?; + + db.insert_session(&Session { + id: "running-1".to_string(), + task: "keep worktree".to_string(), + agent_type: "claude".to_string(), + working_dir: active_path.clone(), + state: SessionState::Running, + pid: None, + worktree: Some(WorktreeInfo { + path: active_path.clone(), + branch: "ecc/running-1".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "stopped-1".to_string(), + task: "prune me".to_string(), + agent_type: "claude".to_string(), + working_dir: stopped_path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(WorktreeInfo { + path: stopped_path.clone(), + branch: "ecc/stopped-1".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let dashboard_store = StateStore::open(&db_path)?; + let mut dashboard = Dashboard::new(dashboard_store, Config::default()); + dashboard.prune_inactive_worktrees().await; + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("pruned 1 inactive worktree(s); skipped 1 active session(s)") + ); + assert!( + db.get_session("stopped-1")? + .expect("stopped session should exist") + .worktree + .is_none() + ); + assert!( + db.get_session("running-1")? + .expect("running session should exist") + .worktree + .is_some() + ); + + let _ = std::fs::remove_dir_all(active_path); + let _ = std::fs::remove_dir_all(stopped_path); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + #[tokio::test] async fn delete_selected_session_removes_inactive_session() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); From 7f2c14ecf8350489d67b7a39d1738487ab66ea9c Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:43:42 -0700 Subject: [PATCH 043/459] 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 From 4834dfd28029e0c2c9bcae9156a4badc9d32505b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 14:57:46 -0700 Subject: [PATCH 044/459] feat: add ecc2 worktree merge actions --- ecc2/src/main.rs | 89 +++++++++++++++++++ ecc2/src/session/manager.rs | 122 ++++++++++++++++++++++++-- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 152 ++++++++++++++++++++++++++++++-- ecc2/src/worktree/mod.rs | 167 +++++++++++++++++++++++++++++++++++- 5 files changed, 513 insertions(+), 18 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 2e73f992..008b2094 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -203,6 +203,17 @@ enum Commands { #[arg(long)] check: bool, }, + /// Merge a session worktree branch into its base branch + MergeWorktree { + /// Session ID or alias + session_id: Option, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + /// Keep the worktree attached after a successful merge + #[arg(long)] + keep_worktree: bool, + }, /// Prune worktrees for inactive sessions and report any active sessions still holding one PruneWorktrees { /// Emit machine-readable JSON instead of the human summary @@ -690,6 +701,21 @@ async fn main() -> Result<()> { std::process::exit(worktree_status_reports_exit_code(&reports)); } } + Some(Commands::MergeWorktree { + session_id, + json, + keep_worktree, + }) => { + let id = session_id.unwrap_or_else(|| "latest".to_string()); + let resolved_id = resolve_session_id(&db, &id)?; + let outcome = + session::manager::merge_session_worktree(&db, &resolved_id, !keep_worktree).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_worktree_merge_human(&outcome)); + } + } Some(Commands::PruneWorktrees { json }) => { let outcome = session::manager::prune_inactive_worktrees(&db).await?; if json { @@ -1054,6 +1080,28 @@ fn format_worktree_status_reports_human(reports: &[WorktreeStatusReport]) -> Str .join("\n\n") } +fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome) -> String { + let mut lines = vec![format!( + "Merged worktree for {}", + short_session(&outcome.session_id) + )]; + lines.push(format!( + "Branch {} -> {}", + outcome.branch, outcome.base_branch + )); + lines.push(if outcome.already_up_to_date { + "Result already up to date".to_string() + } else { + "Result merged into base".to_string() + }); + lines.push(if outcome.cleaned_worktree { + "Cleanup removed worktree and branch".to_string() + } else { + "Cleanup kept worktree attached".to_string() + }); + lines.join("\n") +} + fn worktree_status_exit_code(report: &WorktreeStatusReport) -> i32 { report.check_exit_code } @@ -1513,6 +1561,31 @@ mod tests { } } + #[test] + fn cli_parses_merge_worktree_flags() { + let cli = Cli::try_parse_from([ + "ecc", + "merge-worktree", + "deadbeef", + "--json", + "--keep-worktree", + ]) + .expect("merge-worktree flags should parse"); + + match cli.command { + Some(Commands::MergeWorktree { + session_id, + json, + keep_worktree, + }) => { + assert_eq!(session_id.as_deref(), Some("deadbeef")); + assert!(json); + assert!(keep_worktree); + } + _ => panic!("expected merge-worktree subcommand"), + } + } + #[test] fn format_worktree_status_human_includes_readiness_and_conflicts() { let report = WorktreeStatusReport { @@ -1560,6 +1633,22 @@ mod tests { assert!(text.contains("- active facefeed")); } + #[test] + fn format_worktree_merge_human_reports_merge_and_cleanup() { + let text = format_worktree_merge_human(&session::manager::WorktreeMergeOutcome { + session_id: "deadbeefcafefeed".to_string(), + branch: "ecc/deadbeef".to_string(), + base_branch: "main".to_string(), + already_up_to_date: false, + cleaned_worktree: true, + }); + + assert!(text.contains("Merged worktree for deadbeef")); + assert!(text.contains("Branch ecc/deadbeef -> main")); + assert!(text.contains("Result merged into base")); + assert!(text.contains("Cleanup removed worktree and branch")); + } + #[test] fn format_worktree_status_human_handles_missing_worktree() { let report = WorktreeStatusReport { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 4ec104a6..24f2ab0b 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -603,13 +603,57 @@ pub async fn cleanup_session_worktree(db: &StateStore, id: &str) -> Result<()> { } if let Some(worktree) = session.worktree.as_ref() { - crate::worktree::remove(&worktree.path)?; + crate::worktree::remove(worktree)?; db.clear_worktree(&session.id)?; } Ok(()) } +#[derive(Debug, Clone, Serialize)] +pub struct WorktreeMergeOutcome { + pub session_id: String, + pub branch: String, + pub base_branch: String, + pub already_up_to_date: bool, + pub cleaned_worktree: bool, +} + +pub async fn merge_session_worktree( + db: &StateStore, + id: &str, + cleanup_worktree: bool, +) -> Result { + let session = resolve_session(db, id)?; + + if matches!(session.state, SessionState::Pending | SessionState::Running) { + anyhow::bail!( + "Cannot merge active session {} while it is {}", + session.id, + session.state + ); + } + + let worktree = session + .worktree + .clone() + .ok_or_else(|| anyhow::anyhow!("Session {} has no attached worktree", session.id))?; + let outcome = crate::worktree::merge_into_base(&worktree)?; + + if cleanup_worktree { + crate::worktree::remove(&worktree)?; + db.clear_worktree(&session.id)?; + } + + Ok(WorktreeMergeOutcome { + session_id: session.id, + branch: outcome.branch, + base_branch: outcome.base_branch, + already_up_to_date: outcome.already_up_to_date, + cleaned_worktree: cleanup_worktree, + }) +} + #[derive(Debug, Clone, Serialize)] pub struct WorktreePruneOutcome { pub cleaned_session_ids: Vec, @@ -659,7 +703,7 @@ pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { } if let Some(worktree) = session.worktree.as_ref() { - let _ = crate::worktree::remove(&worktree.path); + let _ = crate::worktree::remove(worktree); } db.delete_session(&session.id)?; @@ -758,7 +802,7 @@ async fn queue_session_in_dir_with_runner_program( db.update_state(&session.id, &SessionState::Failed)?; if let Some(worktree) = session.worktree.as_ref() { - let _ = crate::worktree::remove(&worktree.path); + let _ = crate::worktree::remove(worktree); } Err(error.context(format!("Failed to queue session {}", session.id))) @@ -829,7 +873,7 @@ async fn create_session_in_dir( db.update_state(&session.id, &SessionState::Failed)?; if let Some(worktree) = session.worktree.as_ref() { - let _ = crate::worktree::remove(&worktree.path); + let _ = crate::worktree::remove(worktree); } Err(error.context(format!("Failed to start session {}", session.id))) @@ -1029,7 +1073,7 @@ async fn stop_session_with_options( if cleanup_worktree { if let Some(worktree) = session.worktree.as_ref() { - crate::worktree::remove(&worktree.path)?; + crate::worktree::remove(worktree)?; } } @@ -1525,15 +1569,13 @@ mod tests { fn init_git_repo(path: &Path) -> Result<()> { fs::create_dir_all(path)?; run_git(path, ["init", "-q"])?; + run_git(path, ["config", "user.name", "ECC Tests"])?; + run_git(path, ["config", "user.email", "ecc-tests@example.com"])?; fs::write(path.join("README.md"), "hello\n")?; run_git(path, ["add", "README.md"])?; run_git( path, [ - "-c", - "user.name=ECC Tests", - "-c", - "user.email=ecc-tests@example.com", "commit", "-qm", "init", @@ -1856,6 +1898,68 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn merge_session_worktree_merges_branch_and_cleans_worktree() -> Result<()> { + let tempdir = TestDir::new("manager-merge-worktree")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let (fake_claude, _) = write_fake_claude(tempdir.path())?; + + let session_id = create_session_in_dir( + &db, + &cfg, + "merge later", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + + stop_session_with_options(&db, &session_id, false).await?; + let stopped = db + .get_session(&session_id)? + .context("stopped session should exist")?; + let worktree = stopped + .worktree + .clone() + .context("stopped session worktree missing")?; + + fs::write(worktree.path.join("feature.txt"), "ready to merge\n")?; + run_git(&worktree.path, ["add", "feature.txt"])?; + run_git(&worktree.path, ["commit", "-qm", "feature work"])?; + + let outcome = merge_session_worktree(&db, &session_id, true).await?; + + assert_eq!(outcome.session_id, session_id); + assert_eq!(outcome.branch, worktree.branch); + assert_eq!(outcome.base_branch, worktree.base_branch); + assert!(outcome.cleaned_worktree); + assert!(!outcome.already_up_to_date); + assert_eq!(fs::read_to_string(repo_root.join("feature.txt"))?, "ready to merge\n"); + + let merged = db + .get_session(&outcome.session_id)? + .context("merged session should still exist")?; + assert!(merged.worktree.is_none(), "worktree metadata should be cleared"); + assert!(!worktree.path.exists(), "worktree path should be removed"); + + let branch_output = StdCommand::new("git") + .arg("-C") + .arg(&repo_root) + .args(["branch", "--list", &worktree.branch]) + .output()?; + assert!( + String::from_utf8_lossy(&branch_output.stdout).trim().is_empty(), + "merged worktree branch should be deleted" + ); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn delete_session_removes_inactive_session_and_worktree() -> Result<()> { let tempdir = TestDir::new("manager-delete-session")?; diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index aaad30b2..de9e1c7c 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -46,6 +46,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), + (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), (_, KeyCode::Char(',')) => dashboard.adjust_auto_dispatch_limit(-1), (_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f9661d00..3fda8541 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -21,8 +21,6 @@ use crate::worktree; use crate::session::output::OutputStream; #[cfg(test)] use crate::session::{SessionMetrics, WorktreeInfo}; -#[cfg(test)] -use std::path::Path; const DEFAULT_PANE_SIZE_PERCENT: u16 = 35; const DEFAULT_GRID_SIZE_PERCENT: u16 = 50; @@ -516,6 +514,7 @@ impl Dashboard { " g Auto-dispatch unread handoffs across lead sessions", " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", + " m Merge selected ready worktree into base and clean it up", " p Toggle daemon auto-dispatch policy and persist config", " ,/. Decrease/increase auto-dispatch limit per lead", " s Stop selected session", @@ -1115,6 +1114,43 @@ impl Dashboard { )); } + pub async fn merge_selected_worktree(&mut self) { + let Some(session) = self.sessions.get(self.selected_session) else { + return; + }; + + if session.worktree.is_none() { + self.set_operator_note("selected session has no worktree to merge".to_string()); + return; + } + + let session_id = session.id.clone(); + let outcome = match manager::merge_session_worktree(&self.db, &session_id, true).await { + Ok(outcome) => outcome, + Err(error) => { + tracing::warn!("Failed to merge session {} worktree: {error}", session.id); + self.set_operator_note(format!( + "merge failed for {}: {error}", + format_session_id(&session_id) + )); + return; + } + }; + + self.refresh(); + self.set_operator_note(format!( + "merged {} into {} for {}{}", + outcome.branch, + outcome.base_branch, + format_session_id(&session_id), + if outcome.already_up_to_date { + " (already up to date)" + } else { + "" + } + )); + } + pub async fn prune_inactive_worktrees(&mut self) { match manager::prune_inactive_worktrees(&self.db).await { Ok(outcome) => { @@ -2327,14 +2363,16 @@ fn format_duration(duration_secs: u64) -> String { #[cfg(test)] mod tests { - use anyhow::Result; + use anyhow::{Context, Result}; use chrono::Utc; use ratatui::{Terminal, backend::TestBackend}; - use std::path::PathBuf; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; use uuid::Uuid; use super::*; - use crate::config::PaneLayout; + use crate::config::{Config, PaneLayout, Theme}; #[test] fn render_sessions_shows_summary_headers_and_selected_row() { @@ -3235,6 +3273,68 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn merge_selected_worktree_sets_operator_note_when_ready() -> Result<()> { + let tempdir = std::env::temp_dir().join(format!("dashboard-merge-{}", Uuid::new_v4())); + let repo_root = tempdir.join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(&tempdir); + let db = StateStore::open(&cfg.db_path)?; + let worktree = worktree::create_for_session_in_repo("merge1234", &cfg, &repo_root)?; + let session_id = "merge1234".to_string(); + let now = Utc::now(); + db.insert_session(&Session { + id: session_id.clone(), + task: "merge via dashboard".to_string(), + agent_type: "claude".to_string(), + working_dir: worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + std::fs::write(worktree.path.join("dashboard.txt"), "dashboard merge\n")?; + Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["add", "dashboard.txt"]) + .status()?; + Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["commit", "-qm", "dashboard work"]) + .status()?; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sync_selection_by_id(Some(&session_id)); + dashboard.merge_selected_worktree().await; + + let note = dashboard + .operator_note + .clone() + .context("operator note should be set")?; + assert!(note.contains("merged ecc/merge1234 into")); + assert!(note.contains(&format!("for {}", format_session_id(&session_id)))); + + let session = dashboard + .db + .get_session(&session_id)? + .context("merged session should still exist")?; + assert!(session.worktree.is_none(), "worktree metadata should be cleared"); + assert!(!worktree.path.exists(), "worktree path should be removed"); + assert_eq!( + std::fs::read_to_string(repo_root.join("dashboard.txt"))?, + "dashboard merge\n" + ); + + let _ = std::fs::remove_dir_all(&tempdir); + Ok(()) + } + #[tokio::test] async fn delete_selected_session_removes_inactive_session() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); @@ -3501,6 +3601,48 @@ mod tests { } } + fn build_config(root: &Path) -> Config { + Config { + db_path: root.join("state.db"), + worktree_root: root.join("worktrees"), + max_parallel_sessions: 4, + max_parallel_worktrees: 4, + session_timeout_secs: 60, + heartbeat_interval_secs: 5, + default_agent: "claude".to_string(), + auto_dispatch_unread_handoffs: false, + auto_dispatch_limit_per_session: 5, + cost_budget_usd: 10.0, + token_budget: 500_000, + theme: Theme::Dark, + pane_layout: PaneLayout::Horizontal, + risk_thresholds: Config::RISK_THRESHOLDS, + } + } + + fn init_git_repo(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + run_git(path, &["init", "-q"])?; + run_git(path, &["config", "user.name", "ECC Tests"])?; + run_git(path, &["config", "user.email", "ecc-tests@example.com"])?; + fs::write(path.join("README.md"), "hello\n")?; + run_git(path, &["add", "README.md"])?; + run_git(path, &["commit", "-qm", "init"])?; + Ok(()) + } + + fn run_git(path: &Path, args: &[&str]) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output()?; + if !output.status.success() { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)); + } + Ok(()) + } + fn sample_session( id: &str, agent_type: &str, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index dac77b6c..574cf22d 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; -use std::path::Path; +use serde::Serialize; +use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::Config; @@ -25,6 +26,13 @@ pub enum WorktreeHealth { Conflicted, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct MergeOutcome { + pub branch: String, + pub base_branch: String, + pub already_up_to_date: bool, +} + /// 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")?; @@ -73,18 +81,59 @@ pub(crate) fn create_for_session_in_repo( } /// Remove a worktree and its branch. -pub fn remove(path: &Path) -> Result<()> { +pub fn remove(worktree: &WorktreeInfo) -> Result<()> { + let repo_root = match base_checkout_path(worktree) { + Ok(path) => path, + Err(error) => { + tracing::warn!( + "Falling back to filesystem-only cleanup for {}: {error}", + worktree.path.display() + ); + if worktree.path.exists() { + if let Err(remove_error) = std::fs::remove_dir_all(&worktree.path) { + tracing::warn!( + "Fallback worktree directory cleanup warning for {}: {remove_error}", + worktree.path.display() + ); + } + } + return Ok(()); + } + }; let output = Command::new("git") .arg("-C") - .arg(path) + .arg(&repo_root) .args(["worktree", "remove", "--force"]) - .arg(path) + .arg(&worktree.path) .output() .context("Failed to remove worktree")?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); tracing::warn!("Worktree removal warning: {stderr}"); + if worktree.path.exists() { + if let Err(remove_error) = std::fs::remove_dir_all(&worktree.path) { + tracing::warn!( + "Fallback worktree directory cleanup warning for {}: {remove_error}", + worktree.path.display() + ); + } + } + } + + let branch_output = Command::new("git") + .arg("-C") + .arg(&repo_root) + .args(["branch", "-D", &worktree.branch]) + .output() + .context("Failed to delete worktree branch")?; + + if !branch_output.status.success() { + let stderr = String::from_utf8_lossy(&branch_output.stderr); + tracing::warn!( + "Worktree branch deletion warning for {}: {stderr}", + worktree.branch + ); } Ok(()) @@ -248,6 +297,61 @@ pub fn health(worktree: &WorktreeInfo) -> Result { } } +pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { + let readiness = merge_readiness(worktree)?; + if readiness.status == MergeReadinessStatus::Conflicted { + anyhow::bail!(readiness.summary); + } + + if !git_status_short(&worktree.path)?.is_empty() { + anyhow::bail!( + "Worktree {} has uncommitted changes; commit or discard them before merging", + worktree.branch + ); + } + + let repo_root = base_checkout_path(worktree)?; + let current_branch = get_current_branch(&repo_root)?; + if current_branch != worktree.base_branch { + anyhow::bail!( + "Base branch {} is not checked out in repo root (currently {})", + worktree.base_branch, + current_branch + ); + } + + if !git_status_short(&repo_root)?.is_empty() { + anyhow::bail!( + "Repository root {} has uncommitted changes; commit or stash them before merging", + repo_root.display() + ); + } + + let output = Command::new("git") + .arg("-C") + .arg(&repo_root) + .args(["merge", "--no-edit", &worktree.branch]) + .output() + .context("Failed to merge worktree branch into base")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git merge failed: {stderr}"); + } + + let merged_output = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + Ok(MergeOutcome { + branch: worktree.branch.clone(), + base_branch: worktree.base_branch.clone(), + already_up_to_date: merged_output.contains("Already up to date."), + }) +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -387,6 +491,61 @@ fn get_current_branch(repo_root: &Path) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +fn base_checkout_path(worktree: &WorktreeInfo) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["worktree", "list", "--porcelain"]) + .output() + .context("Failed to resolve git worktree list")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git worktree list --porcelain failed: {stderr}"); + } + + let target_branch = format!("refs/heads/{}", worktree.base_branch); + let mut current_path: Option = None; + let mut current_branch: Option = None; + let mut fallback: Option = None; + + for line in String::from_utf8_lossy(&output.stdout).lines() { + if line.is_empty() { + if let Some(path) = current_path.take() { + if fallback.is_none() && path != worktree.path { + fallback = Some(path.clone()); + } + if current_branch.as_deref() == Some(target_branch.as_str()) && path != worktree.path + { + return Ok(path); + } + } + current_branch = None; + continue; + } + + if let Some(path) = line.strip_prefix("worktree ") { + current_path = Some(PathBuf::from(path.trim())); + } else if let Some(branch) = line.strip_prefix("branch ") { + current_branch = Some(branch.trim().to_string()); + } + } + + if let Some(path) = current_path.take() { + if fallback.is_none() && path != worktree.path { + fallback = Some(path.clone()); + } + if current_branch.as_deref() == Some(target_branch.as_str()) && path != worktree.path { + return Ok(path); + } + } + + fallback.context(format!( + "Failed to locate base checkout for {} from git worktree list", + worktree.base_branch + )) +} + #[cfg(test)] mod tests { use super::*; From e6460534e3d70790273bb5ba4e478f3ffb58646d Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:04:52 -0700 Subject: [PATCH 045/459] feat: add ecc2 bulk worktree merge actions --- ecc2/src/main.rs | 143 +++++++++++++++++++++++++-- ecc2/src/session/manager.rs | 192 +++++++++++++++++++++++++++++++++++- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 125 ++++++++++++++++++++++- ecc2/src/worktree/mod.rs | 6 +- 5 files changed, 457 insertions(+), 10 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 008b2094..e70ee222 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -207,6 +207,9 @@ enum Commands { MergeWorktree { /// Session ID or alias session_id: Option, + /// Merge all ready inactive worktrees + #[arg(long)] + all: bool, /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, @@ -703,17 +706,36 @@ async fn main() -> Result<()> { } Some(Commands::MergeWorktree { session_id, + all, json, keep_worktree, }) => { - let id = session_id.unwrap_or_else(|| "latest".to_string()); - let resolved_id = resolve_session_id(&db, &id)?; - let outcome = - session::manager::merge_session_worktree(&db, &resolved_id, !keep_worktree).await?; - if json { - println!("{}", serde_json::to_string_pretty(&outcome)?); + if all && session_id.is_some() { + return Err(anyhow::anyhow!( + "merge-worktree does not accept a session ID when --all is set" + )); + } + if all { + let outcome = session::manager::merge_ready_worktrees(&db, !keep_worktree).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_bulk_worktree_merge_human(&outcome)); + } } else { - println!("{}", format_worktree_merge_human(&outcome)); + let id = session_id.unwrap_or_else(|| "latest".to_string()); + let resolved_id = resolve_session_id(&db, &id)?; + let outcome = session::manager::merge_session_worktree( + &db, + &resolved_id, + !keep_worktree, + ) + .await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_worktree_merge_human(&outcome)); + } } } Some(Commands::PruneWorktrees { json }) => { @@ -1102,6 +1124,62 @@ fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome) lines.join("\n") } +fn format_bulk_worktree_merge_human(outcome: &session::manager::WorktreeBulkMergeOutcome) -> String { + let mut lines = Vec::new(); + lines.push(format!( + "Merged {} ready worktree(s)", + outcome.merged.len() + )); + + for merged in &outcome.merged { + lines.push(format!( + "- merged {} -> {} for {}{}", + merged.branch, + merged.base_branch, + short_session(&merged.session_id), + if merged.already_up_to_date { + " (already up to date)" + } else { + "" + } + )); + } + + if !outcome.active_with_worktree_ids.is_empty() { + lines.push(format!( + "Skipped {} active worktree session(s)", + outcome.active_with_worktree_ids.len() + )); + } + if !outcome.conflicted_session_ids.is_empty() { + lines.push(format!( + "Skipped {} conflicted worktree(s)", + outcome.conflicted_session_ids.len() + )); + } + if !outcome.dirty_worktree_ids.is_empty() { + lines.push(format!( + "Skipped {} dirty worktree(s)", + outcome.dirty_worktree_ids.len() + )); + } + if !outcome.failures.is_empty() { + lines.push(format!( + "Encountered {} merge failure(s)", + outcome.failures.len() + )); + for failure in &outcome.failures { + lines.push(format!( + "- failed {}: {}", + short_session(&failure.session_id), + failure.reason + )); + } + } + + lines.join("\n") +} + fn worktree_status_exit_code(report: &WorktreeStatusReport) -> i32 { report.check_exit_code } @@ -1575,10 +1653,12 @@ mod tests { match cli.command { Some(Commands::MergeWorktree { session_id, + all, json, keep_worktree, }) => { assert_eq!(session_id.as_deref(), Some("deadbeef")); + assert!(!all); assert!(json); assert!(keep_worktree); } @@ -1586,6 +1666,27 @@ mod tests { } } + #[test] + fn cli_parses_merge_worktree_all_flags() { + let cli = Cli::try_parse_from(["ecc", "merge-worktree", "--all", "--json"]) + .expect("merge-worktree --all --json should parse"); + + match cli.command { + Some(Commands::MergeWorktree { + session_id, + all, + json, + keep_worktree, + }) => { + assert!(session_id.is_none()); + assert!(all); + assert!(json); + assert!(!keep_worktree); + } + _ => panic!("expected merge-worktree subcommand"), + } + } + #[test] fn format_worktree_status_human_includes_readiness_and_conflicts() { let report = WorktreeStatusReport { @@ -1649,6 +1750,34 @@ mod tests { assert!(text.contains("Cleanup removed worktree and branch")); } + #[test] + fn format_bulk_worktree_merge_human_reports_summary_and_skips() { + let text = format_bulk_worktree_merge_human(&session::manager::WorktreeBulkMergeOutcome { + merged: vec![session::manager::WorktreeMergeOutcome { + session_id: "deadbeefcafefeed".to_string(), + branch: "ecc/deadbeefcafefeed".to_string(), + base_branch: "main".to_string(), + already_up_to_date: false, + cleaned_worktree: true, + }], + active_with_worktree_ids: vec!["running12345678".to_string()], + conflicted_session_ids: vec!["conflict123456".to_string()], + dirty_worktree_ids: vec!["dirty123456789".to_string()], + failures: vec![session::manager::WorktreeMergeFailure { + session_id: "fail1234567890".to_string(), + reason: "base branch not checked out".to_string(), + }], + }); + + assert!(text.contains("Merged 1 ready worktree(s)")); + assert!(text.contains("- merged ecc/deadbeefcafefeed -> main for deadbeef")); + assert!(text.contains("Skipped 1 active worktree session(s)")); + assert!(text.contains("Skipped 1 conflicted worktree(s)")); + assert!(text.contains("Skipped 1 dirty worktree(s)")); + assert!(text.contains("Encountered 1 merge failure(s)")); + assert!(text.contains("- failed fail1234: base branch not checked out")); + } + #[test] fn format_worktree_status_human_handles_missing_worktree() { let report = WorktreeStatusReport { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 24f2ab0b..1897f6ad 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -626,7 +626,10 @@ pub async fn merge_session_worktree( ) -> Result { let session = resolve_session(db, id)?; - if matches!(session.state, SessionState::Pending | SessionState::Running) { + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) { anyhow::bail!( "Cannot merge active session {} while it is {}", session.id, @@ -654,6 +657,95 @@ pub async fn merge_session_worktree( }) } +#[derive(Debug, Clone, Serialize)] +pub struct WorktreeMergeFailure { + pub session_id: String, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WorktreeBulkMergeOutcome { + pub merged: Vec, + pub active_with_worktree_ids: Vec, + pub conflicted_session_ids: Vec, + pub dirty_worktree_ids: Vec, + pub failures: Vec, +} + +pub async fn merge_ready_worktrees( + db: &StateStore, + cleanup_worktree: bool, +) -> Result { + let sessions = db.list_sessions()?; + let mut merged = Vec::new(); + let mut active_with_worktree_ids = Vec::new(); + let mut conflicted_session_ids = Vec::new(); + let mut dirty_worktree_ids = Vec::new(); + let mut failures = Vec::new(); + + for session in sessions { + let Some(worktree) = session.worktree.clone() else { + continue; + }; + + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) { + active_with_worktree_ids.push(session.id); + continue; + } + + match crate::worktree::merge_readiness(&worktree) { + Ok(readiness) + if readiness.status == crate::worktree::MergeReadinessStatus::Conflicted => + { + conflicted_session_ids.push(session.id); + continue; + } + Ok(_) => {} + Err(error) => { + failures.push(WorktreeMergeFailure { + session_id: session.id, + reason: error.to_string(), + }); + continue; + } + } + + match crate::worktree::has_uncommitted_changes(&worktree) { + Ok(true) => { + dirty_worktree_ids.push(session.id); + continue; + } + Ok(false) => {} + Err(error) => { + failures.push(WorktreeMergeFailure { + session_id: session.id, + reason: error.to_string(), + }); + continue; + } + } + + match merge_session_worktree(db, &session.id, cleanup_worktree).await { + Ok(outcome) => merged.push(outcome), + Err(error) => failures.push(WorktreeMergeFailure { + session_id: session.id, + reason: error.to_string(), + }), + } + } + + Ok(WorktreeBulkMergeOutcome { + merged, + active_with_worktree_ids, + conflicted_session_ids, + dirty_worktree_ids, + failures, + }) +} + #[derive(Debug, Clone, Serialize)] pub struct WorktreePruneOutcome { pub cleaned_session_ids: Vec, @@ -1960,6 +2052,104 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn merge_ready_worktrees_merges_ready_sessions_and_skips_active_and_dirty() -> Result<()> + { + let tempdir = TestDir::new("manager-merge-ready-worktrees")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let merged_worktree = + crate::worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?; + fs::write(merged_worktree.path.join("merged.txt"), "bulk merge\n")?; + run_git(&merged_worktree.path, ["add", "merged.txt"])?; + run_git(&merged_worktree.path, ["commit", "-qm", "merge ready"])?; + db.insert_session(&Session { + id: "merge-ready".to_string(), + task: "merge me".to_string(), + agent_type: "claude".to_string(), + working_dir: merged_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(merged_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let active_worktree = + crate::worktree::create_for_session_in_repo("active-worktree", &cfg, &repo_root)?; + db.insert_session(&Session { + id: "active-worktree".to_string(), + task: "still running".to_string(), + agent_type: "claude".to_string(), + working_dir: active_worktree.path.clone(), + state: SessionState::Running, + pid: Some(12345), + worktree: Some(active_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let dirty_worktree = + crate::worktree::create_for_session_in_repo("dirty-worktree", &cfg, &repo_root)?; + fs::write(dirty_worktree.path.join("dirty.txt"), "not committed yet\n")?; + db.insert_session(&Session { + id: "dirty-worktree".to_string(), + task: "needs commit".to_string(), + agent_type: "claude".to_string(), + working_dir: dirty_worktree.path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(dirty_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let outcome = merge_ready_worktrees(&db, true).await?; + + assert_eq!(outcome.merged.len(), 1); + assert_eq!(outcome.merged[0].session_id, "merge-ready"); + assert_eq!(outcome.active_with_worktree_ids, vec!["active-worktree".to_string()]); + assert_eq!(outcome.dirty_worktree_ids, vec!["dirty-worktree".to_string()]); + assert!(outcome.conflicted_session_ids.is_empty()); + assert!(outcome.failures.is_empty()); + + assert_eq!( + fs::read_to_string(repo_root.join("merged.txt"))?, + "bulk merge\n" + ); + assert!( + db.get_session("merge-ready")? + .context("merged session should still exist")? + .worktree + .is_none() + ); + assert!( + db.get_session("active-worktree")? + .context("active session should still exist")? + .worktree + .is_some() + ); + assert!( + db.get_session("dirty-worktree")? + .context("dirty session should still exist")? + .worktree + .is_some() + ); + assert!(!merged_worktree.path.exists()); + assert!(active_worktree.path.exists()); + assert!(dirty_worktree.path.exists()); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn delete_session_removes_inactive_session_and_worktree() -> Result<()> { let tempdir = TestDir::new("manager-delete-session")?; diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index de9e1c7c..792f4f31 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -47,6 +47,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, 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(',')) => dashboard.adjust_auto_dispatch_limit(-1), (_, KeyCode::Char('.')) => dashboard.adjust_auto_dispatch_limit(1), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 3fda8541..40ba2dc3 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 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] 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() { @@ -515,6 +515,7 @@ impl Dashboard { " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", " 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", " ,/. Decrease/increase auto-dispatch limit per lead", " s Stop selected session", @@ -1151,6 +1152,51 @@ impl Dashboard { )); } + pub async fn merge_ready_worktrees(&mut self) { + match manager::merge_ready_worktrees(&self.db, true).await { + Ok(outcome) => { + self.refresh(); + if outcome.merged.is_empty() + && outcome.active_with_worktree_ids.is_empty() + && outcome.conflicted_session_ids.is_empty() + && outcome.dirty_worktree_ids.is_empty() + && outcome.failures.is_empty() + { + self.set_operator_note("no ready worktrees to merge".to_string()); + return; + } + + let mut parts = vec![format!("merged {} ready worktree(s)", outcome.merged.len())]; + if !outcome.active_with_worktree_ids.is_empty() { + parts.push(format!( + "skipped {} active", + outcome.active_with_worktree_ids.len() + )); + } + if !outcome.conflicted_session_ids.is_empty() { + parts.push(format!( + "skipped {} conflicted", + outcome.conflicted_session_ids.len() + )); + } + if !outcome.dirty_worktree_ids.is_empty() { + parts.push(format!( + "skipped {} dirty", + outcome.dirty_worktree_ids.len() + )); + } + if !outcome.failures.is_empty() { + parts.push(format!("{} failed", outcome.failures.len())); + } + self.set_operator_note(parts.join("; ")); + } + Err(error) => { + tracing::warn!("Failed to merge ready worktrees: {error}"); + self.set_operator_note(format!("merge ready worktrees failed: {error}")); + } + } + } + pub async fn prune_inactive_worktrees(&mut self) { match manager::prune_inactive_worktrees(&self.db).await { Ok(outcome) => { @@ -3335,6 +3381,83 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn merge_ready_worktrees_sets_operator_note_with_skip_summary() -> Result<()> { + let tempdir = + std::env::temp_dir().join(format!("dashboard-merge-ready-{}", Uuid::new_v4())); + let repo_root = tempdir.join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(&tempdir); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let merged_worktree = worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?; + std::fs::write(merged_worktree.path.join("merged.txt"), "dashboard bulk merge\n")?; + Command::new("git") + .arg("-C") + .arg(&merged_worktree.path) + .args(["add", "merged.txt"]) + .status()?; + Command::new("git") + .arg("-C") + .arg(&merged_worktree.path) + .args(["commit", "-qm", "dashboard bulk merge"]) + .status()?; + db.insert_session(&Session { + id: "merge-ready".to_string(), + task: "merge via dashboard".to_string(), + agent_type: "claude".to_string(), + working_dir: merged_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(merged_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let active_worktree = + worktree::create_for_session_in_repo("active-ready", &cfg, &repo_root)?; + db.insert_session(&Session { + id: "active-ready".to_string(), + task: "still active".to_string(), + agent_type: "claude".to_string(), + working_dir: active_worktree.path.clone(), + state: SessionState::Running, + pid: Some(999), + worktree: Some(active_worktree.clone()), + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.merge_ready_worktrees().await; + + let note = dashboard + .operator_note + .clone() + .context("operator note should be set")?; + assert!(note.contains("merged 1 ready worktree(s)")); + assert!(note.contains("skipped 1 active")); + assert!( + dashboard + .db + .get_session("merge-ready")? + .context("merged session should still exist")? + .worktree + .is_none() + ); + assert_eq!( + std::fs::read_to_string(repo_root.join("merged.txt"))?, + "dashboard bulk merge\n" + ); + + let _ = std::fs::remove_dir_all(&tempdir); + Ok(()) + } + #[tokio::test] async fn delete_selected_session_removes_inactive_session() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 574cf22d..c53a57e0 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -297,13 +297,17 @@ pub fn health(worktree: &WorktreeInfo) -> Result { } } +pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result { + Ok(!git_status_short(&worktree.path)?.is_empty()) +} + pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { let readiness = merge_readiness(worktree)?; if readiness.status == MergeReadinessStatus::Conflicted { anyhow::bail!(readiness.summary); } - if !git_status_short(&worktree.path)?.is_empty() { + if has_uncommitted_changes(worktree)? { anyhow::bail!( "Worktree {} has uncommitted changes; commit or discard them before merging", worktree.branch From 27d7964bb12358687dfd0c619ad25a597a785906 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:11:22 -0700 Subject: [PATCH 046/459] 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, From d8c8178f92f48161857a59818f0b3d7ed088da81 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:17:45 -0700 Subject: [PATCH 047/459] feat: add ecc2 worktree conflict protocol --- ecc2/src/main.rs | 286 ++++++++++++++++++++++++++++++++++++++ ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 135 +++++++++++++++++- 3 files changed, 417 insertions(+), 5 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index e70ee222..6a5b3cd6 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -203,6 +203,20 @@ enum Commands { #[arg(long)] check: bool, }, + /// Show conflict-resolution protocol for a worktree + WorktreeResolution { + /// Session ID or alias + session_id: Option, + /// Show conflict protocol for all conflicted worktrees + #[arg(long)] + all: bool, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + /// Return a non-zero exit code when conflicted worktrees are present + #[arg(long)] + check: bool, + }, /// Merge a session worktree branch into its base branch MergeWorktree { /// Session ID or alias @@ -704,6 +718,46 @@ async fn main() -> Result<()> { std::process::exit(worktree_status_reports_exit_code(&reports)); } } + Some(Commands::WorktreeResolution { + session_id, + all, + json, + check, + }) => { + if all && session_id.is_some() { + return Err(anyhow::anyhow!( + "worktree-resolution does not accept a session ID when --all is set" + )); + } + let reports = if all { + session::manager::list_sessions(&db)? + .into_iter() + .map(|session| build_worktree_resolution_report(&session)) + .collect::>>()? + .into_iter() + .filter(|report| report.conflicted) + .collect::>() + } else { + let id = session_id.unwrap_or_else(|| "latest".to_string()); + let resolved_id = resolve_session_id(&db, &id)?; + let session = db + .get_session(&resolved_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {resolved_id}"))?; + vec![build_worktree_resolution_report(&session)?] + }; + if json { + if all { + println!("{}", serde_json::to_string_pretty(&reports)?); + } else { + println!("{}", serde_json::to_string_pretty(&reports[0])?); + } + } else { + println!("{}", format_worktree_resolution_reports_human(&reports)); + } + if check { + std::process::exit(worktree_resolution_reports_exit_code(&reports)); + } + } Some(Commands::MergeWorktree { session_id, all, @@ -987,6 +1041,22 @@ struct WorktreeStatusReport { merge_readiness: Option, } +#[derive(Debug, Clone, Serialize)] +struct WorktreeResolutionReport { + session_id: String, + task: String, + session_state: String, + attached: bool, + conflicted: bool, + check_exit_code: i32, + path: Option, + branch: Option, + base_branch: Option, + summary: String, + conflicts: Vec, + resolution_steps: Vec, +} + fn build_worktree_status_report(session: &session::Session, include_patch: bool) -> Result { let Some(worktree) = session.worktree.as_ref() else { return Ok(WorktreeStatusReport { @@ -1047,6 +1117,55 @@ fn build_worktree_status_report(session: &session::Session, include_patch: bool) }) } +fn build_worktree_resolution_report(session: &session::Session) -> Result { + let Some(worktree) = session.worktree.as_ref() else { + return Ok(WorktreeResolutionReport { + session_id: session.id.clone(), + task: session.task.clone(), + session_state: session.state.to_string(), + attached: false, + conflicted: false, + check_exit_code: 0, + path: None, + branch: None, + base_branch: None, + summary: "No worktree attached".to_string(), + conflicts: Vec::new(), + resolution_steps: Vec::new(), + }); + }; + + let merge_readiness = worktree::merge_readiness(worktree)?; + let conflicted = merge_readiness.status == worktree::MergeReadinessStatus::Conflicted; + let resolution_steps = if conflicted { + vec![ + format!("Inspect current patch: ecc worktree-status {} --patch", session.id), + format!("Open worktree: cd {}", worktree.path.display()), + "Resolve conflicts and stage files: git add ".to_string(), + format!("Commit the resolution on {}: git commit", worktree.branch), + format!("Re-check readiness: ecc worktree-status {} --check", session.id), + format!("Merge when clear: ecc merge-worktree {}", session.id), + ] + } else { + Vec::new() + }; + + Ok(WorktreeResolutionReport { + session_id: session.id.clone(), + task: session.task.clone(), + session_state: session.state.to_string(), + attached: true, + conflicted, + check_exit_code: if conflicted { 2 } else { 0 }, + path: Some(worktree.path.display().to_string()), + branch: Some(worktree.branch.clone()), + base_branch: Some(worktree.base_branch.clone()), + summary: merge_readiness.summary, + conflicts: merge_readiness.conflicts, + resolution_steps, + }) +} + fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { let mut lines = vec![format!( "Worktree status for {} [{}]", @@ -1102,6 +1221,58 @@ fn format_worktree_status_reports_human(reports: &[WorktreeStatusReport]) -> Str .join("\n\n") } +fn format_worktree_resolution_human(report: &WorktreeResolutionReport) -> String { + let mut lines = vec![format!( + "Worktree resolution for {} [{}]", + short_session(&report.session_id), + report.session_state + )]; + lines.push(format!("Task {}", report.task)); + + if !report.attached { + lines.push(report.summary.clone()); + return lines.join("\n"); + } + + if let Some(path) = report.path.as_ref() { + lines.push(format!("Path {path}")); + } + if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) { + lines.push(format!("Branch {branch} (base {base_branch})")); + } + lines.push(report.summary.clone()); + + if !report.conflicts.is_empty() { + lines.push("Conflicts".to_string()); + for conflict in &report.conflicts { + lines.push(format!("- {conflict}")); + } + } + + if report.resolution_steps.is_empty() { + lines.push("No conflict-resolution steps required".to_string()); + } else { + lines.push("Resolution steps".to_string()); + for (index, step) in report.resolution_steps.iter().enumerate() { + lines.push(format!("{}. {step}", index + 1)); + } + } + + lines.join("\n") +} + +fn format_worktree_resolution_reports_human(reports: &[WorktreeResolutionReport]) -> String { + if reports.is_empty() { + return "No conflicted worktrees found".to_string(); + } + + reports + .iter() + .map(format_worktree_resolution_human) + .collect::>() + .join("\n\n") +} + fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome) -> String { let mut lines = vec![format!( "Merged worktree for {}", @@ -1192,6 +1363,14 @@ fn worktree_status_reports_exit_code(reports: &[WorktreeStatusReport]) -> i32 { .unwrap_or(0) } +fn worktree_resolution_reports_exit_code(reports: &[WorktreeResolutionReport]) -> i32 { + reports + .iter() + .map(|report| report.check_exit_code) + .max() + .unwrap_or(0) +} + fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome) -> String { let mut lines = Vec::new(); @@ -1626,6 +1805,48 @@ mod tests { } } + #[test] + fn cli_parses_worktree_resolution_flags() { + let cli = Cli::try_parse_from(["ecc", "worktree-resolution", "planner", "--json", "--check"]) + .expect("worktree-resolution flags should parse"); + + match cli.command { + Some(Commands::WorktreeResolution { + session_id, + all, + json, + check, + }) => { + assert_eq!(session_id.as_deref(), Some("planner")); + assert!(!all); + assert!(json); + assert!(check); + } + _ => panic!("expected worktree-resolution subcommand"), + } + } + + #[test] + fn cli_parses_worktree_resolution_all_flag() { + let cli = Cli::try_parse_from(["ecc", "worktree-resolution", "--all"]) + .expect("worktree-resolution --all should parse"); + + match cli.command { + Some(Commands::WorktreeResolution { + session_id, + all, + json, + check, + }) => { + assert!(session_id.is_none()); + assert!(all); + assert!(!json); + assert!(!check); + } + _ => panic!("expected worktree-resolution subcommand"), + } + } + #[test] fn cli_parses_prune_worktrees_json_flag() { let cli = Cli::try_parse_from(["ecc", "prune-worktrees", "--json"]) @@ -1721,6 +1942,71 @@ mod tests { assert!(text.contains("--- Branch diff vs main ---")); } + #[test] + fn format_worktree_resolution_human_includes_protocol_steps() { + let report = WorktreeResolutionReport { + session_id: "deadbeefcafefeed".to_string(), + task: "Resolve merge conflict".to_string(), + session_state: "stopped".to_string(), + attached: true, + conflicted: true, + check_exit_code: 2, + path: Some("/tmp/ecc/wt-1".to_string()), + branch: Some("ecc/deadbeefcafefeed".to_string()), + base_branch: Some("main".to_string()), + summary: "Merge blocked by 1 conflict(s): README.md".to_string(), + conflicts: vec!["README.md".to_string()], + resolution_steps: vec![ + "Inspect current patch: ecc worktree-status deadbeefcafefeed --patch".to_string(), + "Open worktree: cd /tmp/ecc/wt-1".to_string(), + "Resolve conflicts and stage files: git add ".to_string(), + ], + }; + + let text = format_worktree_resolution_human(&report); + assert!(text.contains("Worktree resolution for deadbeef [stopped]")); + assert!(text.contains("Merge blocked by 1 conflict(s): README.md")); + assert!(text.contains("Conflicts")); + assert!(text.contains("- README.md")); + assert!(text.contains("Resolution steps")); + assert!(text.contains("1. Inspect current patch")); + } + + #[test] + fn worktree_resolution_reports_exit_code_tracks_conflicts() { + let clear = WorktreeResolutionReport { + session_id: "clear".to_string(), + task: "ok".to_string(), + session_state: "stopped".to_string(), + attached: false, + conflicted: false, + check_exit_code: 0, + path: None, + branch: None, + base_branch: None, + summary: "No worktree attached".to_string(), + conflicts: Vec::new(), + resolution_steps: Vec::new(), + }; + let conflicted = WorktreeResolutionReport { + session_id: "conflicted".to_string(), + task: "resolve".to_string(), + session_state: "failed".to_string(), + attached: true, + conflicted: true, + check_exit_code: 2, + path: Some("/tmp/ecc/wt-2".to_string()), + branch: Some("ecc/conflicted".to_string()), + base_branch: Some("main".to_string()), + summary: "Merge blocked by 1 conflict(s): src/lib.rs".to_string(), + conflicts: vec!["src/lib.rs".to_string()], + resolution_steps: vec!["Inspect current patch".to_string()], + }; + + assert_eq!(worktree_resolution_reports_exit_code(&[clear]), 0); + assert_eq!(worktree_resolution_reports_exit_code(&[conflicted]), 2); + } + #[test] fn format_prune_worktrees_human_reports_cleaned_and_active_sessions() { let text = format_prune_worktrees_human(&session::manager::WorktreePruneOutcome { diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 34f3bc17..8b8e7cac 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -46,6 +46,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), + (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 58aa2d29..1282fb0a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -54,6 +54,7 @@ pub struct Dashboard { selected_diff_summary: Option, selected_diff_preview: Vec, selected_diff_patch: Option, + selected_conflict_protocol: Option, selected_merge_readiness: Option, output_mode: OutputMode, selected_pane: Pane, @@ -94,6 +95,7 @@ enum Pane { enum OutputMode { SessionOutput, WorktreeDiff, + ConflictProtocol, } #[derive(Debug, Clone, Copy)] @@ -173,6 +175,7 @@ impl Dashboard { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, @@ -365,6 +368,16 @@ impl Dashboard { }); (" Diff ", content) } + OutputMode::ConflictProtocol => { + let content = self + .selected_conflict_protocol + .clone() + .unwrap_or_else(|| { + "No conflicted worktree available for the selected session." + .to_string() + }); + (" Conflict Protocol ", content) + } } } else { (" Output ", "No sessions. Press 'n' to start one.".to_string()) @@ -462,7 +475,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] 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 ", + " [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 conflict proto[c]ol [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() { @@ -514,6 +527,7 @@ impl Dashboard { " g Auto-dispatch unread handoffs across lead sessions", " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", + " c Show conflict-resolution protocol for selected conflicted worktree", " 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", @@ -727,6 +741,34 @@ impl Dashboard { self.reset_output_view(); self.set_operator_note("showing session output".to_string()); } + OutputMode::ConflictProtocol => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + } + } + + pub fn toggle_conflict_protocol_mode(&mut self) { + match self.output_mode { + OutputMode::ConflictProtocol => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + _ => { + if self.selected_conflict_protocol.is_some() { + self.output_mode = OutputMode::ConflictProtocol; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = 0; + self.set_operator_note("showing worktree conflict protocol".to_string()); + } else { + self.set_operator_note( + "no conflicted worktree for selected session".to_string(), + ); + } + } } } @@ -1473,10 +1515,8 @@ impl Dashboard { } fn sync_selected_diff(&mut self) { - let worktree = self - .sessions - .get(self.selected_session) - .and_then(|session| session.worktree.as_ref()); + let session = self.sessions.get(self.selected_session); + let worktree = session.and_then(|session| session.worktree.as_ref()); self.selected_diff_summary = worktree.and_then(|worktree| worktree::diff_summary(worktree).ok().flatten()); @@ -1487,9 +1527,20 @@ impl Dashboard { .and_then(|worktree| worktree::diff_patch_preview(worktree, MAX_DIFF_PATCH_LINES).ok().flatten()); self.selected_merge_readiness = worktree .and_then(|worktree| worktree::merge_readiness(worktree).ok()); + self.selected_conflict_protocol = session + .zip(worktree) + .zip(self.selected_merge_readiness.as_ref()) + .and_then(|((session, worktree), merge_readiness)| { + build_conflict_protocol(&session.id, worktree, merge_readiness) + }); if self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_none() { self.output_mode = OutputMode::SessionOutput; } + if self.output_mode == OutputMode::ConflictProtocol + && self.selected_conflict_protocol.is_none() + { + self.output_mode = OutputMode::SessionOutput; + } } fn sync_selected_messages(&mut self) { @@ -2410,6 +2461,44 @@ fn format_session_id(id: &str) -> String { id.chars().take(8).collect() } +fn build_conflict_protocol( + session_id: &str, + worktree: &crate::session::WorktreeInfo, + merge_readiness: &worktree::MergeReadiness, +) -> Option { + if merge_readiness.status != worktree::MergeReadinessStatus::Conflicted { + return None; + } + + let mut lines = vec![ + format!("Conflict protocol for {}", format_session_id(session_id)), + format!("Worktree {}", worktree.path.display()), + format!("Branch {} (base {})", worktree.branch, worktree.base_branch), + merge_readiness.summary.clone(), + ]; + + if !merge_readiness.conflicts.is_empty() { + lines.push("Conflicts".to_string()); + for conflict in &merge_readiness.conflicts { + lines.push(format!("- {conflict}")); + } + } + + lines.push("Resolution steps".to_string()); + lines.push(format!( + "1. Inspect current patch: ecc worktree-status {session_id} --patch" + )); + lines.push(format!("2. Open worktree: cd {}", worktree.path.display())); + lines.push("3. Resolve conflicts and stage files: git add ".to_string()); + lines.push(format!("4. Commit the resolution on {}: git commit", worktree.branch)); + lines.push(format!( + "5. Re-check readiness: ecc worktree-status {session_id} --check" + )); + lines.push(format!("6. Merge when clear: ecc merge-worktree {session_id}")); + + Some(lines.join("\n")) +} + fn assignment_action_label(action: manager::AssignmentAction) -> &'static str { match action { manager::AssignmentAction::Spawned => "spawned", @@ -2566,6 +2655,41 @@ mod tests { assert!(rendered.contains("diff --git a/src/lib.rs b/src/lib.rs")); } + #[test] + fn toggle_conflict_protocol_mode_switches_to_protocol_view() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.selected_merge_readiness = Some(worktree::MergeReadiness { + status: worktree::MergeReadinessStatus::Conflicted, + summary: "Merge blocked by 1 conflict(s): src/main.rs".to_string(), + conflicts: vec!["src/main.rs".to_string()], + }); + dashboard.selected_conflict_protocol = Some( + "Conflict protocol for focus-12\nResolution steps\n1. Inspect current patch: ecc worktree-status focus-12345678 --patch" + .to_string(), + ); + + dashboard.toggle_conflict_protocol_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::ConflictProtocol); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing worktree conflict protocol") + ); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Conflict Protocol")); + assert!(rendered.contains("Resolution steps")); + } + #[test] fn selected_session_metrics_text_includes_team_capacity_summary() { let mut dashboard = test_dashboard( @@ -3762,6 +3886,7 @@ mod tests { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, selected_pane: Pane::Sessions, From dada1337840f0e3cb26dddbbbcf32ae4b6ec8e28 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:27:16 -0700 Subject: [PATCH 048/459] feat: surface ecc2 daemon auto-merge activity --- ecc2/src/session/daemon.rs | 71 +++++++++++++++++++-- ecc2/src/session/manager.rs | 24 +++++++ ecc2/src/session/store.rs | 122 +++++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 57 +++++++++++++++++ 4 files changed, 267 insertions(+), 7 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index e54faea4..01907807 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -342,13 +342,33 @@ where } 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 + maybe_auto_merge_ready_worktrees_with_recorder( + cfg, + || manager::merge_ready_worktrees(db, true), + |merged, active, conflicted, dirty, failed| { + db.record_daemon_auto_merge_pass(merged, active, conflicted, dirty, failed) + }, + ) + .await } async fn maybe_auto_merge_ready_worktrees_with(cfg: &Config, merge: F) -> Result where F: Fn() -> Fut, Fut: Future>, +{ + maybe_auto_merge_ready_worktrees_with_recorder(cfg, merge, |_, _, _, _, _| Ok(())).await +} + +async fn maybe_auto_merge_ready_worktrees_with_recorder( + cfg: &Config, + merge: F, + mut record: R, +) -> Result +where + F: Fn() -> Fut, + Fut: Future>, + R: FnMut(usize, usize, usize, usize, usize) -> Result<()>, { if !cfg.auto_merge_ready_worktrees { return Ok(0); @@ -356,22 +376,33 @@ where let outcome = merge().await?; let merged = outcome.merged.len(); + let active = outcome.active_with_worktree_ids.len(); + let conflicted = outcome.conflicted_session_ids.len(); + let dirty = outcome.dirty_worktree_ids.len(); + let failed = outcome.failures.len(); + record(merged, active, conflicted, dirty, failed)?; if merged > 0 { tracing::info!("Auto-merged {merged} ready worktree(s)"); } - if !outcome.conflicted_session_ids.is_empty() { + if conflicted > 0 { tracing::warn!( "Skipped {} conflicted worktree(s) during auto-merge", - outcome.conflicted_session_ids.len() + conflicted ); } - if !outcome.dirty_worktree_ids.is_empty() { + if dirty > 0 { tracing::warn!( "Skipped {} dirty worktree(s) during auto-merge", - outcome.dirty_worktree_ids.len() + dirty ); } + if active > 0 { + tracing::info!("Skipped {active} active worktree(s) during auto-merge"); + } + if failed > 0 { + tracing::warn!("Auto-merge failed for {failed} worktree(s)"); + } Ok(merged) } @@ -735,6 +766,12 @@ mod tests { last_rebalance_at: None, last_rebalance_rerouted: 0, last_rebalance_leads: 0, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let dispatch_order = order.clone(); @@ -792,6 +829,12 @@ mod tests { last_rebalance_at: None, last_rebalance_rerouted: 0, last_rebalance_leads: 0, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); let recorded_clone = recorded.clone(); @@ -841,6 +884,12 @@ mod tests { last_rebalance_at: Some(now - chrono::Duration::seconds(1)), last_rebalance_rerouted: 0, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_clone = calls.clone(); @@ -891,6 +940,12 @@ mod tests { last_rebalance_at: Some(now - chrono::Duration::seconds(1)), last_rebalance_rerouted: 0, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_clone = calls.clone(); @@ -941,6 +996,12 @@ mod tests { last_rebalance_at: Some(now), last_rebalance_rerouted: 1, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let rebalance_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let rebalance_calls_clone = rebalance_calls.clone(); diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index cebe3651..39ea386c 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1555,6 +1555,19 @@ impl fmt::Display for CoordinationStatus { } } + if let Some(last_auto_merge_at) = self.daemon_activity.last_auto_merge_at.as_ref() { + writeln!( + f, + "Last daemon auto-merge: {} merged / {} active / {} conflicted / {} dirty / {} failed @ {}", + self.daemon_activity.last_auto_merge_merged, + self.daemon_activity.last_auto_merge_active_skipped, + self.daemon_activity.last_auto_merge_conflicted_skipped, + self.daemon_activity.last_auto_merge_dirty_skipped, + self.daemon_activity.last_auto_merge_failed, + last_auto_merge_at.to_rfc3339() + )?; + } + Ok(()) } } @@ -1656,6 +1669,12 @@ mod tests { last_rebalance_at: Some(now - Duration::seconds(2)), last_rebalance_rerouted: 0, last_rebalance_leads: 1, + last_auto_merge_at: Some(now - Duration::seconds(1)), + last_auto_merge_merged: 1, + last_auto_merge_active_skipped: 1, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, } } @@ -3100,6 +3119,11 @@ mod tests { assert!(rendered.contains("Last daemon dispatch: 3 routed / 1 deferred across 2 lead(s)")); assert!(rendered.contains("Last daemon recovery dispatch: 2 handoff(s) across 1 lead(s)")); assert!(rendered.contains("Last daemon rebalance: 0 handoff(s) across 1 lead(s)")); + assert!( + rendered.contains( + "Last daemon auto-merge: 1 merged / 1 active / 0 conflicted / 0 dirty / 0 failed" + ) + ); } #[test] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index cc1a73ff..8b493457 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -27,6 +27,12 @@ pub struct DaemonActivity { pub last_rebalance_at: Option>, pub last_rebalance_rerouted: usize, pub last_rebalance_leads: usize, + pub last_auto_merge_at: Option>, + pub last_auto_merge_merged: usize, + pub last_auto_merge_active_skipped: usize, + pub last_auto_merge_conflicted_skipped: usize, + pub last_auto_merge_dirty_skipped: usize, + pub last_auto_merge_failed: usize, } impl DaemonActivity { @@ -162,7 +168,13 @@ impl StateStore { last_recovery_dispatch_leads INTEGER NOT NULL DEFAULT 0, last_rebalance_at TEXT, last_rebalance_rerouted INTEGER NOT NULL DEFAULT 0, - last_rebalance_leads INTEGER NOT NULL DEFAULT 0 + last_rebalance_leads INTEGER NOT NULL DEFAULT 0, + last_auto_merge_at TEXT, + last_auto_merge_merged INTEGER NOT NULL DEFAULT 0, + last_auto_merge_active_skipped INTEGER NOT NULL DEFAULT 0, + last_auto_merge_conflicted_skipped INTEGER NOT NULL DEFAULT 0, + last_auto_merge_dirty_skipped INTEGER NOT NULL DEFAULT 0, + last_auto_merge_failed INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state); @@ -241,6 +253,60 @@ impl StateStore { .context("Failed to add chronic_saturation_streak column to daemon_activity table")?; } + if !self.has_column("daemon_activity", "last_auto_merge_at")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_at TEXT", + [], + ) + .context("Failed to add last_auto_merge_at column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_merge_merged")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_merged INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_merge_merged column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_merge_active_skipped")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_active_skipped INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_merge_active_skipped column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_merge_conflicted_skipped")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_conflicted_skipped INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_merge_conflicted_skipped column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_merge_dirty_skipped")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_dirty_skipped INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_merge_dirty_skipped column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_merge_failed")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_merge_failed INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_merge_failed column to daemon_activity table")?; + } + Ok(()) } @@ -643,7 +709,10 @@ impl StateStore { "SELECT last_dispatch_at, last_dispatch_routed, last_dispatch_deferred, last_dispatch_leads, chronic_saturation_streak, last_recovery_dispatch_at, last_recovery_dispatch_routed, last_recovery_dispatch_leads, - last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads + last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads, + last_auto_merge_at, last_auto_merge_merged, last_auto_merge_active_skipped, + last_auto_merge_conflicted_skipped, last_auto_merge_dirty_skipped, + last_auto_merge_failed FROM daemon_activity WHERE id = 1", [], @@ -677,6 +746,12 @@ impl StateStore { last_rebalance_at: parse_ts(row.get(8)?)?, last_rebalance_rerouted: row.get::<_, i64>(9)? as usize, last_rebalance_leads: row.get::<_, i64>(10)? as usize, + last_auto_merge_at: parse_ts(row.get(11)?)?, + last_auto_merge_merged: row.get::<_, i64>(12)? as usize, + last_auto_merge_active_skipped: row.get::<_, i64>(13)? as usize, + last_auto_merge_conflicted_skipped: row.get::<_, i64>(14)? as usize, + last_auto_merge_dirty_skipped: row.get::<_, i64>(15)? as usize, + last_auto_merge_failed: row.get::<_, i64>(16)? as usize, }) }, ) @@ -742,6 +817,36 @@ impl StateStore { Ok(()) } + pub fn record_daemon_auto_merge_pass( + &self, + merged: usize, + active_skipped: usize, + conflicted_skipped: usize, + dirty_skipped: usize, + failed: usize, + ) -> Result<()> { + self.conn.execute( + "UPDATE daemon_activity + SET last_auto_merge_at = ?1, + last_auto_merge_merged = ?2, + last_auto_merge_active_skipped = ?3, + last_auto_merge_conflicted_skipped = ?4, + last_auto_merge_dirty_skipped = ?5, + last_auto_merge_failed = ?6 + WHERE id = 1", + rusqlite::params![ + chrono::Utc::now().to_rfc3339(), + merged as i64, + active_skipped as i64, + conflicted_skipped as i64, + dirty_skipped as i64, + failed as i64, + ], + )?; + + Ok(()) + } + pub fn delegated_children(&self, session_id: &str, limit: usize) -> Result> { let mut stmt = self.conn.prepare( "SELECT to_session @@ -1117,6 +1222,7 @@ mod tests { db.record_daemon_dispatch_pass(4, 1, 2)?; db.record_daemon_recovery_dispatch_pass(2, 1)?; db.record_daemon_rebalance_pass(3, 1)?; + db.record_daemon_auto_merge_pass(2, 1, 1, 1, 0)?; let activity = db.daemon_activity()?; assert_eq!(activity.last_dispatch_routed, 4); @@ -1127,9 +1233,15 @@ mod tests { assert_eq!(activity.last_recovery_dispatch_leads, 1); assert_eq!(activity.last_rebalance_rerouted, 3); assert_eq!(activity.last_rebalance_leads, 1); + assert_eq!(activity.last_auto_merge_merged, 2); + assert_eq!(activity.last_auto_merge_active_skipped, 1); + assert_eq!(activity.last_auto_merge_conflicted_skipped, 1); + assert_eq!(activity.last_auto_merge_dirty_skipped, 1); + assert_eq!(activity.last_auto_merge_failed, 0); assert!(activity.last_dispatch_at.is_some()); assert!(activity.last_recovery_dispatch_at.is_some()); assert!(activity.last_rebalance_at.is_some()); + assert!(activity.last_auto_merge_at.is_some()); Ok(()) } @@ -1156,6 +1268,12 @@ mod tests { last_rebalance_at: None, last_rebalance_rerouted: 0, last_rebalance_leads: 0, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; assert!(unresolved.prefers_rebalance_first()); assert!(unresolved.dispatch_cooloff_active()); diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 1282fb0a..b593c9f4 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1912,6 +1912,18 @@ impl Dashboard { } } + if let Some(last_auto_merge_at) = self.daemon_activity.last_auto_merge_at.as_ref() { + lines.push(format!( + "Last daemon auto-merge {} merged / {} active / {} conflicted / {} dirty / {} failed @ {}", + self.daemon_activity.last_auto_merge_merged, + self.daemon_activity.last_auto_merge_active_skipped, + self.daemon_activity.last_auto_merge_conflicted_skipped, + self.daemon_activity.last_auto_merge_dirty_skipped, + self.daemon_activity.last_auto_merge_failed, + self.short_timestamp(&last_auto_merge_at.to_rfc3339()) + )); + } + if let Some(route_preview) = self.selected_route_preview.as_ref() { lines.push(format!("Next route {route_preview}")); } @@ -2774,6 +2786,12 @@ mod tests { last_rebalance_at: Some(now + chrono::Duration::seconds(2)), last_rebalance_rerouted: 1, last_rebalance_leads: 1, + last_auto_merge_at: Some(now + chrono::Duration::seconds(3)), + last_auto_merge_merged: 2, + last_auto_merge_active_skipped: 1, + last_auto_merge_conflicted_skipped: 1, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -2782,6 +2800,9 @@ mod tests { assert!(text.contains("Last daemon dispatch 4 routed / 2 deferred across 2 lead(s)")); assert!(text.contains("Last daemon recovery dispatch 1 handoff(s) across 1 lead(s)")); assert!(text.contains("Last daemon rebalance 1 handoff(s) across 1 lead(s)")); + assert!( + text.contains("Last daemon auto-merge 2 merged / 1 active / 1 conflicted / 0 dirty / 0 failed") + ); } #[test] @@ -2809,6 +2830,12 @@ mod tests { last_rebalance_at: Some(Utc::now()), last_rebalance_rerouted: 1, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -2840,6 +2867,12 @@ mod tests { last_rebalance_at: Some(Utc::now()), last_rebalance_rerouted: 1, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -2873,6 +2906,12 @@ mod tests { last_rebalance_at: Some(Utc::now()), last_rebalance_rerouted: 0, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -2907,6 +2946,12 @@ mod tests { last_rebalance_at: Some(now), last_rebalance_rerouted: 1, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -2956,6 +3001,12 @@ mod tests { last_rebalance_at: Some(now), last_rebalance_rerouted: 1, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3052,6 +3103,12 @@ mod tests { last_rebalance_at: Some(now), last_rebalance_rerouted: 1, last_rebalance_leads: 1, + last_auto_merge_at: None, + last_auto_merge_merged: 0, + last_auto_merge_active_skipped: 0, + last_auto_merge_conflicted_skipped: 0, + last_auto_merge_dirty_skipped: 0, + last_auto_merge_failed: 0, }; let text = dashboard.selected_session_metrics_text(); From eb274d25d90b19c067ab3ee030ac556c542cd2bc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:30:21 -0700 Subject: [PATCH 049/459] feat: add ecc2 split diff viewer --- ecc2/src/tui/dashboard.rs | 136 +++++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 2 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index b593c9f4..a4dfb43c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -32,6 +32,12 @@ const MAX_LOG_ENTRIES: u64 = 12; const MAX_DIFF_PREVIEW_LINES: usize = 6; const MAX_DIFF_PATCH_LINES: usize = 80; +#[derive(Debug, Clone, PartialEq, Eq)] +struct WorktreeDiffColumns { + removals: String, + additions: String, +} + pub struct Dashboard { db: StateStore, cfg: Config, @@ -341,6 +347,14 @@ impl Dashboard { fn render_output(&mut self, frame: &mut Frame, area: Rect) { self.sync_output_scroll(area.height.saturating_sub(2) as usize); + if self.sessions.get(self.selected_session).is_some() + && self.output_mode == OutputMode::WorktreeDiff + && self.selected_diff_patch.is_some() + { + self.render_split_diff_output(frame, area); + return; + } + let (title, content) = if self.sessions.get(self.selected_session).is_some() { match self.output_mode { OutputMode::SessionOutput => { @@ -394,6 +408,40 @@ impl Dashboard { frame.render_widget(paragraph, area); } + fn render_split_diff_output(&mut self, frame: &mut Frame, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Diff ") + .border_style(self.pane_border_style(Pane::Output)); + let inner_area = block.inner(area); + frame.render_widget(block, area); + + if inner_area.is_empty() { + return; + } + + let Some(patch) = self.selected_diff_patch.as_ref() else { + return; + }; + let columns = build_worktree_diff_columns(patch); + let column_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner_area); + + let removals = Paragraph::new(columns.removals) + .block(Block::default().borders(Borders::ALL).title(" Removals ")) + .scroll((self.output_scroll_offset as u16, 0)) + .wrap(Wrap { trim: false }); + frame.render_widget(removals, column_chunks[0]); + + let additions = Paragraph::new(columns.additions) + .block(Block::default().borders(Borders::ALL).title(" Additions ")) + .scroll((self.output_scroll_offset as u16, 0)) + .wrap(Wrap { trim: false }); + frame.render_widget(additions, column_chunks[1]); + } + fn render_metrics(&self, frame: &mut Frame, area: Rect) { let block = Block::default() .borders(Borders::ALL) @@ -2447,6 +2495,62 @@ fn truncate_for_dashboard(value: &str, max_chars: usize) -> String { format!("{truncated}…") } +fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { + let mut removals = Vec::new(); + let mut additions = Vec::new(); + + for line in patch.lines() { + if line.is_empty() { + continue; + } + + if line.starts_with("--- ") && !line.starts_with("--- a/") { + removals.push(line.to_string()); + additions.push(line.to_string()); + continue; + } + + if let Some(path) = line.strip_prefix("--- a/") { + removals.push(format!("File {path}")); + continue; + } + + if let Some(path) = line.strip_prefix("+++ b/") { + additions.push(format!("File {path}")); + continue; + } + + if line.starts_with("diff --git ") || line.starts_with("@@") { + removals.push(line.to_string()); + additions.push(line.to_string()); + continue; + } + + if line.starts_with('-') { + removals.push(line.to_string()); + continue; + } + + if line.starts_with('+') { + additions.push(line.to_string()); + continue; + } + } + + WorktreeDiffColumns { + removals: if removals.is_empty() { + "No removals in this bounded preview.".to_string() + } else { + removals.join("\n") + }, + additions: if additions.is_empty() { + "No additions in this bounded preview.".to_string() + } else { + additions.join("\n") + }, + } +} + fn session_state_label(state: &SessionState) -> &'static str { match state { SessionState::Pending => "Pending", @@ -2652,7 +2756,7 @@ mod tests { ); dashboard.selected_diff_summary = Some("1 file changed".to_string()); dashboard.selected_diff_patch = Some( - "--- Branch diff vs main ---\ndiff --git a/src/lib.rs b/src/lib.rs\n+hello".to_string(), + "--- Branch diff vs main ---\ndiff --git a/src/lib.rs b/src/lib.rs\n@@ -1 +1 @@\n-old line\n+new line".to_string(), ); dashboard.toggle_output_mode(); @@ -2664,7 +2768,35 @@ mod tests { ); let rendered = dashboard.rendered_output_text(180, 30); assert!(rendered.contains("Diff")); - assert!(rendered.contains("diff --git a/src/lib.rs b/src/lib.rs")); + assert!(rendered.contains("Removals")); + assert!(rendered.contains("Additions")); + assert!(rendered.contains("-old line")); + assert!(rendered.contains("+new line")); + } + + #[test] + fn worktree_diff_columns_split_removed_and_added_lines() { + let patch = "\ +--- Branch diff vs main --- +diff --git a/src/lib.rs b/src/lib.rs +@@ -1,2 +1,2 @@ +-old line + context ++new line + +--- Working tree diff --- +diff --git a/src/next.rs b/src/next.rs +@@ -3 +3 @@ +-bye ++hello"; + + let columns = build_worktree_diff_columns(patch); + assert!(columns.removals.contains("Branch diff vs main")); + assert!(columns.removals.contains("-old line")); + assert!(columns.removals.contains("-bye")); + assert!(columns.additions.contains("Working tree diff")); + assert!(columns.additions.contains("+new line")); + assert!(columns.additions.contains("+hello")); } #[test] From e363c540577d13c008cd039b72dd2245da954f4a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:34:34 -0700 Subject: [PATCH 050/459] fix: treat oauth mcp 401 probes as reachable --- scripts/hooks/mcp-health-check.js | 5 ++- tests/hooks/mcp-health-check.test.js | 62 ++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index ec425148..4c40e114 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -24,7 +24,10 @@ const DEFAULT_TTL_MS = 2 * 60 * 1000; const DEFAULT_TIMEOUT_MS = 5000; const DEFAULT_BACKOFF_MS = 30 * 1000; const MAX_BACKOFF_MS = 10 * 60 * 1000; -const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 405]); +// The preflight HTTP probe only checks reachability; it does not have access to +// Claude Code's stored OAuth bearer token. Treat auth-gated responses as +// reachable so the real MCP client can attempt the authenticated call. +const HEALTHY_HTTP_CODES = new Set([200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 405]); const RECONNECT_STATUS_CODES = new Set([401, 403, 429, 503]); const FAILURE_PATTERNS = [ { code: 401, pattern: /\b401\b|unauthori[sz]ed|auth(?:entication)?\s+(?:failed|expired|invalid)/i }, diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index 28650b27..04d4a7b5 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -358,6 +358,68 @@ async function runTests() { } })) passed++; else failed++; + if (await asyncTest('treats HTTP 401 probe responses as healthy reachable OAuth-protected servers', async () => { + const tempDir = createTempDir(); + const configPath = path.join(tempDir, 'claude.json'); + const statePath = path.join(tempDir, 'mcp-health.json'); + const serverScript = path.join(tempDir, 'http-401-server.js'); + const portFile = path.join(tempDir, 'server-port.txt'); + + fs.writeFileSync( + serverScript, + [ + "const fs = require('fs');", + "const http = require('http');", + "const portFile = process.argv[2];", + "const server = http.createServer((_req, res) => {", + " res.writeHead(401, {", + " 'Content-Type': 'application/json',", + " 'WWW-Authenticate': 'Bearer realm=\"OAuth\", error=\"invalid_token\"'", + " });", + " res.end(JSON.stringify({ error: 'missing bearer token' }));", + "});", + "server.listen(0, '127.0.0.1', () => {", + " fs.writeFileSync(portFile, String(server.address().port));", + "});", + "setInterval(() => {}, 1000);" + ].join('\n') + ); + + const serverProcess = spawn(process.execPath, [serverScript, portFile], { + stdio: 'ignore' + }); + + try { + const port = waitForFile(portFile).trim(); + + writeConfig(configPath, { + mcpServers: { + atlassian: { + type: 'http', + url: `http://127.0.0.1:${port}/mcp` + } + } + }); + + const input = { tool_name: 'mcp__atlassian__search', tool_input: {} }; + const result = runHook(input, { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: configPath, + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_HEALTH_TIMEOUT_MS: '500' + }); + + assert.strictEqual(result.code, 0, `Expected HTTP 401 probe to be treated as healthy, got ${result.code}`); + assert.strictEqual(result.stdout.trim(), JSON.stringify(input), 'Expected original JSON on stdout'); + + const state = readState(statePath); + assert.strictEqual(state.servers.atlassian.status, 'healthy', 'Expected OAuth-protected HTTP MCP server to be marked healthy'); + } finally { + serverProcess.kill('SIGTERM'); + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From e226772a725ff4b102c803940c45b7360222a095 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:38:49 -0700 Subject: [PATCH 051/459] feat: add gemini agent adapter --- package.json | 1 + scripts/gemini-adapt-agents.js | 189 ++++++++++++++++++++++ tests/scripts/gemini-adapt-agents.test.js | 136 ++++++++++++++++ 3 files changed, 326 insertions(+) create mode 100644 scripts/gemini-adapt-agents.js create mode 100644 tests/scripts/gemini-adapt-agents.test.js diff --git a/package.json b/package.json index c51155b6..1a569589 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "schemas/", "scripts/ci/", "scripts/ecc.js", + "scripts/gemini-adapt-agents.js", "scripts/hooks/", "scripts/lib/", "scripts/claw.js", diff --git a/scripts/gemini-adapt-agents.js b/scripts/gemini-adapt-agents.js new file mode 100644 index 00000000..45faabe3 --- /dev/null +++ b/scripts/gemini-adapt-agents.js @@ -0,0 +1,189 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const TOOL_NAME_MAP = new Map([ + ['Read', 'read_file'], + ['Write', 'write_file'], + ['Edit', 'replace'], + ['Bash', 'run_shell_command'], + ['Grep', 'grep_search'], + ['Glob', 'glob'], + ['WebSearch', 'google_web_search'], + ['WebFetch', 'web_fetch'], +]); + +function usage() { + return [ + 'Adapt ECC agent frontmatter for Gemini CLI.', + '', + 'Usage:', + ' node scripts/gemini-adapt-agents.js [agents-dir]', + '', + 'Defaults to .gemini/agents under the current working directory.', + 'Rewrites tools: to Gemini-compatible tool names and removes unsupported color: metadata.' + ].join('\n'); +} + +function parseArgs(argv) { + if (argv.includes('--help') || argv.includes('-h')) { + return { help: true }; + } + + const positional = argv.filter(arg => !arg.startsWith('-')); + if (positional.length > 1) { + throw new Error('Expected at most one agents directory argument'); + } + + return { + help: false, + agentsDir: path.resolve(positional[0] || path.join(process.cwd(), '.gemini', 'agents')), + }; +} + +function ensureDirectory(dirPath) { + if (!fs.existsSync(dirPath)) { + throw new Error(`Agents directory not found: ${dirPath}`); + } + + if (!fs.statSync(dirPath).isDirectory()) { + throw new Error(`Expected a directory: ${dirPath}`); + } +} + +function stripQuotes(value) { + return value.trim().replace(/^['"]|['"]$/g, ''); +} + +function parseToolList(line) { + const match = line.match(/^(\s*tools\s*:\s*)\[(.*)\]\s*$/); + if (!match) { + return null; + } + + const rawItems = match[2].trim(); + if (!rawItems) { + return []; + } + + return rawItems + .split(',') + .map(part => stripQuotes(part)) + .filter(Boolean); +} + +function adaptToolName(toolName) { + const mapped = TOOL_NAME_MAP.get(toolName); + if (mapped) { + return mapped; + } + + if (toolName.startsWith('mcp__')) { + return toolName + .replace(/^mcp__/, 'mcp_') + .replace(/__/g, '_') + .replace(/[^A-Za-z0-9_]/g, '_') + .toLowerCase(); + } + + return toolName; +} + +function formatToolLine(tools) { + return `tools: [${tools.map(tool => JSON.stringify(tool)).join(', ')}]`; +} + +function adaptFrontmatter(text) { + const match = text.match(/^---\n([\s\S]*?)\n---(\n|$)/); + if (!match) { + return { text, changed: false }; + } + + let changed = false; + const updatedLines = []; + + for (const line of match[1].split('\n')) { + if (/^\s*color\s*:/.test(line)) { + changed = true; + continue; + } + + const tools = parseToolList(line); + if (tools) { + const adaptedTools = []; + const seen = new Set(); + + for (const tool of tools.map(adaptToolName)) { + if (seen.has(tool)) { + continue; + } + seen.add(tool); + adaptedTools.push(tool); + } + + const updatedLine = formatToolLine(adaptedTools); + if (updatedLine !== line) { + changed = true; + } + updatedLines.push(updatedLine); + continue; + } + + updatedLines.push(line); + } + + if (!changed) { + return { text, changed: false }; + } + + return { + text: `---\n${updatedLines.join('\n')}\n---${match[2]}${text.slice(match[0].length)}`, + changed: true, + }; +} + +function adaptAgents(dirPath) { + ensureDirectory(dirPath); + + let updated = 0; + let unchanged = 0; + + for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) { + continue; + } + + const filePath = path.join(dirPath, entry.name); + const original = fs.readFileSync(filePath, 'utf8'); + const adapted = adaptFrontmatter(original); + + if (adapted.changed) { + fs.writeFileSync(filePath, adapted.text); + updated += 1; + } else { + unchanged += 1; + } + } + + return { updated, unchanged }; +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + + const result = adaptAgents(options.agentsDir); + console.log(`Updated ${result.updated} agent file(s); ${result.unchanged} already compatible`); +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/tests/scripts/gemini-adapt-agents.test.js b/tests/scripts/gemini-adapt-agents.test.js new file mode 100644 index 00000000..4afc419d --- /dev/null +++ b/tests/scripts/gemini-adapt-agents.test.js @@ -0,0 +1,136 @@ +/** + * Tests for scripts/gemini-adapt-agents.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'gemini-adapt-agents.js'); + +function run(args = [], options = {}) { + try { + const stdout = execFileSync('node', [SCRIPT, ...args], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + cwd: options.cwd, + timeout: 10000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (error) { + return { + code: error.status || 1, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } +} + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-gemini-adapt-')); +} + +function cleanupTempDir(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function writeAgent(dirPath, name, body) { + fs.mkdirSync(dirPath, { recursive: true }); + fs.writeFileSync(path.join(dirPath, name), body); +} + +function runTests() { + console.log('\n=== Testing gemini-adapt-agents.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('shows help with an explicit help flag', () => { + const result = run(['--help']); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(result.stdout.includes('Adapt ECC agent frontmatter for Gemini CLI')); + assert.ok(result.stdout.includes('Usage:')); + })) passed++; else failed++; + + if (test('adapts Claude Code tool names and strips unsupported color metadata', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'gan-planner.md', + [ + '---', + 'name: gan-planner', + 'description: Planner agent', + 'tools: [Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__resolve-library-id]', + 'model: opus', + 'color: purple', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([agentsDir]); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(result.stdout.includes('Updated 1 agent file(s)')); + + const updated = fs.readFileSync(path.join(agentsDir, 'gan-planner.md'), 'utf8'); + assert.ok(updated.includes('tools: ["read_file", "write_file", "replace", "run_shell_command", "grep_search", "glob", "google_web_search", "web_fetch", "mcp_context7_resolve_library_id"]')); + assert.ok(!updated.includes('color: purple')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + if (test('defaults to the cwd .gemini/agents directory', () => { + const tempDir = createTempDir(); + const agentsDir = path.join(tempDir, '.gemini', 'agents'); + + try { + writeAgent( + agentsDir, + 'architect.md', + [ + '---', + 'name: architect', + 'description: Architect agent', + 'tools: ["Read", "Grep", "Glob"]', + 'model: opus', + '---', + '', + 'Body' + ].join('\n') + ); + + const result = run([], { cwd: tempDir }); + assert.strictEqual(result.code, 0, result.stderr); + + const updated = fs.readFileSync(path.join(agentsDir, 'architect.md'), 'utf8'); + assert.ok(updated.includes('tools: ["read_file", "grep_search", "glob"]')); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); From 9bd8e8b3c7e1677bd8c7753fa405f57ee4353c0a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:40:26 -0700 Subject: [PATCH 052/459] fix: resolve markdownlint violations --- docs/zh-CN/hooks/README.md | 1 + hooks/README.md | 1 + skills/api-connector-builder/SKILL.md | 1 - skills/dashboard-builder/SKILL.md | 1 - 4 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/zh-CN/hooks/README.md b/docs/zh-CN/hooks/README.md index 0cfb571f..4d0f0a26 100644 --- a/docs/zh-CN/hooks/README.md +++ b/docs/zh-CN/hooks/README.md @@ -25,6 +25,7 @@ | **Git 推送提醒器** | `Bash` | 在 `git push` 前提醒检查变更 | 0 (警告) | | **文档文件警告器** | `Write` | 对非标准 `.md`/`.txt` 文件发出警告(允许 README、CLAUDE、CONTRIBUTING、CHANGELOG、LICENSE、SKILL、docs/、skills/);跨平台路径处理 | 0 (警告) | | **策略性压缩提醒器** | `Edit\|Write` | 建议在逻辑间隔(约每 50 次工具调用)手动执行 `/compact` | 0 (警告) | + ### PostToolUse 钩子 | 钩子 | 匹配器 | 功能 | diff --git a/hooks/README.md b/hooks/README.md index bbdbed10..3305bb40 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -26,6 +26,7 @@ User request → Claude picks a tool → PreToolUse hook runs → Tool executes | **Pre-commit quality check** | `Bash` | Runs quality checks before `git commit`: lints staged files, validates commit message format when provided via `-m/--message`, detects console.log/debugger/secrets | 2 (blocks critical) / 0 (warns) | | **Doc file warning** | `Write` | Warns about non-standard `.md`/`.txt` files (allows README, CLAUDE, CONTRIBUTING, CHANGELOG, LICENSE, SKILL, docs/, skills/); cross-platform path handling | 0 (warns) | | **Strategic compact** | `Edit\|Write` | Suggests manual `/compact` at logical intervals (every ~50 tool calls) | 0 (warns) | + ### PostToolUse Hooks | Hook | Matcher | What It Does | diff --git a/skills/api-connector-builder/SKILL.md b/skills/api-connector-builder/SKILL.md index 29e38c0c..234f50fb 100644 --- a/skills/api-connector-builder/SKILL.md +++ b/skills/api-connector-builder/SKILL.md @@ -118,4 +118,3 @@ src/integrations/ - `backend-patterns` - `mcp-server-patterns` - `github-ops` - diff --git a/skills/dashboard-builder/SKILL.md b/skills/dashboard-builder/SKILL.md index 4313cb4f..58c7a548 100644 --- a/skills/dashboard-builder/SKILL.md +++ b/skills/dashboard-builder/SKILL.md @@ -106,4 +106,3 @@ Every panel should answer a real question. If it does not, remove it. - `research-ops` - `backend-patterns` - `terminal-ops` - From 86cbe3d6166ef415ae2e2d487c5593dd40f11009 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:42:49 -0700 Subject: [PATCH 053/459] feat: add c language compatibility --- AGENTS.md | 4 ++-- manifests/install-components.json | 8 ++++++++ scripts/lib/install-manifests.js | 2 ++ scripts/lib/project-detect.js | 5 +++++ tests/lib/install-manifests.test.js | 14 ++++++++++++++ tests/lib/project-detect.test.js | 14 ++++++++++++++ 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3412f269..fd7b24cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,8 @@ This is a **production-ready AI coding plugin** providing 47 specialized agents, | e2e-runner | End-to-end Playwright testing | Critical user flows | | refactor-cleaner | Dead code cleanup | Code maintenance | | doc-updater | Documentation and codemaps | Updating docs | -| cpp-reviewer | C++ code review | C++ projects | -| cpp-build-resolver | C++ build errors | C++ build failures | +| cpp-reviewer | C/C++ code review | C and C++ projects | +| cpp-build-resolver | C/C++ build errors | C and C++ build failures | | docs-lookup | Documentation lookup via Context7 | API/docs questions | | go-reviewer | Go code review | Go projects | | go-build-resolver | Go build errors | Go build failures | diff --git a/manifests/install-components.json b/manifests/install-components.json index 675aa78f..045d8e62 100644 --- a/manifests/install-components.json +++ b/manifests/install-components.json @@ -193,6 +193,14 @@ "framework-language" ] }, + { + "id": "lang:c", + "family": "language", + "description": "C engineering guidance using the shared C/C++ standards and testing stack. Currently resolves through the shared framework-language module.", + "modules": [ + "framework-language" + ] + }, { "id": "lang:kotlin", "family": "language", diff --git a/scripts/lib/install-manifests.js b/scripts/lib/install-manifests.js index cd6541d6..3121f7b1 100644 --- a/scripts/lib/install-manifests.js +++ b/scripts/lib/install-manifests.js @@ -37,6 +37,7 @@ const LEGACY_COMPAT_BASE_MODULE_IDS_BY_TARGET = Object.freeze({ ], }); const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({ + c: 'c', cpp: 'cpp', csharp: 'csharp', go: 'go', @@ -52,6 +53,7 @@ const LEGACY_LANGUAGE_ALIAS_TO_CANONICAL = Object.freeze({ typescript: 'typescript', }); const LEGACY_LANGUAGE_EXTRA_MODULE_IDS = Object.freeze({ + c: ['framework-language'], cpp: ['framework-language'], csharp: ['framework-language'], go: ['framework-language'], diff --git a/scripts/lib/project-detect.js b/scripts/lib/project-detect.js index cac0f060..dafd9b34 100644 --- a/scripts/lib/project-detect.js +++ b/scripts/lib/project-detect.js @@ -50,6 +50,11 @@ const LANGUAGE_RULES = [ markers: ['pom.xml', 'build.gradle', 'build.gradle.kts'], extensions: ['.java'] }, + { + type: 'c', + markers: [], + extensions: ['.c'] + }, { type: 'csharp', markers: [], diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index 4e64ea56..662a1270 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -74,6 +74,8 @@ function runTests() { const components = listInstallComponents(); assert.ok(components.some(component => component.id === 'lang:typescript'), 'Should include lang:typescript'); + assert.ok(components.some(component => component.id === 'lang:c'), + 'Should include lang:c'); assert.ok(components.some(component => component.id === 'capability:security'), 'Should include capability:security'); })) passed++; else failed++; @@ -87,6 +89,7 @@ function runTests() { assert.ok(languages.includes('kotlin')); assert.ok(languages.includes('rust')); assert.ok(languages.includes('cpp')); + assert.ok(languages.includes('c')); assert.ok(languages.includes('csharp')); })) passed++; else failed++; @@ -183,6 +186,17 @@ function runTests() { 'cpp should resolve to framework-language module'); })) passed++; else failed++; + if (test('resolves c legacy compatibility into framework-language module', () => { + const selection = resolveLegacyCompatibilitySelection({ + target: 'cursor', + legacyLanguages: ['c'], + }); + + assert.ok(selection.moduleIds.includes('rules-core')); + assert.ok(selection.moduleIds.includes('framework-language'), + 'c should resolve to framework-language module'); + })) passed++; else failed++; + if (test('resolves csharp legacy compatibility into framework-language module', () => { const selection = resolveLegacyCompatibilitySelection({ target: 'cursor', diff --git a/tests/lib/project-detect.test.js b/tests/lib/project-detect.test.js index 12294060..0d7aec8d 100644 --- a/tests/lib/project-detect.test.js +++ b/tests/lib/project-detect.test.js @@ -220,6 +220,20 @@ function runTests() { } })) passed++; else failed++; + console.log('\nC Detection:'); + + if (test('detects c from top-level .c files', () => { + const dir = createTempDir(); + try { + writeTestFile(dir, 'main.c', 'int main(void) { return 0; }\n'); + const result = detectProjectType(dir); + assert.ok(result.languages.includes('c')); + assert.strictEqual(result.primary, 'c'); + } finally { + cleanupDir(dir); + } + })) passed++; else failed++; + // Go detection console.log('\nGo Detection:'); From b3f781a6486282aba6d07ff72750607ca9124f03 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 15:58:31 -0700 Subject: [PATCH 054/459] feat: default ecc2 worktrees through policy --- ecc2/src/config/mod.rs | 5 + ecc2/src/main.rs | 290 ++++++++++++++++++++------------ ecc2/src/session/daemon.rs | 5 +- ecc2/src/session/manager.rs | 228 +++++++++++++++---------- ecc2/src/session/runtime.rs | 18 +- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 323 +++++++++++++++++++++++++----------- ecc2/src/worktree/mod.rs | 32 ++-- 8 files changed, 576 insertions(+), 326 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index b989c060..634a301a 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_create_worktrees: bool, pub auto_merge_ready_worktrees: bool, pub cost_budget_usd: f64, pub token_budget: u64, @@ -58,6 +59,7 @@ impl Default for Config { default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, + auto_create_worktrees: true, auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, @@ -156,6 +158,7 @@ theme = "Dark" config.auto_dispatch_limit_per_session, defaults.auto_dispatch_limit_per_session ); + assert_eq!(config.auto_create_worktrees, defaults.auto_create_worktrees); assert_eq!( config.auto_merge_ready_worktrees, defaults.auto_merge_ready_worktrees @@ -185,6 +188,7 @@ theme = "Dark" let mut config = Config::default(); config.auto_dispatch_unread_handoffs = true; config.auto_dispatch_limit_per_session = 9; + config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; config.save_to_path(&path).unwrap(); @@ -193,6 +197,7 @@ theme = "Dark" assert!(loaded.auto_dispatch_unread_handoffs); assert_eq!(loaded.auto_dispatch_limit_per_session, 9); + assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); let _ = std::fs::remove_file(path); diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 6a5b3cd6..2c16abe9 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -18,6 +18,28 @@ struct Cli { command: Option, } +#[derive(clap::Args, Debug, Clone, Default)] +struct WorktreePolicyArgs { + /// Create a dedicated worktree + #[arg(short = 'w', long = "worktree", action = clap::ArgAction::SetTrue, overrides_with = "no_worktree")] + worktree: bool, + /// Skip dedicated worktree creation + #[arg(long = "no-worktree", action = clap::ArgAction::SetTrue, overrides_with = "worktree")] + no_worktree: bool, +} + +impl WorktreePolicyArgs { + fn resolve(&self, cfg: &config::Config) -> bool { + if self.worktree { + true + } else if self.no_worktree { + false + } else { + cfg.auto_create_worktrees + } + } +} + #[derive(clap::Subcommand, Debug)] enum Commands { /// Launch the TUI dashboard @@ -30,9 +52,8 @@ enum Commands { /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree for this session - #[arg(short, long)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, /// Source session to delegate from #[arg(long)] from_session: Option, @@ -47,9 +68,8 @@ enum Commands { /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree for the delegated session - #[arg(short, long, default_value_t = true)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, }, /// Route work to an existing delegate when possible, otherwise spawn a new one Assign { @@ -61,9 +81,8 @@ enum Commands { /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree if a new delegate must be spawned - #[arg(short, long, default_value_t = true)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, }, /// Route unread task handoffs from a lead session inbox through the assignment policy DrainInbox { @@ -72,9 +91,8 @@ enum Commands { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree if new delegates must be spawned - #[arg(short, long, default_value_t = true)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, /// Maximum unread task handoffs to route #[arg(long, default_value_t = 5)] limit: usize, @@ -84,9 +102,8 @@ enum Commands { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree if new delegates must be spawned - #[arg(short, long, default_value_t = true)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, @@ -96,9 +113,8 @@ enum Commands { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree if new delegates must be spawned - #[arg(short, long, default_value_t = true)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, @@ -129,9 +145,8 @@ enum Commands { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree if new delegates must be spawned - #[arg(short, long, default_value_t = true)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, @@ -150,9 +165,8 @@ enum Commands { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree if new delegates must be spawned - #[arg(short, long, default_value_t = true)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass #[arg(long, default_value_t = 10)] lead_limit: usize, @@ -164,9 +178,8 @@ enum Commands { /// Agent type for routed delegates #[arg(short, long, default_value = "claude")] agent: String, - /// Create a dedicated worktree if new delegates must be spawned - #[arg(short, long, default_value_t = true)] - worktree: bool, + #[command(flatten)] + worktree: WorktreePolicyArgs, /// Maximum handoffs to reroute in one pass #[arg(long, default_value_t = 5)] limit: usize, @@ -319,9 +332,10 @@ async fn main() -> Result<()> { Some(Commands::Start { task, agent, - worktree: use_worktree, + worktree, from_session, }) => { + let use_worktree = worktree.resolve(&cfg); let session_id = session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?; if let Some(from_session) = from_session { @@ -334,8 +348,9 @@ async fn main() -> Result<()> { from_session, task, agent, - worktree: use_worktree, + worktree, }) => { + let use_worktree = worktree.resolve(&cfg); let from_id = resolve_session_id(&db, &from_session)?; let source = db .get_session(&from_id)? @@ -361,18 +376,13 @@ async fn main() -> Result<()> { from_session, task, agent, - worktree: use_worktree, + worktree, }) => { + let use_worktree = worktree.resolve(&cfg); let lead_id = resolve_session_id(&db, &from_session)?; - let outcome = session::manager::assign_session( - &db, - &cfg, - &lead_id, - &task, - &agent, - use_worktree, - ) - .await?; + let outcome = + session::manager::assign_session(&db, &cfg, &lead_id, &task, &agent, use_worktree) + .await?; if session::manager::assignment_action_routes_work(outcome.action) { println!( "Assignment routed: {} -> {} ({})", @@ -396,32 +406,28 @@ async fn main() -> Result<()> { Some(Commands::DrainInbox { session_id, agent, - worktree: use_worktree, + worktree, limit, }) => { + let use_worktree = worktree.resolve(&cfg); let lead_id = resolve_session_id(&db, &session_id)?; - let outcomes = session::manager::drain_inbox( - &db, - &cfg, - &lead_id, - &agent, - use_worktree, - limit, - ) - .await?; + let outcomes = + session::manager::drain_inbox(&db, &cfg, &lead_id, &agent, use_worktree, limit) + .await?; if outcomes.is_empty() { println!("No unread task handoffs for {}", short_session(&lead_id)); } else { let routed_count = outcomes .iter() - .filter(|outcome| session::manager::assignment_action_routes_work(outcome.action)) + .filter(|outcome| { + session::manager::assignment_action_routes_work(outcome.action) + }) .count(); let deferred_count = outcomes.len().saturating_sub(routed_count); println!( "Processed {} inbox task handoff(s) from {} ({} routed, {} deferred)", outcomes.len(), - short_session(&lead_id) - , + short_session(&lead_id), routed_count, deferred_count ); @@ -445,9 +451,10 @@ async fn main() -> Result<()> { } Some(Commands::AutoDispatch { agent, - worktree: use_worktree, + worktree, lead_limit, }) => { + let use_worktree = worktree.resolve(&cfg); let outcomes = session::manager::auto_dispatch_backlog( &db, &cfg, @@ -459,14 +466,17 @@ async fn main() -> Result<()> { if outcomes.is_empty() { println!("No unread task handoff backlog found"); } else { - let total_processed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); + let total_processed: usize = + outcomes.iter().map(|outcome| outcome.routed.len()).sum(); let total_routed: usize = outcomes .iter() .map(|outcome| { outcome .routed .iter() - .filter(|item| session::manager::assignment_action_routes_work(item.action)) + .filter(|item| { + session::manager::assignment_action_routes_work(item.action) + }) .count() }) .sum(); @@ -497,18 +507,15 @@ async fn main() -> Result<()> { } Some(Commands::CoordinateBacklog { agent, - worktree: use_worktree, + worktree, lead_limit, json, check, until_healthy, max_passes, }) => { - let pass_budget = if until_healthy { - max_passes.max(1) - } else { - 1 - }; + let use_worktree = worktree.resolve(&cfg); + let pass_budget = if until_healthy { max_passes.max(1) } else { 1 }; let run = run_coordination_loop( &db, &cfg, @@ -542,12 +549,13 @@ async fn main() -> Result<()> { } Some(Commands::MaintainCoordination { agent, - worktree: use_worktree, + worktree, lead_limit, json, check, max_passes, }) => { + let use_worktree = worktree.resolve(&cfg); let initial_status = session::manager::get_coordination_status(&db, &cfg)?; let run = if matches!( initial_status.health, @@ -591,17 +599,13 @@ async fn main() -> Result<()> { } Some(Commands::RebalanceAll { agent, - worktree: use_worktree, + worktree, lead_limit, }) => { - let outcomes = session::manager::rebalance_all_teams( - &db, - &cfg, - &agent, - use_worktree, - lead_limit, - ) - .await?; + let use_worktree = worktree.resolve(&cfg); + let outcomes = + session::manager::rebalance_all_teams(&db, &cfg, &agent, use_worktree, lead_limit) + .await?; if outcomes.is_empty() { println!("No delegate backlog needed global rebalancing"); } else { @@ -624,9 +628,10 @@ async fn main() -> Result<()> { Some(Commands::RebalanceTeam { session_id, agent, - worktree: use_worktree, + worktree, limit, }) => { + let use_worktree = worktree.resolve(&cfg); let lead_id = resolve_session_id(&db, &session_id)?; let outcomes = session::manager::rebalance_team_backlog( &db, @@ -638,7 +643,10 @@ async fn main() -> Result<()> { ) .await?; if outcomes.is_empty() { - println!("No delegate backlog needed rebalancing for {}", short_session(&lead_id)); + println!( + "No delegate backlog needed rebalancing for {}", + short_session(&lead_id) + ); } else { println!( "Rebalanced {} task handoff(s) for {}", @@ -779,12 +787,9 @@ async fn main() -> Result<()> { } else { let id = session_id.unwrap_or_else(|| "latest".to_string()); let resolved_id = resolve_session_id(&db, &id)?; - let outcome = session::manager::merge_session_worktree( - &db, - &resolved_id, - !keep_worktree, - ) - .await?; + let outcome = + session::manager::merge_session_worktree(&db, &resolved_id, !keep_worktree) + .await?; if json { println!("{}", serde_json::to_string_pretty(&outcome)?); } else { @@ -821,7 +826,11 @@ async fn main() -> Result<()> { let to = resolve_session_id(&db, &to)?; let message = build_message(kind, text, context, file)?; comms::send(&db, &from, &to, &message)?; - println!("Message sent: {} -> {}", short_session(&from), short_session(&to)); + println!( + "Message sent: {} -> {}", + short_session(&from), + short_session(&to) + ); } MessageCommands::Inbox { session_id, limit } => { let session_id = resolve_session_id(&db, &session_id)?; @@ -1057,7 +1066,10 @@ struct WorktreeResolutionReport { resolution_steps: Vec, } -fn build_worktree_status_report(session: &session::Session, include_patch: bool) -> Result { +fn build_worktree_status_report( + session: &session::Session, + include_patch: bool, +) -> Result { let Some(worktree) = session.worktree.as_ref() else { return Ok(WorktreeStatusReport { session_id: session.id.clone(), @@ -1117,7 +1129,9 @@ fn build_worktree_status_report(session: &session::Session, include_patch: bool) }) } -fn build_worktree_resolution_report(session: &session::Session) -> Result { +fn build_worktree_resolution_report( + session: &session::Session, +) -> Result { let Some(worktree) = session.worktree.as_ref() else { return Ok(WorktreeResolutionReport { session_id: session.id.clone(), @@ -1139,11 +1153,17 @@ fn build_worktree_resolution_report(session: &session::Session) -> Result".to_string(), format!("Commit the resolution on {}: git commit", worktree.branch), - format!("Re-check readiness: ecc worktree-status {} --check", session.id), + format!( + "Re-check readiness: ecc worktree-status {} --check", + session.id + ), format!("Merge when clear: ecc merge-worktree {}", session.id), ] } else { @@ -1183,7 +1203,8 @@ fn format_worktree_status_human(report: &WorktreeStatusReport) -> String { if let Some(path) = report.path.as_ref() { lines.push(format!("Path {path}")); } - if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) { + if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) + { lines.push(format!("Branch {branch} (base {base_branch})")); } if let Some(diff_summary) = report.diff_summary.as_ref() { @@ -1237,7 +1258,8 @@ fn format_worktree_resolution_human(report: &WorktreeResolutionReport) -> String if let Some(path) = report.path.as_ref() { lines.push(format!("Path {path}")); } - if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) { + if let (Some(branch), Some(base_branch)) = (report.branch.as_ref(), report.base_branch.as_ref()) + { lines.push(format!("Branch {branch} (base {base_branch})")); } lines.push(report.summary.clone()); @@ -1295,12 +1317,11 @@ fn format_worktree_merge_human(outcome: &session::manager::WorktreeMergeOutcome) lines.join("\n") } -fn format_bulk_worktree_merge_human(outcome: &session::manager::WorktreeBulkMergeOutcome) -> String { +fn format_bulk_worktree_merge_human( + outcome: &session::manager::WorktreeBulkMergeOutcome, +) -> String { let mut lines = Vec::new(); - lines.push(format!( - "Merged {} ready worktree(s)", - outcome.merged.len() - )); + lines.push(format!("Merged {} ready worktree(s)", outcome.merged.len())); for merged in &outcome.merged { lines.push(format!( @@ -1427,7 +1448,10 @@ fn summarize_coordinate_backlog( .map(|rebalance| rebalance.rerouted.len()) .sum(); - let message = if total_routed == 0 && total_rerouted == 0 && outcome.remaining_backlog_sessions == 0 { + let message = if total_routed == 0 + && total_rerouted == 0 + && outcome.remaining_backlog_sessions == 0 + { "Backlog already clear".to_string() } else { format!( @@ -1470,11 +1494,7 @@ fn coordination_status_exit_code(status: &session::manager::CoordinationStatus) } } -fn send_handoff_message( - db: &session::store::StateStore, - from_id: &str, - to_id: &str, -) -> Result<()> { +fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: &str) -> Result<()> { let from_session = db .get_session(from_id)? .ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?; @@ -1508,6 +1528,37 @@ fn send_handoff_message( #[cfg(test)] mod tests { use super::*; + use crate::config::Config; + + #[test] + fn worktree_policy_defaults_to_config_setting() { + let mut cfg = Config::default(); + let policy = WorktreePolicyArgs::default(); + + assert!(policy.resolve(&cfg)); + + cfg.auto_create_worktrees = false; + assert!(!policy.resolve(&cfg)); + } + + #[test] + fn worktree_policy_explicit_flags_override_config_setting() { + let mut cfg = Config::default(); + cfg.auto_create_worktrees = false; + + assert!(WorktreePolicyArgs { + worktree: true, + no_worktree: false, + } + .resolve(&cfg)); + + cfg.auto_create_worktrees = true; + assert!(!WorktreePolicyArgs { + worktree: false, + no_worktree: true, + } + .resolve(&cfg)); + } #[test] fn cli_parses_resume_command() { @@ -1586,6 +1637,20 @@ mod tests { } } + #[test] + fn cli_parses_start_no_worktree_override() { + let cli = Cli::try_parse_from(["ecc", "start", "--task", "Follow up", "--no-worktree"]) + .expect("start --no-worktree should parse"); + + match cli.command { + Some(Commands::Start { worktree, .. }) => { + assert!(!worktree.worktree); + assert!(worktree.no_worktree); + } + _ => panic!("expected start subcommand"), + } + } + #[test] fn cli_parses_delegate_command() { let cli = Cli::try_parse_from([ @@ -1614,6 +1679,20 @@ mod tests { } } + #[test] + fn cli_parses_delegate_worktree_override() { + let cli = Cli::try_parse_from(["ecc", "delegate", "planner", "--worktree"]) + .expect("delegate --worktree should parse"); + + match cli.command { + Some(Commands::Delegate { worktree, .. }) => { + assert!(worktree.worktree); + assert!(!worktree.no_worktree); + } + _ => panic!("expected delegate subcommand"), + } + } + #[test] fn cli_parses_team_command() { let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"]) @@ -1704,9 +1783,7 @@ mod tests { let command = err.command.expect("expected command"); let Commands::WorktreeStatus { - session_id, - all, - .. + session_id, all, .. } = command else { panic!("expected worktree-status subcommand"); @@ -1807,8 +1884,9 @@ mod tests { #[test] fn cli_parses_worktree_resolution_flags() { - let cli = Cli::try_parse_from(["ecc", "worktree-resolution", "planner", "--json", "--check"]) - .expect("worktree-resolution flags should parse"); + let cli = + Cli::try_parse_from(["ecc", "worktree-resolution", "planner", "--json", "--check"]) + .expect("worktree-resolution flags should parse"); match cli.command { Some(Commands::WorktreeResolution { @@ -2280,9 +2358,7 @@ mod tests { match cli.command { Some(Commands::AutoDispatch { - agent, - lead_limit, - .. + agent, lead_limit, .. }) => { assert_eq!(agent, "claude"); assert_eq!(lead_limit, 4); @@ -2406,9 +2482,7 @@ mod tests { match cli.command { Some(Commands::RebalanceAll { - agent, - lead_limit, - .. + agent, lead_limit, .. }) => { assert_eq!(agent, "claude"); assert_eq!(lead_limit, 6); diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 01907807..16f7c629 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -392,10 +392,7 @@ where ); } if dirty > 0 { - tracing::warn!( - "Skipped {} dirty worktree(s) during auto-merge", - dirty - ); + tracing::warn!("Skipped {} dirty worktree(s) during auto-merge", dirty); } if active > 0 { tracing::info!("Skipped {active} active worktree(s) during auto-merge"); diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 39ea386c..f288308c 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -101,7 +101,8 @@ pub async fn drain_inbox( ) -> Result> { let repo_root = std::env::current_dir().context("Failed to resolve current working directory")?; - let runner_program = std::env::current_exe().context("Failed to resolve ECC executable path")?; + let runner_program = + std::env::current_exe().context("Failed to resolve ECC executable path")?; let lead = resolve_session(db, lead_id)?; let messages = db.unread_task_handoffs_for_session(&lead.id, limit)?; let mut outcomes = Vec::new(); @@ -184,7 +185,12 @@ pub async fn rebalance_all_teams( for session in sessions .into_iter() - .filter(|session| matches!(session.state, SessionState::Running | SessionState::Pending | SessionState::Idle)) + .filter(|session| { + matches!( + session.state, + SessionState::Running | SessionState::Pending | SessionState::Idle + ) + }) .take(lead_limit) { let rerouted = rebalance_team_backlog( @@ -245,7 +251,8 @@ pub async fn rebalance_team_backlog( ) -> Result> { let repo_root = std::env::current_dir().context("Failed to resolve current working directory")?; - let runner_program = std::env::current_exe().context("Failed to resolve ECC executable path")?; + let runner_program = + std::env::current_exe().context("Failed to resolve ECC executable path")?; let lead = resolve_session(db, lead_id)?; let mut outcomes = Vec::new(); @@ -888,7 +895,15 @@ async fn queue_session_in_dir_with_runner_program( .map(|worktree| worktree.path.as_path()) .unwrap_or(repo_root); - match spawn_session_runner_for_program(task, &session.id, agent_type, working_dir, runner_program).await { + match spawn_session_runner_for_program( + task, + &session.id, + agent_type, + working_dir, + runner_program, + ) + .await + { Ok(()) => Ok(session.id), Err(error) => { db.update_state(&session.id, &SessionState::Failed)?; @@ -989,7 +1004,11 @@ async fn spawn_session_runner( .await } -fn direct_delegate_sessions(db: &StateStore, lead_id: &str, agent_type: &str) -> Result> { +fn direct_delegate_sessions( + db: &StateStore, + lead_id: &str, + agent_type: &str, +) -> Result> { let mut sessions = Vec::new(); for child_id in db.delegated_children(lead_id, 50)? { let Some(session) = db.get_session(&child_id)? else { @@ -1101,12 +1120,7 @@ async fn spawn_session_runner_for_program( .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() - .with_context(|| { - format!( - "Failed to spawn ECC runner from {}", - current_exe.display() - ) - })?; + .with_context(|| format!("Failed to spawn ECC runner from {}", current_exe.display()))?; child .id() @@ -1114,7 +1128,12 @@ async fn spawn_session_runner_for_program( Ok(()) } -fn build_agent_command(agent_program: &Path, task: &str, session_id: &str, working_dir: &Path) -> Command { +fn build_agent_command( + agent_program: &Path, + task: &str, + session_id: &str, + working_dir: &Path, +) -> Command { let mut command = Command::new(agent_program); command .arg("--print") @@ -1414,7 +1433,11 @@ impl fmt::Display for TeamStatus { writeln!(f, "Branch: {}", worktree.branch)?; } - let lead_handoff_backlog = self.handoff_backlog.get(&self.root.id).copied().unwrap_or(0); + let lead_handoff_backlog = self + .handoff_backlog + .get(&self.root.id) + .copied() + .unwrap_or(0); writeln!(f, "Backlog: {}", lead_handoff_backlog)?; if self.descendants.is_empty() { @@ -1424,7 +1447,8 @@ impl fmt::Display for TeamStatus { writeln!(f, "Board:")?; let mut lanes: BTreeMap<&'static str, Vec<&DelegatedSessionSummary>> = BTreeMap::new(); for summary in &self.descendants { - lanes.entry(session_state_label(&summary.session.state)) + lanes + .entry(session_state_label(&summary.session.state)) .or_default() .push(summary); } @@ -1502,18 +1526,11 @@ impl fmt::Display for CoordinationStatus { } if self.operator_escalation_required { - writeln!( - f, - "Operator escalation: chronic saturation is not clearing" - )?; + writeln!(f, "Operator escalation: chronic saturation is not clearing")?; } if let Some(cleared_at) = self.daemon_activity.chronic_saturation_cleared_at() { - writeln!( - f, - "Chronic saturation cleared: {}", - cleared_at.to_rfc3339() - )?; + writeln!(f, "Chronic saturation cleared: {}", cleared_at.to_rfc3339())?; } if let Some(stabilized_at) = stabilized { @@ -1631,6 +1648,7 @@ mod tests { default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, + auto_create_worktrees: true, auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, @@ -1685,14 +1703,7 @@ mod tests { run_git(path, ["config", "user.email", "ecc-tests@example.com"])?; fs::write(path.join("README.md"), "hello\n")?; run_git(path, ["add", "README.md"])?; - run_git( - path, - [ - "commit", - "-qm", - "init", - ], - )?; + run_git(path, ["commit", "-qm", "init"])?; Ok(()) } @@ -1885,7 +1896,13 @@ mod tests { assert!(log.contains("--session-id")); assert!(log.contains("deadbeef")); assert!(log.contains("resume previous task")); - assert!(log.contains(tempdir.path().join("resume-working-dir").to_string_lossy().as_ref())); + assert!(log.contains( + tempdir + .path() + .join("resume-working-dir") + .to_string_lossy() + .as_ref() + )); Ok(()) } @@ -1920,14 +1937,20 @@ mod tests { .clone() .context("stopped session worktree missing")? .path; - assert!(worktree_path.exists(), "worktree should still exist before cleanup"); + assert!( + worktree_path.exists(), + "worktree should still exist before cleanup" + ); cleanup_session_worktree(&db, &session_id).await?; let cleaned = db .get_session(&session_id)? .context("cleaned session should still exist")?; - assert!(cleaned.worktree.is_none(), "worktree metadata should be cleared"); + assert!( + cleaned.worktree.is_none(), + "worktree metadata should be cleared" + ); assert!(!worktree_path.exists(), "worktree path should be removed"); Ok(()) @@ -2051,12 +2074,18 @@ mod tests { assert_eq!(outcome.base_branch, worktree.base_branch); assert!(outcome.cleaned_worktree); assert!(!outcome.already_up_to_date); - assert_eq!(fs::read_to_string(repo_root.join("feature.txt"))?, "ready to merge\n"); + assert_eq!( + fs::read_to_string(repo_root.join("feature.txt"))?, + "ready to merge\n" + ); let merged = db .get_session(&outcome.session_id)? .context("merged session should still exist")?; - assert!(merged.worktree.is_none(), "worktree metadata should be cleared"); + assert!( + merged.worktree.is_none(), + "worktree metadata should be cleared" + ); assert!(!worktree.path.exists(), "worktree path should be removed"); let branch_output = StdCommand::new("git") @@ -2065,7 +2094,9 @@ mod tests { .args(["branch", "--list", &worktree.branch]) .output()?; assert!( - String::from_utf8_lossy(&branch_output.stdout).trim().is_empty(), + String::from_utf8_lossy(&branch_output.stdout) + .trim() + .is_empty(), "merged worktree branch should be deleted" ); @@ -2136,8 +2167,14 @@ mod tests { assert_eq!(outcome.merged.len(), 1); assert_eq!(outcome.merged[0].session_id, "merge-ready"); - assert_eq!(outcome.active_with_worktree_ids, vec!["active-worktree".to_string()]); - assert_eq!(outcome.dirty_worktree_ids, vec!["dirty-worktree".to_string()]); + assert_eq!( + outcome.active_with_worktree_ids, + vec!["active-worktree".to_string()] + ); + assert_eq!( + outcome.dirty_worktree_ids, + vec!["dirty-worktree".to_string()] + ); assert!(outcome.conflicted_session_ids.is_empty()); assert!(outcome.failures.is_empty()); @@ -2145,24 +2182,21 @@ mod tests { fs::read_to_string(repo_root.join("merged.txt"))?, "bulk merge\n" ); - assert!( - db.get_session("merge-ready")? - .context("merged session should still exist")? - .worktree - .is_none() - ); - assert!( - db.get_session("active-worktree")? - .context("active session should still exist")? - .worktree - .is_some() - ); - assert!( - db.get_session("dirty-worktree")? - .context("dirty session should still exist")? - .worktree - .is_some() - ); + assert!(db + .get_session("merge-ready")? + .context("merged session should still exist")? + .worktree + .is_none()); + assert!(db + .get_session("active-worktree")? + .context("active session should still exist")? + .worktree + .is_some()); + assert!(db + .get_session("dirty-worktree")? + .context("dirty session should still exist")? + .worktree + .is_some()); assert!(!merged_worktree.path.exists()); assert!(active_worktree.path.exists()); assert!(dirty_worktree.path.exists()); @@ -2203,7 +2237,10 @@ mod tests { delete_session(&db, &session_id).await?; - assert!(db.get_session(&session_id)?.is_none(), "session should be deleted"); + assert!( + db.get_session(&session_id)?.is_none(), + "session should be deleted" + ); assert!(!worktree_path.exists(), "worktree path should be removed"); Ok(()) @@ -2233,8 +2270,16 @@ mod tests { let db = StateStore::open(&cfg.db_path)?; let now = Utc::now(); - db.insert_session(&build_session("parent", SessionState::Running, now - Duration::minutes(2)))?; - db.insert_session(&build_session("child", SessionState::Pending, now - Duration::minutes(1)))?; + db.insert_session(&build_session( + "parent", + SessionState::Running, + now - Duration::minutes(2), + ))?; + db.insert_session(&build_session( + "child", + SessionState::Pending, + now - Duration::minutes(1), + ))?; db.insert_session(&build_session("sibling", SessionState::Idle, now))?; db.send_message( @@ -2270,9 +2315,21 @@ mod tests { let db = StateStore::open(&tempdir.path().join("state.db"))?; let now = Utc::now(); - db.insert_session(&build_session("lead", SessionState::Running, now - Duration::minutes(3)))?; - db.insert_session(&build_session("worker-a", SessionState::Running, now - Duration::minutes(2)))?; - db.insert_session(&build_session("worker-b", SessionState::Pending, now - Duration::minutes(1)))?; + db.insert_session(&build_session( + "lead", + SessionState::Running, + now - Duration::minutes(3), + ))?; + db.insert_session(&build_session( + "worker-a", + SessionState::Running, + now - Duration::minutes(2), + ))?; + db.insert_session(&build_session( + "worker-b", + SessionState::Pending, + now - Duration::minutes(1), + ))?; db.insert_session(&build_session("reviewer", SessionState::Completed, now))?; db.send_message( @@ -2444,15 +2501,15 @@ mod tests { let spawned_messages = db.list_messages_for_session(&outcome.session_id, 10)?; assert!(spawned_messages.iter().any(|message| { - message.msg_type == "task_handoff" - && message.content.contains("Fresh delegated task") + message.msg_type == "task_handoff" && message.content.contains("Fresh delegated task") })); Ok(()) } #[tokio::test(flavor = "current_thread")] - async fn assign_session_reuses_idle_delegate_when_only_non_handoff_messages_are_unread() -> Result<()> { + async fn assign_session_reuses_idle_delegate_when_only_non_handoff_messages_are_unread( + ) -> Result<()> { let tempdir = TestDir::new("manager-assign-reuse-idle-info-inbox")?; let repo_root = tempdir.path().join("repo"); init_git_repo(&repo_root)?; @@ -2512,8 +2569,7 @@ mod tests { let idle_messages = db.list_messages_for_session("idle-worker", 10)?; assert!(idle_messages.iter().any(|message| { - message.msg_type == "task_handoff" - && message.content.contains("Fresh delegated task") + message.msg_type == "task_handoff" && message.content.contains("Fresh delegated task") })); Ok(()) @@ -2583,8 +2639,7 @@ mod tests { let messages = db.list_messages_for_session(&outcome.session_id, 10)?; assert!(messages.iter().any(|message| { - message.msg_type == "task_handoff" - && message.content.contains("New delegated task") + message.msg_type == "task_handoff" && message.content.contains("New delegated task") })); Ok(()) @@ -2650,8 +2705,7 @@ mod tests { let busy_messages = db.list_messages_for_session("busy-worker", 10)?; assert!(!busy_messages.iter().any(|message| { - message.msg_type == "task_handoff" - && message.content.contains("New delegated task") + message.msg_type == "task_handoff" && message.content.contains("New delegated task") })); Ok(()) @@ -2697,8 +2751,7 @@ mod tests { let messages = db.list_messages_for_session(&outcomes[0].session_id, 10)?; assert!(messages.iter().any(|message| { - message.msg_type == "task_handoff" - && message.content.contains("Review auth changes") + message.msg_type == "task_handoff" && message.content.contains("Review auth changes") })); Ok(()) @@ -2764,8 +2817,7 @@ mod tests { let messages = db.list_messages_for_session("busy-worker", 10)?; assert!(!messages.iter().any(|message| { - message.msg_type == "task_handoff" - && message.content.contains("Review auth changes") + message.msg_type == "task_handoff" && message.content.contains("Review auth changes") })); Ok(()) @@ -3030,8 +3082,7 @@ mod tests { let worker_b_messages = db.list_messages_for_session("worker-b", 10)?; assert!(worker_b_messages.iter().any(|message| { - message.msg_type == "task_handoff" - && message.content.contains("Review auth flow") + message.msg_type == "task_handoff" && message.content.contains("Review auth flow") })); Ok(()) @@ -3108,22 +3159,18 @@ mod tests { }; let rendered = status.to_string(); - assert!( - rendered.contains( - "Global handoff backlog: 2 lead(s) / 5 handoff(s) [1 absorbable, 1 saturated]" - ) - ); + assert!(rendered.contains( + "Global handoff backlog: 2 lead(s) / 5 handoff(s) [1 absorbable, 1 saturated]" + )); assert!(rendered.contains("Auto-dispatch: on @ 4/lead")); assert!(rendered.contains("Coordination mode: rebalance-first (chronic saturation)")); assert!(rendered.contains("Chronic saturation streak: 2 cycle(s)")); assert!(rendered.contains("Last daemon dispatch: 3 routed / 1 deferred across 2 lead(s)")); assert!(rendered.contains("Last daemon recovery dispatch: 2 handoff(s) across 1 lead(s)")); assert!(rendered.contains("Last daemon rebalance: 0 handoff(s) across 1 lead(s)")); - assert!( - rendered.contains( - "Last daemon auto-merge: 1 merged / 1 active / 0 conflicted / 0 dirty / 0 failed" - ) - ); + assert!(rendered.contains( + "Last daemon auto-merge: 1 merged / 1 active / 0 conflicted / 0 dirty / 0 failed" + )); } #[test] @@ -3174,7 +3221,10 @@ mod tests { assert_eq!(status.backlog_messages, 3); assert_eq!(status.absorbable_sessions, 2); assert_eq!(status.saturated_sessions, 1); - assert_eq!(status.mode, CoordinationMode::RebalanceFirstChronicSaturation); + assert_eq!( + status.mode, + CoordinationMode::RebalanceFirstChronicSaturation + ); assert_eq!(status.health, CoordinationHealth::Saturated); assert!(!status.operator_escalation_required); assert_eq!(status.daemon_activity.last_dispatch_routed, 1); diff --git a/ecc2/src/session/runtime.rs b/ecc2/src/session/runtime.rs index 3fe605cf..3c75fe6d 100644 --- a/ecc2/src/session/runtime.rs +++ b/ecc2/src/session/runtime.rs @@ -70,11 +70,7 @@ impl DbWriter { } } -fn run_db_writer( - db_path: PathBuf, - session_id: String, - mut rx: mpsc::UnboundedReceiver, -) { +fn run_db_writer(db_path: PathBuf, session_id: String, mut rx: mpsc::UnboundedReceiver) { let (opened, open_error) = match StateStore::open(&db_path) { Ok(db) => (Some(db), None), Err(error) => (None, Some(error.to_string())), @@ -84,7 +80,9 @@ fn run_db_writer( match message { DbMessage::UpdateState { state, ack } => { let result = match opened.as_ref() { - Some(db) => db.update_state(&session_id, &state).map_err(|error| error.to_string()), + Some(db) => db + .update_state(&session_id, &state) + .map_err(|error| error.to_string()), None => Err(open_error .clone() .unwrap_or_else(|| "Failed to open state store".to_string())), @@ -93,7 +91,9 @@ fn run_db_writer( } DbMessage::UpdatePid { pid, ack } => { let result = match opened.as_ref() { - Some(db) => db.update_pid(&session_id, pid).map_err(|error| error.to_string()), + Some(db) => db + .update_pid(&session_id, pid) + .map_err(|error| error.to_string()), None => Err(open_error .clone() .unwrap_or_else(|| "Failed to open state store".to_string())), @@ -205,9 +205,7 @@ where let mut lines = BufReader::new(reader).lines(); while let Some(line) = lines.next_line().await? { - db_writer - .append_output_line(stream, line.clone()) - .await?; + db_writer.append_output_line(stream, line.clone()).await?; output_store.push_line(&session_id, stream, line); } diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 8b8e7cac..f9c60aaf 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -50,6 +50,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('t')) => dashboard.toggle_auto_worktree_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), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a4dfb43c..cc5f9912 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -7,12 +7,12 @@ use ratatui::{ use std::collections::HashMap; use tokio::sync::broadcast; -use super::widgets::{BudgetState, TokenMeter, budget_state, format_currency, format_token_count}; +use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; use crate::config::{Config, PaneLayout}; use crate::observability::ToolLogEntry; use crate::session::manager; -use crate::session::output::{OUTPUT_BUFFER_LIMIT, OutputEvent, OutputLine, SessionOutputStore}; +use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; use crate::session::store::{DaemonActivity, StateStore}; use crate::session::{Session, SessionMessage, SessionState}; use crate::worktree; @@ -362,7 +362,11 @@ impl Dashboard { let content = if lines.is_empty() { "Waiting for session output...".to_string() } else { - lines.iter().map(|line| line.text.as_str()).collect::>().join("\n") + lines + .iter() + .map(|line| line.text.as_str()) + .collect::>() + .join("\n") }; (" Output ", content) } @@ -383,18 +387,17 @@ impl Dashboard { (" Diff ", content) } OutputMode::ConflictProtocol => { - let content = self - .selected_conflict_protocol - .clone() - .unwrap_or_else(|| { - "No conflicted worktree available for the selected session." - .to_string() - }); + let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| { + "No conflicted worktree available for the selected session.".to_string() + }); (" Conflict Protocol ", content) } } } else { - (" Output ", "No sessions. Press 'n' to start one.".to_string()) + ( + " Output ", + "No sessions. Press 'n' to start one.".to_string(), + ) }; let paragraph = Paragraph::new(content) @@ -523,7 +526,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 conflict proto[c]ol [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 ", + " [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 conflict proto[c]ol [m]erge merge ready [M] auto-worktree [t] 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() { @@ -578,6 +581,7 @@ impl Dashboard { " c Show conflict-resolution protocol for selected conflicted worktree", " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", + " t Toggle default worktree creation for new sessions and delegated work", " 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", @@ -714,15 +718,22 @@ impl Dashboard { let task = self.new_session_task(); let agent = self.cfg.default_agent.clone(); - let session_id = - match manager::create_session(&self.db, &self.cfg, &task, &agent, true).await { - Ok(session_id) => session_id, - Err(error) => { - tracing::warn!("Failed to create new session from dashboard: {error}"); - self.set_operator_note(format!("new session failed: {error}")); - return; - } - }; + let session_id = match manager::create_session( + &self.db, + &self.cfg, + &task, + &agent, + self.cfg.auto_create_worktrees, + ) + .await + { + Ok(session_id) => session_id, + Err(error) => { + tracing::warn!("Failed to create new session from dashboard: {error}"); + self.set_operator_note(format!("new session failed: {error}")); + return; + } + }; if let Some(source_session) = self.sessions.get(self.selected_session) { let context = format!( @@ -834,7 +845,7 @@ impl Dashboard { &source_session.id, &task, &agent, - true, + self.cfg.auto_create_worktrees, ) .await { @@ -876,7 +887,7 @@ impl Dashboard { &self.cfg, &source_session_id, &agent, - true, + self.cfg.auto_create_worktrees, self.cfg.auto_dispatch_limit_per_session, ) .await @@ -930,7 +941,7 @@ impl Dashboard { &self.cfg, &source_session_id, &agent, - true, + self.cfg.auto_create_worktrees, self.cfg.max_parallel_sessions, ) .await @@ -975,17 +986,22 @@ impl Dashboard { let agent = self.cfg.default_agent.clone(); let lead_limit = self.sessions.len().max(1); - let outcomes = - match manager::auto_dispatch_backlog(&self.db, &self.cfg, &agent, true, lead_limit) - .await - { - Ok(outcomes) => outcomes, - Err(error) => { - tracing::warn!("Failed to auto-dispatch backlog from dashboard: {error}"); - self.set_operator_note(format!("global auto-dispatch failed: {error}")); - return; - } - }; + let outcomes = match manager::auto_dispatch_backlog( + &self.db, + &self.cfg, + &agent, + self.cfg.auto_create_worktrees, + lead_limit, + ) + .await + { + Ok(outcomes) => outcomes, + Err(error) => { + tracing::warn!("Failed to auto-dispatch backlog from dashboard: {error}"); + self.set_operator_note(format!("global auto-dispatch failed: {error}")); + return; + } + }; let total_processed: usize = outcomes.iter().map(|outcome| outcome.routed.len()).sum(); let total_routed: usize = outcomes @@ -1029,16 +1045,22 @@ impl Dashboard { let agent = self.cfg.default_agent.clone(); let lead_limit = self.sessions.len().max(1); - let outcomes = - match manager::rebalance_all_teams(&self.db, &self.cfg, &agent, true, lead_limit).await - { - Ok(outcomes) => outcomes, - Err(error) => { - tracing::warn!("Failed to rebalance teams from dashboard: {error}"); - self.set_operator_note(format!("global rebalance failed: {error}")); - return; - } - }; + let outcomes = match manager::rebalance_all_teams( + &self.db, + &self.cfg, + &agent, + self.cfg.auto_create_worktrees, + lead_limit, + ) + .await + { + Ok(outcomes) => outcomes, + Err(error) => { + tracing::warn!("Failed to rebalance teams from dashboard: {error}"); + self.set_operator_note(format!("global rebalance failed: {error}")); + return; + } + }; let total_rerouted: usize = outcomes.iter().map(|outcome| outcome.rerouted.len()).sum(); let selected_session_id = self @@ -1070,7 +1092,11 @@ impl Dashboard { let lead_limit = self.sessions.len().max(1); let outcome = match manager::coordinate_backlog( - &self.db, &self.cfg, &agent, true, lead_limit, + &self.db, + &self.cfg, + &agent, + self.cfg.auto_create_worktrees, + lead_limit, ) .await { @@ -1386,6 +1412,29 @@ impl Dashboard { } } + pub fn toggle_auto_worktree_policy(&mut self) { + self.cfg.auto_create_worktrees = !self.cfg.auto_create_worktrees; + match self.cfg.save() { + Ok(()) => { + let state = if self.cfg.auto_create_worktrees { + "enabled" + } else { + "disabled" + }; + self.set_operator_note(format!( + "default worktree creation {state} | saved to {}", + crate::config::Config::config_path().display() + )); + } + Err(error) => { + self.cfg.auto_create_worktrees = !self.cfg.auto_create_worktrees; + self.set_operator_note(format!( + "failed to persist worktree creation 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; @@ -1571,10 +1620,13 @@ impl Dashboard { self.selected_diff_preview = worktree .and_then(|worktree| worktree::diff_file_preview(worktree, MAX_DIFF_PREVIEW_LINES).ok()) .unwrap_or_default(); - self.selected_diff_patch = worktree - .and_then(|worktree| worktree::diff_patch_preview(worktree, MAX_DIFF_PATCH_LINES).ok().flatten()); - self.selected_merge_readiness = worktree - .and_then(|worktree| worktree::merge_readiness(worktree).ok()); + self.selected_diff_patch = worktree.and_then(|worktree| { + worktree::diff_patch_preview(worktree, MAX_DIFF_PATCH_LINES) + .ok() + .flatten() + }); + self.selected_merge_readiness = + worktree.and_then(|worktree| worktree::merge_readiness(worktree).ok()); self.selected_conflict_protocol = session .zip(worktree) .zip(self.selected_merge_readiness.as_ref()) @@ -1870,7 +1922,7 @@ impl Dashboard { } lines.push(format!( - "Global handoff backlog {} lead(s) / {} handoff(s) | Auto-dispatch {} @ {}/lead | Auto-merge {}", + "Global handoff backlog {} lead(s) / {} handoff(s) | Auto-dispatch {} @ {}/lead | Auto-worktree {} | Auto-merge {}", self.global_handoff_backlog_leads, self.global_handoff_backlog_messages, if self.cfg.auto_dispatch_unread_handoffs { @@ -1879,6 +1931,11 @@ impl Dashboard { "off" }, self.cfg.auto_dispatch_limit_per_session, + if self.cfg.auto_create_worktrees { + "on" + } else { + "off" + }, if self.cfg.auto_merge_ready_worktrees { "on" } else { @@ -2099,10 +2156,7 @@ impl Dashboard { .is_some(); for session in &self.sessions { - if self - .worktree_health_by_session - .get(&session.id) - .copied() + if self.worktree_health_by_session.get(&session.id).copied() == Some(worktree::WorktreeHealth::Conflicted) { items.push(format!( @@ -2283,7 +2337,11 @@ impl Dashboard { fn log_field<'a>(&self, value: &'a str) -> &'a str { let trimmed = value.trim(); - if trimmed.is_empty() { "n/a" } else { trimmed } + if trimmed.is_empty() { + "n/a" + } else { + trimmed + } } fn short_timestamp(&self, timestamp: &str) -> String { @@ -2423,11 +2481,19 @@ fn summary_line(summary: &SessionSummary) -> Line<'static> { ]; if summary.conflicted_worktrees > 0 { - spans.push(summary_span("Conflicts", summary.conflicted_worktrees, Color::Red)); + 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)); + spans.push(summary_span( + "Worktrees", + summary.in_progress_worktrees, + Color::Cyan, + )); } Line::from(spans) @@ -2462,17 +2528,19 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta ]); } - let mut spans = vec![ - Span::styled( - "Attention queue ", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ), - ]; + 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.push(summary_span( + "Conflicts", + summary.conflicted_worktrees, + Color::Red, + )); } spans.extend([ @@ -2606,11 +2674,16 @@ fn build_conflict_protocol( )); lines.push(format!("2. Open worktree: cd {}", worktree.path.display())); lines.push("3. Resolve conflicts and stage files: git add ".to_string()); - lines.push(format!("4. Commit the resolution on {}: git commit", worktree.branch)); + lines.push(format!( + "4. Commit the resolution on {}: git commit", + worktree.branch + )); lines.push(format!( "5. Re-check readiness: ecc worktree-status {session_id} --check" )); - lines.push(format!("6. Merge when clear: ecc merge-worktree {session_id}")); + lines.push(format!( + "6. Merge when clear: ecc merge-worktree {session_id}" + )); Some(lines.join("\n")) } @@ -2643,7 +2716,7 @@ fn format_duration(duration_secs: u64) -> String { mod tests { use anyhow::{Context, Result}; use chrono::Utc; - use ratatui::{Terminal, backend::TestBackend}; + use ratatui::{backend::TestBackend, Terminal}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -2862,14 +2935,14 @@ diff --git a/src/next.rs b/src/next.rs 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 | Auto-merge off" + "Global handoff backlog 2 lead(s) / 5 handoff(s) | Auto-dispatch off @ 5/lead | Auto-worktree on | 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() { + fn selected_session_metrics_text_shows_worktree_and_auto_merge_policy_state() { let mut dashboard = test_dashboard( vec![sample_session( "focus-12345678", @@ -2882,16 +2955,60 @@ diff --git a/src/next.rs b/src/next.rs 0, ); dashboard.cfg.auto_dispatch_unread_handoffs = true; + dashboard.cfg.auto_create_worktrees = false; 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" + "Global handoff backlog 1 lead(s) / 2 handoff(s) | Auto-dispatch on @ 5/lead | Auto-worktree off | Auto-merge on" )); } + #[test] + fn toggle_auto_worktree_policy_persists_config() { + let tempdir = std::env::temp_dir().join(format!("ecc2-worktree-policy-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tempdir).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &tempdir); + + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.cfg.auto_create_worktrees = true; + + dashboard.toggle_auto_worktree_policy(); + + assert!(!dashboard.cfg.auto_create_worktrees); + let expected_note = format!( + "default worktree creation disabled | saved to {}", + crate::config::Config::config_path().display() + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some(expected_note.as_str()) + ); + + let saved = std::fs::read_to_string(crate::config::Config::config_path()).unwrap(); + assert!(saved.contains("auto_create_worktrees = false")); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = std::fs::remove_dir_all(tempdir); + } + #[test] fn selected_session_metrics_text_includes_daemon_activity() { let now = Utc::now(); @@ -2932,9 +3049,9 @@ diff --git a/src/next.rs b/src/next.rs assert!(text.contains("Last daemon dispatch 4 routed / 2 deferred across 2 lead(s)")); assert!(text.contains("Last daemon recovery dispatch 1 handoff(s) across 1 lead(s)")); assert!(text.contains("Last daemon rebalance 1 handoff(s) across 1 lead(s)")); - assert!( - text.contains("Last daemon auto-merge 2 merged / 1 active / 1 conflicted / 0 dirty / 0 failed") - ); + assert!(text.contains( + "Last daemon auto-merge 2 merged / 1 active / 1 conflicted / 0 dirty / 0 failed" + )); } #[test] @@ -3013,8 +3130,8 @@ diff --git a/src/next.rs b/src/next.rs } #[test] - fn selected_session_metrics_text_recommends_operator_escalation_when_chronic_saturation_is_stuck() - { + fn selected_session_metrics_text_recommends_operator_escalation_when_chronic_saturation_is_stuck( + ) { let mut dashboard = test_dashboard( vec![sample_session( "focus-12345678", @@ -3664,18 +3781,16 @@ diff --git a/src/next.rs b/src/next.rs dashboard.operator_note.as_deref(), Some("pruned 1 inactive worktree(s); skipped 1 active session(s)") ); - assert!( - db.get_session("stopped-1")? - .expect("stopped session should exist") - .worktree - .is_none() - ); - assert!( - db.get_session("running-1")? - .expect("running session should exist") - .worktree - .is_some() - ); + assert!(db + .get_session("stopped-1")? + .expect("stopped session should exist") + .worktree + .is_none()); + assert!(db + .get_session("running-1")? + .expect("running session should exist") + .worktree + .is_some()); let _ = std::fs::remove_dir_all(active_path); let _ = std::fs::remove_dir_all(stopped_path); @@ -3734,7 +3849,10 @@ diff --git a/src/next.rs b/src/next.rs .db .get_session(&session_id)? .context("merged session should still exist")?; - assert!(session.worktree.is_none(), "worktree metadata should be cleared"); + assert!( + session.worktree.is_none(), + "worktree metadata should be cleared" + ); assert!(!worktree.path.exists(), "worktree path should be removed"); assert_eq!( std::fs::read_to_string(repo_root.join("dashboard.txt"))?, @@ -3756,8 +3874,12 @@ diff --git a/src/next.rs b/src/next.rs let db = StateStore::open(&cfg.db_path)?; let now = Utc::now(); - let merged_worktree = worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?; - std::fs::write(merged_worktree.path.join("merged.txt"), "dashboard bulk merge\n")?; + let merged_worktree = + worktree::create_for_session_in_repo("merge-ready", &cfg, &repo_root)?; + std::fs::write( + merged_worktree.path.join("merged.txt"), + "dashboard bulk merge\n", + )?; Command::new("git") .arg("-C") .arg(&merged_worktree.path) @@ -3805,14 +3927,12 @@ diff --git a/src/next.rs b/src/next.rs .context("operator note should be set")?; assert!(note.contains("merged 1 ready worktree(s)")); assert!(note.contains("skipped 1 active")); - assert!( - dashboard - .db - .get_session("merge-ready")? - .context("merged session should still exist")? - .worktree - .is_none() - ); + assert!(dashboard + .db + .get_session("merge-ready")? + .context("merged session should still exist")? + .worktree + .is_none()); assert_eq!( std::fs::read_to_string(repo_root.join("merged.txt"))?, "dashboard bulk merge\n" @@ -4100,6 +4220,7 @@ diff --git a/src/next.rs b/src/next.rs default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, + auto_create_worktrees: true, auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index c53a57e0..95c93c29 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -237,7 +237,12 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result { let output = Command::new("git") .arg("-C") .arg(&worktree.path) - .args(["merge-tree", "--write-tree", &worktree.base_branch, &worktree.branch]) + .args([ + "merge-tree", + "--write-tree", + &worktree.base_branch, + &worktree.branch, + ]) .output() .context("Failed to generate merge readiness preview")?; @@ -519,7 +524,8 @@ fn base_checkout_path(worktree: &WorktreeInfo) -> Result { if fallback.is_none() && path != worktree.path { fallback = Some(path.clone()); } - if current_branch.as_deref() == Some(target_branch.as_str()) && path != worktree.path + if current_branch.as_deref() == Some(target_branch.as_str()) + && path != worktree.path { return Ok(path); } @@ -660,16 +666,12 @@ mod tests { }; let preview = diff_file_preview(&info, 6)?; - assert!( - preview - .iter() - .any(|line| line.contains("Branch A") && line.contains("src.txt")) - ); - assert!( - preview - .iter() - .any(|line| line.contains("Working M") && line.contains("README.md")) - ); + assert!(preview + .iter() + .any(|line| line.contains("Branch A") && line.contains("src.txt"))); + assert!(preview + .iter() + .any(|line| line.contains("Working M") && line.contains("README.md"))); let _ = Command::new("git") .arg("-C") @@ -736,7 +738,8 @@ mod tests { #[test] fn merge_readiness_reports_ready_worktree() -> Result<()> { - let root = std::env::temp_dir().join(format!("ecc2-worktree-merge-ready-{}", Uuid::new_v4())); + let root = + std::env::temp_dir().join(format!("ecc2-worktree-merge-ready-{}", Uuid::new_v4())); let repo = root.join("repo"); fs::create_dir_all(&repo)?; @@ -787,7 +790,8 @@ mod tests { #[test] fn merge_readiness_reports_conflicted_worktree() -> Result<()> { - let root = std::env::temp_dir().join(format!("ecc2-worktree-merge-conflict-{}", Uuid::new_v4())); + let root = + std::env::temp_dir().join(format!("ecc2-worktree-merge-conflict-{}", Uuid::new_v4())); let repo = root.join("repo"); fs::create_dir_all(&repo)?; From adfe8a8311c0e224f73214b662e779736a3c5fa0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 16:08:29 -0700 Subject: [PATCH 055/459] feat: auto-prune inactive ecc2 worktrees --- ecc2/src/session/daemon.rs | 83 +++++++++++++++++++++++++++++++++++++ ecc2/src/session/manager.rs | 14 +++++++ ecc2/src/session/store.rs | 69 +++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 31 ++++++++++++++ 4 files changed, 195 insertions(+), 2 deletions(-) diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 16f7c629..c2783322 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -37,6 +37,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Worktree auto-merge pass failed: {e}"); } + if let Err(e) = maybe_auto_prune_inactive_worktrees(&db).await { + tracing::error!("Worktree auto-prune pass failed: {e}"); + } + time::sleep(heartbeat_interval).await; } } @@ -404,6 +408,46 @@ where Ok(merged) } +async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result { + maybe_auto_prune_inactive_worktrees_with_recorder( + || manager::prune_inactive_worktrees(db), + |pruned, active| db.record_daemon_auto_prune_pass(pruned, active), + ) + .await +} + +async fn maybe_auto_prune_inactive_worktrees_with(prune: F) -> Result +where + F: Fn() -> Fut, + Fut: Future>, +{ + maybe_auto_prune_inactive_worktrees_with_recorder(prune, |_, _| Ok(())).await +} + +async fn maybe_auto_prune_inactive_worktrees_with_recorder( + prune: F, + mut record: R, +) -> Result +where + F: Fn() -> Fut, + Fut: Future>, + R: FnMut(usize, usize) -> Result<()>, +{ + let outcome = prune().await?; + let pruned = outcome.cleaned_session_ids.len(); + let active = outcome.active_with_worktree_ids.len(); + record(pruned, active)?; + + if pruned > 0 { + tracing::info!("Auto-pruned {pruned} inactive worktree(s)"); + } + if active > 0 { + tracing::info!("Skipped {active} active worktree(s) during auto-prune"); + } + + Ok(pruned) +} + #[cfg(unix)] fn pid_is_alive(pid: u32) -> bool { if pid == 0 { @@ -769,6 +813,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let dispatch_order = order.clone(); @@ -832,6 +879,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); let recorded_clone = recorded.clone(); @@ -887,6 +937,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_clone = calls.clone(); @@ -943,6 +996,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let calls_clone = calls.clone(); @@ -999,6 +1055,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let rebalance_calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let rebalance_calls_clone = rebalance_calls.clone(); @@ -1199,4 +1258,28 @@ mod tests { assert_eq!(merged, 2); Ok(()) } + + #[tokio::test] + async fn maybe_auto_prune_inactive_worktrees_records_pruned_and_active_counts() -> Result<()> { + let recorded = std::sync::Arc::new(std::sync::Mutex::new(None)); + let recorded_clone = recorded.clone(); + + let pruned = maybe_auto_prune_inactive_worktrees_with_recorder( + || async move { + Ok(manager::WorktreePruneOutcome { + cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()], + active_with_worktree_ids: vec!["running-a".to_string()], + }) + }, + move |pruned, active| { + *recorded_clone.lock().unwrap() = Some((pruned, active)); + Ok(()) + }, + ) + .await?; + + assert_eq!(pruned, 2); + assert_eq!(*recorded.lock().unwrap(), Some((2, 1))); + Ok(()) + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index f288308c..e13cdd6e 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1585,6 +1585,16 @@ impl fmt::Display for CoordinationStatus { )?; } + if let Some(last_auto_prune_at) = self.daemon_activity.last_auto_prune_at.as_ref() { + writeln!( + f, + "Last daemon auto-prune: {} pruned / {} active @ {}", + self.daemon_activity.last_auto_prune_pruned, + self.daemon_activity.last_auto_prune_active_skipped, + last_auto_prune_at.to_rfc3339() + )?; + } + Ok(()) } } @@ -1693,6 +1703,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: Some(now), + last_auto_prune_pruned: 2, + last_auto_prune_active_skipped: 1, } } @@ -3171,6 +3184,7 @@ mod tests { assert!(rendered.contains( "Last daemon auto-merge: 1 merged / 1 active / 0 conflicted / 0 dirty / 0 failed" )); + assert!(rendered.contains("Last daemon auto-prune: 2 pruned / 1 active")); } #[test] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 8b493457..2cb906b8 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -33,6 +33,9 @@ pub struct DaemonActivity { pub last_auto_merge_conflicted_skipped: usize, pub last_auto_merge_dirty_skipped: usize, pub last_auto_merge_failed: usize, + pub last_auto_prune_at: Option>, + pub last_auto_prune_pruned: usize, + pub last_auto_prune_active_skipped: usize, } impl DaemonActivity { @@ -174,7 +177,10 @@ impl StateStore { last_auto_merge_active_skipped INTEGER NOT NULL DEFAULT 0, last_auto_merge_conflicted_skipped INTEGER NOT NULL DEFAULT 0, last_auto_merge_dirty_skipped INTEGER NOT NULL DEFAULT 0, - last_auto_merge_failed INTEGER NOT NULL DEFAULT 0 + last_auto_merge_failed INTEGER NOT NULL DEFAULT 0, + last_auto_prune_at TEXT, + last_auto_prune_pruned INTEGER NOT NULL DEFAULT 0, + last_auto_prune_active_skipped INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state); @@ -307,6 +313,33 @@ impl StateStore { .context("Failed to add last_auto_merge_failed column to daemon_activity table")?; } + if !self.has_column("daemon_activity", "last_auto_prune_at")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_at TEXT", + [], + ) + .context("Failed to add last_auto_prune_at column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_prune_pruned")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_pruned INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_prune_pruned column to daemon_activity table")?; + } + + if !self.has_column("daemon_activity", "last_auto_prune_active_skipped")? { + self.conn + .execute( + "ALTER TABLE daemon_activity ADD COLUMN last_auto_prune_active_skipped INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add last_auto_prune_active_skipped column to daemon_activity table")?; + } + Ok(()) } @@ -712,7 +745,8 @@ impl StateStore { last_rebalance_at, last_rebalance_rerouted, last_rebalance_leads, last_auto_merge_at, last_auto_merge_merged, last_auto_merge_active_skipped, last_auto_merge_conflicted_skipped, last_auto_merge_dirty_skipped, - last_auto_merge_failed + last_auto_merge_failed, last_auto_prune_at, last_auto_prune_pruned, + last_auto_prune_active_skipped FROM daemon_activity WHERE id = 1", [], @@ -752,6 +786,9 @@ impl StateStore { last_auto_merge_conflicted_skipped: row.get::<_, i64>(14)? as usize, last_auto_merge_dirty_skipped: row.get::<_, i64>(15)? as usize, last_auto_merge_failed: row.get::<_, i64>(16)? as usize, + last_auto_prune_at: parse_ts(row.get(17)?)?, + last_auto_prune_pruned: row.get::<_, i64>(18)? as usize, + last_auto_prune_active_skipped: row.get::<_, i64>(19)? as usize, }) }, ) @@ -847,6 +884,27 @@ impl StateStore { Ok(()) } + pub fn record_daemon_auto_prune_pass( + &self, + pruned: usize, + active_skipped: usize, + ) -> Result<()> { + self.conn.execute( + "UPDATE daemon_activity + SET last_auto_prune_at = ?1, + last_auto_prune_pruned = ?2, + last_auto_prune_active_skipped = ?3 + WHERE id = 1", + rusqlite::params![ + chrono::Utc::now().to_rfc3339(), + pruned as i64, + active_skipped as i64, + ], + )?; + + Ok(()) + } + pub fn delegated_children(&self, session_id: &str, limit: usize) -> Result> { let mut stmt = self.conn.prepare( "SELECT to_session @@ -1223,6 +1281,7 @@ mod tests { db.record_daemon_recovery_dispatch_pass(2, 1)?; db.record_daemon_rebalance_pass(3, 1)?; db.record_daemon_auto_merge_pass(2, 1, 1, 1, 0)?; + db.record_daemon_auto_prune_pass(3, 1)?; let activity = db.daemon_activity()?; assert_eq!(activity.last_dispatch_routed, 4); @@ -1238,10 +1297,13 @@ mod tests { assert_eq!(activity.last_auto_merge_conflicted_skipped, 1); assert_eq!(activity.last_auto_merge_dirty_skipped, 1); assert_eq!(activity.last_auto_merge_failed, 0); + assert_eq!(activity.last_auto_prune_pruned, 3); + assert_eq!(activity.last_auto_prune_active_skipped, 1); assert!(activity.last_dispatch_at.is_some()); assert!(activity.last_recovery_dispatch_at.is_some()); assert!(activity.last_rebalance_at.is_some()); assert!(activity.last_auto_merge_at.is_some()); + assert!(activity.last_auto_prune_at.is_some()); Ok(()) } @@ -1274,6 +1336,9 @@ mod tests { last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; assert!(unresolved.prefers_rebalance_first()); assert!(unresolved.dispatch_cooloff_active()); diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index cc5f9912..4195a493 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -2029,6 +2029,15 @@ impl Dashboard { )); } + if let Some(last_auto_prune_at) = self.daemon_activity.last_auto_prune_at.as_ref() { + lines.push(format!( + "Last daemon auto-prune {} pruned / {} active @ {}", + self.daemon_activity.last_auto_prune_pruned, + self.daemon_activity.last_auto_prune_active_skipped, + self.short_timestamp(&last_auto_prune_at.to_rfc3339()) + )); + } + if let Some(route_preview) = self.selected_route_preview.as_ref() { lines.push(format!("Next route {route_preview}")); } @@ -3041,6 +3050,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 1, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: Some(now + chrono::Duration::seconds(4)), + last_auto_prune_pruned: 3, + last_auto_prune_active_skipped: 1, }; let text = dashboard.selected_session_metrics_text(); @@ -3052,6 +3064,7 @@ diff --git a/src/next.rs b/src/next.rs assert!(text.contains( "Last daemon auto-merge 2 merged / 1 active / 1 conflicted / 0 dirty / 0 failed" )); + assert!(text.contains("Last daemon auto-prune 3 pruned / 1 active")); } #[test] @@ -3085,6 +3098,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3122,6 +3138,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3161,6 +3180,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3201,6 +3223,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3256,6 +3281,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); @@ -3358,6 +3386,9 @@ diff --git a/src/next.rs b/src/next.rs last_auto_merge_conflicted_skipped: 0, last_auto_merge_dirty_skipped: 0, last_auto_merge_failed: 0, + last_auto_prune_at: None, + last_auto_prune_pruned: 0, + last_auto_prune_active_skipped: 0, }; let text = dashboard.selected_session_metrics_text(); From 2e5e94cb7fbd85ebfaae010126e469bc0abf3495 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 16:27:30 -0700 Subject: [PATCH 056/459] fix: harden claude plugin manifest surfaces --- .claude-plugin/marketplace.json | 2 -- commands/prp-commit.md | 4 +-- commands/prp-pr.md | 4 +-- commands/prp-prd.md | 4 +-- scripts/ci/validate-commands.js | 52 +++++++++++++++++++++++++++++++++ tests/plugin-manifest.test.js | 25 ++++++++++++++++ 6 files changed, 83 insertions(+), 8 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bdae85c8..8250c266 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,5 @@ { - "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "ecc", - "description": "Battle-tested Claude Code configurations from an Anthropic hackathon winner — agents, skills, hooks, rules, and legacy command shims evolved over 10+ months of intensive daily use", "owner": { "name": "Affaan Mustafa", "email": "me@affaanmustafa.com" diff --git a/commands/prp-commit.md b/commands/prp-commit.md index 5a9c0763..85935b8c 100644 --- a/commands/prp-commit.md +++ b/commands/prp-commit.md @@ -1,6 +1,6 @@ --- -description: Quick commit with natural language file targeting — describe what to commit in plain English -argument-hint: [target description] (blank = all changes) +description: "Quick commit with natural language file targeting — describe what to commit in plain English" +argument-hint: "[target description] (blank = all changes)" --- # Smart Commit diff --git a/commands/prp-pr.md b/commands/prp-pr.md index 6551e2e2..9469cb88 100644 --- a/commands/prp-pr.md +++ b/commands/prp-pr.md @@ -1,6 +1,6 @@ --- -description: Create a GitHub PR from current branch with unpushed commits — discovers templates, analyzes changes, pushes -argument-hint: [base-branch] (default: main) +description: "Create a GitHub PR from current branch with unpushed commits — discovers templates, analyzes changes, pushes" +argument-hint: "[base-branch] (default: main)" --- # Create Pull Request diff --git a/commands/prp-prd.md b/commands/prp-prd.md index 969fdc3a..5292c38c 100644 --- a/commands/prp-prd.md +++ b/commands/prp-prd.md @@ -1,6 +1,6 @@ --- -description: Interactive PRD generator - problem-first, hypothesis-driven product spec with back-and-forth questioning -argument-hint: [feature/product idea] (blank = start with questions) +description: "Interactive PRD generator - problem-first, hypothesis-driven product spec with back-and-forth questioning" +argument-hint: "[feature/product idea] (blank = start with questions)" --- # Product Requirements Document Generator diff --git a/scripts/ci/validate-commands.js b/scripts/ci/validate-commands.js index 1ca5ae49..ffe09e90 100644 --- a/scripts/ci/validate-commands.js +++ b/scripts/ci/validate-commands.js @@ -12,6 +12,53 @@ const COMMANDS_DIR = path.join(ROOT_DIR, 'commands'); const AGENTS_DIR = path.join(ROOT_DIR, 'agents'); const SKILLS_DIR = path.join(ROOT_DIR, 'skills'); +function validateFrontmatter(file, content) { + if (!content.startsWith('---\n')) { + return []; + } + + const endIndex = content.indexOf('\n---\n', 4); + if (endIndex === -1) { + return [`${file} - frontmatter block is missing a closing --- delimiter`]; + } + + const block = content.slice(4, endIndex); + const errors = []; + + for (const rawLine of block.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!match) { + errors.push(`${file} - invalid frontmatter line: ${rawLine}`); + continue; + } + + const value = match[2].trim(); + const isQuoted = ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ); + + if (!isQuoted && value.startsWith('[') && !value.endsWith(']')) { + errors.push( + `${file} - frontmatter value for "${match[1]}" starts with "[" but is not a closed YAML sequence; wrap it in quotes`, + ); + } + + if (!isQuoted && value.startsWith('{') && !value.endsWith('}')) { + errors.push( + `${file} - frontmatter value for "${match[1]}" starts with "{" but is not a closed YAML mapping; wrap it in quotes`, + ); + } + } + + return errors; +} + function validateCommands() { if (!fs.existsSync(COMMANDS_DIR)) { console.log('No commands directory found, skipping validation'); @@ -68,6 +115,11 @@ function validateCommands() { continue; } + for (const error of validateFrontmatter(file, content)) { + console.error(`ERROR: ${error}`); + hasErrors = true; + } + // Strip fenced code blocks before checking cross-references. // Examples/templates inside ``` blocks are not real references. const contentNoCodeBlocks = content.replace(/```[\s\S]*?```/g, ''); diff --git a/tests/plugin-manifest.test.js b/tests/plugin-manifest.test.js index 20ad64bb..0fa99946 100644 --- a/tests/plugin-manifest.test.js +++ b/tests/plugin-manifest.test.js @@ -68,6 +68,7 @@ function assertSafeRepoRelativePath(relativePath, label) { console.log('\n=== .claude-plugin/plugin.json ===\n'); const claudePluginPath = path.join(repoRoot, '.claude-plugin', 'plugin.json'); +const claudeMarketplacePath = path.join(repoRoot, '.claude-plugin', 'marketplace.json'); test('claude plugin.json exists', () => { assert.ok(fs.existsSync(claudePluginPath), 'Expected .claude-plugin/plugin.json to exist'); @@ -131,6 +132,30 @@ test('claude plugin.json does NOT have explicit hooks declaration', () => { ); }); +console.log('\n=== .claude-plugin/marketplace.json ===\n'); + +test('claude marketplace.json exists', () => { + assert.ok(fs.existsSync(claudeMarketplacePath), 'Expected .claude-plugin/marketplace.json to exist'); +}); + +const claudeMarketplace = loadJsonObject(claudeMarketplacePath, '.claude-plugin/marketplace.json'); + +test('claude marketplace.json keeps only Claude-supported top-level keys', () => { + const unsupportedTopLevelKeys = ['$schema', 'description']; + for (const key of unsupportedTopLevelKeys) { + assert.ok( + !(key in claudeMarketplace), + `.claude-plugin/marketplace.json must not declare unsupported top-level key "${key}"`, + ); + } +}); + +test('claude marketplace.json has plugins array with a short ecc plugin entry', () => { + assert.ok(Array.isArray(claudeMarketplace.plugins) && claudeMarketplace.plugins.length > 0, 'Expected plugins array'); + assert.strictEqual(claudeMarketplace.name, 'ecc'); + assert.strictEqual(claudeMarketplace.plugins[0].name, 'ecc'); +}); + // ── Codex plugin manifest ───────────────────────────────────────────────────── // Per official docs: https://platform.openai.com/docs/codex/plugins // - .codex-plugin/plugin.json is the required manifest From 1b3ccb85aab00db271ca6068e7ac37beaf4a438d Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Wed, 8 Apr 2026 16:31:58 -0700 Subject: [PATCH 057/459] docs: mark continuous-learning v1 as legacy --- README.md | 5 +++-- manifests/install-components.json | 2 +- manifests/install-modules.json | 2 +- skills/configure-ecc/SKILL.md | 2 +- tests/lib/install-manifests.test.js | 8 ++++++++ 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 08294a94..91e7303d 100644 --- a/README.md +++ b/README.md @@ -351,7 +351,7 @@ everything-claude-code/ | |-- market-research/ # Source-attributed market, competitor, and investor research (NEW) | |-- investor-materials/ # Pitch decks, one-pagers, memos, and financial models (NEW) | |-- investor-outreach/ # Personalized fundraising outreach and follow-up (NEW) -| |-- continuous-learning/ # Auto-extract patterns from sessions (Longform Guide) +| |-- continuous-learning/ # Legacy v1 Stop-hook pattern extraction | |-- continuous-learning-v2/ # Instinct-based learning with confidence scoring | |-- iterative-retrieval/ # Progressive context refinement for subagents | |-- strategic-compact/ # Manual compaction suggestions (Longform Guide) @@ -515,7 +515,7 @@ Use the `/skill-create` command for local analysis without external services: ```bash /skill-create # Analyze current repo -/skill-create --instincts # Also generate instincts for continuous-learning +/skill-create --instincts # Also generate instincts for continuous-learning-v2 ``` This analyzes your git history locally and generates SKILL.md files. @@ -580,6 +580,7 @@ The instinct-based learning system automatically learns your patterns: ``` See `skills/continuous-learning-v2/` for full documentation. +Keep `continuous-learning/` only when you explicitly want the legacy v1 Stop-hook learned-skill flow. --- diff --git a/manifests/install-components.json b/manifests/install-components.json index 045d8e62..78ad53e2 100644 --- a/manifests/install-components.json +++ b/manifests/install-components.json @@ -358,7 +358,7 @@ { "id": "skill:continuous-learning", "family": "skill", - "description": "Session pattern extraction and continuous learning skill.", + "description": "Legacy v1 Stop-hook session pattern extraction skill; prefer continuous-learning-v2 for new installs.", "modules": [ "workflow-quality" ] diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 29c4b841..dc944c6e 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -197,7 +197,7 @@ { "id": "workflow-quality", "kind": "skills", - "description": "Evaluation, TDD, verification, learning, and compaction skills.", + "description": "Evaluation, TDD, verification, compaction, and learning skills, including the legacy continuous-learning v1 path.", "paths": [ "skills/agent-sort", "skills/agent-introspection-debugging", diff --git a/skills/configure-ecc/SKILL.md b/skills/configure-ecc/SKILL.md index aa4ff175..62d2aac1 100644 --- a/skills/configure-ecc/SKILL.md +++ b/skills/configure-ecc/SKILL.md @@ -139,7 +139,7 @@ For each selected category, print the full list of skills below and ask the user | Skill | Description | |-------|-------------| -| `continuous-learning` | Auto-extract reusable patterns from sessions as learned skills | +| `continuous-learning` | Legacy v1 Stop-hook session pattern extraction; prefer `continuous-learning-v2` for new installs | | `continuous-learning-v2` | Instinct-based learning with confidence scoring, evolves into skills, agents, and optional legacy command shims | | `eval-harness` | Formal evaluation framework for eval-driven development (EDD) | | `iterative-retrieval` | Progressive context refinement for subagent context problem | diff --git a/tests/lib/install-manifests.test.js b/tests/lib/install-manifests.test.js index 662a1270..a7cbba79 100644 --- a/tests/lib/install-manifests.test.js +++ b/tests/lib/install-manifests.test.js @@ -80,6 +80,14 @@ function runTests() { 'Should include capability:security'); })) passed++; else failed++; + if (test('labels continuous-learning as a legacy v1 install surface', () => { + const components = listInstallComponents({ family: 'skill' }); + const component = components.find(entry => entry.id === 'skill:continuous-learning'); + assert.ok(component, 'Should include skill:continuous-learning'); + assert.match(component.description, /legacy/i, 'Should label continuous-learning as legacy'); + assert.match(component.description, /continuous-learning-v2/, 'Should point new installs to continuous-learning-v2'); + })) passed++; else failed++; + if (test('lists supported legacy compatibility languages', () => { const languages = listLegacyCompatibilityLanguages(); assert.ok(languages.includes('typescript')); From 3eb9bc8ef55538d0f0a14a07636ff0d51d307c0b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 03:39:17 -0700 Subject: [PATCH 058/459] feat: add ecc2 runtime pane layout switching --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 95 +++++++++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index f9c60aaf..36b69667 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('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, + (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), (_, KeyCode::Char('t')) => dashboard.toggle_auto_worktree_policy(), (_, KeyCode::Char('w')) => dashboard.toggle_auto_merge_policy(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 4195a493..1b46eea1 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -32,6 +32,13 @@ const MAX_LOG_ENTRIES: u64 = 12; const MAX_DIFF_PREVIEW_LINES: usize = 6; const MAX_DIFF_PATCH_LINES: usize = 80; +fn default_pane_size(layout: PaneLayout) -> u16 { + match layout { + PaneLayout::Grid => DEFAULT_GRID_SIZE_PERCENT, + PaneLayout::Horizontal | PaneLayout::Vertical => DEFAULT_PANE_SIZE_PERCENT, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct WorktreeDiffColumns { removals: String, @@ -148,10 +155,7 @@ impl Dashboard { cfg: Config, output_store: SessionOutputStore, ) -> Self { - let pane_size_percent = match cfg.pane_layout { - PaneLayout::Grid => DEFAULT_GRID_SIZE_PERCENT, - PaneLayout::Horizontal | PaneLayout::Vertical => DEFAULT_PANE_SIZE_PERCENT, - }; + let pane_size_percent = default_pane_size(cfg.pane_layout); let sessions = db.list_sessions().unwrap_or_default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -526,7 +530,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 conflict proto[c]ol [m]erge merge ready [M] auto-worktree [t] 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 ", + " [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 conflict proto[c]ol [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [?] help [q]uit ", self.layout_label() ); let text = if let Some(note) = self.operator_note.as_ref() { @@ -581,6 +585,7 @@ impl Dashboard { " c Show conflict-resolution protocol for selected conflicted worktree", " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", + " l Cycle pane layout and persist it", " t Toggle default worktree creation for new sessions and delegated work", " p Toggle daemon auto-dispatch policy and persist config", " w Toggle daemon auto-merge for ready inactive worktrees", @@ -632,6 +637,42 @@ impl Dashboard { self.selected_pane = visible_panes[previous_index]; } + pub fn cycle_pane_layout(&mut self) { + let config_path = crate::config::Config::config_path(); + self.cycle_pane_layout_with_save(&config_path, |cfg| cfg.save()); + } + + fn cycle_pane_layout_with_save(&mut self, config_path: &std::path::Path, save: F) + where + F: FnOnce(&Config) -> anyhow::Result<()>, + { + let previous_layout = self.cfg.pane_layout; + let previous_pane_size = self.pane_size_percent; + let previous_selected_pane = self.selected_pane; + + self.cfg.pane_layout = match self.cfg.pane_layout { + PaneLayout::Horizontal => PaneLayout::Vertical, + PaneLayout::Vertical => PaneLayout::Grid, + PaneLayout::Grid => PaneLayout::Horizontal, + }; + self.pane_size_percent = default_pane_size(self.cfg.pane_layout); + self.ensure_selected_pane_visible(); + + match save(&self.cfg) { + Ok(()) => self.set_operator_note(format!( + "pane layout set to {} | saved to {}", + self.layout_label(), + config_path.display() + )), + Err(error) => { + self.cfg.pane_layout = previous_layout; + self.pane_size_percent = previous_pane_size; + self.selected_pane = previous_selected_pane; + self.set_operator_note(format!("failed to persist pane layout: {error}")); + } + } + } + pub fn increase_pane_size(&mut self) { self.pane_size_percent = (self.pane_size_percent + PANE_RESIZE_STEP_PERCENT).min(MAX_PANE_SIZE_PERCENT); @@ -4190,6 +4231,45 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.selected_pane, Pane::Log); } + #[test] + fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.pane_size_percent = 77; + dashboard.selected_pane = Pane::Log; + + dashboard.cycle_pane_layout(); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Horizontal); + assert_eq!(dashboard.pane_size_percent, DEFAULT_PANE_SIZE_PERCENT); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + } + + #[test] + fn cycle_pane_layout_persists_config() { + let mut dashboard = test_dashboard(Vec::new(), 0); + let tempdir = std::env::temp_dir().join(format!("ecc2-layout-policy-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tempdir).unwrap(); + let config_path = tempdir.join("ecc2.toml"); + + dashboard.cycle_pane_layout_with_save(&config_path, |cfg| cfg.save_to_path(&config_path)); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Vertical); + let expected_note = format!( + "pane layout set to vertical | saved to {}", + config_path.display() + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some(expected_note.as_str()) + ); + + let saved = std::fs::read_to_string(&config_path).unwrap(); + let loaded: Config = toml::from_str(&saved).unwrap(); + assert_eq!(loaded.pane_layout, PaneLayout::Vertical); + let _ = std::fs::remove_dir_all(tempdir); + } + fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); @@ -4202,10 +4282,7 @@ diff --git a/src/next.rs b/src/next.rs Dashboard { db: StateStore::open(Path::new(":memory:")).expect("open test db"), - pane_size_percent: match cfg.pane_layout { - PaneLayout::Grid => DEFAULT_GRID_SIZE_PERCENT, - PaneLayout::Horizontal | PaneLayout::Vertical => DEFAULT_PANE_SIZE_PERCENT, - }, + pane_size_percent: default_pane_size(cfg.pane_layout), cfg, output_store, output_rx, From 63299b15b34be14f41945c88a9323cc060c91e30 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 03:43:28 -0700 Subject: [PATCH 059/459] feat: add ecc2 runtime theme toggle --- ecc2/src/config/mod.rs | 2 +- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 119 ++++++++++++++++++++++++++++++++++---- 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 634a301a..b2750bff 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -40,7 +40,7 @@ pub struct Config { pub risk_thresholds: RiskThresholds, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Theme { Dark, Light, diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 36b69667..1ecc3072 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -50,6 +50,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('l')) => dashboard.cycle_pane_layout(), + (_, KeyCode::Char('T')) => dashboard.toggle_theme(), (_, KeyCode::Char('p')) => dashboard.toggle_auto_dispatch_policy(), (_, KeyCode::Char('t')) => dashboard.toggle_auto_worktree_policy(), (_, KeyCode::Char('w')) => dashboard.toggle_auto_merge_policy(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 1b46eea1..efced3eb 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -9,7 +9,7 @@ use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; -use crate::config::{Config, PaneLayout}; +use crate::config::{Config, PaneLayout, Theme}; use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; @@ -45,6 +45,14 @@ struct WorktreeDiffColumns { additions: String, } +#[derive(Debug, Clone, Copy)] +struct ThemePalette { + accent: Color, + row_highlight_bg: Color, + muted: Color, + help_border: Color, +} + pub struct Dashboard { db: StateStore, cfg: Config, @@ -244,11 +252,13 @@ impl Dashboard { .filter(|session| session.state == SessionState::Running) .count(); let total = self.sessions.len(); + let palette = self.theme_palette(); let title = format!( - " ECC 2.0 | {running} running / {total} total | {} {}% ", + " ECC 2.0 | {running} running / {total} total | {} {}% | {} ", self.layout_label(), - self.pane_size_percent + self.pane_size_percent, + self.theme_label() ); let tabs = Tabs::new( self.visible_panes() @@ -260,7 +270,7 @@ impl Dashboard { .select(self.selected_pane_index()) .highlight_style( Style::default() - .fg(Color::Cyan) + .fg(palette.accent) .add_modifier(Modifier::BOLD), ); @@ -332,7 +342,7 @@ impl Dashboard { .highlight_spacing(HighlightSpacing::Always) .row_highlight_style( Style::default() - .bg(Color::DarkGray) + .bg(self.theme_palette().row_highlight_bg) .add_modifier(Modifier::BOLD), ); @@ -530,8 +540,9 @@ 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 conflict proto[c]ol [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [?] help [q]uit ", - self.layout_label() + " [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 conflict proto[c]ol [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", + self.layout_label(), + self.theme_label() ); let text = if let Some(note) = self.operator_note.as_ref() { format!(" {} |{}", truncate_for_dashboard(note, 96), text) @@ -559,7 +570,7 @@ impl Dashboard { .split(inner); frame.render_widget( - Paragraph::new(text).style(Style::default().fg(Color::DarkGray)), + Paragraph::new(text).style(Style::default().fg(self.theme_palette().muted)), chunks[0], ); frame.render_widget( @@ -586,6 +597,7 @@ impl Dashboard { " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", " l Cycle pane layout and persist it", + " T Toggle theme and persist it", " t Toggle default worktree creation for new sessions and delegated work", " p Toggle daemon auto-dispatch policy and persist config", " w Toggle daemon auto-merge for ready inactive worktrees", @@ -610,7 +622,7 @@ impl Dashboard { Block::default() .borders(Borders::ALL) .title(" Help ") - .border_style(Style::default().fg(Color::Yellow)), + .border_style(Style::default().fg(self.theme_palette().help_border)), ); frame.render_widget(paragraph, area); } @@ -673,6 +685,34 @@ impl Dashboard { } } + pub fn toggle_theme(&mut self) { + let config_path = crate::config::Config::config_path(); + self.toggle_theme_with_save(&config_path, |cfg| cfg.save()); + } + + fn toggle_theme_with_save(&mut self, config_path: &std::path::Path, save: F) + where + F: FnOnce(&Config) -> anyhow::Result<()>, + { + let previous_theme = self.cfg.theme; + self.cfg.theme = match self.cfg.theme { + Theme::Dark => Theme::Light, + Theme::Light => Theme::Dark, + }; + + match save(&self.cfg) { + Ok(()) => self.set_operator_note(format!( + "theme set to {} | saved to {}", + self.theme_label(), + config_path.display() + )), + Err(error) => { + self.cfg.theme = previous_theme; + self.set_operator_note(format!("failed to persist theme: {error}")); + } + } + } + pub fn increase_pane_size(&mut self) { self.pane_size_percent = (self.pane_size_percent + PANE_RESIZE_STEP_PERCENT).min(MAX_PANE_SIZE_PERCENT); @@ -2371,7 +2411,7 @@ impl Dashboard { fn pane_border_style(&self, pane: Pane) -> Style { if self.selected_pane == pane { - Style::default().fg(Color::Cyan) + Style::default().fg(self.theme_palette().accent) } else { Style::default() } @@ -2385,6 +2425,30 @@ impl Dashboard { } } + fn theme_label(&self) -> &'static str { + match self.cfg.theme { + Theme::Dark => "dark", + Theme::Light => "light", + } + } + + fn theme_palette(&self) -> ThemePalette { + match self.cfg.theme { + Theme::Dark => ThemePalette { + accent: Color::Cyan, + row_highlight_bg: Color::DarkGray, + muted: Color::DarkGray, + help_border: Color::Yellow, + }, + Theme::Light => ThemePalette { + accent: Color::Blue, + row_highlight_bg: Color::Gray, + muted: Color::Black, + help_border: Color::Blue, + }, + } + } + fn log_field<'a>(&self, value: &'a str) -> &'a str { let trimmed = value.trim(); if trimmed.is_empty() { @@ -4270,6 +4334,41 @@ diff --git a/src/next.rs b/src/next.rs let _ = std::fs::remove_dir_all(tempdir); } + #[test] + fn toggle_theme_persists_config() { + let mut dashboard = test_dashboard(Vec::new(), 0); + let tempdir = std::env::temp_dir().join(format!("ecc2-theme-policy-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tempdir).unwrap(); + let config_path = tempdir.join("ecc2.toml"); + + dashboard.toggle_theme_with_save(&config_path, |cfg| cfg.save_to_path(&config_path)); + + assert_eq!(dashboard.cfg.theme, Theme::Light); + let expected_note = format!("theme set to light | saved to {}", config_path.display()); + assert_eq!( + dashboard.operator_note.as_deref(), + Some(expected_note.as_str()) + ); + + let saved = std::fs::read_to_string(&config_path).unwrap(); + let loaded: Config = toml::from_str(&saved).unwrap(); + assert_eq!(loaded.theme, Theme::Light); + let _ = std::fs::remove_dir_all(tempdir); + } + + #[test] + fn light_theme_uses_light_palette_accent() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.theme = Theme::Light; + dashboard.selected_pane = Pane::Sessions; + + assert_eq!( + dashboard.pane_border_style(Pane::Sessions), + Style::default().fg(Color::Blue) + ); + assert_eq!(dashboard.theme_palette().row_highlight_bg, Color::Gray); + } + fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); From c7bf1434505b3b8dd3160349e07289eaf9a1970c Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 03:50:29 -0700 Subject: [PATCH 060/459] feat: persist ecc2 pane sizes by layout --- ecc2/src/config/mod.rs | 24 ++++++ ecc2/src/session/manager.rs | 2 + ecc2/src/tui/dashboard.rs | 150 ++++++++++++++++++++++++++++++------ 3 files changed, 154 insertions(+), 22 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index b2750bff..75275f81 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -37,6 +37,8 @@ pub struct Config { pub token_budget: u64, pub theme: Theme, pub pane_layout: PaneLayout, + pub linear_pane_size_percent: u16, + pub grid_pane_size_percent: u16, pub risk_thresholds: RiskThresholds, } @@ -65,6 +67,8 @@ impl Default for Config { token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + linear_pane_size_percent: 35, + grid_pane_size_percent: 50, risk_thresholds: Self::RISK_THRESHOLDS, } } @@ -149,6 +153,14 @@ theme = "Dark" assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); assert_eq!(config.pane_layout, defaults.pane_layout); + assert_eq!( + config.linear_pane_size_percent, + defaults.linear_pane_size_percent + ); + assert_eq!( + config.grid_pane_size_percent, + defaults.grid_pane_size_percent + ); assert_eq!(config.risk_thresholds, defaults.risk_thresholds); assert_eq!( config.auto_dispatch_unread_handoffs, @@ -170,6 +182,14 @@ theme = "Dark" assert_eq!(Config::default().pane_layout, PaneLayout::Horizontal); } + #[test] + fn default_pane_sizes_match_dashboard_defaults() { + let config = Config::default(); + + assert_eq!(config.linear_pane_size_percent, 35); + assert_eq!(config.grid_pane_size_percent, 50); + } + #[test] fn pane_layout_deserializes_from_toml() { let config: Config = toml::from_str(r#"pane_layout = "grid""#).unwrap(); @@ -190,6 +210,8 @@ theme = "Dark" config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; + config.linear_pane_size_percent = 42; + config.grid_pane_size_percent = 55; config.save_to_path(&path).unwrap(); let content = std::fs::read_to_string(&path).unwrap(); @@ -199,6 +221,8 @@ theme = "Dark" assert_eq!(loaded.auto_dispatch_limit_per_session, 9); assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); + assert_eq!(loaded.linear_pane_size_percent, 42); + assert_eq!(loaded.grid_pane_size_percent, 55); let _ = std::fs::remove_file(path); } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index e13cdd6e..61eb07f2 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1664,6 +1664,8 @@ mod tests { token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + linear_pane_size_percent: 35, + grid_pane_size_percent: 50, risk_thresholds: Config::RISK_THRESHOLDS, } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index efced3eb..eb996ba8 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -22,7 +22,6 @@ use crate::session::output::OutputStream; #[cfg(test)] use crate::session::{SessionMetrics, WorktreeInfo}; -const DEFAULT_PANE_SIZE_PERCENT: u16 = 35; const DEFAULT_GRID_SIZE_PERCENT: u16 = 50; const OUTPUT_PANE_PERCENT: u16 = 70; const MIN_PANE_SIZE_PERCENT: u16 = 20; @@ -32,13 +31,6 @@ const MAX_LOG_ENTRIES: u64 = 12; const MAX_DIFF_PREVIEW_LINES: usize = 6; const MAX_DIFF_PATCH_LINES: usize = 80; -fn default_pane_size(layout: PaneLayout) -> u16 { - match layout { - PaneLayout::Grid => DEFAULT_GRID_SIZE_PERCENT, - PaneLayout::Horizontal | PaneLayout::Vertical => DEFAULT_PANE_SIZE_PERCENT, - } -} - #[derive(Debug, Clone, PartialEq, Eq)] struct WorktreeDiffColumns { removals: String, @@ -163,7 +155,7 @@ impl Dashboard { cfg: Config, output_store: SessionOutputStore, ) -> Self { - let pane_size_percent = default_pane_size(cfg.pane_layout); + let pane_size_percent = configured_pane_size(&cfg, cfg.pane_layout); let sessions = db.list_sessions().unwrap_or_default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -611,8 +603,8 @@ impl Dashboard { " S-Tab Previous pane", " j/↓ Scroll down", " k/↑ Scroll up", - " +/= Increase pane size", - " - Decrease pane size", + " +/= Increase pane size and persist it", + " - Decrease pane size and persist it", " r Refresh", " ? Toggle help", " q/C-c Quit", @@ -667,7 +659,8 @@ impl Dashboard { PaneLayout::Vertical => PaneLayout::Grid, PaneLayout::Grid => PaneLayout::Horizontal, }; - self.pane_size_percent = default_pane_size(self.cfg.pane_layout); + self.pane_size_percent = configured_pane_size(&self.cfg, self.cfg.pane_layout); + self.persist_current_pane_size(); self.ensure_selected_pane_visible(); match save(&self.cfg) { @@ -685,6 +678,61 @@ impl Dashboard { } } + fn adjust_pane_size_with_save( + &mut self, + delta: isize, + config_path: &std::path::Path, + save: F, + ) where + F: FnOnce(&Config) -> anyhow::Result<()>, + { + let previous_size = self.pane_size_percent; + let previous_linear = self.cfg.linear_pane_size_percent; + let previous_grid = self.cfg.grid_pane_size_percent; + let next = (self.pane_size_percent as isize + delta).clamp( + MIN_PANE_SIZE_PERCENT as isize, + MAX_PANE_SIZE_PERCENT as isize, + ) as u16; + + if next == self.pane_size_percent { + self.set_operator_note(format!( + "pane size unchanged at {}% for {} layout", + self.pane_size_percent, + self.layout_label() + )); + return; + } + + self.pane_size_percent = next; + self.persist_current_pane_size(); + + match save(&self.cfg) { + Ok(()) => self.set_operator_note(format!( + "pane size set to {}% for {} layout | saved to {}", + self.pane_size_percent, + self.layout_label(), + config_path.display() + )), + Err(error) => { + self.pane_size_percent = previous_size; + self.cfg.linear_pane_size_percent = previous_linear; + self.cfg.grid_pane_size_percent = previous_grid; + self.set_operator_note(format!("failed to persist pane size: {error}")); + } + } + } + + fn persist_current_pane_size(&mut self) { + match self.cfg.pane_layout { + PaneLayout::Horizontal | PaneLayout::Vertical => { + self.cfg.linear_pane_size_percent = self.pane_size_percent; + } + PaneLayout::Grid => { + self.cfg.grid_pane_size_percent = self.pane_size_percent; + } + } + } + pub fn toggle_theme(&mut self) { let config_path = crate::config::Config::config_path(); self.toggle_theme_with_save(&config_path, |cfg| cfg.save()); @@ -714,15 +762,19 @@ impl Dashboard { } pub fn increase_pane_size(&mut self) { - self.pane_size_percent = - (self.pane_size_percent + PANE_RESIZE_STEP_PERCENT).min(MAX_PANE_SIZE_PERCENT); + let config_path = crate::config::Config::config_path(); + self.adjust_pane_size_with_save(PANE_RESIZE_STEP_PERCENT as isize, &config_path, |cfg| { + cfg.save() + }); } pub fn decrease_pane_size(&mut self) { - self.pane_size_percent = self - .pane_size_percent - .saturating_sub(PANE_RESIZE_STEP_PERCENT) - .max(MIN_PANE_SIZE_PERCENT); + let config_path = crate::config::Config::config_path(); + self.adjust_pane_size_with_save( + -(PANE_RESIZE_STEP_PERCENT as isize), + &config_path, + |cfg| cfg.save(), + ); } pub fn scroll_down(&mut self) { @@ -2677,6 +2729,15 @@ fn truncate_for_dashboard(value: &str, max_chars: usize) -> String { format!("{truncated}…") } +fn configured_pane_size(cfg: &Config, layout: PaneLayout) -> u16 { + let configured = match layout { + PaneLayout::Horizontal | PaneLayout::Vertical => cfg.linear_pane_size_percent, + PaneLayout::Grid => cfg.grid_pane_size_percent, + }; + + configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT) +} + fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { let mut removals = Vec::new(); let mut additions = Vec::new(); @@ -4269,12 +4330,12 @@ diff --git a/src/next.rs b/src/next.rs dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; for _ in 0..20 { - dashboard.increase_pane_size(); + dashboard.adjust_pane_size_with_save(5, Path::new("/tmp/ecc2-noop.toml"), |_| Ok(())); } assert_eq!(dashboard.pane_size_percent, MAX_PANE_SIZE_PERCENT); for _ in 0..40 { - dashboard.decrease_pane_size(); + dashboard.adjust_pane_size_with_save(-5, Path::new("/tmp/ecc2-noop.toml"), |_| Ok(())); } assert_eq!(dashboard.pane_size_percent, MIN_PANE_SIZE_PERCENT); } @@ -4299,13 +4360,15 @@ diff --git a/src/next.rs b/src/next.rs fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { let mut dashboard = test_dashboard(Vec::new(), 0); dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.cfg.linear_pane_size_percent = 44; + dashboard.cfg.grid_pane_size_percent = 77; dashboard.pane_size_percent = 77; dashboard.selected_pane = Pane::Log; dashboard.cycle_pane_layout(); assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Horizontal); - assert_eq!(dashboard.pane_size_percent, DEFAULT_PANE_SIZE_PERCENT); + assert_eq!(dashboard.pane_size_percent, 44); assert_eq!(dashboard.selected_pane, Pane::Sessions); } @@ -4334,6 +4397,47 @@ diff --git a/src/next.rs b/src/next.rs let _ = std::fs::remove_dir_all(tempdir); } + #[test] + fn pane_resize_persists_linear_setting() { + let mut dashboard = test_dashboard(Vec::new(), 0); + let tempdir = std::env::temp_dir().join(format!("ecc2-pane-size-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tempdir).unwrap(); + let config_path = tempdir.join("ecc2.toml"); + + dashboard.adjust_pane_size_with_save(5, &config_path, |cfg| cfg.save_to_path(&config_path)); + + assert_eq!(dashboard.pane_size_percent, 40); + assert_eq!(dashboard.cfg.linear_pane_size_percent, 40); + let expected_note = format!( + "pane size set to 40% for horizontal layout | saved to {}", + config_path.display() + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some(expected_note.as_str()) + ); + + let saved = std::fs::read_to_string(&config_path).unwrap(); + let loaded: Config = toml::from_str(&saved).unwrap(); + assert_eq!(loaded.linear_pane_size_percent, 40); + assert_eq!(loaded.grid_pane_size_percent, 50); + let _ = std::fs::remove_dir_all(tempdir); + } + + #[test] + fn cycle_pane_layout_uses_persisted_grid_size() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Vertical; + dashboard.cfg.linear_pane_size_percent = 41; + dashboard.cfg.grid_pane_size_percent = 63; + dashboard.pane_size_percent = 41; + + dashboard.cycle_pane_layout_with_save(Path::new("/tmp/ecc2-noop.toml"), |_| Ok(())); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid); + assert_eq!(dashboard.pane_size_percent, 63); + } + #[test] fn toggle_theme_persists_config() { let mut dashboard = test_dashboard(Vec::new(), 0); @@ -4381,7 +4485,7 @@ diff --git a/src/next.rs b/src/next.rs Dashboard { db: StateStore::open(Path::new(":memory:")).expect("open test db"), - pane_size_percent: default_pane_size(cfg.pane_layout), + pane_size_percent: configured_pane_size(&cfg, cfg.pane_layout), cfg, output_store, output_rx, @@ -4433,6 +4537,8 @@ diff --git a/src/next.rs b/src/next.rs token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + linear_pane_size_percent: 35, + grid_pane_size_percent: 50, risk_thresholds: Config::RISK_THRESHOLDS, } } From 8440181001d282480a1c77a941f6bfa805eaaace Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 03:57:12 -0700 Subject: [PATCH 061/459] feat: add ecc2 output search mode --- ecc2/src/tui/app.rs | 26 +++ ecc2/src/tui/dashboard.rs | 423 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 434 insertions(+), 15 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 1ecc3072..e0f29f0d 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -27,6 +27,24 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { + if dashboard.is_search_mode() { + match (key.modifiers, key.code) { + (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + (_, KeyCode::Esc) => dashboard.cancel_search_input(), + (_, KeyCode::Enter) => dashboard.submit_search(), + (_, KeyCode::Backspace) => dashboard.pop_search_char(), + (modifiers, KeyCode::Char(ch)) + if !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) => + { + dashboard.push_search_char(ch); + } + _ => {} + } + + continue; + } + match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, (_, KeyCode::Char('q')) => break, @@ -38,6 +56,14 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('-')) => dashboard.decrease_pane_size(), (_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(), (_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(), + (_, KeyCode::Char('/')) => dashboard.begin_search(), + (_, KeyCode::Esc) => dashboard.clear_search(), + (_, KeyCode::Char('n')) if dashboard.has_active_search() => { + dashboard.next_search_match() + } + (_, KeyCode::Char('N')) if dashboard.has_active_search() => { + dashboard.prev_search_match() + } (_, KeyCode::Char('n')) => dashboard.new_session().await, (_, KeyCode::Char('a')) => dashboard.assign_selected().await, (_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index eb996ba8..c1244892 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -78,6 +78,10 @@ pub struct Dashboard { output_scroll_offset: usize, last_output_height: usize, pane_size_percent: u16, + search_input: Option, + search_query: Option, + search_matches: Vec, + selected_search_match: usize, session_table_state: TableState, } @@ -196,6 +200,10 @@ impl Dashboard { output_scroll_offset: 0, last_output_height: 0, pane_size_percent, + search_input: None, + search_query: None, + search_matches: Vec::new(), + selected_search_match: 0, session_table_state, }; dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); @@ -366,15 +374,18 @@ impl Dashboard { OutputMode::SessionOutput => { let lines = self.selected_output_lines(); let content = if lines.is_empty() { - "Waiting for session output...".to_string() + Text::from("Waiting for session output...") + } else if self.search_query.is_some() { + self.render_searchable_output(lines) } else { - lines - .iter() - .map(|line| line.text.as_str()) - .collect::>() - .join("\n") + Text::from( + lines + .iter() + .map(|line| Line::from(line.text.clone())) + .collect::>(), + ) }; - (" Output ", content) + (self.output_title(), content) } OutputMode::WorktreeDiff => { let content = self @@ -390,19 +401,19 @@ impl Dashboard { .unwrap_or_else(|| { "No worktree diff available for the selected session.".to_string() }); - (" Diff ", content) + (" Diff ".to_string(), Text::from(content)) } OutputMode::ConflictProtocol => { let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| { "No conflicted worktree available for the selected session.".to_string() }); - (" Conflict Protocol ", content) + (" Conflict Protocol ".to_string(), Text::from(content)) } } } else { ( - " Output ", - "No sessions. Press 'n' to start one.".to_string(), + self.output_title(), + Text::from("No sessions. Press 'n' to start one."), ) }; @@ -451,6 +462,50 @@ impl Dashboard { frame.render_widget(additions, column_chunks[1]); } + fn output_title(&self) -> String { + if let Some(input) = self.search_input.as_ref() { + return format!(" Output /{input}_ "); + } + + if let Some(query) = self.search_query.as_ref() { + let total = self.search_matches.len(); + let current = if total == 0 { + 0 + } else { + self.selected_search_match.min(total.saturating_sub(1)) + 1 + }; + return format!(" Output /{query} {current}/{total} "); + } + + " Output ".to_string() + } + + fn render_searchable_output(&self, lines: &[OutputLine]) -> Text<'static> { + let Some(query) = self.search_query.as_deref() else { + return Text::from( + lines + .iter() + .map(|line| Line::from(line.text.clone())) + .collect::>(), + ); + }; + + Text::from( + lines + .iter() + .enumerate() + .map(|(index, line)| { + highlight_output_line( + &line.text, + query, + self.search_matches.get(self.selected_search_match).copied() == Some(index), + self.theme_palette(), + ) + }) + .collect::>(), + ) + } + fn render_metrics(&self, frame: &mut Frame, area: Rect) { let block = Block::default() .borders(Borders::ALL) @@ -531,15 +586,32 @@ impl Dashboard { } fn render_status_bar(&self, frame: &mut Frame, area: Rect) { - let text = format!( + let base_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 conflict proto[c]ol [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); - let text = if let Some(note) = self.operator_note.as_ref() { - format!(" {} |{}", truncate_for_dashboard(note, 96), text) + + let search_prefix = if let Some(input) = self.search_input.as_ref() { + format!(" /{input}_ | [Enter] apply [Esc] cancel |") + } else if let Some(query) = self.search_query.as_ref() { + let total = self.search_matches.len(); + let current = if total == 0 { + 0 + } else { + self.selected_search_match.min(total.saturating_sub(1)) + 1 + }; + format!(" /{query} {current}/{total} | [n/N] navigate [Esc] clear |") } else { - text + String::new() + }; + + let text = if self.search_input.is_some() || self.search_query.is_some() { + format!(" {search_prefix}") + } else if let Some(note) = self.operator_note.as_ref() { + format!(" {} |{}", truncate_for_dashboard(note, 96), base_text) + } else { + base_text }; let aggregate = self.aggregate_usage(); let (summary_text, summary_style) = self.aggregate_cost_summary(); @@ -603,6 +675,9 @@ impl Dashboard { " S-Tab Previous pane", " j/↓ Scroll down", " k/↑ Scroll up", + " / Search current session output", + " n/N Next/previous search match when search is active", + " Esc Clear active search or cancel search input", " +/= Increase pane size and persist it", " - Decrease pane size and persist it", " r Refresh", @@ -1503,6 +1578,101 @@ impl Dashboard { self.show_help = !self.show_help; } + pub fn is_search_mode(&self) -> bool { + self.search_input.is_some() + } + + pub fn has_active_search(&self) -> bool { + self.search_query.is_some() + } + + pub fn begin_search(&mut self) { + if self.output_mode != OutputMode::SessionOutput { + self.set_operator_note("search is only available in session output view".to_string()); + return; + } + + self.search_input = Some(self.search_query.clone().unwrap_or_default()); + self.set_operator_note("search mode | type a query and press Enter".to_string()); + } + + pub fn push_search_char(&mut self, ch: char) { + if let Some(input) = self.search_input.as_mut() { + input.push(ch); + } + } + + pub fn pop_search_char(&mut self) { + if let Some(input) = self.search_input.as_mut() { + input.pop(); + } + } + + pub fn cancel_search_input(&mut self) { + if self.search_input.take().is_some() { + self.set_operator_note("search input cancelled".to_string()); + } + } + + pub fn submit_search(&mut self) { + let Some(input) = self.search_input.take() else { + return; + }; + + let query = input.trim().to_string(); + if query.is_empty() { + self.clear_search(); + return; + } + + self.search_query = Some(query.clone()); + self.recompute_search_matches(); + if self.search_matches.is_empty() { + self.set_operator_note(format!("search /{query} found no matches")); + } else { + self.set_operator_note(format!( + "search /{query} matched {} line(s) | n/N navigate matches", + self.search_matches.len() + )); + } + } + + pub fn clear_search(&mut self) { + let had_query = self.search_query.take().is_some(); + let had_input = self.search_input.take().is_some(); + self.search_matches.clear(); + self.selected_search_match = 0; + if had_query || had_input { + self.set_operator_note("cleared output search".to_string()); + } + } + + pub fn next_search_match(&mut self) { + if self.search_matches.is_empty() { + self.set_operator_note("no output search matches to navigate".to_string()); + return; + } + + self.selected_search_match = (self.selected_search_match + 1) % self.search_matches.len(); + self.focus_selected_search_match(); + self.set_operator_note(self.search_navigation_note()); + } + + pub fn prev_search_match(&mut self) { + if self.search_matches.is_empty() { + self.set_operator_note("no output search matches to navigate".to_string()); + return; + } + + self.selected_search_match = if self.selected_search_match == 0 { + self.search_matches.len() - 1 + } else { + self.selected_search_match - 1 + }; + self.focus_selected_search_match(); + self.set_operator_note(self.search_navigation_note()); + } + pub fn toggle_auto_dispatch_policy(&mut self) { self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; match self.cfg.save() { @@ -1730,6 +1900,8 @@ impl Dashboard { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.output_scroll_offset = 0; self.output_follow = true; + self.search_matches.clear(); + self.selected_search_match = 0; return; }; @@ -1737,6 +1909,7 @@ impl Dashboard { Ok(lines) => { self.output_store.replace_lines(&session_id, lines.clone()); self.session_output_cache.insert(session_id, lines); + self.recompute_search_matches(); } Err(error) => { tracing::warn!("Failed to load session output: {error}"); @@ -1965,6 +2138,54 @@ impl Dashboard { .unwrap_or(&[]) } + fn recompute_search_matches(&mut self) { + let Some(query) = self.search_query.clone() else { + self.search_matches.clear(); + self.selected_search_match = 0; + return; + }; + + self.search_matches = self + .selected_output_lines() + .iter() + .enumerate() + .filter_map(|(index, line)| line_matches_query(&line.text, &query).then_some(index)) + .collect(); + + if self.search_matches.is_empty() { + self.selected_search_match = 0; + return; + } + + self.selected_search_match = self + .selected_search_match + .min(self.search_matches.len().saturating_sub(1)); + self.focus_selected_search_match(); + } + + fn focus_selected_search_match(&mut self) { + let Some(line_index) = self.search_matches.get(self.selected_search_match).copied() else { + return; + }; + + self.output_follow = false; + let viewport_height = self.last_output_height.max(1); + let offset = line_index.saturating_sub(viewport_height.saturating_sub(1) / 2); + self.output_scroll_offset = offset.min(self.max_output_scroll()); + } + + fn search_navigation_note(&self) -> String { + let query = self.search_query.as_deref().unwrap_or_default(); + let total = self.search_matches.len(); + let current = if total == 0 { + 0 + } else { + self.selected_search_match.min(total.saturating_sub(1)) + 1 + }; + + format!("search /{query} match {current}/{total}") + } + fn sync_output_scroll(&mut self, viewport_height: usize) { self.last_output_height = viewport_height.max(1); let max_scroll = self.max_output_scroll(); @@ -2738,6 +2959,60 @@ fn configured_pane_size(cfg: &Config, layout: PaneLayout) -> u16 { configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT) } +fn line_matches_query(text: &str, query: &str) -> bool { + if query.is_empty() { + return false; + } + + text.contains(query) +} + +fn highlight_output_line( + text: &str, + query: &str, + is_current_match: bool, + palette: ThemePalette, +) -> Line<'static> { + if query.is_empty() { + return Line::from(text.to_string()); + } + + let mut spans = Vec::new(); + let mut cursor = 0; + let mut search_start = 0; + + while let Some(relative_index) = text[search_start..].find(query) { + let start = search_start + relative_index; + let end = start + query.len(); + + if start > cursor { + spans.push(Span::raw(text[cursor..start].to_string())); + } + + let match_style = if is_current_match { + Style::default() + .bg(palette.accent) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(Color::Yellow).fg(Color::Black) + }; + spans.push(Span::styled(text[start..end].to_string(), match_style)); + cursor = end; + search_start = end; + } + + if cursor < text.len() { + spans.push(Span::raw(text[cursor..].to_string())); + } + + if spans.is_empty() { + Line::from(text.to_string()) + } else { + Line::from(spans) + } +} + fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { let mut removals = Vec::new(); let mut additions = Vec::new(); @@ -3783,6 +4058,120 @@ diff --git a/src/next.rs b/src/next.rs Ok(()) } + #[test] + fn submit_search_tracks_matches_and_sets_navigation_note() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + OutputLine { + stream: OutputStream::Stdout, + text: "alpha".to_string(), + }, + OutputLine { + stream: OutputStream::Stdout, + text: "beta".to_string(), + }, + OutputLine { + stream: OutputStream::Stdout, + text: "alpha tail".to_string(), + }, + ], + ); + dashboard.last_output_height = 2; + + dashboard.begin_search(); + for ch in "alpha".chars() { + dashboard.push_search_char(ch); + } + dashboard.submit_search(); + + assert_eq!(dashboard.search_query.as_deref(), Some("alpha")); + assert_eq!(dashboard.search_matches, vec![0, 2]); + assert_eq!(dashboard.selected_search_match, 0); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("search /alpha matched 2 line(s) | n/N navigate matches") + ); + } + + #[test] + fn next_search_match_wraps_and_updates_scroll_offset() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + OutputLine { + stream: OutputStream::Stdout, + text: "alpha".to_string(), + }, + OutputLine { + stream: OutputStream::Stdout, + text: "beta".to_string(), + }, + OutputLine { + stream: OutputStream::Stdout, + text: "alpha tail".to_string(), + }, + ], + ); + dashboard.search_query = Some("alpha".to_string()); + dashboard.last_output_height = 1; + dashboard.recompute_search_matches(); + + dashboard.next_search_match(); + assert_eq!(dashboard.selected_search_match, 1); + assert_eq!(dashboard.output_scroll_offset, 2); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("search /alpha match 2/2") + ); + + dashboard.next_search_match(); + assert_eq!(dashboard.selected_search_match, 0); + assert_eq!(dashboard.output_scroll_offset, 0); + } + + #[test] + fn clear_search_resets_active_query_and_matches() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.search_input = Some("draft".to_string()); + dashboard.search_query = Some("alpha".to_string()); + dashboard.search_matches = vec![1, 3]; + dashboard.selected_search_match = 1; + + dashboard.clear_search(); + + assert!(dashboard.search_input.is_none()); + assert!(dashboard.search_query.is_none()); + assert!(dashboard.search_matches.is_empty()); + assert_eq!(dashboard.selected_search_match, 0); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("cleared output search") + ); + } + #[tokio::test] async fn stop_selected_uses_session_manager_transition() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); @@ -4516,6 +4905,10 @@ diff --git a/src/next.rs b/src/next.rs output_follow: true, output_scroll_offset: 0, last_output_height: 0, + search_input: None, + search_query: None, + search_matches: Vec::new(), + selected_search_match: 0, session_table_state, } } From 8fc40da739aeee328f4dedcf4f015157237fe1d7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:00:31 -0700 Subject: [PATCH 062/459] feat: add ecc2 regex output search --- ecc2/Cargo.lock | 1 + ecc2/Cargo.toml | 1 + ecc2/src/tui/dashboard.rs | 80 +++++++++++++++++++++++++++++---------- 3 files changed, 62 insertions(+), 20 deletions(-) diff --git a/ecc2/Cargo.lock b/ecc2/Cargo.lock index c96cc19a..2161a8e7 100644 --- a/ecc2/Cargo.lock +++ b/ecc2/Cargo.lock @@ -497,6 +497,7 @@ dependencies = [ "git2", "libc", "ratatui", + "regex", "rusqlite", "serde", "serde_json", diff --git a/ecc2/Cargo.toml b/ecc2/Cargo.toml index 13d2e2c4..7283b053 100644 --- a/ecc2/Cargo.toml +++ b/ecc2/Cargo.toml @@ -25,6 +25,7 @@ git2 = "0.20" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" +regex = "1" # CLI clap = { version = "4", features = ["derive"] } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c1244892..d88c6bf6 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -4,6 +4,7 @@ use ratatui::{ Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap, }, }; +use regex::Regex; use std::collections::HashMap; use tokio::sync::broadcast; @@ -1625,6 +1626,12 @@ impl Dashboard { return; } + if let Err(error) = compile_search_regex(&query) { + self.search_input = Some(query.clone()); + self.set_operator_note(format!("invalid regex /{query}: {error}")); + return; + } + self.search_query = Some(query.clone()); self.recompute_search_matches(); if self.search_matches.is_empty() { @@ -2145,11 +2152,17 @@ impl Dashboard { return; }; + let Ok(regex) = compile_search_regex(&query) else { + self.search_matches.clear(); + self.selected_search_match = 0; + return; + }; + self.search_matches = self .selected_output_lines() .iter() .enumerate() - .filter_map(|(index, line)| line_matches_query(&line.text, &query).then_some(index)) + .filter_map(|(index, line)| regex.is_match(&line.text).then_some(index)) .collect(); if self.search_matches.is_empty() { @@ -2959,12 +2972,8 @@ fn configured_pane_size(cfg: &Config, layout: PaneLayout) -> u16 { configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT) } -fn line_matches_query(text: &str, query: &str) -> bool { - if query.is_empty() { - return false; - } - - text.contains(query) +fn compile_search_regex(query: &str) -> Result { + Regex::new(query) } fn highlight_output_line( @@ -2977,13 +2986,15 @@ fn highlight_output_line( return Line::from(text.to_string()); } + let Ok(regex) = compile_search_regex(query) else { + return Line::from(text.to_string()); + }; + let mut spans = Vec::new(); let mut cursor = 0; - let mut search_start = 0; - - while let Some(relative_index) = text[search_start..].find(query) { - let start = search_start + relative_index; - let end = start + query.len(); + for matched in regex.find_iter(text) { + let start = matched.start(); + let end = matched.end(); if start > cursor { spans.push(Span::raw(text[cursor..start].to_string())); @@ -2999,7 +3010,6 @@ fn highlight_output_line( }; spans.push(Span::styled(text[start..end].to_string(), match_style)); cursor = end; - search_start = end; } if cursor < text.len() { @@ -4091,17 +4101,17 @@ diff --git a/src/next.rs b/src/next.rs dashboard.last_output_height = 2; dashboard.begin_search(); - for ch in "alpha".chars() { + for ch in "alpha.*".chars() { dashboard.push_search_char(ch); } dashboard.submit_search(); - assert_eq!(dashboard.search_query.as_deref(), Some("alpha")); + assert_eq!(dashboard.search_query.as_deref(), Some("alpha.*")); assert_eq!(dashboard.search_matches, vec![0, 2]); assert_eq!(dashboard.selected_search_match, 0); assert_eq!( dashboard.operator_note.as_deref(), - Some("search /alpha matched 2 line(s) | n/N navigate matches") + Some("search /alpha.* matched 2 line(s) | n/N navigate matches") ); } @@ -4123,7 +4133,7 @@ diff --git a/src/next.rs b/src/next.rs vec![ OutputLine { stream: OutputStream::Stdout, - text: "alpha".to_string(), + text: "alpha-1".to_string(), }, OutputLine { stream: OutputStream::Stdout, @@ -4131,11 +4141,11 @@ diff --git a/src/next.rs b/src/next.rs }, OutputLine { stream: OutputStream::Stdout, - text: "alpha tail".to_string(), + text: "alpha-2".to_string(), }, ], ); - dashboard.search_query = Some("alpha".to_string()); + dashboard.search_query = Some(r"alpha-\d".to_string()); dashboard.last_output_height = 1; dashboard.recompute_search_matches(); @@ -4144,7 +4154,7 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.output_scroll_offset, 2); assert_eq!( dashboard.operator_note.as_deref(), - Some("search /alpha match 2/2") + Some(r"search /alpha-\d match 2/2") ); dashboard.next_search_match(); @@ -4152,6 +4162,36 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.output_scroll_offset, 0); } + #[test] + fn submit_search_rejects_invalid_regex_and_keeps_input() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + + dashboard.begin_search(); + for ch in "(".chars() { + dashboard.push_search_char(ch); + } + dashboard.submit_search(); + + assert_eq!(dashboard.search_input.as_deref(), Some("(")); + assert!(dashboard.search_query.is_none()); + assert!(dashboard.search_matches.is_empty()); + assert!(dashboard + .operator_note + .as_deref() + .unwrap_or_default() + .starts_with("invalid regex /(:")); + } + #[test] fn clear_search_resets_active_query_and_matches() { let mut dashboard = test_dashboard(Vec::new(), 0); From 077f46b7775928556aa8697bf54beeb38ba1b1c8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:04:25 -0700 Subject: [PATCH 063/459] feat: add ecc2 stderr output filter --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 181 +++++++++++++++++++++++++++++++++++--- 2 files changed, 169 insertions(+), 13 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index e0f29f0d..ec3e3aa1 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -73,6 +73,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), + (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index d88c6bf6..c3d64595 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -13,13 +13,13 @@ use crate::comms; use crate::config::{Config, PaneLayout, Theme}; use crate::observability::ToolLogEntry; use crate::session::manager; -use crate::session::output::{OutputEvent, OutputLine, SessionOutputStore, OUTPUT_BUFFER_LIMIT}; +use crate::session::output::{ + OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT, +}; use crate::session::store::{DaemonActivity, StateStore}; use crate::session::{Session, SessionMessage, SessionState}; use crate::worktree; -#[cfg(test)] -use crate::session::output::OutputStream; #[cfg(test)] use crate::session::{SessionMetrics, WorktreeInfo}; @@ -71,6 +71,7 @@ pub struct Dashboard { selected_conflict_protocol: Option, selected_merge_readiness: Option, output_mode: OutputMode, + output_filter: OutputFilter, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -116,6 +117,12 @@ enum OutputMode { ConflictProtocol, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputFilter { + All, + ErrorsOnly, +} + #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, @@ -193,6 +200,7 @@ impl Dashboard { selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, + output_filter: OutputFilter::All, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -373,11 +381,11 @@ impl Dashboard { let (title, content) = if self.sessions.get(self.selected_session).is_some() { match self.output_mode { OutputMode::SessionOutput => { - let lines = self.selected_output_lines(); + let lines = self.visible_output_lines(); let content = if lines.is_empty() { - Text::from("Waiting for session output...") + Text::from(self.empty_output_message()) } else if self.search_query.is_some() { - self.render_searchable_output(lines) + self.render_searchable_output(&lines) } else { Text::from( lines @@ -464,8 +472,9 @@ impl Dashboard { } fn output_title(&self) -> String { + let filter = self.output_filter_label(); if let Some(input) = self.search_input.as_ref() { - return format!(" Output /{input}_ "); + return format!(" Output{filter} /{input}_ "); } if let Some(query) = self.search_query.as_ref() { @@ -475,13 +484,27 @@ impl Dashboard { } else { self.selected_search_match.min(total.saturating_sub(1)) + 1 }; - return format!(" Output /{query} {current}/{total} "); + return format!(" Output{filter} /{query} {current}/{total} "); } - " Output ".to_string() + format!(" Output{filter} ") } - fn render_searchable_output(&self, lines: &[OutputLine]) -> Text<'static> { + fn output_filter_label(&self) -> &'static str { + match self.output_filter { + OutputFilter::All => "", + OutputFilter::ErrorsOnly => " errors", + } + } + + fn empty_output_message(&self) -> &'static str { + match self.output_filter { + OutputFilter::All => "Waiting for session output...", + OutputFilter::ErrorsOnly => "No stderr output for this session yet.", + } + } + + fn render_searchable_output(&self, lines: &[&OutputLine]) -> Text<'static> { let Some(query) = self.search_query.as_deref() else { return Text::from( lines @@ -588,7 +611,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_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 conflict proto[c]ol [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] 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 conflict proto[c]ol [e]rrors [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -659,6 +682,7 @@ impl Dashboard { " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", " c Show conflict-resolution protocol for selected conflicted worktree", + " e Toggle output filter between all lines and stderr only", " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", " l Cycle pane layout and persist it", @@ -1680,6 +1704,26 @@ impl Dashboard { self.set_operator_note(self.search_navigation_note()); } + pub fn toggle_output_filter(&mut self) { + if self.output_mode != OutputMode::SessionOutput { + self.set_operator_note( + "output filters are only available in session output view".to_string(), + ); + return; + } + + self.output_filter = match self.output_filter { + OutputFilter::All => OutputFilter::ErrorsOnly, + OutputFilter::ErrorsOnly => OutputFilter::All, + }; + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note(format!( + "output filter set to {}", + self.output_filter.label() + )); + } + pub fn toggle_auto_dispatch_policy(&mut self) { self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; match self.cfg.save() { @@ -2145,6 +2189,13 @@ impl Dashboard { .unwrap_or(&[]) } + fn visible_output_lines(&self) -> Vec<&OutputLine> { + self.selected_output_lines() + .iter() + .filter(|line| self.output_filter.matches(line.stream)) + .collect() + } + fn recompute_search_matches(&mut self) { let Some(query) = self.search_query.clone() else { self.search_matches.clear(); @@ -2159,7 +2210,7 @@ impl Dashboard { }; self.search_matches = self - .selected_output_lines() + .visible_output_lines() .iter() .enumerate() .filter_map(|(index, line)| regex.is_match(&line.text).then_some(index)) @@ -2211,11 +2262,20 @@ impl Dashboard { } fn max_output_scroll(&self) -> usize { - self.selected_output_lines() + self.visible_output_lines() .len() .saturating_sub(self.last_output_height.max(1)) } + #[cfg(test)] + fn visible_output_text(&self) -> String { + self.visible_output_lines() + .iter() + .map(|line| line.text.clone()) + .collect::>() + .join("\n") + } + fn reset_output_view(&mut self) { self.output_follow = true; self.output_scroll_offset = 0; @@ -2790,6 +2850,22 @@ impl Pane { } } +impl OutputFilter { + fn matches(self, stream: OutputStream) -> bool { + match self { + OutputFilter::All => true, + OutputFilter::ErrorsOnly => stream == OutputStream::Stderr, + } + } + + fn label(self) -> &'static str { + match self { + OutputFilter::All => "all", + OutputFilter::ErrorsOnly => "errors", + } + } +} + impl SessionSummary { fn from_sessions( sessions: &[Session], @@ -4212,6 +4288,84 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn toggle_output_filter_keeps_only_stderr_lines() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + OutputLine { + stream: OutputStream::Stdout, + text: "stdout line".to_string(), + }, + OutputLine { + stream: OutputStream::Stderr, + text: "stderr line".to_string(), + }, + ], + ); + + dashboard.toggle_output_filter(); + + assert_eq!(dashboard.output_filter, OutputFilter::ErrorsOnly); + assert_eq!(dashboard.visible_output_text(), "stderr line"); + assert_eq!(dashboard.output_title(), " Output errors "); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("output filter set to errors") + ); + } + + #[test] + fn search_matches_respect_error_only_filter() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + OutputLine { + stream: OutputStream::Stdout, + text: "alpha stdout".to_string(), + }, + OutputLine { + stream: OutputStream::Stderr, + text: "alpha stderr".to_string(), + }, + OutputLine { + stream: OutputStream::Stderr, + text: "beta stderr".to_string(), + }, + ], + ); + dashboard.output_filter = OutputFilter::ErrorsOnly; + dashboard.search_query = Some("alpha.*".to_string()); + dashboard.last_output_height = 1; + + dashboard.recompute_search_matches(); + + assert_eq!(dashboard.search_matches, vec![0]); + assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr"); + } + #[tokio::test] async fn stop_selected_uses_session_manager_transition() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); @@ -4938,6 +5092,7 @@ diff --git a/src/next.rs b/src/next.rs selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, + output_filter: OutputFilter::All, selected_pane: Pane::Sessions, selected_session, show_help: false, From 3b700c8715477ee68b68b1476ae9fb3be094d8de Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:10:51 -0700 Subject: [PATCH 064/459] feat: add ecc2 output time filters --- ecc2/src/session/output.rs | 31 ++++- ecc2/src/session/store.rs | 12 +- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 256 ++++++++++++++++++++++++++++--------- 4 files changed, 230 insertions(+), 70 deletions(-) diff --git a/ecc2/src/session/output.rs b/ecc2/src/session/output.rs index 6cae21f3..d7ac8745 100644 --- a/ecc2/src/session/output.rs +++ b/ecc2/src/session/output.rs @@ -32,6 +32,31 @@ impl OutputStream { pub struct OutputLine { pub stream: OutputStream, pub text: String, + pub timestamp: String, +} + +impl OutputLine { + pub fn new( + stream: OutputStream, + text: impl Into, + timestamp: impl Into, + ) -> Self { + Self { + stream, + text: text.into(), + timestamp: timestamp.into(), + } + } + + pub fn with_current_timestamp(stream: OutputStream, text: impl Into) -> Self { + Self::new(stream, text, chrono::Utc::now().to_rfc3339()) + } + + pub fn occurred_at(&self) -> Option> { + chrono::DateTime::parse_from_rfc3339(&self.timestamp) + .ok() + .map(|timestamp| timestamp.with_timezone(&chrono::Utc)) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -70,10 +95,7 @@ impl SessionOutputStore { } pub fn push_line(&self, session_id: &str, stream: OutputStream, text: impl Into) { - let line = OutputLine { - stream, - text: text.into(), - }; + let line = OutputLine::with_current_timestamp(stream, text); { let mut buffers = self.lock_buffers(); @@ -145,5 +167,6 @@ mod tests { assert_eq!(event.session_id, "session-1"); assert_eq!(event.line.stream, OutputStream::Stderr); assert_eq!(event.line.text, "problem"); + assert!(event.line.occurred_at().is_some()); } } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 2cb906b8..128c7c4e 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -961,9 +961,9 @@ impl StateStore { pub fn get_output_lines(&self, session_id: &str, limit: usize) -> Result> { let mut stmt = self.conn.prepare( - "SELECT stream, line + "SELECT stream, line, timestamp FROM ( - SELECT id, stream, line + SELECT id, stream, line, timestamp FROM session_output WHERE session_id = ?1 ORDER BY id DESC @@ -976,11 +976,13 @@ impl StateStore { .query_map(rusqlite::params![session_id, limit as i64], |row| { let stream: String = row.get(0)?; let text: String = row.get(1)?; + let timestamp: String = row.get(2)?; - Ok(OutputLine { - stream: OutputStream::from_db_value(&stream), + Ok(OutputLine::new( + OutputStream::from_db_value(&stream), text, - }) + timestamp, + )) })? .collect::, _>>()?; diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index ec3e3aa1..c47a92c6 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -74,6 +74,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), + (_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(), (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c3d64595..9ddb75ec 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1,3 +1,4 @@ +use chrono::{Duration, Utc}; use ratatui::{ prelude::*, widgets::{ @@ -72,6 +73,7 @@ pub struct Dashboard { selected_merge_readiness: Option, output_mode: OutputMode, output_filter: OutputFilter, + output_time_filter: OutputTimeFilter, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -123,6 +125,14 @@ enum OutputFilter { ErrorsOnly, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputTimeFilter { + AllTime, + Last15Minutes, + LastHour, + Last24Hours, +} + #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, @@ -201,6 +211,7 @@ impl Dashboard { selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, + output_time_filter: OutputTimeFilter::AllTime, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -472,7 +483,11 @@ impl Dashboard { } fn output_title(&self) -> String { - let filter = self.output_filter_label(); + let filter = format!( + "{}{}", + self.output_filter.title_suffix(), + self.output_time_filter.title_suffix() + ); if let Some(input) = self.search_input.as_ref() { return format!(" Output{filter} /{input}_ "); } @@ -490,17 +505,14 @@ impl Dashboard { format!(" Output{filter} ") } - fn output_filter_label(&self) -> &'static str { - match self.output_filter { - OutputFilter::All => "", - OutputFilter::ErrorsOnly => " errors", - } - } - fn empty_output_message(&self) -> &'static str { - match self.output_filter { - OutputFilter::All => "Waiting for session output...", - OutputFilter::ErrorsOnly => "No stderr output for this session yet.", + match (self.output_filter, self.output_time_filter) { + (OutputFilter::All, OutputTimeFilter::AllTime) => "Waiting for session output...", + (OutputFilter::ErrorsOnly, OutputTimeFilter::AllTime) => { + "No stderr output for this session yet." + } + (OutputFilter::All, _) => "No output lines in the selected time range.", + (OutputFilter::ErrorsOnly, _) => "No stderr output in the selected time range.", } } @@ -611,7 +623,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_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 conflict proto[c]ol [e]rrors [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] 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 conflict proto[c]ol [e]rrors time [f]ilter [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -683,6 +695,7 @@ impl Dashboard { " v Toggle selected worktree diff in output pane", " c Show conflict-resolution protocol for selected conflicted worktree", " e Toggle output filter between all lines and stderr only", + " f Cycle output time filter between all/15m/1h/24h", " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", " l Cycle pane layout and persist it", @@ -1724,6 +1737,23 @@ impl Dashboard { )); } + pub fn cycle_output_time_filter(&mut self) { + if self.output_mode != OutputMode::SessionOutput { + self.set_operator_note( + "output time filters are only available in session output view".to_string(), + ); + return; + } + + self.output_time_filter = self.output_time_filter.next(); + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note(format!( + "output time filter set to {}", + self.output_time_filter.label() + )); + } + pub fn toggle_auto_dispatch_policy(&mut self) { self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; match self.cfg.save() { @@ -2192,7 +2222,9 @@ impl Dashboard { fn visible_output_lines(&self) -> Vec<&OutputLine> { self.selected_output_lines() .iter() - .filter(|line| self.output_filter.matches(line.stream)) + .filter(|line| { + self.output_filter.matches(line.stream) && self.output_time_filter.matches(line) + }) .collect() } @@ -2864,6 +2896,60 @@ impl OutputFilter { OutputFilter::ErrorsOnly => "errors", } } + + fn title_suffix(self) -> &'static str { + match self { + OutputFilter::All => "", + OutputFilter::ErrorsOnly => " errors", + } + } +} + +impl OutputTimeFilter { + fn next(self) -> Self { + match self { + Self::AllTime => Self::Last15Minutes, + Self::Last15Minutes => Self::LastHour, + Self::LastHour => Self::Last24Hours, + Self::Last24Hours => Self::AllTime, + } + } + + fn matches(self, line: &OutputLine) -> bool { + match self { + Self::AllTime => true, + Self::Last15Minutes => line + .occurred_at() + .map(|timestamp| timestamp >= Utc::now() - Duration::minutes(15)) + .unwrap_or(false), + Self::LastHour => line + .occurred_at() + .map(|timestamp| timestamp >= Utc::now() - Duration::hours(1)) + .unwrap_or(false), + Self::Last24Hours => line + .occurred_at() + .map(|timestamp| timestamp >= Utc::now() - Duration::hours(24)) + .unwrap_or(false), + } + } + + fn label(self) -> &'static str { + match self { + Self::AllTime => "all time", + Self::Last15Minutes => "last 15m", + Self::LastHour => "last 1h", + Self::Last24Hours => "last 24h", + } + } + + fn title_suffix(self) -> &'static str { + match self { + Self::AllTime => "", + Self::Last15Minutes => " last 15m", + Self::LastHour => " last 1h", + Self::Last24Hours => " last 24h", + } + } } impl SessionSummary { @@ -3320,10 +3406,7 @@ mod tests { ); dashboard.session_output_cache.insert( "focus-12345678".to_string(), - vec![OutputLine { - stream: OutputStream::Stdout, - text: "last useful output".to_string(), - }], + vec![test_output_line(OutputStream::Stdout, "last useful output")], ); dashboard.selected_diff_summary = Some("1 file changed, 2 insertions(+)".to_string()); dashboard.selected_diff_preview = vec![ @@ -4160,18 +4243,9 @@ diff --git a/src/next.rs b/src/next.rs dashboard.session_output_cache.insert( "focus-12345678".to_string(), vec![ - OutputLine { - stream: OutputStream::Stdout, - text: "alpha".to_string(), - }, - OutputLine { - stream: OutputStream::Stdout, - text: "beta".to_string(), - }, - OutputLine { - stream: OutputStream::Stdout, - text: "alpha tail".to_string(), - }, + test_output_line(OutputStream::Stdout, "alpha"), + test_output_line(OutputStream::Stdout, "beta"), + test_output_line(OutputStream::Stdout, "alpha tail"), ], ); dashboard.last_output_height = 2; @@ -4207,18 +4281,9 @@ diff --git a/src/next.rs b/src/next.rs dashboard.session_output_cache.insert( "focus-12345678".to_string(), vec![ - OutputLine { - stream: OutputStream::Stdout, - text: "alpha-1".to_string(), - }, - OutputLine { - stream: OutputStream::Stdout, - text: "beta".to_string(), - }, - OutputLine { - stream: OutputStream::Stdout, - text: "alpha-2".to_string(), - }, + test_output_line(OutputStream::Stdout, "alpha-1"), + test_output_line(OutputStream::Stdout, "beta"), + test_output_line(OutputStream::Stdout, "alpha-2"), ], ); dashboard.search_query = Some(r"alpha-\d".to_string()); @@ -4304,14 +4369,8 @@ diff --git a/src/next.rs b/src/next.rs dashboard.session_output_cache.insert( "focus-12345678".to_string(), vec![ - OutputLine { - stream: OutputStream::Stdout, - text: "stdout line".to_string(), - }, - OutputLine { - stream: OutputStream::Stderr, - text: "stderr line".to_string(), - }, + test_output_line(OutputStream::Stdout, "stdout line"), + test_output_line(OutputStream::Stderr, "stderr line"), ], ); @@ -4342,18 +4401,9 @@ diff --git a/src/next.rs b/src/next.rs dashboard.session_output_cache.insert( "focus-12345678".to_string(), vec![ - OutputLine { - stream: OutputStream::Stdout, - text: "alpha stdout".to_string(), - }, - OutputLine { - stream: OutputStream::Stderr, - text: "alpha stderr".to_string(), - }, - OutputLine { - stream: OutputStream::Stderr, - text: "beta stderr".to_string(), - }, + test_output_line(OutputStream::Stdout, "alpha stdout"), + test_output_line(OutputStream::Stderr, "alpha stderr"), + test_output_line(OutputStream::Stderr, "beta stderr"), ], ); dashboard.output_filter = OutputFilter::ErrorsOnly; @@ -4366,6 +4416,73 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr"); } + #[test] + fn cycle_output_time_filter_keeps_only_recent_lines() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + test_output_line_minutes_ago(OutputStream::Stdout, "recent line", 5), + test_output_line_minutes_ago(OutputStream::Stdout, "older line", 45), + test_output_line_minutes_ago(OutputStream::Stdout, "stale line", 180), + ], + ); + + dashboard.cycle_output_time_filter(); + + assert_eq!( + dashboard.output_time_filter, + OutputTimeFilter::Last15Minutes + ); + assert_eq!(dashboard.visible_output_text(), "recent line"); + assert_eq!(dashboard.output_title(), " Output last 15m "); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("output time filter set to last 15m") + ); + } + + #[test] + fn search_matches_respect_time_filter() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + test_output_line_minutes_ago(OutputStream::Stdout, "alpha recent", 10), + test_output_line_minutes_ago(OutputStream::Stdout, "beta recent", 10), + test_output_line_minutes_ago(OutputStream::Stdout, "alpha stale", 180), + ], + ); + dashboard.output_time_filter = OutputTimeFilter::Last15Minutes; + dashboard.search_query = Some("alpha.*".to_string()); + dashboard.last_output_height = 1; + + dashboard.recompute_search_matches(); + + assert_eq!(dashboard.search_matches, vec![0]); + assert_eq!(dashboard.visible_output_text(), "alpha recent\nbeta recent"); + } + #[tokio::test] async fn stop_selected_uses_session_manager_transition() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); @@ -5056,6 +5173,22 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.theme_palette().row_highlight_bg, Color::Gray); } + fn test_output_line(stream: OutputStream, text: &str) -> OutputLine { + OutputLine::new(stream, text, Utc::now().to_rfc3339()) + } + + fn test_output_line_minutes_ago( + stream: OutputStream, + text: &str, + minutes_ago: i64, + ) -> OutputLine { + OutputLine::new( + stream, + text, + (Utc::now() - chrono::Duration::minutes(minutes_ago)).to_rfc3339(), + ) + } + fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); @@ -5093,6 +5226,7 @@ diff --git a/src/next.rs b/src/next.rs selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, + output_time_filter: OutputTimeFilter::AllTime, selected_pane: Pane::Sessions, selected_session, show_help: false, From 1755069df2c0a711186a96e7d938aa7545a9e5f8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:17:03 -0700 Subject: [PATCH 065/459] feat: add ecc2 global output search --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 372 +++++++++++++++++++++++++++++++++----- 2 files changed, 331 insertions(+), 42 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index c47a92c6..f80cdd01 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -75,6 +75,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), (_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(), + (_, KeyCode::Char('A')) => dashboard.toggle_search_scope(), (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 9ddb75ec..efd80383 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -6,7 +6,7 @@ use ratatui::{ }, }; use regex::Regex; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; @@ -84,7 +84,8 @@ pub struct Dashboard { pane_size_percent: u16, search_input: Option, search_query: Option, - search_matches: Vec, + search_scope: SearchScope, + search_matches: Vec, selected_search_match: usize, session_table_state: TableState, } @@ -133,6 +134,18 @@ enum OutputTimeFilter { Last24Hours, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SearchScope { + SelectedSession, + AllSessions, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SearchMatch { + session_id: String, + line_index: usize, +} + #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, @@ -222,6 +235,7 @@ impl Dashboard { pane_size_percent, search_input: None, search_query: None, + search_scope: SearchScope::SelectedSession, search_matches: Vec::new(), selected_search_match: 0, session_table_state, @@ -488,8 +502,9 @@ impl Dashboard { self.output_filter.title_suffix(), self.output_time_filter.title_suffix() ); + let scope = self.search_scope.title_suffix(); if let Some(input) = self.search_input.as_ref() { - return format!(" Output{filter} /{input}_ "); + return format!(" Output{filter}{scope} /{input}_ "); } if let Some(query) = self.search_query.as_ref() { @@ -499,10 +514,10 @@ impl Dashboard { } else { self.selected_search_match.min(total.saturating_sub(1)) + 1 }; - return format!(" Output{filter} /{query} {current}/{total} "); + return format!(" Output{filter}{scope} /{query} {current}/{total} "); } - format!(" Output{filter} ") + format!(" Output{filter}{scope} ") } fn empty_output_message(&self) -> &'static str { @@ -526,6 +541,9 @@ impl Dashboard { ); }; + let selected_session_id = self.selected_session_id(); + let active_match = self.search_matches.get(self.selected_search_match); + Text::from( lines .iter() @@ -534,7 +552,13 @@ impl Dashboard { highlight_output_line( &line.text, query, - self.search_matches.get(self.selected_search_match).copied() == Some(index), + active_match + .zip(selected_session_id) + .map(|(search_match, session_id)| { + search_match.session_id == session_id + && search_match.line_index == index + }) + .unwrap_or(false), self.theme_palette(), ) }) @@ -623,13 +647,16 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_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 conflict proto[c]ol [e]rrors time [f]ilter [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] 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 conflict proto[c]ol [e]rrors time [f]ilter search scope [A] [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); let search_prefix = if let Some(input) = self.search_input.as_ref() { - format!(" /{input}_ | [Enter] apply [Esc] cancel |") + format!( + " /{input}_ | {} | [Enter] apply [Esc] cancel |", + self.search_scope.label() + ) } else if let Some(query) = self.search_query.as_ref() { let total = self.search_matches.len(); let current = if total == 0 { @@ -637,7 +664,10 @@ impl Dashboard { } else { self.selected_search_match.min(total.saturating_sub(1)) + 1 }; - format!(" /{query} {current}/{total} | [n/N] navigate [Esc] clear |") + format!( + " /{query} {current}/{total} | {} | [n/N] navigate [Esc] clear |", + self.search_scope.label() + ) } else { String::new() }; @@ -696,6 +726,7 @@ impl Dashboard { " c Show conflict-resolution protocol for selected conflicted worktree", " e Toggle output filter between all lines and stderr only", " f Cycle output time filter between all/15m/1h/24h", + " A Toggle search scope between selected session and all sessions", " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", " l Cycle pane layout and persist it", @@ -1624,6 +1655,29 @@ impl Dashboard { self.search_query.is_some() } + pub fn toggle_search_scope(&mut self) { + if self.output_mode != OutputMode::SessionOutput { + self.set_operator_note( + "search scope is only available in session output view".to_string(), + ); + return; + } + + self.search_scope = self.search_scope.next(); + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + + if self.search_query.is_some() { + self.set_operator_note(format!( + "search scope set to {} | {} match(es)", + self.search_scope.label(), + self.search_matches.len() + )); + } else { + self.set_operator_note(format!("search scope set to {}", self.search_scope.label())); + } + } + pub fn begin_search(&mut self) { if self.output_mode != OutputMode::SessionOutput { self.set_operator_note("search is only available in session output view".to_string()); @@ -1675,8 +1729,9 @@ impl Dashboard { self.set_operator_note(format!("search /{query} found no matches")); } else { self.set_operator_note(format!( - "search /{query} matched {} line(s) | n/N navigate matches", - self.search_matches.len() + "search /{query} matched {} line(s) across {} session(s) | n/N navigate matches", + self.search_matches.len(), + self.search_match_session_count() )); } } @@ -1878,6 +1933,7 @@ impl Dashboard { self.sync_worktree_health_by_session(); self.sync_global_handoff_backlog(); self.sync_daemon_activity(); + self.sync_output_cache(); self.sync_selection_by_id(selected_id.as_deref()); self.ensure_selected_pane_visible(); self.sync_selected_output(); @@ -1910,6 +1966,28 @@ impl Dashboard { self.sync_selection(); } + fn sync_output_cache(&mut self) { + let active_session_ids: HashSet<_> = self + .sessions + .iter() + .map(|session| session.id.as_str()) + .collect(); + self.session_output_cache + .retain(|session_id, _| active_session_ids.contains(session_id.as_str())); + + for session in &self.sessions { + match self.db.get_output_lines(&session.id, OUTPUT_BUFFER_LIMIT) { + Ok(lines) => { + self.output_store.replace_lines(&session.id, lines.clone()); + self.session_output_cache.insert(session.id.clone(), lines); + } + Err(error) => { + tracing::warn!("Failed to load session output for {}: {error}", session.id); + } + } + } + } + fn ensure_selected_pane_visible(&mut self) { if !self.visible_panes().contains(&self.selected_pane) { self.selected_pane = Pane::Sessions; @@ -1978,24 +2056,15 @@ impl Dashboard { } fn sync_selected_output(&mut self) { - let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { + if self.selected_session_id().is_none() { self.output_scroll_offset = 0; self.output_follow = true; self.search_matches.clear(); self.selected_search_match = 0; return; - }; - - match self.db.get_output_lines(&session_id, OUTPUT_BUFFER_LIMIT) { - Ok(lines) => { - self.output_store.replace_lines(&session_id, lines.clone()); - self.session_output_cache.insert(session_id, lines); - self.recompute_search_matches(); - } - Err(error) => { - tracing::warn!("Failed to load session output: {error}"); - } } + + self.recompute_search_matches(); } fn sync_selected_diff(&mut self) { @@ -2219,13 +2288,25 @@ impl Dashboard { .unwrap_or(&[]) } - fn visible_output_lines(&self) -> Vec<&OutputLine> { - self.selected_output_lines() - .iter() - .filter(|line| { - self.output_filter.matches(line.stream) && self.output_time_filter.matches(line) + fn visible_output_lines_for_session(&self, session_id: &str) -> Vec<&OutputLine> { + self.session_output_cache + .get(session_id) + .map(|lines| { + lines + .iter() + .filter(|line| { + self.output_filter.matches(line.stream) + && self.output_time_filter.matches(line) + }) + .collect() }) - .collect() + .unwrap_or_default() + } + + fn visible_output_lines(&self) -> Vec<&OutputLine> { + self.selected_session_id() + .map(|session_id| self.visible_output_lines_for_session(session_id)) + .unwrap_or_default() } fn recompute_search_matches(&mut self) { @@ -2242,10 +2323,21 @@ impl Dashboard { }; self.search_matches = self - .visible_output_lines() - .iter() - .enumerate() - .filter_map(|(index, line)| regex.is_match(&line.text).then_some(index)) + .search_scope + .session_ids(self.selected_session_id(), &self.sessions) + .into_iter() + .flat_map(|session_id| { + self.visible_output_lines_for_session(session_id) + .into_iter() + .enumerate() + .filter_map(|(index, line)| { + regex.is_match(&line.text).then_some(SearchMatch { + session_id: session_id.to_string(), + line_index: index, + }) + }) + .collect::>() + }) .collect(); if self.search_matches.is_empty() { @@ -2260,13 +2352,25 @@ impl Dashboard { } fn focus_selected_search_match(&mut self) { - let Some(line_index) = self.search_matches.get(self.selected_search_match).copied() else { + let Some(search_match) = self.search_matches.get(self.selected_search_match).cloned() + else { return; }; + if self.selected_session_id() != Some(search_match.session_id.as_str()) { + self.sync_selection_by_id(Some(&search_match.session_id)); + self.sync_selected_output(); + self.sync_selected_diff(); + self.sync_selected_messages(); + self.sync_selected_lineage(); + self.refresh_logs(); + } + self.output_follow = false; let viewport_height = self.last_output_height.max(1); - let offset = line_index.saturating_sub(viewport_height.saturating_sub(1) / 2); + let offset = search_match + .line_index + .saturating_sub(viewport_height.saturating_sub(1) / 2); self.output_scroll_offset = offset.min(self.max_output_scroll()); } @@ -2279,7 +2383,18 @@ impl Dashboard { self.selected_search_match.min(total.saturating_sub(1)) + 1 }; - format!("search /{query} match {current}/{total}") + format!( + "search /{query} match {current}/{total} | {}", + self.search_scope.label() + ) + } + + fn search_match_session_count(&self) -> usize { + self.search_matches + .iter() + .map(|search_match| search_match.session_id.as_str()) + .collect::>() + .len() } fn sync_output_scroll(&mut self, viewport_height: usize) { @@ -2952,6 +3067,40 @@ impl OutputTimeFilter { } } +impl SearchScope { + fn next(self) -> Self { + match self { + Self::SelectedSession => Self::AllSessions, + Self::AllSessions => Self::SelectedSession, + } + } + + fn label(self) -> &'static str { + match self { + Self::SelectedSession => "selected session", + Self::AllSessions => "all sessions", + } + } + + fn title_suffix(self) -> &'static str { + match self { + Self::SelectedSession => "", + Self::AllSessions => " all sessions", + } + } + + fn session_ids<'a>( + self, + selected_session_id: Option<&'a str>, + sessions: &'a [Session], + ) -> Vec<&'a str> { + match self { + Self::SelectedSession => selected_session_id.into_iter().collect(), + Self::AllSessions => sessions.iter().map(|session| session.id.as_str()).collect(), + } + } +} + impl SessionSummary { fn from_sessions( sessions: &[Session], @@ -4257,11 +4406,23 @@ diff --git a/src/next.rs b/src/next.rs dashboard.submit_search(); assert_eq!(dashboard.search_query.as_deref(), Some("alpha.*")); - assert_eq!(dashboard.search_matches, vec![0, 2]); + assert_eq!( + dashboard.search_matches, + vec![ + SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 0, + }, + SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 2, + }, + ] + ); assert_eq!(dashboard.selected_search_match, 0); assert_eq!( dashboard.operator_note.as_deref(), - Some("search /alpha.* matched 2 line(s) | n/N navigate matches") + Some("search /alpha.* matched 2 line(s) across 1 session(s) | n/N navigate matches") ); } @@ -4295,7 +4456,7 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.output_scroll_offset, 2); assert_eq!( dashboard.operator_note.as_deref(), - Some(r"search /alpha-\d match 2/2") + Some(r"search /alpha-\d match 2/2 | selected session") ); dashboard.next_search_match(); @@ -4338,7 +4499,16 @@ diff --git a/src/next.rs b/src/next.rs let mut dashboard = test_dashboard(Vec::new(), 0); dashboard.search_input = Some("draft".to_string()); dashboard.search_query = Some("alpha".to_string()); - dashboard.search_matches = vec![1, 3]; + dashboard.search_matches = vec![ + SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 1, + }, + SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 3, + }, + ]; dashboard.selected_search_match = 1; dashboard.clear_search(); @@ -4412,7 +4582,13 @@ diff --git a/src/next.rs b/src/next.rs dashboard.recompute_search_matches(); - assert_eq!(dashboard.search_matches, vec![0]); + assert_eq!( + dashboard.search_matches, + vec![SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 0, + }] + ); assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr"); } @@ -4479,10 +4655,121 @@ diff --git a/src/next.rs b/src/next.rs dashboard.recompute_search_matches(); - assert_eq!(dashboard.search_matches, vec![0]); + assert_eq!( + dashboard.search_matches, + vec![SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 0, + }] + ); assert_eq!(dashboard.visible_output_text(), "alpha recent\nbeta recent"); } + #[test] + fn search_scope_all_sessions_matches_across_output_buffers() { + let mut dashboard = test_dashboard( + vec![ + sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ), + sample_session( + "review-87654321", + "reviewer", + SessionState::Running, + None, + 1, + 1, + ), + ], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![test_output_line(OutputStream::Stdout, "alpha local")], + ); + dashboard.session_output_cache.insert( + "review-87654321".to_string(), + vec![test_output_line(OutputStream::Stdout, "alpha global")], + ); + dashboard.search_query = Some("alpha.*".to_string()); + + dashboard.toggle_search_scope(); + + assert_eq!(dashboard.search_scope, SearchScope::AllSessions); + assert_eq!( + dashboard.search_matches, + vec![ + SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 0, + }, + SearchMatch { + session_id: "review-87654321".to_string(), + line_index: 0, + }, + ] + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("search scope set to all sessions | 2 match(es)") + ); + assert_eq!( + dashboard.output_title(), + " Output all sessions /alpha.* 1/2 " + ); + } + + #[test] + fn next_search_match_switches_selected_session_in_all_sessions_scope() { + let mut dashboard = test_dashboard( + vec![ + sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ), + sample_session( + "review-87654321", + "reviewer", + SessionState::Running, + None, + 1, + 1, + ), + ], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![test_output_line(OutputStream::Stdout, "alpha local")], + ); + dashboard.session_output_cache.insert( + "review-87654321".to_string(), + vec![test_output_line(OutputStream::Stdout, "alpha global")], + ); + dashboard.search_scope = SearchScope::AllSessions; + dashboard.search_query = Some("alpha.*".to_string()); + dashboard.last_output_height = 1; + dashboard.recompute_search_matches(); + + dashboard.next_search_match(); + + assert_eq!(dashboard.selected_session_id(), Some("review-87654321")); + assert_eq!(dashboard.selected_search_match, 1); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("search /alpha.* match 2/2 | all sessions") + ); + } + #[tokio::test] async fn stop_selected_uses_session_manager_transition() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); @@ -5236,6 +5523,7 @@ diff --git a/src/next.rs b/src/next.rs last_output_height: 0, search_input: None, search_query: None, + search_scope: SearchScope::SelectedSession, search_matches: Vec::new(), selected_search_match: 0, session_table_state, From bab03bd8af0466bfd7d64bcfe744f2ee8b49a9f1 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:21:23 -0700 Subject: [PATCH 066/459] feat: add ecc2 agent output filters --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 214 +++++++++++++++++++++++++++++++++++--- 2 files changed, 198 insertions(+), 17 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index f80cdd01..c78d1f99 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -76,6 +76,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), (_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(), (_, KeyCode::Char('A')) => dashboard.toggle_search_scope(), + (_, KeyCode::Char('o')) => dashboard.toggle_search_agent_filter(), (_, KeyCode::Char('m')) => dashboard.merge_selected_worktree().await, (_, KeyCode::Char('M')) => dashboard.merge_ready_worktrees().await, (_, KeyCode::Char('l')) => dashboard.cycle_pane_layout(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index efd80383..7c40c2d1 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -85,6 +85,7 @@ pub struct Dashboard { search_input: Option, search_query: Option, search_scope: SearchScope, + search_agent_filter: SearchAgentFilter, search_matches: Vec, selected_search_match: usize, session_table_state: TableState, @@ -140,6 +141,12 @@ enum SearchScope { AllSessions, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SearchAgentFilter { + AllAgents, + SelectedAgentType, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SearchMatch { session_id: String, @@ -236,6 +243,7 @@ impl Dashboard { search_input: None, search_query: None, search_scope: SearchScope::SelectedSession, + search_agent_filter: SearchAgentFilter::AllAgents, search_matches: Vec::new(), selected_search_match: 0, session_table_state, @@ -503,8 +511,9 @@ impl Dashboard { self.output_time_filter.title_suffix() ); let scope = self.search_scope.title_suffix(); + let agent = self.search_agent_title_suffix(); if let Some(input) = self.search_input.as_ref() { - return format!(" Output{filter}{scope} /{input}_ "); + return format!(" Output{filter}{scope}{agent} /{input}_ "); } if let Some(query) = self.search_query.as_ref() { @@ -514,10 +523,10 @@ impl Dashboard { } else { self.selected_search_match.min(total.saturating_sub(1)) + 1 }; - return format!(" Output{filter}{scope} /{query} {current}/{total} "); + return format!(" Output{filter}{scope}{agent} /{query} {current}/{total} "); } - format!(" Output{filter}{scope} ") + format!(" Output{filter}{scope}{agent} ") } fn empty_output_message(&self) -> &'static str { @@ -647,15 +656,16 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_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 conflict proto[c]ol [e]rrors time [f]ilter search scope [A] [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] 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 conflict proto[c]ol [e]rrors time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); let search_prefix = if let Some(input) = self.search_input.as_ref() { format!( - " /{input}_ | {} | [Enter] apply [Esc] cancel |", - self.search_scope.label() + " /{input}_ | {} | {} | [Enter] apply [Esc] cancel |", + self.search_scope.label(), + self.search_agent_filter_label() ) } else if let Some(query) = self.search_query.as_ref() { let total = self.search_matches.len(); @@ -665,8 +675,9 @@ impl Dashboard { self.selected_search_match.min(total.saturating_sub(1)) + 1 }; format!( - " /{query} {current}/{total} | {} | [n/N] navigate [Esc] clear |", - self.search_scope.label() + " /{query} {current}/{total} | {} | {} | [n/N] navigate [Esc] clear |", + self.search_scope.label(), + self.search_agent_filter_label() ) } else { String::new() @@ -727,6 +738,7 @@ impl Dashboard { " e Toggle output filter between all lines and stderr only", " f Cycle output time filter between all/15m/1h/24h", " A Toggle search scope between selected session and all sessions", + " o Toggle search agent filter between all agents and selected agent type", " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", " l Cycle pane layout and persist it", @@ -1678,6 +1690,40 @@ impl Dashboard { } } + pub fn toggle_search_agent_filter(&mut self) { + if self.output_mode != OutputMode::SessionOutput { + self.set_operator_note( + "search agent filter is only available in session output view".to_string(), + ); + return; + } + + let Some(selected_agent_type) = self.selected_agent_type().map(str::to_owned) else { + self.set_operator_note("search agent filter requires a selected session".to_string()); + return; + }; + + self.search_agent_filter = match self.search_agent_filter { + SearchAgentFilter::AllAgents => SearchAgentFilter::SelectedAgentType, + SearchAgentFilter::SelectedAgentType => SearchAgentFilter::AllAgents, + }; + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + + if self.search_query.is_some() { + self.set_operator_note(format!( + "search agent filter set to {} | {} match(es)", + self.search_agent_filter.label(&selected_agent_type), + self.search_matches.len() + )); + } else { + self.set_operator_note(format!( + "search agent filter set to {}", + self.search_agent_filter.label(&selected_agent_type) + )); + } + } + pub fn begin_search(&mut self) { if self.output_mode != OutputMode::SessionOutput { self.set_operator_note("search is only available in session output view".to_string()); @@ -2288,6 +2334,28 @@ impl Dashboard { .unwrap_or(&[]) } + fn selected_agent_type(&self) -> Option<&str> { + self.sessions + .get(self.selected_session) + .map(|session| session.agent_type.as_str()) + } + + fn search_agent_filter_label(&self) -> String { + self.search_agent_filter + .label(self.selected_agent_type().unwrap_or("selected agent")) + .to_string() + } + + fn search_agent_title_suffix(&self) -> String { + match self.selected_agent_type() { + Some(agent_type) => self + .search_agent_filter + .title_suffix(agent_type) + .to_string(), + None => String::new(), + } + } + fn visible_output_lines_for_session(&self, session_id: &str) -> Vec<&OutputLine> { self.session_output_cache .get(session_id) @@ -2323,8 +2391,7 @@ impl Dashboard { }; self.search_matches = self - .search_scope - .session_ids(self.selected_session_id(), &self.sessions) + .search_target_session_ids() .into_iter() .flat_map(|session_id| { self.visible_output_lines_for_session(session_id) @@ -2397,6 +2464,23 @@ impl Dashboard { .len() } + fn search_target_session_ids(&self) -> Vec<&str> { + let selected_session_id = self.selected_session_id(); + let selected_agent_type = self.selected_agent_type(); + + self.sessions + .iter() + .filter(|session| { + self.search_scope + .matches(selected_session_id, session.id.as_str()) + && self + .search_agent_filter + .matches(selected_agent_type, session.agent_type.as_str()) + }) + .map(|session| session.id.as_str()) + .collect() + } + fn sync_output_scroll(&mut self, viewport_height: usize) { self.last_output_height = viewport_height.max(1); let max_scroll = self.max_output_scroll(); @@ -3089,14 +3173,33 @@ impl SearchScope { } } - fn session_ids<'a>( - self, - selected_session_id: Option<&'a str>, - sessions: &'a [Session], - ) -> Vec<&'a str> { + fn matches(self, selected_session_id: Option<&str>, session_id: &str) -> bool { match self { - Self::SelectedSession => selected_session_id.into_iter().collect(), - Self::AllSessions => sessions.iter().map(|session| session.id.as_str()).collect(), + Self::SelectedSession => selected_session_id == Some(session_id), + Self::AllSessions => true, + } + } +} + +impl SearchAgentFilter { + fn matches(self, selected_agent_type: Option<&str>, session_agent_type: &str) -> bool { + match self { + Self::AllAgents => true, + Self::SelectedAgentType => selected_agent_type == Some(session_agent_type), + } + } + + fn label(self, selected_agent_type: &str) -> String { + match self { + Self::AllAgents => "all agents".to_string(), + Self::SelectedAgentType => format!("agent {}", selected_agent_type), + } + } + + fn title_suffix(self, selected_agent_type: &str) -> String { + match self { + Self::AllAgents => String::new(), + Self::SelectedAgentType => format!(" {}", self.label(selected_agent_type)), } } } @@ -4770,6 +4873,82 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn search_agent_filter_selected_agent_type_limits_global_search() { + let mut dashboard = test_dashboard( + vec![ + sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ), + sample_session( + "planner-2222222", + "planner", + SessionState::Running, + None, + 1, + 1, + ), + sample_session( + "review-87654321", + "reviewer", + SessionState::Running, + None, + 1, + 1, + ), + ], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![test_output_line(OutputStream::Stdout, "alpha local")], + ); + dashboard.session_output_cache.insert( + "planner-2222222".to_string(), + vec![test_output_line(OutputStream::Stdout, "alpha planner")], + ); + dashboard.session_output_cache.insert( + "review-87654321".to_string(), + vec![test_output_line(OutputStream::Stdout, "alpha reviewer")], + ); + dashboard.search_scope = SearchScope::AllSessions; + dashboard.search_query = Some("alpha.*".to_string()); + dashboard.recompute_search_matches(); + + dashboard.toggle_search_agent_filter(); + + assert_eq!( + dashboard.search_agent_filter, + SearchAgentFilter::SelectedAgentType + ); + assert_eq!( + dashboard.search_matches, + vec![ + SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 0, + }, + SearchMatch { + session_id: "planner-2222222".to_string(), + line_index: 0, + }, + ] + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("search agent filter set to agent planner | 2 match(es)") + ); + assert_eq!( + dashboard.output_title(), + " Output all sessions agent planner /alpha.* 1/2 " + ); + } + #[tokio::test] async fn stop_selected_uses_session_manager_transition() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); @@ -5524,6 +5703,7 @@ diff --git a/src/next.rs b/src/next.rs search_input: None, search_query: None, search_scope: SearchScope::SelectedSession, + search_agent_filter: SearchAgentFilter::AllAgents, search_matches: Vec::new(), selected_search_match: 0, session_table_state, From 15e05d96ad9c32d02b94666e1e9da23697d42723 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:26:06 -0700 Subject: [PATCH 067/459] feat: add ecc2 output content filters --- ecc2/src/tui/dashboard.rs | 257 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 247 insertions(+), 10 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 7c40c2d1..78959f37 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -125,6 +125,8 @@ enum OutputMode { enum OutputFilter { All, ErrorsOnly, + ToolCallsOnly, + FileChangesOnly, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -535,8 +537,18 @@ impl Dashboard { (OutputFilter::ErrorsOnly, OutputTimeFilter::AllTime) => { "No stderr output for this session yet." } + (OutputFilter::ToolCallsOnly, OutputTimeFilter::AllTime) => { + "No tool-call output for this session yet." + } + (OutputFilter::FileChangesOnly, OutputTimeFilter::AllTime) => { + "No file-change output for this session yet." + } (OutputFilter::All, _) => "No output lines in the selected time range.", (OutputFilter::ErrorsOnly, _) => "No stderr output in the selected time range.", + (OutputFilter::ToolCallsOnly, _) => "No tool-call output in the selected time range.", + (OutputFilter::FileChangesOnly, _) => { + "No file-change output in the selected time range." + } } } @@ -656,7 +668,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_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 conflict proto[c]ol [e]rrors time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] 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 conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -735,7 +747,7 @@ impl Dashboard { " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", " c Show conflict-resolution protocol for selected conflicted worktree", - " e Toggle output filter between all lines and stderr only", + " e Cycle output content filter: all/errors/tool calls/file changes", " f Cycle output time filter between all/15m/1h/24h", " A Toggle search scope between selected session and all sessions", " o Toggle search agent filter between all agents and selected agent type", @@ -1826,10 +1838,7 @@ impl Dashboard { return; } - self.output_filter = match self.output_filter { - OutputFilter::All => OutputFilter::ErrorsOnly, - OutputFilter::ErrorsOnly => OutputFilter::All, - }; + self.output_filter = self.output_filter.next(); self.recompute_search_matches(); self.sync_output_scroll(self.last_output_height.max(1)); self.set_operator_note(format!( @@ -2363,8 +2372,7 @@ impl Dashboard { lines .iter() .filter(|line| { - self.output_filter.matches(line.stream) - && self.output_time_filter.matches(line) + self.output_filter.matches(line) && self.output_time_filter.matches(line) }) .collect() }) @@ -3082,10 +3090,21 @@ impl Pane { } impl OutputFilter { - fn matches(self, stream: OutputStream) -> bool { + fn next(self) -> Self { + match self { + Self::All => Self::ErrorsOnly, + Self::ErrorsOnly => Self::ToolCallsOnly, + Self::ToolCallsOnly => Self::FileChangesOnly, + Self::FileChangesOnly => Self::All, + } + } + + fn matches(self, line: &OutputLine) -> bool { match self { OutputFilter::All => true, - OutputFilter::ErrorsOnly => stream == OutputStream::Stderr, + OutputFilter::ErrorsOnly => line.stream == OutputStream::Stderr, + OutputFilter::ToolCallsOnly => looks_like_tool_call(&line.text), + OutputFilter::FileChangesOnly => looks_like_file_change(&line.text), } } @@ -3093,6 +3112,8 @@ impl OutputFilter { match self { OutputFilter::All => "all", OutputFilter::ErrorsOnly => "errors", + OutputFilter::ToolCallsOnly => "tool calls", + OutputFilter::FileChangesOnly => "file changes", } } @@ -3100,10 +3121,97 @@ impl OutputFilter { match self { OutputFilter::All => "", OutputFilter::ErrorsOnly => " errors", + OutputFilter::ToolCallsOnly => " tool calls", + OutputFilter::FileChangesOnly => " file changes", } } } +fn looks_like_tool_call(text: &str) -> bool { + let lower = text.trim().to_ascii_lowercase(); + if lower.is_empty() { + return false; + } + + const TOOL_PREFIXES: &[&str] = &[ + "tool ", + "tool:", + "[tool", + "tool call", + "calling tool", + "running tool", + "invoking tool", + "using tool", + "read(", + "write(", + "edit(", + "multi_edit(", + "bash(", + "grep(", + "glob(", + "search(", + "ls(", + "apply_patch(", + ]; + + TOOL_PREFIXES.iter().any(|prefix| lower.starts_with(prefix)) +} + +fn looks_like_file_change(text: &str) -> bool { + let lower = text.trim().to_ascii_lowercase(); + if lower.is_empty() { + return false; + } + + if lower.contains("applied patch") + || lower.contains("patch applied") + || lower.starts_with("diff --git ") + { + return true; + } + + const FILE_CHANGE_VERBS: &[&str] = &[ + "updated ", + "created ", + "deleted ", + "renamed ", + "modified ", + "wrote ", + "editing ", + "edited ", + "writing ", + ]; + + FILE_CHANGE_VERBS + .iter() + .any(|prefix| lower.starts_with(prefix) && contains_path_like_token(text)) +} + +fn contains_path_like_token(text: &str) -> bool { + text.split_whitespace().any(|token| { + let trimmed = token.trim_matches(|ch: char| { + matches!( + ch, + '[' | ']' | '(' | ')' | '{' | '}' | ',' | ':' | ';' | '"' | '\'' + ) + }); + + trimmed.contains('/') + || trimmed.contains('\\') + || trimmed.starts_with("./") + || trimmed.starts_with("../") + || trimmed + .rsplit_once('.') + .map(|(stem, ext)| { + !stem.is_empty() + && !ext.is_empty() + && ext.len() <= 10 + && ext.chars().all(|ch| ch.is_ascii_alphanumeric()) + }) + .unwrap_or(false) + }) +} + impl OutputTimeFilter { fn next(self) -> Self { match self { @@ -4658,6 +4766,55 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn toggle_output_filter_cycles_tool_calls_and_file_changes() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + test_output_line(OutputStream::Stdout, "normal output"), + test_output_line(OutputStream::Stdout, "Read(src/lib.rs)"), + test_output_line(OutputStream::Stdout, "Updated ecc2/src/tui/dashboard.rs"), + test_output_line(OutputStream::Stderr, "stderr line"), + ], + ); + + dashboard.toggle_output_filter(); + assert_eq!(dashboard.output_filter, OutputFilter::ErrorsOnly); + assert_eq!(dashboard.visible_output_text(), "stderr line"); + + dashboard.toggle_output_filter(); + assert_eq!(dashboard.output_filter, OutputFilter::ToolCallsOnly); + assert_eq!(dashboard.visible_output_text(), "Read(src/lib.rs)"); + assert_eq!(dashboard.output_title(), " Output tool calls "); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("output filter set to tool calls") + ); + + dashboard.toggle_output_filter(); + assert_eq!(dashboard.output_filter, OutputFilter::FileChangesOnly); + assert_eq!( + dashboard.visible_output_text(), + "Updated ecc2/src/tui/dashboard.rs" + ); + assert_eq!(dashboard.output_title(), " Output file changes "); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("output filter set to file changes") + ); + } + #[test] fn search_matches_respect_error_only_filter() { let mut dashboard = test_dashboard( @@ -4695,6 +4852,86 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.visible_output_text(), "alpha stderr\nbeta stderr"); } + #[test] + fn search_matches_respect_tool_call_filter() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + test_output_line(OutputStream::Stdout, "alpha normal"), + test_output_line(OutputStream::Stdout, "Read(alpha.rs)"), + test_output_line(OutputStream::Stdout, "Write(beta.rs)"), + ], + ); + dashboard.output_filter = OutputFilter::ToolCallsOnly; + dashboard.search_query = Some("alpha.*".to_string()); + dashboard.last_output_height = 1; + + dashboard.recompute_search_matches(); + + assert_eq!( + dashboard.search_matches, + vec![SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 0, + }] + ); + assert_eq!( + dashboard.visible_output_text(), + "Read(alpha.rs)\nWrite(beta.rs)" + ); + } + + #[test] + fn search_matches_respect_file_change_filter() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + )], + 0, + ); + dashboard.session_output_cache.insert( + "focus-12345678".to_string(), + vec![ + test_output_line(OutputStream::Stdout, "alpha normal"), + test_output_line(OutputStream::Stdout, "Updated alpha.rs"), + test_output_line(OutputStream::Stdout, "Renamed beta.rs to gamma.rs"), + ], + ); + dashboard.output_filter = OutputFilter::FileChangesOnly; + dashboard.search_query = Some("alpha.*".to_string()); + dashboard.last_output_height = 1; + + dashboard.recompute_search_matches(); + + assert_eq!( + dashboard.search_matches, + vec![SearchMatch { + session_id: "focus-12345678".to_string(), + line_index: 0, + }] + ); + assert_eq!( + dashboard.visible_output_text(), + "Updated alpha.rs\nRenamed beta.rs to gamma.rs" + ); + } + #[test] fn cycle_output_time_filter_keeps_only_recent_lines() { let mut dashboard = test_dashboard( From cc5fe121bf2c09a3d9b2c78b62ac389364fefcc0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:33:17 -0700 Subject: [PATCH 068/459] feat: add ecc2 natural-language session spawner --- ecc2/src/tui/app.rs | 11 +- ecc2/src/tui/dashboard.rs | 372 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 364 insertions(+), 19 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index c78d1f99..b33f6891 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -27,17 +27,17 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { - if dashboard.is_search_mode() { + if dashboard.is_input_mode() { match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, - (_, KeyCode::Esc) => dashboard.cancel_search_input(), - (_, KeyCode::Enter) => dashboard.submit_search(), - (_, KeyCode::Backspace) => dashboard.pop_search_char(), + (_, KeyCode::Esc) => dashboard.cancel_input(), + (_, KeyCode::Enter) => dashboard.submit_input().await, + (_, KeyCode::Backspace) => dashboard.pop_input_char(), (modifiers, KeyCode::Char(ch)) if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT) => { - dashboard.push_search_char(ch); + dashboard.push_input_char(ch); } _ => {} } @@ -64,6 +64,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('N')) if dashboard.has_active_search() => { dashboard.prev_search_match() } + (_, KeyCode::Char('N')) => dashboard.begin_spawn_prompt(), (_, KeyCode::Char('n')) => dashboard.new_session().await, (_, KeyCode::Char('a')) => dashboard.assign_selected().await, (_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 78959f37..171e87d1 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -83,6 +83,7 @@ pub struct Dashboard { last_output_height: usize, pane_size_percent: u16, search_input: Option, + spawn_input: Option, search_query: Option, search_scope: SearchScope, search_agent_filter: SearchAgentFilter, @@ -155,6 +156,19 @@ struct SearchMatch { line_index: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SpawnRequest { + requested_count: usize, + task: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SpawnPlan { + requested_count: usize, + spawn_count: usize, + task: String, +} + #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, @@ -243,6 +257,7 @@ impl Dashboard { last_output_height: 0, pane_size_percent, search_input: None, + spawn_input: None, search_query: None, search_scope: SearchScope::SelectedSession, search_agent_filter: SearchAgentFilter::AllAgents, @@ -668,12 +683,14 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_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 conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); - let search_prefix = if let Some(input) = self.search_input.as_ref() { + let search_prefix = if let Some(input) = self.spawn_input.as_ref() { + format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |") + } else if let Some(input) = self.search_input.as_ref() { format!( " /{input}_ | {} | {} | [Enter] apply [Esc] cancel |", self.search_scope.label(), @@ -695,7 +712,10 @@ impl Dashboard { String::new() }; - let text = if self.search_input.is_some() || self.search_query.is_some() { + let text = if self.spawn_input.is_some() + || self.search_input.is_some() + || self.search_query.is_some() + { format!(" {search_prefix}") } else if let Some(note) = self.operator_note.as_ref() { format!(" {} |{}", truncate_for_dashboard(note, 96), base_text) @@ -739,6 +759,7 @@ impl Dashboard { "Keyboard Shortcuts:", "", " n New session", + " N Natural-language multi-agent spawn prompt", " a Assign follow-up work from selected session", " b Rebalance backed-up delegate handoff backlog for selected lead", " B Rebalance backed-up delegate handoff backlog across lead teams", @@ -1013,6 +1034,10 @@ impl Dashboard { "Cannot queue new session: active session limit reached ({})", self.cfg.max_parallel_sessions ); + self.set_operator_note(format!( + "cannot queue new session: active session limit reached ({})", + self.cfg.max_parallel_sessions + )); return; } @@ -1671,14 +1696,28 @@ impl Dashboard { self.show_help = !self.show_help; } - pub fn is_search_mode(&self) -> bool { - self.search_input.is_some() + pub fn is_input_mode(&self) -> bool { + self.spawn_input.is_some() || self.search_input.is_some() } pub fn has_active_search(&self) -> bool { self.search_query.is_some() } + pub fn begin_spawn_prompt(&mut self) { + if self.search_input.is_some() { + self.set_operator_note( + "finish output search input before opening spawn prompt".to_string(), + ); + return; + } + + self.spawn_input = Some(self.spawn_prompt_seed()); + self.set_operator_note( + "spawn mode | try: give me 3 agents working on fix flaky tests".to_string(), + ); + } + pub fn toggle_search_scope(&mut self) { if self.output_mode != OutputMode::SessionOutput { self.set_operator_note( @@ -1737,6 +1776,11 @@ impl Dashboard { } pub fn begin_search(&mut self) { + if self.spawn_input.is_some() { + self.set_operator_note("finish spawn prompt before searching output".to_string()); + return; + } + if self.output_mode != OutputMode::SessionOutput { self.set_operator_note("search is only available in session output view".to_string()); return; @@ -1746,25 +1790,39 @@ impl Dashboard { self.set_operator_note("search mode | type a query and press Enter".to_string()); } - pub fn push_search_char(&mut self, ch: char) { - if let Some(input) = self.search_input.as_mut() { + pub fn push_input_char(&mut self, ch: char) { + if let Some(input) = self.spawn_input.as_mut() { + input.push(ch); + } else if let Some(input) = self.search_input.as_mut() { input.push(ch); } } - pub fn pop_search_char(&mut self) { - if let Some(input) = self.search_input.as_mut() { + pub fn pop_input_char(&mut self) { + if let Some(input) = self.spawn_input.as_mut() { + input.pop(); + } else if let Some(input) = self.search_input.as_mut() { input.pop(); } } - pub fn cancel_search_input(&mut self) { - if self.search_input.take().is_some() { + pub fn cancel_input(&mut self) { + if self.spawn_input.take().is_some() { + self.set_operator_note("spawn input cancelled".to_string()); + } else if self.search_input.take().is_some() { self.set_operator_note("search input cancelled".to_string()); } } - pub fn submit_search(&mut self) { + pub async fn submit_input(&mut self) { + if self.spawn_input.is_some() { + self.submit_spawn_prompt().await; + } else { + self.submit_search(); + } + } + + fn submit_search(&mut self) { let Some(input) = self.search_input.take() else { return; }; @@ -1794,6 +1852,99 @@ impl Dashboard { } } + async fn submit_spawn_prompt(&mut self) { + let Some(input) = self.spawn_input.take() else { + return; + }; + + let plan = match self.build_spawn_plan(&input) { + Ok(plan) => plan, + Err(error) => { + self.spawn_input = Some(input); + self.set_operator_note(error); + return; + } + }; + + let source_session = self.sessions.get(self.selected_session).cloned(); + let handoff_context = source_session.as_ref().map(|session| { + format!( + "Dashboard handoff from {} [{}] | cwd {}{}", + format_session_id(&session.id), + session.agent_type, + session.working_dir.display(), + session + .worktree + .as_ref() + .map(|worktree| format!( + " | worktree {} ({})", + worktree.branch, + worktree.path.display() + )) + .unwrap_or_default() + ) + }); + let source_task = source_session.as_ref().map(|session| session.task.clone()); + let source_session_id = source_session.as_ref().map(|session| session.id.clone()); + let agent = self.cfg.default_agent.clone(); + let mut created_ids = Vec::new(); + + for task in expand_spawn_tasks(&plan.task, plan.spawn_count) { + let session_id = match manager::create_session( + &self.db, + &self.cfg, + &task, + &agent, + self.cfg.auto_create_worktrees, + ) + .await + { + Ok(session_id) => session_id, + Err(error) => { + self.refresh_after_spawn(created_ids.first().map(String::as_str)); + let summary = if created_ids.is_empty() { + format!("spawn failed: {error}") + } else { + format!( + "spawn partially completed: {} of {} queued before failure: {error}", + created_ids.len(), + plan.spawn_count + ) + }; + self.set_operator_note(summary); + return; + } + }; + + if let (Some(source_id), Some(task), Some(context)) = ( + source_session_id.as_ref(), + source_task.as_ref(), + handoff_context.as_ref(), + ) { + if let Err(error) = comms::send( + &self.db, + source_id, + &session_id, + &comms::MessageType::TaskHandoff { + task: task.clone(), + context: context.clone(), + }, + ) { + tracing::warn!( + "Failed to send handoff from session {} to {}: {error}", + source_id, + session_id + ); + } + } + + created_ids.push(session_id); + } + + self.refresh_after_spawn(created_ids.first().map(String::as_str)); + self.set_operator_note(build_spawn_note(&plan, created_ids.len())); + } + pub fn clear_search(&mut self) { let had_query = self.search_query.take().is_some(); let had_input = self.search_input.take().is_some(); @@ -2892,6 +3043,17 @@ impl Dashboard { .count() } + fn refresh_after_spawn(&mut self, select_session_id: Option<&str>) { + self.refresh(); + self.sync_selection_by_id(select_session_id); + self.reset_output_view(); + self.sync_selected_output(); + self.sync_selected_diff(); + self.sync_selected_messages(); + self.sync_selected_lineage(); + self.refresh_logs(); + } + fn new_session_task(&self) -> String { self.sessions .get(self.selected_session) @@ -2905,6 +3067,31 @@ impl Dashboard { .unwrap_or_else(|| "New ECC 2.0 session".to_string()) } + fn spawn_prompt_seed(&self) -> String { + format!("give me 2 agents working on {}", self.new_session_task()) + } + + fn build_spawn_plan(&self, input: &str) -> Result { + let request = parse_spawn_request(input)?; + let available_slots = self + .cfg + .max_parallel_sessions + .saturating_sub(self.active_session_count()); + + if available_slots == 0 { + return Err(format!( + "cannot queue sessions: active session limit reached ({})", + self.cfg.max_parallel_sessions + )); + } + + Ok(SpawnPlan { + requested_count: request.requested_count, + spawn_count: request.requested_count.min(available_slots), + task: request.task, + }) + } + fn pane_areas(&self, area: Rect) -> PaneAreas { match self.cfg.pane_layout { PaneLayout::Horizontal => { @@ -3157,6 +3344,78 @@ fn looks_like_tool_call(text: &str) -> bool { TOOL_PREFIXES.iter().any(|prefix| lower.starts_with(prefix)) } +fn parse_spawn_request(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("spawn request cannot be empty".to_string()); + } + + let count = Regex::new(r"\b([1-9]\d*)\b") + .expect("spawn count regex") + .captures(trimmed) + .and_then(|captures| captures.get(1)) + .and_then(|count| count.as_str().parse::().ok()) + .unwrap_or(1); + + let task = extract_spawn_task(trimmed); + if task.is_empty() { + return Err("spawn request must include a task description".to_string()); + } + + Ok(SpawnRequest { + requested_count: count, + task, + }) +} + +fn extract_spawn_task(input: &str) -> String { + let trimmed = input.trim(); + let lower = trimmed.to_ascii_lowercase(); + + for marker in ["working on ", "work on ", "for ", ":"] { + if let Some(start) = lower.find(marker) { + let task = trimmed[start + marker.len()..] + .trim_matches(|ch: char| ch.is_whitespace() || ch == ':' || ch == '-'); + if !task.is_empty() { + return task.to_string(); + } + } + } + + let stripped = + Regex::new(r"(?i)^\s*(give me|spawn|queue|start|launch)\s+\d+\s+(agents?|sessions?)\s*") + .expect("spawn command regex") + .replace(trimmed, ""); + let stripped = stripped.trim_matches(|ch: char| ch.is_whitespace() || ch == ':' || ch == '-'); + if !stripped.is_empty() && stripped != trimmed { + return stripped.to_string(); + } + + trimmed.to_string() +} + +fn expand_spawn_tasks(task: &str, count: usize) -> Vec { + if count <= 1 { + return vec![task.to_string()]; + } + + (0..count) + .map(|index| format!("{task} [{}/{}]", index + 1, count)) + .collect() +} + +fn build_spawn_note(plan: &SpawnPlan, created_count: usize) -> String { + let task = truncate_for_dashboard(&plan.task, 72); + if plan.spawn_count < plan.requested_count { + format!( + "spawned {created_count} session(s) for {task} (requested {}, capped at {})", + plan.requested_count, plan.spawn_count + ) + } else { + format!("spawned {created_count} session(s) for {task}") + } +} + fn looks_like_file_change(text: &str) -> bool { let lower = text.trim().to_ascii_lowercase(); if lower.is_empty() { @@ -4471,6 +4730,90 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.active_session_count(), 3); } + #[test] + fn spawn_prompt_seed_uses_selected_session_context() { + let dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + + assert_eq!( + dashboard.spawn_prompt_seed(), + "give me 2 agents working on Follow up on focus-12: Render dashboard rows" + ); + } + + #[test] + fn parse_spawn_request_extracts_count_and_task_from_natural_language() { + let request = parse_spawn_request("give me 10 agents working on stabilize the queue") + .expect("spawn request should parse"); + + assert_eq!( + request, + SpawnRequest { + requested_count: 10, + task: "stabilize the queue".to_string(), + } + ); + } + + #[test] + fn parse_spawn_request_defaults_to_single_session_without_count() { + let request = parse_spawn_request("stabilize the queue").expect("spawn request"); + + assert_eq!( + request, + SpawnRequest { + requested_count: 1, + task: "stabilize the queue".to_string(), + } + ); + } + + #[test] + fn build_spawn_plan_caps_requested_count_to_available_slots() { + let dashboard = test_dashboard( + vec![ + sample_session("pending-1", "planner", SessionState::Pending, None, 1, 1), + sample_session("running-1", "planner", SessionState::Running, None, 1, 1), + sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1), + ], + 0, + ); + + let plan = dashboard + .build_spawn_plan("give me 9 agents working on ship release notes") + .expect("spawn plan"); + + assert_eq!( + plan, + SpawnPlan { + requested_count: 9, + spawn_count: 5, + task: "ship release notes".to_string(), + } + ); + } + + #[test] + fn expand_spawn_tasks_suffixes_multi_session_requests() { + assert_eq!( + expand_spawn_tasks("stabilize the queue", 3), + vec![ + "stabilize the queue [1/3]".to_string(), + "stabilize the queue [2/3]".to_string(), + "stabilize the queue [3/3]".to_string(), + ] + ); + } + #[test] fn refresh_preserves_selected_session_by_id() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); @@ -4612,7 +4955,7 @@ diff --git a/src/next.rs b/src/next.rs dashboard.begin_search(); for ch in "alpha.*".chars() { - dashboard.push_search_char(ch); + dashboard.push_input_char(ch); } dashboard.submit_search(); @@ -4691,7 +5034,7 @@ diff --git a/src/next.rs b/src/next.rs dashboard.begin_search(); for ch in "(".chars() { - dashboard.push_search_char(ch); + dashboard.push_input_char(ch); } dashboard.submit_search(); @@ -5938,6 +6281,7 @@ diff --git a/src/next.rs b/src/next.rs output_scroll_offset: 0, last_output_height: 0, search_input: None, + spawn_input: None, search_query: None, search_scope: SearchScope::SelectedSession, search_agent_filter: SearchAgentFilter::AllAgents, From 1c27f7b29ab5bad711499e92dff4089a648a470f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:42:13 -0700 Subject: [PATCH 069/459] feat: add ecc2 approval queue sidebar --- ecc2/src/session/store.rs | 92 ++++++++++++++++++ ecc2/src/tui/dashboard.rs | 199 +++++++++++++++++++++++++++++++++++--- 2 files changed, 279 insertions(+), 12 deletions(-) diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 128c7c4e..22362723 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -641,6 +641,51 @@ impl StateStore { Ok(counts) } + pub fn unread_approval_counts(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT to_session, COUNT(*) + FROM messages + WHERE read = 0 AND msg_type IN ('query', 'conflict') + GROUP BY to_session", + )?; + + let counts = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)) + })? + .collect::, _>>()?; + + Ok(counts) + } + + pub fn unread_approval_queue(&self, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, from_session, to_session, content, msg_type, read, timestamp + FROM messages + WHERE read = 0 AND msg_type IN ('query', 'conflict') + ORDER BY id ASC + LIMIT ?1", + )?; + + let messages = stmt.query_map(rusqlite::params![limit as i64], |row| { + let timestamp: String = row.get(6)?; + + Ok(SessionMessage { + id: row.get(0)?, + from_session: row.get(1)?, + to_session: row.get(2)?, + content: row.get(3)?, + msg_type: row.get(4)?, + read: row.get::<_, i64>(5)? != 0, + timestamp: chrono::DateTime::parse_from_rfc3339(×tamp) + .unwrap_or_default() + .with_timezone(&chrono::Utc), + }) + })?; + + messages.collect::, _>>().map_err(Into::into) + } + pub fn unread_task_handoffs_for_session( &self, session_id: &str, @@ -1274,6 +1319,53 @@ mod tests { Ok(()) } + #[test] + fn approval_queue_counts_only_queries_and_conflicts() -> Result<()> { + let tempdir = TestDir::new("store-approval-queue")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + + db.insert_session(&build_session("planner", SessionState::Running))?; + db.insert_session(&build_session("worker", SessionState::Pending))?; + db.insert_session(&build_session("worker-2", SessionState::Pending))?; + + db.send_message( + "planner", + "worker", + "{\"question\":\"Need operator approval\"}", + "query", + )?; + db.send_message( + "planner", + "worker", + "{\"file\":\"src/main.rs\",\"description\":\"Merge conflict\"}", + "conflict", + )?; + db.send_message( + "worker", + "planner", + "{\"summary\":\"Finished pass\",\"files_changed\":[]}", + "completed", + )?; + db.send_message( + "planner", + "worker-2", + "{\"task\":\"Review auth flow\",\"context\":\"Delegated from planner\"}", + "task_handoff", + )?; + + let counts = db.unread_approval_counts()?; + assert_eq!(counts.get("worker"), Some(&2)); + assert_eq!(counts.get("planner"), None); + assert_eq!(counts.get("worker-2"), None); + + let queue = db.unread_approval_queue(10)?; + assert_eq!(queue.len(), 2); + assert_eq!(queue[0].msg_type, "query"); + assert_eq!(queue[1].msg_type, "conflict"); + + Ok(()) + } + #[test] fn daemon_activity_round_trips_latest_passes() -> Result<()> { let tempdir = TestDir::new("store-daemon-activity")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 171e87d1..85d51bb0 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -55,6 +55,8 @@ pub struct Dashboard { sessions: Vec, session_output_cache: HashMap>, unread_message_counts: HashMap, + approval_queue_counts: HashMap, + approval_queue_preview: Vec, handoff_backlog_counts: HashMap, worktree_health_by_session: HashMap, global_handoff_backlog_leads: usize, @@ -229,6 +231,8 @@ impl Dashboard { sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), + approval_queue_counts: HashMap::new(), + approval_queue_preview: Vec::new(), handoff_backlog_counts: HashMap::new(), worktree_health_by_session: HashMap::new(), global_handoff_backlog_leads: 0, @@ -358,22 +362,31 @@ impl Dashboard { &self.worktree_health_by_session, stabilized, ); + let mut overview_lines = vec![ + summary_line(&summary), + attention_queue_line(&summary, stabilized), + approval_queue_line(&self.approval_queue_counts), + ]; + if let Some(preview) = approval_queue_preview_line(&self.approval_queue_preview) { + overview_lines.push(preview); + } let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([Constraint::Length(2), Constraint::Min(3)]) + .constraints([ + Constraint::Length(overview_lines.len() as u16), + Constraint::Min(3), + ]) .split(inner_area); - frame.render_widget( - Paragraph::new(vec![ - summary_line(&summary), - attention_queue_line(&summary, stabilized), - ]), - chunks[0], - ); + frame.render_widget(Paragraph::new(overview_lines), chunks[0]); let rows = self.sessions.iter().map(|session| { session_row( session, + self.approval_queue_counts + .get(&session.id) + .copied() + .unwrap_or(0), self.handoff_backlog_counts .get(&session.id) .copied() @@ -381,7 +394,14 @@ impl Dashboard { ) }); let header = Row::new([ - "ID", "Agent", "State", "Branch", "Backlog", "Tokens", "Duration", + "ID", + "Agent", + "State", + "Branch", + "Approvals", + "Backlog", + "Tokens", + "Duration", ]) .style(Style::default().add_modifier(Modifier::BOLD)); let widths = [ @@ -389,6 +409,7 @@ impl Dashboard { Constraint::Length(10), Constraint::Length(10), Constraint::Min(12), + Constraint::Length(10), Constraint::Length(7), Constraint::Length(8), Constraint::Length(8), @@ -2216,6 +2237,23 @@ impl Dashboard { } } + fn sync_approval_queue(&mut self) { + self.approval_queue_counts = match self.db.unread_approval_counts() { + Ok(counts) => counts, + Err(error) => { + tracing::warn!("Failed to refresh approval queue counts: {error}"); + HashMap::new() + } + }; + self.approval_queue_preview = match self.db.unread_approval_queue(3) { + Ok(messages) => messages, + Err(error) => { + tracing::warn!("Failed to refresh approval queue preview: {error}"); + Vec::new() + } + }; + } + fn sync_handoff_backlog_counts(&mut self) { let limit = self.sessions.len().max(1); self.handoff_backlog_counts.clear(); @@ -2308,6 +2346,7 @@ impl Dashboard { fn sync_selected_messages(&mut self) { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.selected_messages.clear(); + self.sync_approval_queue(); return; }; @@ -2337,6 +2376,8 @@ impl Dashboard { Vec::new() } }; + + self.sync_approval_queue(); } fn sync_selected_lineage(&mut self) { @@ -3620,7 +3661,11 @@ impl SessionSummary { } } -fn session_row(session: &Session, unread_messages: usize) -> Row<'static> { +fn session_row( + session: &Session, + approval_requests: usize, + unread_messages: usize, +) -> Row<'static> { Row::new(vec![ Cell::from(format_session_id(&session.id)), Cell::from(session.agent_type.clone()), @@ -3630,6 +3675,18 @@ fn session_row(session: &Session, unread_messages: usize) -> Row<'static> { .add_modifier(Modifier::BOLD), ), Cell::from(session_branch(session)), + Cell::from(if approval_requests == 0 { + "-".to_string() + } else { + approval_requests.to_string() + }) + .style(if approval_requests == 0 { + Style::default() + } else { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + }), Cell::from(if unread_messages == 0 { "-".to_string() } else { @@ -3734,6 +3791,49 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta Line::from(spans) } +fn approval_queue_line(approval_queue_counts: &HashMap) -> Line<'static> { + let pending_sessions = approval_queue_counts.len(); + let pending_items: usize = approval_queue_counts.values().sum(); + + if pending_items == 0 { + return Line::from(vec![ + Span::styled( + "Approval queue clear", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" no unanswered queries or conflicts"), + ]); + } + + Line::from(vec![ + Span::styled( + "Approval queue ", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + summary_span("Pending", pending_items, Color::Yellow), + summary_span("Sessions", pending_sessions, Color::Yellow), + ]) +} + +fn approval_queue_preview_line(messages: &[SessionMessage]) -> Option> { + let message = messages.first()?; + let preview = truncate_for_dashboard(&comms::preview(&message.msg_type, &message.content), 72); + + Some(Line::from(vec![ + Span::raw("- "), + Span::styled( + format_session_id(&message.to_session), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(" | "), + Span::raw(preview), + ])) +} + fn truncate_for_dashboard(value: &str, max_chars: usize) -> String { let trimmed = value.trim(); if trimmed.chars().count() <= max_chars { @@ -3968,7 +4068,7 @@ mod tests { #[test] fn render_sessions_shows_summary_headers_and_selected_row() { - let dashboard = test_dashboard( + let mut dashboard = test_dashboard( vec![ sample_session( "run-12345678", @@ -3989,6 +4089,16 @@ mod tests { ], 1, ); + dashboard.approval_queue_counts = HashMap::from([(String::from("run-12345678"), 2usize)]); + dashboard.approval_queue_preview = vec![SessionMessage { + id: 1, + from_session: "lead-12345678".to_string(), + to_session: "run-12345678".to_string(), + content: "{\"question\":\"Need approval to continue\"}".to_string(), + msg_type: "query".to_string(), + read: false, + timestamp: Utc::now(), + }]; let rendered = render_dashboard_text(dashboard, 180, 24); assert!(rendered.contains("ID")); @@ -3996,10 +4106,73 @@ mod tests { assert!(rendered.contains("Total 2")); assert!(rendered.contains("Running 1")); assert!(rendered.contains("Completed 1")); - assert!(rendered.contains("Attention queue clear")); + assert!(rendered.contains("Approval queue")); assert!(rendered.contains("done-876")); } + #[test] + fn approval_queue_preview_line_uses_target_session_and_preview() { + let line = approval_queue_preview_line(&[SessionMessage { + id: 1, + from_session: "lead-12345678".to_string(), + to_session: "run-12345678".to_string(), + content: "{\"question\":\"Need approval to continue\"}".to_string(), + msg_type: "query".to_string(), + read: false, + timestamp: Utc::now(), + }]) + .expect("approval preview line"); + + let rendered = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert!(rendered.contains("run-123")); + assert!(rendered.contains("query")); + } + + #[test] + fn sync_selected_messages_refreshes_approval_queue_after_marking_read() { + let sessions = vec![ + sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ), + sample_session( + "worker-123456", + "reviewer", + SessionState::Idle, + Some("ecc/worker"), + 64, + 5, + ), + ]; + let mut dashboard = test_dashboard(sessions, 1); + for session in &dashboard.sessions { + dashboard.db.insert_session(session).unwrap(); + } + dashboard + .db + .send_message( + "lead-12345678", + "worker-123456", + "{\"question\":\"Need operator input\"}", + "query", + ) + .unwrap(); + dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap(); + + dashboard.sync_selected_messages(); + + assert_eq!(dashboard.approval_queue_counts.get("worker-123456"), None); + assert!(dashboard.approval_queue_preview.is_empty()); + } + #[test] fn selected_session_metrics_text_includes_worktree_output_and_attention_queue() { let mut dashboard = test_dashboard( @@ -6254,6 +6427,8 @@ diff --git a/src/next.rs b/src/next.rs sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), + approval_queue_counts: HashMap::new(), + approval_queue_preview: Vec::new(), handoff_backlog_counts: HashMap::new(), worktree_health_by_session: HashMap::new(), global_handoff_backlog_leads: 0, From 669d9cc790c58ea2001b7488d3a21aad1d49eade Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:48:46 -0700 Subject: [PATCH 070/459] feat: auto-split ecc2 after multi-agent spawn --- ecc2/src/tui/dashboard.rs | 174 +++++++++++++++++++++++++++++++++++++- 1 file changed, 172 insertions(+), 2 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 85d51bb0..63cb7133 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -888,6 +888,65 @@ impl Dashboard { } } + fn auto_split_layout_after_spawn(&mut self, spawned_count: usize) -> Option { + let config_path = crate::config::Config::config_path(); + self.auto_split_layout_after_spawn_with_save(spawned_count, &config_path, |cfg| cfg.save()) + } + + fn auto_split_layout_after_spawn_with_save( + &mut self, + spawned_count: usize, + config_path: &std::path::Path, + save: F, + ) -> Option + where + F: FnOnce(&Config) -> anyhow::Result<()>, + { + if spawned_count <= 1 { + return None; + } + + let live_session_count = self.active_session_count(); + let target_layout = recommended_spawn_layout(live_session_count); + if self.cfg.pane_layout == target_layout { + self.selected_pane = Pane::Sessions; + self.ensure_selected_pane_visible(); + return Some(format!( + "auto-focused sessions in {} layout for {} live session(s)", + pane_layout_name(target_layout), + live_session_count + )); + } + + let previous_layout = self.cfg.pane_layout; + let previous_pane_size = self.pane_size_percent; + let previous_selected_pane = self.selected_pane; + + self.cfg.pane_layout = target_layout; + self.pane_size_percent = configured_pane_size(&self.cfg, target_layout); + self.persist_current_pane_size(); + self.selected_pane = Pane::Sessions; + self.ensure_selected_pane_visible(); + + match save(&self.cfg) { + Ok(()) => Some(format!( + "auto-split {} layout for {} live session(s)", + pane_layout_name(target_layout), + live_session_count + )), + Err(error) => { + self.cfg.pane_layout = previous_layout; + self.pane_size_percent = previous_pane_size; + self.selected_pane = previous_selected_pane; + Some(format!( + "spawned {} session(s) but failed to persist auto-split layout to {}: {error}", + spawned_count, + config_path.display() + )) + } + } + } + fn adjust_pane_size_with_save( &mut self, delta: isize, @@ -1923,7 +1982,7 @@ impl Dashboard { Ok(session_id) => session_id, Err(error) => { self.refresh_after_spawn(created_ids.first().map(String::as_str)); - let summary = if created_ids.is_empty() { + let mut summary = if created_ids.is_empty() { format!("spawn failed: {error}") } else { format!( @@ -1932,6 +1991,11 @@ impl Dashboard { plan.spawn_count ) }; + if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len()) + { + summary.push_str(" | "); + summary.push_str(&layout_note); + } self.set_operator_note(summary); return; } @@ -1963,7 +2027,12 @@ impl Dashboard { } self.refresh_after_spawn(created_ids.first().map(String::as_str)); - self.set_operator_note(build_spawn_note(&plan, created_ids.len())); + let mut note = build_spawn_note(&plan, created_ids.len()); + if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len()) { + note.push_str(" | "); + note.push_str(&layout_note); + } + self.set_operator_note(note); } pub fn clear_search(&mut self) { @@ -3853,6 +3922,22 @@ fn configured_pane_size(cfg: &Config, layout: PaneLayout) -> u16 { configured.clamp(MIN_PANE_SIZE_PERCENT, MAX_PANE_SIZE_PERCENT) } +fn recommended_spawn_layout(live_session_count: usize) -> PaneLayout { + if live_session_count >= 3 { + PaneLayout::Grid + } else { + PaneLayout::Vertical + } +} + +fn pane_layout_name(layout: PaneLayout) -> &'static str { + match layout { + PaneLayout::Horizontal => "horizontal", + PaneLayout::Vertical => "vertical", + PaneLayout::Grid => "grid", + } +} + fn compile_search_regex(query: &str) -> Result { Regex::new(query) } @@ -6357,6 +6442,91 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.pane_size_percent, 63); } + #[test] + fn auto_split_layout_after_spawn_prefers_vertical_for_two_live_sessions() { + let mut dashboard = test_dashboard( + vec![ + sample_session("running-1", "planner", SessionState::Running, None, 1, 1), + sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1), + ], + 0, + ); + + let note = dashboard.auto_split_layout_after_spawn_with_save( + 2, + Path::new("/tmp/ecc2-noop.toml"), + |_| Ok(()), + ); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Vertical); + assert_eq!( + dashboard.pane_size_percent, + dashboard.cfg.linear_pane_size_percent + ); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + assert_eq!( + note.as_deref(), + Some("auto-split vertical layout for 2 live session(s)") + ); + } + + #[test] + fn auto_split_layout_after_spawn_prefers_grid_for_three_live_sessions() { + let mut dashboard = test_dashboard( + vec![ + sample_session("pending-1", "planner", SessionState::Pending, None, 1, 1), + sample_session("running-1", "planner", SessionState::Running, None, 1, 1), + sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1), + ], + 1, + ); + dashboard.selected_pane = Pane::Output; + + let note = dashboard.auto_split_layout_after_spawn_with_save( + 2, + Path::new("/tmp/ecc2-noop.toml"), + |_| Ok(()), + ); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid); + assert_eq!( + dashboard.pane_size_percent, + dashboard.cfg.grid_pane_size_percent + ); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + assert_eq!( + note.as_deref(), + Some("auto-split grid layout for 3 live session(s)") + ); + } + + #[test] + fn auto_split_layout_after_spawn_focuses_sessions_when_layout_already_matches() { + let mut dashboard = test_dashboard( + vec![ + sample_session("pending-1", "planner", SessionState::Pending, None, 1, 1), + sample_session("running-1", "planner", SessionState::Running, None, 1, 1), + sample_session("idle-1", "planner", SessionState::Idle, None, 1, 1), + ], + 1, + ); + dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.selected_pane = Pane::Output; + + let note = dashboard.auto_split_layout_after_spawn_with_save( + 3, + Path::new("/tmp/ecc2-noop.toml"), + |_| Ok(()), + ); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + assert_eq!( + note.as_deref(), + Some("auto-focused sessions in grid layout for 3 live session(s)") + ); + } + #[test] fn toggle_theme_persists_config() { let mut dashboard = test_dashboard(Vec::new(), 0); From 92c9d1f2c9c43200025f3da92bb594664c2ebca5 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:52:36 -0700 Subject: [PATCH 071/459] feat: keep ecc2 lead selected after multi-spawn --- ecc2/src/tui/dashboard.rs | 46 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 63cb7133..cb55d98f 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1981,7 +1981,9 @@ impl Dashboard { { Ok(session_id) => session_id, Err(error) => { - self.refresh_after_spawn(created_ids.first().map(String::as_str)); + let preferred_selection = + post_spawn_selection_id(source_session_id.as_deref(), &created_ids); + self.refresh_after_spawn(preferred_selection.as_deref()); let mut summary = if created_ids.is_empty() { format!("spawn failed: {error}") } else { @@ -2026,7 +2028,9 @@ impl Dashboard { created_ids.push(session_id); } - self.refresh_after_spawn(created_ids.first().map(String::as_str)); + let preferred_selection = + post_spawn_selection_id(source_session_id.as_deref(), &created_ids); + self.refresh_after_spawn(preferred_selection.as_deref()); let mut note = build_spawn_note(&plan, created_ids.len()); if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len()) { note.push_str(" | "); @@ -3526,6 +3530,19 @@ fn build_spawn_note(plan: &SpawnPlan, created_count: usize) -> String { } } +fn post_spawn_selection_id( + source_session_id: Option<&str>, + created_ids: &[String], +) -> Option { + if created_ids.len() > 1 { + source_session_id + .map(ToOwned::to_owned) + .or_else(|| created_ids.first().cloned()) + } else { + created_ids.first().cloned() + } +} + fn looks_like_file_change(text: &str) -> bool { let lower = text.trim().to_ascii_lowercase(); if lower.is_empty() { @@ -6527,6 +6544,31 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn post_spawn_selection_prefers_lead_for_multi_spawn() { + let preferred = post_spawn_selection_id( + Some("lead-12345678"), + &["child-a".to_string(), "child-b".to_string()], + ); + + assert_eq!(preferred.as_deref(), Some("lead-12345678")); + } + + #[test] + fn post_spawn_selection_keeps_single_spawn_on_created_session() { + let preferred = post_spawn_selection_id(Some("lead-12345678"), &["child-a".to_string()]); + + assert_eq!(preferred.as_deref(), Some("child-a")); + } + + #[test] + fn post_spawn_selection_falls_back_to_first_created_when_no_lead_exists() { + let preferred = + post_spawn_selection_id(None, &["child-a".to_string(), "child-b".to_string()]); + + assert_eq!(preferred.as_deref(), Some("child-a")); + } + #[test] fn toggle_theme_persists_config() { let mut dashboard = test_dashboard(Vec::new(), 0); From 7e3bb3aec2b5b9e9b2edddb5b36ca2b21b5212da Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:56:26 -0700 Subject: [PATCH 072/459] feat: add ecc2 delegate activity board --- ecc2/src/tui/dashboard.rs | 130 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 4 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index cb55d98f..2cb042dd 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -193,6 +193,9 @@ struct DelegatedChildSummary { session_id: String, state: SessionState, handoff_backlog: usize, + task_preview: String, + branch: Option, + last_output_preview: Option, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] @@ -2505,11 +2508,33 @@ impl Dashboard { handoff_backlog, state: state.clone(), session_id: child_id.clone(), + task_preview: truncate_for_dashboard(&session.task, 40), + branch: session + .worktree + .as_ref() + .map(|worktree| worktree.branch.clone()), + last_output_preview: self + .db + .get_output_lines(&child_id, 1) + .ok() + .and_then(|lines| lines.last().cloned()) + .map(|line| truncate_for_dashboard(&line.text, 48)), }); delegated.push(DelegatedChildSummary { handoff_backlog, state, session_id: child_id, + task_preview: truncate_for_dashboard(&session.task, 40), + branch: session + .worktree + .as_ref() + .map(|worktree| worktree.branch.clone()), + last_output_preview: self + .db + .get_output_lines(&session.id, 1) + .ok() + .and_then(|lines| lines.last().cloned()) + .map(|line| truncate_for_dashboard(&line.text, 48)), }); } Ok(None) => {} @@ -2976,12 +3001,20 @@ impl Dashboard { if !self.selected_child_sessions.is_empty() { lines.push("Delegates".to_string()); for child in &self.selected_child_sessions { - lines.push(format!( - "- {} [{}] | backlog {}", + let mut child_line = format!( + "- {} [{}] | backlog {} | task {}", format_session_id(&child.session_id), session_state_label(&child.state), - child.handoff_backlog - )); + child.handoff_backlog, + child.task_preview + ); + if let Some(branch) = child.branch.as_ref() { + child_line.push_str(&format!(" | branch {branch}")); + } + lines.push(child_line); + if let Some(last_output_preview) = child.last_output_preview.as_ref() { + lines.push(format!(" last output {last_output_preview}")); + } } } @@ -4454,6 +4487,37 @@ diff --git a/src/next.rs b/src/next.rs assert!(text.contains("Next route reuse idle worker-1")); } + #[test] + fn selected_session_metrics_text_includes_delegate_task_board() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.selected_child_sessions = vec![DelegatedChildSummary { + session_id: "delegate-12345678".to_string(), + state: SessionState::Running, + handoff_backlog: 2, + task_preview: "Implement rust tui delegate board".to_string(), + branch: Some("ecc/delegate-12345678".to_string()), + last_output_preview: Some("Investigating pane selection behavior".to_string()), + }]; + + let text = dashboard.selected_session_metrics_text(); + assert!( + text.contains( + "- delegate [Running] | backlog 2 | task Implement rust tui delegate board | branch ecc/delegate-12345678" + ) + ); + assert!(text.contains(" last output Investigating pane selection behavior")); + } + #[test] fn selected_session_metrics_text_shows_worktree_and_auto_merge_policy_state() { let mut dashboard = test_dashboard( @@ -4953,6 +5017,64 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.selected_child_sessions[0].handoff_backlog, 0); } + #[test] + fn sync_selected_lineage_populates_delegate_task_and_output_previews() { + let lead = sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ); + let mut child = sample_session( + "worker-12345678", + "planner", + SessionState::Running, + Some("ecc/worker"), + 128, + 12, + ); + child.task = "Implement delegate metrics board for ECC 2.0".to_string(); + + let mut dashboard = test_dashboard(vec![lead.clone(), child.clone()], 0); + dashboard.db.insert_session(&lead).unwrap(); + dashboard.db.insert_session(&child).unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "worker-12345678", + "{\"task\":\"Delegated work\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + dashboard + .db + .append_output_line( + "worker-12345678", + OutputStream::Stdout, + "Reviewing delegate metrics board layout", + ) + .unwrap(); + + dashboard.sync_selected_lineage(); + + assert_eq!(dashboard.selected_child_sessions.len(), 1); + assert_eq!( + dashboard.selected_child_sessions[0].task_preview, + "Implement delegate metrics board for EC…" + ); + assert_eq!( + dashboard.selected_child_sessions[0].branch.as_deref(), + Some("ecc/worker") + ); + assert_eq!( + dashboard.selected_child_sessions[0].last_output_preview.as_deref(), + Some("Reviewing delegate metrics board layout") + ); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); From e50c97c29bb6ec9b00a0cd2812f9965b504cf8f1 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 04:59:45 -0700 Subject: [PATCH 073/459] feat: add ecc2 delegate progress signals --- ecc2/src/tui/dashboard.rs | 44 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 2cb042dd..b5dbe845 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -192,7 +192,11 @@ struct AggregateUsage { struct DelegatedChildSummary { session_id: String, state: SessionState, + approval_backlog: usize, handoff_backlog: usize, + tokens_used: u64, + files_changed: u32, + duration_secs: u64, task_preview: String, branch: Option, last_output_preview: Option, @@ -2483,6 +2487,11 @@ impl Dashboard { match self.db.get_session(&child_id) { Ok(Some(session)) => { team.total += 1; + let approval_backlog = self + .approval_queue_counts + .get(&child_id) + .copied() + .unwrap_or(0); let handoff_backlog = match self.db.unread_task_handoff_count(&child_id) { Ok(count) => count, @@ -2505,9 +2514,13 @@ impl Dashboard { } route_candidates.push(DelegatedChildSummary { + approval_backlog, handoff_backlog, state: state.clone(), session_id: child_id.clone(), + tokens_used: session.metrics.tokens_used, + files_changed: session.metrics.files_changed, + duration_secs: session.metrics.duration_secs, task_preview: truncate_for_dashboard(&session.task, 40), branch: session .worktree @@ -2521,9 +2534,13 @@ impl Dashboard { .map(|line| truncate_for_dashboard(&line.text, 48)), }); delegated.push(DelegatedChildSummary { + approval_backlog, handoff_backlog, state, session_id: child_id, + tokens_used: session.metrics.tokens_used, + files_changed: session.metrics.files_changed, + duration_secs: session.metrics.duration_secs, task_preview: truncate_for_dashboard(&session.task, 40), branch: session .worktree @@ -3002,10 +3019,14 @@ impl Dashboard { lines.push("Delegates".to_string()); for child in &self.selected_child_sessions { let mut child_line = format!( - "- {} [{}] | backlog {} | task {}", + "- {} [{}] | approvals {} | backlog {} | progress {} tok / {} files / {} | task {}", format_session_id(&child.session_id), session_state_label(&child.state), + child.approval_backlog, child.handoff_backlog, + format_token_count(child.tokens_used), + child.files_changed, + format_duration(child.duration_secs), child.task_preview ); if let Some(branch) = child.branch.as_ref() { @@ -4503,7 +4524,11 @@ diff --git a/src/next.rs b/src/next.rs dashboard.selected_child_sessions = vec![DelegatedChildSummary { session_id: "delegate-12345678".to_string(), state: SessionState::Running, + approval_backlog: 1, handoff_backlog: 2, + tokens_used: 1_280, + files_changed: 3, + duration_secs: 12, task_preview: "Implement rust tui delegate board".to_string(), branch: Some("ecc/delegate-12345678".to_string()), last_output_preview: Some("Investigating pane selection behavior".to_string()), @@ -4512,7 +4537,7 @@ diff --git a/src/next.rs b/src/next.rs let text = dashboard.selected_session_metrics_text(); assert!( text.contains( - "- delegate [Running] | backlog 2 | task Implement rust tui delegate board | branch ecc/delegate-12345678" + "- delegate [Running] | approvals 1 | backlog 2 | progress 1,280 tok / 3 files / 00:00:12 | task Implement rust tui delegate board | branch ecc/delegate-12345678" ) ); assert!(text.contains(" last output Investigating pane selection behavior")); @@ -5040,6 +5065,10 @@ diff --git a/src/next.rs b/src/next.rs let mut dashboard = test_dashboard(vec![lead.clone(), child.clone()], 0); dashboard.db.insert_session(&lead).unwrap(); dashboard.db.insert_session(&child).unwrap(); + dashboard + .db + .update_metrics("worker-12345678", &child.metrics) + .unwrap(); dashboard .db .send_message( @@ -5057,10 +5086,17 @@ diff --git a/src/next.rs b/src/next.rs "Reviewing delegate metrics board layout", ) .unwrap(); + dashboard + .approval_queue_counts + .insert("worker-12345678".into(), 2); dashboard.sync_selected_lineage(); assert_eq!(dashboard.selected_child_sessions.len(), 1); + assert_eq!(dashboard.selected_child_sessions[0].approval_backlog, 2); + assert_eq!(dashboard.selected_child_sessions[0].tokens_used, 128); + assert_eq!(dashboard.selected_child_sessions[0].files_changed, 2); + assert_eq!(dashboard.selected_child_sessions[0].duration_secs, 12); assert_eq!( dashboard.selected_child_sessions[0].task_preview, "Implement delegate metrics board for EC…" @@ -5070,7 +5106,9 @@ diff --git a/src/next.rs b/src/next.rs Some("ecc/worker") ); assert_eq!( - dashboard.selected_child_sessions[0].last_output_preview.as_deref(), + dashboard.selected_child_sessions[0] + .last_output_preview + .as_deref(), Some("Reviewing delegate metrics board layout") ); } From f29e70883c05c34e5b442c8443b8fe22fa0d79a5 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:05:53 -0700 Subject: [PATCH 074/459] feat: add ecc2 delegate blocker hints --- ecc2/src/tui/dashboard.rs | 168 +++++++++++++++++++++++++++++++++++++- 1 file changed, 165 insertions(+), 3 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index b5dbe845..f47183da 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -192,6 +192,7 @@ struct AggregateUsage { struct DelegatedChildSummary { session_id: String, state: SessionState, + worktree_health: Option, approval_backlog: usize, handoff_backlog: usize, tokens_used: u64, @@ -2514,6 +2515,10 @@ impl Dashboard { } route_candidates.push(DelegatedChildSummary { + worktree_health: self + .worktree_health_by_session + .get(&child_id) + .copied(), approval_backlog, handoff_backlog, state: state.clone(), @@ -2534,6 +2539,10 @@ impl Dashboard { .map(|line| truncate_for_dashboard(&line.text, 48)), }); delegated.push(DelegatedChildSummary { + worktree_health: self + .worktree_health_by_session + .get(&session.id) + .copied(), approval_backlog, handoff_backlog, state, @@ -2567,6 +2576,14 @@ impl Dashboard { self.selected_team_summary = if team.total > 0 { Some(team) } else { None }; self.selected_route_preview = self.build_route_preview(team.total, &route_candidates); + delegated.sort_by_key(|delegate| { + ( + delegate_attention_priority(delegate), + std::cmp::Reverse(delegate.approval_backlog), + std::cmp::Reverse(delegate.handoff_backlog), + delegate.session_id.clone(), + ) + }); delegated.truncate(3); delegated } @@ -3019,16 +3036,26 @@ impl Dashboard { lines.push("Delegates".to_string()); for child in &self.selected_child_sessions { let mut child_line = format!( - "- {} [{}] | approvals {} | backlog {} | progress {} tok / {} files / {} | task {}", + "- {} [{}] | next {}", format_session_id(&child.session_id), session_state_label(&child.state), + delegate_next_action(child) + ); + if let Some(worktree_health) = child.worktree_health { + child_line.push_str(&format!( + " | worktree {}", + delegate_worktree_health_label(worktree_health) + )); + } + child_line.push_str(&format!( + " | approvals {} | backlog {} | progress {} tok / {} files / {} | task {}", child.approval_backlog, child.handoff_backlog, format_token_count(child.tokens_used), child.files_changed, format_duration(child.duration_secs), child.task_preview - ); + )); if let Some(branch) = child.branch.as_ref() { child_line.push_str(&format!(" | branch {branch}")); } @@ -4194,6 +4221,65 @@ fn assignment_action_label(action: manager::AssignmentAction) -> &'static str { } } +fn delegate_worktree_health_label(health: worktree::WorktreeHealth) -> &'static str { + match health { + worktree::WorktreeHealth::Clear => "clear", + worktree::WorktreeHealth::InProgress => "in progress", + worktree::WorktreeHealth::Conflicted => "conflicted", + } +} + +fn delegate_next_action(delegate: &DelegatedChildSummary) -> &'static str { + if delegate.worktree_health == Some(worktree::WorktreeHealth::Conflicted) { + return "resolve conflict"; + } + if delegate.approval_backlog > 0 { + return "review approvals"; + } + if delegate.handoff_backlog > 0 && delegate.state == SessionState::Idle { + return "process handoff"; + } + if delegate.handoff_backlog > 0 { + return "drain backlog"; + } + if delegate.worktree_health == Some(worktree::WorktreeHealth::InProgress) { + return "finish worktree changes"; + } + match delegate.state { + SessionState::Pending => "wait for startup", + SessionState::Running => "let it run", + SessionState::Idle => "assign next task", + SessionState::Failed => "inspect failure", + SessionState::Stopped => "resume or reassign", + SessionState::Completed => "merge or cleanup", + } +} + +fn delegate_attention_priority(delegate: &DelegatedChildSummary) -> u8 { + if delegate.worktree_health == Some(worktree::WorktreeHealth::Conflicted) { + return 0; + } + if delegate.approval_backlog > 0 { + return 1; + } + if matches!(delegate.state, SessionState::Failed | SessionState::Stopped) { + return 2; + } + if delegate.handoff_backlog > 0 { + return 3; + } + if delegate.worktree_health == Some(worktree::WorktreeHealth::InProgress) { + return 4; + } + match delegate.state { + SessionState::Pending => 5, + SessionState::Running => 6, + SessionState::Idle => 7, + SessionState::Completed => 8, + SessionState::Failed | SessionState::Stopped => unreachable!(), + } +} + fn session_branch(session: &Session) -> String { session .worktree @@ -4524,6 +4610,7 @@ diff --git a/src/next.rs b/src/next.rs dashboard.selected_child_sessions = vec![DelegatedChildSummary { session_id: "delegate-12345678".to_string(), state: SessionState::Running, + worktree_health: Some(worktree::WorktreeHealth::Conflicted), approval_backlog: 1, handoff_backlog: 2, tokens_used: 1_280, @@ -4537,7 +4624,7 @@ diff --git a/src/next.rs b/src/next.rs let text = dashboard.selected_session_metrics_text(); assert!( text.contains( - "- delegate [Running] | approvals 1 | backlog 2 | progress 1,280 tok / 3 files / 00:00:12 | task Implement rust tui delegate board | branch ecc/delegate-12345678" + "- delegate [Running] | next resolve conflict | worktree conflicted | approvals 1 | backlog 2 | progress 1,280 tok / 3 files / 00:00:12 | task Implement rust tui delegate board | branch ecc/delegate-12345678" ) ); assert!(text.contains(" last output Investigating pane selection behavior")); @@ -5089,10 +5176,18 @@ diff --git a/src/next.rs b/src/next.rs dashboard .approval_queue_counts .insert("worker-12345678".into(), 2); + dashboard.worktree_health_by_session.insert( + "worker-12345678".into(), + worktree::WorktreeHealth::InProgress, + ); dashboard.sync_selected_lineage(); assert_eq!(dashboard.selected_child_sessions.len(), 1); + assert_eq!( + dashboard.selected_child_sessions[0].worktree_health, + Some(worktree::WorktreeHealth::InProgress) + ); assert_eq!(dashboard.selected_child_sessions[0].approval_backlog, 2); assert_eq!(dashboard.selected_child_sessions[0].tokens_used, 128); assert_eq!(dashboard.selected_child_sessions[0].files_changed, 2); @@ -5113,6 +5208,73 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn sync_selected_lineage_prioritizes_conflicted_delegate_rows() { + let lead = sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ); + let conflicted = sample_session( + "worker-conflict", + "planner", + SessionState::Running, + Some("ecc/conflict"), + 128, + 12, + ); + let idle = sample_session( + "worker-idle", + "planner", + SessionState::Idle, + Some("ecc/idle"), + 64, + 6, + ); + + let mut dashboard = test_dashboard(vec![lead.clone(), conflicted.clone(), idle.clone()], 0); + dashboard.db.insert_session(&lead).unwrap(); + dashboard.db.insert_session(&conflicted).unwrap(); + dashboard.db.insert_session(&idle).unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "worker-conflict", + "{\"task\":\"Handle conflict\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "worker-idle", + "{\"task\":\"Idle follow-up\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + dashboard.worktree_health_by_session.insert( + "worker-conflict".into(), + worktree::WorktreeHealth::Conflicted, + ); + + dashboard.sync_selected_lineage(); + + assert_eq!(dashboard.selected_child_sessions.len(), 2); + assert_eq!( + dashboard.selected_child_sessions[0].session_id, + "worker-conflict" + ); + assert_eq!( + dashboard.selected_child_sessions[0].worktree_health, + Some(worktree::WorktreeHealth::Conflicted) + ); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); From 6fc3f7c3f405e5fc8e0665dedcf34d063a0b0282 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:10:40 -0700 Subject: [PATCH 075/459] feat: scroll ecc2 metrics across full teams --- ecc2/src/tui/dashboard.rs | 93 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 87 insertions(+), 6 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f47183da..e2e20a7f 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -83,6 +83,8 @@ pub struct Dashboard { output_follow: bool, output_scroll_offset: usize, last_output_height: usize, + metrics_scroll_offset: usize, + last_metrics_height: usize, pane_size_percent: u16, search_input: Option, spawn_input: Option, @@ -267,6 +269,8 @@ impl Dashboard { output_follow: true, output_scroll_offset: 0, last_output_height: 0, + metrics_scroll_offset: 0, + last_metrics_height: 0, pane_size_percent, search_input: None, spawn_input: None, @@ -631,7 +635,7 @@ impl Dashboard { ) } - fn render_metrics(&self, frame: &mut Frame, area: Rect) { + fn render_metrics(&mut self, frame: &mut Frame, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Metrics ") @@ -670,9 +674,12 @@ impl Dashboard { chunks[1], ); frame.render_widget( - Paragraph::new(self.selected_session_metrics_text()).wrap(Wrap { trim: true }), + Paragraph::new(self.selected_session_metrics_text()) + .scroll((self.metrics_scroll_offset as u16, 0)) + .wrap(Wrap { trim: true }), chunks[2], ); + self.sync_metrics_scroll(chunks[2].height as usize); } fn render_log(&self, frame: &mut Frame, area: Rect) { @@ -1060,6 +1067,7 @@ impl Dashboard { self.selected_session = (self.selected_session + 1).min(self.sessions.len() - 1); self.sync_selection(); self.reset_output_view(); + self.reset_metrics_view(); self.sync_selected_output(); self.sync_selected_diff(); self.sync_selected_messages(); @@ -1079,7 +1087,11 @@ impl Dashboard { self.output_scroll_offset = self.output_scroll_offset.saturating_add(1); } } - Pane::Metrics => {} + Pane::Metrics => { + let max_scroll = self.max_metrics_scroll(); + self.metrics_scroll_offset = + self.metrics_scroll_offset.saturating_add(1).min(max_scroll); + } Pane::Log => { self.output_follow = false; self.output_scroll_offset = self.output_scroll_offset.saturating_add(1); @@ -1094,6 +1106,7 @@ impl Dashboard { self.selected_session = self.selected_session.saturating_sub(1); self.sync_selection(); self.reset_output_view(); + self.reset_metrics_view(); self.sync_selected_output(); self.sync_selected_diff(); self.sync_selected_messages(); @@ -1108,7 +1121,9 @@ impl Dashboard { self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1); } - Pane::Metrics => {} + Pane::Metrics => { + self.metrics_scroll_offset = self.metrics_scroll_offset.saturating_sub(1); + } Pane::Log => { self.output_follow = false; self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1); @@ -2584,7 +2599,6 @@ impl Dashboard { delegate.session_id.clone(), ) }); - delegated.truncate(3); delegated } Err(error) => { @@ -2830,6 +2844,19 @@ impl Dashboard { .saturating_sub(self.last_output_height.max(1)) } + fn sync_metrics_scroll(&mut self, viewport_height: usize) { + self.last_metrics_height = viewport_height.max(1); + let max_scroll = self.max_metrics_scroll(); + self.metrics_scroll_offset = self.metrics_scroll_offset.min(max_scroll); + } + + fn max_metrics_scroll(&self) -> usize { + self.selected_session_metrics_text() + .lines() + .count() + .saturating_sub(self.last_metrics_height.max(1)) + } + #[cfg(test)] fn visible_output_text(&self) -> String { self.visible_output_lines() @@ -2844,6 +2871,10 @@ impl Dashboard { self.output_scroll_offset = 0; } + fn reset_metrics_view(&mut self) { + self.metrics_scroll_offset = 0; + } + fn refresh_logs(&mut self) { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.logs.clear(); @@ -3242,6 +3273,7 @@ impl Dashboard { self.refresh(); self.sync_selection_by_id(select_session_id); self.reset_output_view(); + self.reset_metrics_view(); self.sync_selected_output(); self.sync_selected_diff(); self.sync_selected_messages(); @@ -5275,6 +5307,50 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn sync_selected_lineage_keeps_all_delegate_rows() { + let lead = sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ); + + let mut sessions = vec![lead.clone()]; + let mut dashboard = test_dashboard(vec![lead.clone()], 0); + dashboard.db.insert_session(&lead).unwrap(); + + for index in 0..5 { + let child_id = format!("worker-{index}"); + let child = sample_session( + &child_id, + "planner", + SessionState::Running, + Some(&format!("ecc/{child_id}")), + 64, + 6, + ); + sessions.push(child.clone()); + dashboard.db.insert_session(&child).unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + &child_id, + "{\"task\":\"Delegated work\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + } + + dashboard.sessions = sessions; + dashboard.sync_selected_lineage(); + + assert_eq!(dashboard.selected_child_sessions.len(), 5); + } + #[test] fn aggregate_cost_summary_mentions_total_cost() { let db = StateStore::open(Path::new(":memory:")).unwrap(); @@ -5454,7 +5530,7 @@ diff --git a/src/next.rs b/src/next.rs } #[test] - fn metrics_scroll_does_not_mutate_output_scroll() -> Result<()> { + fn metrics_scroll_uses_independent_offset() -> Result<()> { let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); let db = StateStore::open(&db_path)?; let now = Utc::now(); @@ -5484,10 +5560,13 @@ diff --git a/src/next.rs b/src/next.rs let previous_scroll = dashboard.output_scroll_offset; dashboard.selected_pane = Pane::Metrics; + dashboard.last_metrics_height = 2; dashboard.scroll_up(); dashboard.scroll_down(); + dashboard.scroll_down(); assert_eq!(dashboard.output_scroll_offset, previous_scroll); + assert_eq!(dashboard.metrics_scroll_offset, 2); let _ = std::fs::remove_file(db_path); Ok(()) } @@ -6989,6 +7068,8 @@ diff --git a/src/next.rs b/src/next.rs output_follow: true, output_scroll_offset: 0, last_output_height: 0, + metrics_scroll_offset: 0, + last_metrics_height: 0, search_input: None, spawn_input: None, search_query: None, From dc36a636af508d85677324960f641516d1d49e6d Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:21:02 -0700 Subject: [PATCH 076/459] feat: navigate delegates from ecc2 lead board --- ecc2/src/tui/app.rs | 3 + ecc2/src/tui/dashboard.rs | 413 +++++++++++++++++++++++++++++++++++++- 2 files changed, 414 insertions(+), 2 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index b33f6891..716c8215 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -56,6 +56,9 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('-')) => dashboard.decrease_pane_size(), (_, KeyCode::Char('j')) | (_, KeyCode::Down) => dashboard.scroll_down(), (_, KeyCode::Char('k')) | (_, KeyCode::Up) => dashboard.scroll_up(), + (_, KeyCode::Char('[')) => dashboard.focus_previous_delegate(), + (_, KeyCode::Char(']')) => dashboard.focus_next_delegate(), + (_, KeyCode::Enter) => dashboard.open_focused_delegate(), (_, KeyCode::Char('/')) => dashboard.begin_search(), (_, KeyCode::Esc) => dashboard.clear_search(), (_, KeyCode::Char('n')) if dashboard.has_active_search() => { diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index e2e20a7f..a303939a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -65,6 +65,7 @@ pub struct Dashboard { selected_messages: Vec, selected_parent_session: Option, selected_child_sessions: Vec, + focused_delegate_session_id: Option, selected_team_summary: Option, selected_route_preview: Option, logs: Vec, @@ -251,6 +252,7 @@ impl Dashboard { selected_messages: Vec::new(), selected_parent_session: None, selected_child_sessions: Vec::new(), + focused_delegate_session_id: None, selected_team_summary: None, selected_route_preview: None, logs: Vec::new(), @@ -719,7 +721,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -825,6 +827,8 @@ impl Dashboard { " S-Tab Previous pane", " j/↓ Scroll down", " k/↑ Scroll up", + " [ or ] Focus previous/next delegate in lead Metrics board", + " Enter Open focused delegate from lead Metrics board", " / Search current session output", " n/N Next/previous search match when search is active", " Esc Clear active search or cancel search input", @@ -1131,6 +1135,49 @@ impl Dashboard { } } + pub fn focus_next_delegate(&mut self) { + let Some(current_index) = self.focused_delegate_index() else { + return; + }; + let next_index = (current_index + 1) % self.selected_child_sessions.len(); + self.set_focused_delegate_by_index(next_index); + } + + pub fn focus_previous_delegate(&mut self) { + let Some(current_index) = self.focused_delegate_index() else { + return; + }; + let previous_index = if current_index == 0 { + self.selected_child_sessions.len() - 1 + } else { + current_index - 1 + }; + self.set_focused_delegate_by_index(previous_index); + } + + pub fn open_focused_delegate(&mut self) { + let Some(delegate_session_id) = self + .focused_delegate_index() + .and_then(|index| self.selected_child_sessions.get(index)) + .map(|delegate| delegate.session_id.clone()) + else { + return; + }; + + self.sync_selection_by_id(Some(&delegate_session_id)); + self.reset_output_view(); + self.reset_metrics_view(); + self.sync_selected_output(); + self.sync_selected_diff(); + self.sync_selected_messages(); + self.sync_selected_lineage(); + self.refresh_logs(); + self.set_operator_note(format!( + "opened delegate {}", + format_session_id(&delegate_session_id) + )); + } + pub async fn new_session(&mut self) { if self.active_session_count() >= self.cfg.max_parallel_sessions { tracing::warn!( @@ -2480,6 +2527,7 @@ impl Dashboard { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.selected_parent_session = None; self.selected_child_sessions.clear(); + self.focused_delegate_session_id = None; self.selected_team_summary = None; self.selected_route_preview = None; return; @@ -2608,6 +2656,7 @@ impl Dashboard { Vec::new() } }; + self.sync_focused_delegate_selection(); } fn build_route_preview( @@ -2857,6 +2906,136 @@ impl Dashboard { .saturating_sub(self.last_metrics_height.max(1)) } + fn focused_delegate_index(&self) -> Option { + if self.selected_child_sessions.is_empty() { + return None; + } + + self.focused_delegate_session_id + .as_deref() + .and_then(|session_id| { + self.selected_child_sessions + .iter() + .position(|delegate| delegate.session_id == session_id) + }) + .or(Some(0)) + } + + fn set_focused_delegate_by_index(&mut self, index: usize) { + let Some(delegate) = self.selected_child_sessions.get(index) else { + return; + }; + let delegate_session_id = delegate.session_id.clone(); + + self.focused_delegate_session_id = Some(delegate_session_id.clone()); + self.ensure_focused_delegate_visible(); + self.set_operator_note(format!( + "focused delegate {}", + format_session_id(&delegate_session_id) + )); + } + + fn sync_focused_delegate_selection(&mut self) { + self.focused_delegate_session_id = self + .focused_delegate_index() + .and_then(|index| self.selected_child_sessions.get(index)) + .map(|delegate| delegate.session_id.clone()); + self.ensure_focused_delegate_visible(); + } + + fn ensure_focused_delegate_visible(&mut self) { + let Some(delegate_index) = self.focused_delegate_index() else { + return; + }; + let Some(line_index) = self.delegate_metrics_line_index(delegate_index) else { + return; + }; + + let viewport_height = self.last_metrics_height.max(1); + if line_index < self.metrics_scroll_offset { + self.metrics_scroll_offset = line_index; + } else if line_index >= self.metrics_scroll_offset + viewport_height { + self.metrics_scroll_offset = + line_index.saturating_sub(viewport_height.saturating_sub(1)); + } + self.metrics_scroll_offset = self.metrics_scroll_offset.min(self.max_metrics_scroll()); + } + + fn delegate_metrics_line_index(&self, target_index: usize) -> Option { + if target_index >= self.selected_child_sessions.len() { + return None; + } + + let mut line_index = self.metrics_line_count_before_delegates(); + for delegate in self.selected_child_sessions.iter().take(target_index) { + line_index += 1; + if delegate.last_output_preview.is_some() { + line_index += 1; + } + } + + Some(line_index) + } + + fn metrics_line_count_before_delegates(&self) -> usize { + if self.sessions.get(self.selected_session).is_none() { + return 0; + } + + let mut line_count = 2; + if self.selected_parent_session.is_some() { + line_count += 1; + } + if self.selected_team_summary.is_some() { + line_count += 1; + } + line_count += 1; + line_count += 1; + + let stabilized = self.daemon_activity.stabilized_after_recovery_at(); + if self.daemon_activity.chronic_saturation_streak > 0 { + line_count += 1; + } + if self.daemon_activity.operator_escalation_required() { + line_count += 1; + } + if self + .daemon_activity + .chronic_saturation_cleared_at() + .is_some() + { + line_count += 1; + } + if stabilized.is_some() { + line_count += 1; + } + if self.daemon_activity.last_dispatch_at.is_some() { + line_count += 1; + } + if stabilized.is_none() { + if self.daemon_activity.last_recovery_dispatch_at.is_some() { + line_count += 1; + } + if self.daemon_activity.last_rebalance_at.is_some() { + line_count += 1; + } + } + if self.daemon_activity.last_auto_merge_at.is_some() { + line_count += 1; + } + if self.daemon_activity.last_auto_prune_at.is_some() { + line_count += 1; + } + if self.selected_route_preview.is_some() { + line_count += 1; + } + if !self.selected_child_sessions.is_empty() { + line_count += 1; + } + + line_count + } + #[cfg(test)] fn visible_output_text(&self) -> String { self.visible_output_lines() @@ -3067,7 +3246,14 @@ impl Dashboard { lines.push("Delegates".to_string()); for child in &self.selected_child_sessions { let mut child_line = format!( - "- {} [{}] | next {}", + "{} {} [{}] | next {}", + if self.focused_delegate_session_id.as_deref() + == Some(child.session_id.as_str()) + { + ">>" + } else { + "-" + }, format_session_id(&child.session_id), session_state_label(&child.state), delegate_next_action(child) @@ -4662,6 +4848,164 @@ diff --git a/src/next.rs b/src/next.rs assert!(text.contains(" last output Investigating pane selection behavior")); } + #[test] + fn selected_session_metrics_text_marks_focused_delegate_row() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.selected_child_sessions = vec![ + DelegatedChildSummary { + session_id: "delegate-12345678".to_string(), + state: SessionState::Running, + worktree_health: None, + approval_backlog: 0, + handoff_backlog: 0, + tokens_used: 128, + files_changed: 1, + duration_secs: 5, + task_preview: "First delegate".to_string(), + branch: None, + last_output_preview: None, + }, + DelegatedChildSummary { + session_id: "delegate-22345678".to_string(), + state: SessionState::Idle, + worktree_health: Some(worktree::WorktreeHealth::InProgress), + approval_backlog: 1, + handoff_backlog: 2, + tokens_used: 64, + files_changed: 2, + duration_secs: 10, + task_preview: "Second delegate".to_string(), + branch: Some("ecc/delegate-22345678".to_string()), + last_output_preview: Some("Waiting on approval".to_string()), + }, + ]; + dashboard.focused_delegate_session_id = Some("delegate-22345678".to_string()); + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("- delegate [Running] | next let it run")); + assert!(text.contains( + ">> delegate [Idle] | next review approvals | worktree in progress | approvals 1 | backlog 2 | progress 64 tok / 2 files / 00:00:10 | task Second delegate | branch ecc/delegate-22345678" + )); + assert!(text.contains(" last output Waiting on approval")); + } + + #[test] + fn focus_next_delegate_wraps_across_delegate_board() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.selected_child_sessions = vec![ + DelegatedChildSummary { + session_id: "delegate-12345678".to_string(), + state: SessionState::Running, + worktree_health: None, + approval_backlog: 0, + handoff_backlog: 0, + tokens_used: 128, + files_changed: 1, + duration_secs: 5, + task_preview: "First delegate".to_string(), + branch: None, + last_output_preview: None, + }, + DelegatedChildSummary { + session_id: "delegate-22345678".to_string(), + state: SessionState::Idle, + worktree_health: None, + approval_backlog: 0, + handoff_backlog: 0, + tokens_used: 64, + files_changed: 2, + duration_secs: 10, + task_preview: "Second delegate".to_string(), + branch: None, + last_output_preview: None, + }, + ]; + dashboard.focused_delegate_session_id = Some("delegate-12345678".to_string()); + + dashboard.focus_next_delegate(); + assert_eq!( + dashboard.focused_delegate_session_id.as_deref(), + Some("delegate-22345678") + ); + + dashboard.focus_next_delegate(); + assert_eq!( + dashboard.focused_delegate_session_id.as_deref(), + Some("delegate-12345678") + ); + } + + #[test] + fn open_focused_delegate_switches_selected_session() { + let sessions = vec![ + sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ), + sample_session( + "delegate-12345678", + "claude", + SessionState::Running, + Some("ecc/delegate"), + 256, + 12, + ), + ]; + let mut dashboard = test_dashboard(sessions, 0); + dashboard.selected_child_sessions = vec![DelegatedChildSummary { + session_id: "delegate-12345678".to_string(), + state: SessionState::Running, + worktree_health: Some(worktree::WorktreeHealth::InProgress), + approval_backlog: 1, + handoff_backlog: 0, + tokens_used: 256, + files_changed: 2, + duration_secs: 12, + task_preview: "Investigate focused delegate navigation".to_string(), + branch: Some("ecc/delegate".to_string()), + last_output_preview: Some("Reviewing lead metrics".to_string()), + }]; + dashboard.focused_delegate_session_id = Some("delegate-12345678".to_string()); + dashboard.output_follow = false; + dashboard.output_scroll_offset = 9; + dashboard.metrics_scroll_offset = 4; + + dashboard.open_focused_delegate(); + + assert_eq!(dashboard.selected_session_id(), Some("delegate-12345678")); + assert!(dashboard.output_follow); + assert_eq!(dashboard.output_scroll_offset, 0); + assert_eq!(dashboard.metrics_scroll_offset, 0); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("opened delegate delegate") + ); + } + #[test] fn selected_session_metrics_text_shows_worktree_and_auto_merge_policy_state() { let mut dashboard = test_dashboard( @@ -5307,6 +5651,70 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn sync_selected_lineage_preserves_focused_delegate_by_session_id() { + let lead = sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ); + let conflicted = sample_session( + "worker-conflict", + "planner", + SessionState::Running, + Some("ecc/conflict"), + 128, + 12, + ); + let idle = sample_session( + "worker-idle", + "planner", + SessionState::Idle, + Some("ecc/idle"), + 64, + 6, + ); + + let mut dashboard = test_dashboard(vec![lead.clone(), conflicted.clone(), idle.clone()], 0); + dashboard.db.insert_session(&lead).unwrap(); + dashboard.db.insert_session(&conflicted).unwrap(); + dashboard.db.insert_session(&idle).unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "worker-conflict", + "{\"task\":\"Handle conflict\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "worker-idle", + "{\"task\":\"Idle follow-up\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + dashboard.sync_selected_lineage(); + dashboard.focused_delegate_session_id = Some("worker-idle".to_string()); + dashboard.worktree_health_by_session.insert( + "worker-conflict".into(), + worktree::WorktreeHealth::Conflicted, + ); + + dashboard.sync_selected_lineage(); + + assert_eq!( + dashboard.focused_delegate_session_id.as_deref(), + Some("worker-idle") + ); + } + #[test] fn sync_selected_lineage_keeps_all_delegate_rows() { let lead = sample_session( @@ -7050,6 +7458,7 @@ diff --git a/src/next.rs b/src/next.rs selected_messages: Vec::new(), selected_parent_session: None, selected_child_sessions: Vec::new(), + focused_delegate_session_id: None, selected_team_summary: None, selected_route_preview: None, logs: Vec::new(), From f2cfaee6fe5d986677ab3e9c9bfb414e2f73d5c6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:27:43 -0700 Subject: [PATCH 077/459] feat: jump ecc2 approval queue targets --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 215 +++++++++++++++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 1 deletion(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 716c8215..ce6198ac 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -73,6 +73,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('b')) => dashboard.rebalance_selected_team().await, (_, KeyCode::Char('B')) => dashboard.rebalance_all_teams().await, (_, KeyCode::Char('i')) => dashboard.drain_inbox_selected().await, + (_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(), (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a303939a..c5b78d84 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -721,7 +721,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -802,6 +802,7 @@ impl Dashboard { " b Rebalance backed-up delegate handoff backlog for selected lead", " B Rebalance backed-up delegate handoff backlog across lead teams", " i Drain unread task handoffs from selected lead", + " I Jump to the next unread approval/conflict target session", " g Auto-dispatch unread handoffs across lead sessions", " G Dispatch then rebalance backlog across lead teams", " v Toggle selected worktree diff in output pane", @@ -1178,6 +1179,28 @@ impl Dashboard { )); } + pub fn focus_next_approval_target(&mut self) { + self.sync_approval_queue(); + let Some(target_session_id) = self.next_approval_target_session_id() else { + self.set_operator_note("approval queue clear".to_string()); + return; + }; + + self.sync_selection_by_id(Some(&target_session_id)); + self.reset_output_view(); + self.reset_metrics_view(); + self.sync_selected_output(); + self.sync_selected_diff(); + self.unread_message_counts = self.db.unread_message_counts().unwrap_or_default(); + self.sync_selected_messages(); + self.sync_selected_lineage(); + self.refresh_logs(); + self.set_operator_note(format!( + "focused approval target {}", + format_session_id(&target_session_id) + )); + } + pub async fn new_session(&mut self) { if self.active_session_count() >= self.cfg.max_parallel_sessions { tracing::warn!( @@ -2876,6 +2899,44 @@ impl Dashboard { .collect() } + fn next_approval_target_session_id(&self) -> Option { + let pending_items: usize = self.approval_queue_counts.values().sum(); + if pending_items == 0 { + return None; + } + + let active_session_ids: HashSet<_> = + self.sessions.iter().map(|session| &session.id).collect(); + let queue = self.db.unread_approval_queue(pending_items).ok()?; + let mut seen = HashSet::new(); + let ordered_targets = queue + .into_iter() + .filter_map(|message| { + if active_session_ids.contains(&message.to_session) + && seen.insert(message.to_session.clone()) + { + Some(message.to_session) + } else { + None + } + }) + .collect::>(); + + if ordered_targets.is_empty() { + return None; + } + + let current_session_id = self.selected_session_id(); + current_session_id + .and_then(|session_id| { + ordered_targets + .iter() + .position(|target_session_id| target_session_id == session_id) + .map(|index| ordered_targets[(index + 1) % ordered_targets.len()].clone()) + }) + .or_else(|| ordered_targets.first().cloned()) + } + fn sync_output_scroll(&mut self, viewport_height: usize) { self.last_output_height = viewport_height.max(1); let max_scroll = self.max_output_scroll(); @@ -4633,6 +4694,158 @@ mod tests { assert!(dashboard.approval_queue_preview.is_empty()); } + #[test] + fn focus_next_approval_target_selects_oldest_unread_target() { + let sessions = vec![ + sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ), + sample_session( + "worker-a", + "reviewer", + SessionState::Idle, + Some("ecc/worker-a"), + 64, + 5, + ), + sample_session( + "worker-b", + "reviewer", + SessionState::Idle, + Some("ecc/worker-b"), + 64, + 5, + ), + ]; + let mut dashboard = test_dashboard(sessions, 0); + for session in &dashboard.sessions { + dashboard.db.insert_session(session).unwrap(); + } + dashboard + .db + .send_message( + "lead-12345678", + "worker-b", + "{\"question\":\"Need approval on B\"}", + "query", + ) + .unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "worker-a", + "{\"question\":\"Need approval on A\"}", + "query", + ) + .unwrap(); + dashboard.sync_approval_queue(); + + dashboard.focus_next_approval_target(); + + assert_eq!(dashboard.selected_session_id(), Some("worker-b")); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("focused approval target worker-b") + ); + } + + #[test] + fn focus_next_approval_target_cycles_distinct_targets() { + let sessions = vec![ + sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ), + sample_session( + "worker-a", + "reviewer", + SessionState::Idle, + Some("ecc/worker-a"), + 64, + 5, + ), + sample_session( + "worker-b", + "reviewer", + SessionState::Idle, + Some("ecc/worker-b"), + 64, + 5, + ), + ]; + let mut dashboard = test_dashboard(sessions, 1); + for session in &dashboard.sessions { + dashboard.db.insert_session(session).unwrap(); + } + dashboard + .db + .send_message( + "lead-12345678", + "worker-a", + "{\"question\":\"Need approval on A\"}", + "query", + ) + .unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "worker-a", + "{\"question\":\"Need another approval on A\"}", + "conflict", + ) + .unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "worker-b", + "{\"question\":\"Need approval on B\"}", + "query", + ) + .unwrap(); + dashboard.sync_approval_queue(); + + dashboard.focus_next_approval_target(); + + assert_eq!(dashboard.selected_session_id(), Some("worker-b")); + assert_eq!(dashboard.approval_queue_counts.get("worker-a"), Some(&2)); + assert_eq!(dashboard.approval_queue_counts.get("worker-b"), None); + } + + #[test] + fn focus_next_approval_target_reports_clear_queue() { + let mut dashboard = test_dashboard( + vec![sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + )], + 0, + ); + + dashboard.focus_next_approval_target(); + + assert_eq!(dashboard.selected_session_id(), Some("lead-12345678")); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("approval queue clear") + ); + } + #[test] fn selected_session_metrics_text_includes_worktree_output_and_attention_queue() { let mut dashboard = test_dashboard( From 996edff6d1aee1d30ab19087efbf70088a97c22d Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:34:36 -0700 Subject: [PATCH 078/459] feat: collapse ecc2 detail panes --- ecc2/src/tui/app.rs | 2 + ecc2/src/tui/dashboard.rs | 295 +++++++++++++++++++++++++++++++------- 2 files changed, 246 insertions(+), 51 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index ce6198ac..d1dff768 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -76,6 +76,8 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(), (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, + (_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(), + (_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c5b78d84..d2f0ccbb 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -87,6 +87,7 @@ pub struct Dashboard { metrics_scroll_offset: usize, last_metrics_height: usize, pane_size_percent: u16, + collapsed_panes: HashSet, search_input: Option, spawn_input: Option, search_query: Option, @@ -112,7 +113,7 @@ struct SessionSummary { in_progress_worktrees: usize, } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] enum Pane { Sessions, Output, @@ -177,11 +178,22 @@ struct SpawnPlan { #[derive(Debug, Clone, Copy)] struct PaneAreas { sessions: Rect, - output: Rect, - metrics: Rect, + output: Option, + metrics: Option, log: Option, } +impl PaneAreas { + fn assign(&mut self, pane: Pane, area: Rect) { + match pane { + Pane::Sessions => self.sessions = area, + Pane::Output => self.output = Some(area), + Pane::Metrics => self.metrics = Some(area), + Pane::Log => self.log = Some(area), + } + } +} + #[derive(Debug, Clone, Copy)] struct AggregateUsage { total_tokens: u64, @@ -274,6 +286,7 @@ impl Dashboard { metrics_scroll_offset: 0, last_metrics_height: 0, pane_size_percent, + collapsed_panes: HashSet::new(), search_input: None, spawn_input: None, search_query: None, @@ -311,8 +324,12 @@ impl Dashboard { } else { let pane_areas = self.pane_areas(chunks[1]); self.render_sessions(frame, pane_areas.sessions); - self.render_output(frame, pane_areas.output); - self.render_metrics(frame, pane_areas.metrics); + if let Some(output_area) = pane_areas.output { + self.render_output(frame, output_area); + } + if let Some(metrics_area) = pane_areas.metrics { + self.render_metrics(frame, metrics_area); + } if let Some(log_area) = pane_areas.log { self.render_log(frame, log_area); @@ -721,7 +738,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -805,6 +822,8 @@ impl Dashboard { " I Jump to the next unread approval/conflict target session", " g Auto-dispatch unread handoffs across lead sessions", " G Dispatch then rebalance backlog across lead teams", + " h Collapse the focused non-session pane", + " H Restore all collapsed panes", " v Toggle selected worktree diff in output pane", " c Show conflict-resolution protocol for selected conflicted worktree", " e Cycle output content filter: all/errors/tool calls/file changes", @@ -871,6 +890,38 @@ impl Dashboard { self.selected_pane = visible_panes[previous_index]; } + pub fn collapse_selected_pane(&mut self) { + if self.selected_pane == Pane::Sessions { + self.set_operator_note("cannot collapse sessions pane".to_string()); + return; + } + + if self.visible_detail_panes().len() <= 1 { + self.set_operator_note("cannot collapse last detail pane".to_string()); + return; + } + + let collapsed = self.selected_pane; + self.collapsed_panes.insert(collapsed); + self.ensure_selected_pane_visible(); + self.set_operator_note(format!( + "collapsed {} pane", + collapsed.title().to_lowercase() + )); + } + + pub fn restore_collapsed_panes(&mut self) { + if self.collapsed_panes.is_empty() { + self.set_operator_note("no collapsed panes".to_string()); + return; + } + + let restored_count = self.collapsed_panes.len(); + self.collapsed_panes.clear(); + self.ensure_selected_pane_visible(); + self.set_operator_note(format!("restored {restored_count} collapsed pane(s)")); + } + pub fn cycle_pane_layout(&mut self) { let config_path = crate::config::Config::config_path(); self.cycle_pane_layout_with_save(&config_path, |cfg| cfg.save()); @@ -3567,66 +3618,76 @@ impl Dashboard { } fn pane_areas(&self, area: Rect) -> PaneAreas { + let detail_panes = self.visible_detail_panes(); match self.cfg.pane_layout { PaneLayout::Horizontal => { let columns = Layout::default() .direction(Direction::Horizontal) .constraints(self.primary_constraints()) .split(area); - let right_rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(OUTPUT_PANE_PERCENT), - Constraint::Percentage(100 - OUTPUT_PANE_PERCENT), - ]) - .split(columns[1]); - - PaneAreas { + let mut pane_areas = PaneAreas { sessions: columns[0], - output: right_rows[0], - metrics: right_rows[1], + output: None, + metrics: None, log: None, + }; + for (pane, rect) in horizontal_detail_layout(columns[1], &detail_panes) { + pane_areas.assign(pane, rect); } + pane_areas } PaneLayout::Vertical => { let rows = Layout::default() .direction(Direction::Vertical) .constraints(self.primary_constraints()) .split(area); - let bottom_columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(OUTPUT_PANE_PERCENT), - Constraint::Percentage(100 - OUTPUT_PANE_PERCENT), - ]) - .split(rows[1]); - - PaneAreas { + let mut pane_areas = PaneAreas { sessions: rows[0], - output: bottom_columns[0], - metrics: bottom_columns[1], + output: None, + metrics: None, log: None, + }; + for (pane, rect) in vertical_detail_layout(rows[1], &detail_panes) { + pane_areas.assign(pane, rect); } + pane_areas } PaneLayout::Grid => { - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints(self.primary_constraints()) - .split(area); - let top_columns = Layout::default() - .direction(Direction::Horizontal) - .constraints(self.primary_constraints()) - .split(rows[0]); - let bottom_columns = Layout::default() - .direction(Direction::Horizontal) - .constraints(self.primary_constraints()) - .split(rows[1]); + if detail_panes.len() < 3 { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints(self.primary_constraints()) + .split(area); + let mut pane_areas = PaneAreas { + sessions: columns[0], + output: None, + metrics: None, + log: None, + }; + for (pane, rect) in horizontal_detail_layout(columns[1], &detail_panes) { + pane_areas.assign(pane, rect); + } + pane_areas + } else { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints(self.primary_constraints()) + .split(area); + let top_columns = Layout::default() + .direction(Direction::Horizontal) + .constraints(self.primary_constraints()) + .split(rows[0]); + let bottom_columns = Layout::default() + .direction(Direction::Horizontal) + .constraints(self.primary_constraints()) + .split(rows[1]); - PaneAreas { - sessions: top_columns[0], - output: top_columns[1], - metrics: bottom_columns[0], - log: Some(bottom_columns[1]), + PaneAreas { + sessions: top_columns[0], + output: Some(top_columns[1]), + metrics: Some(bottom_columns[0]), + log: Some(bottom_columns[1]), + } } } } @@ -3639,11 +3700,25 @@ impl Dashboard { ] } - fn visible_panes(&self) -> &'static [Pane] { + fn visible_panes(&self) -> Vec { + self.layout_panes() + .into_iter() + .filter(|pane| !self.collapsed_panes.contains(pane)) + .collect() + } + + fn visible_detail_panes(&self) -> Vec { + self.visible_panes() + .into_iter() + .filter(|pane| *pane != Pane::Sessions) + .collect() + } + + fn layout_panes(&self) -> Vec { match self.cfg.pane_layout { - PaneLayout::Grid => &[Pane::Sessions, Pane::Output, Pane::Metrics, Pane::Log], + PaneLayout::Grid => vec![Pane::Sessions, Pane::Output, Pane::Metrics, Pane::Log], PaneLayout::Horizontal | PaneLayout::Vertical => { - &[Pane::Sessions, Pane::Output, Pane::Metrics] + vec![Pane::Sessions, Pane::Output, Pane::Metrics] } } } @@ -4315,6 +4390,42 @@ fn pane_layout_name(layout: PaneLayout) -> &'static str { } } +fn horizontal_detail_layout(area: Rect, panes: &[Pane]) -> Vec<(Pane, Rect)> { + match panes { + [] => Vec::new(), + [pane] => vec![(*pane, area)], + [first, second] => { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(OUTPUT_PANE_PERCENT), + Constraint::Percentage(100 - OUTPUT_PANE_PERCENT), + ]) + .split(area); + vec![(*first, rows[0]), (*second, rows[1])] + } + _ => unreachable!("horizontal layouts support at most two detail panes"), + } +} + +fn vertical_detail_layout(area: Rect, panes: &[Pane]) -> Vec<(Pane, Rect)> { + match panes { + [] => Vec::new(), + [pane] => vec![(*pane, area)], + [first, second] => { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(OUTPUT_PANE_PERCENT), + Constraint::Percentage(100 - OUTPUT_PANE_PERCENT), + ]) + .split(area); + vec![(*first, columns[0]), (*second, columns[1])] + } + _ => unreachable!("vertical layouts support at most two detail panes"), + } +} + fn compile_search_regex(query: &str) -> Result { Regex::new(query) } @@ -7359,11 +7470,92 @@ diff --git a/src/next.rs b/src/next.rs dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; let areas = dashboard.pane_areas(Rect::new(0, 0, 100, 40)); + let output_area = areas.output.expect("grid layout should include output"); + let metrics_area = areas.metrics.expect("grid layout should include metrics"); let log_area = areas.log.expect("grid layout should include a log pane"); - assert!(areas.output.x > areas.sessions.x); - assert!(areas.metrics.y > areas.sessions.y); - assert!(log_area.x > areas.metrics.x); + assert!(output_area.x > areas.sessions.x); + assert!(metrics_area.y > areas.sessions.y); + assert!(log_area.x > metrics_area.x); + } + + #[test] + fn collapse_selected_pane_hides_metrics_and_moves_focus() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.selected_pane = Pane::Metrics; + + dashboard.collapse_selected_pane(); + + assert_eq!(dashboard.selected_pane, Pane::Sessions); + assert_eq!( + dashboard.visible_panes(), + vec![Pane::Sessions, Pane::Output] + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("collapsed metrics pane") + ); + } + + #[test] + fn collapse_selected_pane_rejects_sessions_and_last_detail_pane() { + let mut dashboard = test_dashboard(Vec::new(), 0); + + dashboard.collapse_selected_pane(); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("cannot collapse sessions pane") + ); + + dashboard.selected_pane = Pane::Metrics; + dashboard.collapse_selected_pane(); + dashboard.selected_pane = Pane::Output; + dashboard.collapse_selected_pane(); + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("cannot collapse last detail pane") + ); + assert_eq!( + dashboard.visible_panes(), + vec![Pane::Sessions, Pane::Output] + ); + } + + #[test] + fn restore_collapsed_panes_restores_hidden_tabs() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.selected_pane = Pane::Metrics; + dashboard.collapse_selected_pane(); + + dashboard.restore_collapsed_panes(); + + assert_eq!( + dashboard.visible_panes(), + vec![Pane::Sessions, Pane::Output, Pane::Metrics] + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("restored 1 collapsed pane(s)") + ); + } + + #[test] + fn collapsed_grid_reflows_to_horizontal_detail_stack() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; + dashboard.selected_pane = Pane::Log; + dashboard.collapse_selected_pane(); + + let areas = dashboard.pane_areas(Rect::new(0, 0, 100, 40)); + let output_area = areas.output.expect("output should stay visible"); + let metrics_area = areas.metrics.expect("metrics should stay visible"); + + assert!(areas.log.is_none()); + assert_eq!(areas.sessions.height, 40); + assert_eq!(output_area.width, metrics_area.width); + assert!(metrics_area.y > output_area.y); } #[test] @@ -7692,6 +7884,7 @@ diff --git a/src/next.rs b/src/next.rs last_output_height: 0, metrics_scroll_offset: 0, last_metrics_height: 0, + collapsed_panes: HashSet::new(), search_input: None, spawn_input: None, search_query: None, From 0c509fe57ef702c7c8f22aea8a53529ea1007dd1 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:43:34 -0700 Subject: [PATCH 079/459] feat: add ecc2 session timeline mode --- ecc2/src/tui/app.rs | 2 + ecc2/src/tui/dashboard.rs | 482 +++++++++++++++++++++++++++++++++++++- 2 files changed, 474 insertions(+), 10 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index d1dff768..d3daedfb 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -78,6 +78,8 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, (_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(), (_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(), + (_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(), + (_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index d2f0ccbb..e285e4e2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -77,6 +77,7 @@ pub struct Dashboard { output_mode: OutputMode, output_filter: OutputFilter, output_time_filter: OutputTimeFilter, + timeline_event_filter: TimelineEventFilter, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -124,6 +125,7 @@ enum Pane { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OutputMode { SessionOutput, + Timeline, WorktreeDiff, ConflictProtocol, } @@ -144,6 +146,15 @@ enum OutputTimeFilter { Last24Hours, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimelineEventFilter { + All, + Lifecycle, + Messages, + ToolCalls, + FileChanges, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SearchScope { SelectedSession, @@ -162,6 +173,21 @@ struct SearchMatch { line_index: usize, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimelineEventType { + Lifecycle, + Message, + ToolCall, + FileChange, +} + +#[derive(Debug, Clone)] +struct TimelineEvent { + occurred_at: chrono::DateTime, + event_type: TimelineEventType, + summary: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SpawnRequest { requested_count: usize, @@ -276,6 +302,7 @@ impl Dashboard { output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, + timeline_event_filter: TimelineEventFilter::All, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -498,6 +525,15 @@ impl Dashboard { }; (self.output_title(), content) } + OutputMode::Timeline => { + let lines = self.visible_timeline_lines(); + let content = if lines.is_empty() { + Text::from(self.empty_timeline_message()) + } else { + Text::from(lines) + }; + (self.output_title(), content) + } OutputMode::WorktreeDiff => { let content = self .selected_diff_patch @@ -574,6 +610,14 @@ impl Dashboard { } fn output_title(&self) -> String { + if self.output_mode == OutputMode::Timeline { + return format!( + " Timeline{}{} ", + self.timeline_event_filter.title_suffix(), + self.output_time_filter.title_suffix() + ); + } + let filter = format!( "{}{}", self.output_filter.title_suffix(), @@ -619,6 +663,37 @@ impl Dashboard { } } + fn empty_timeline_message(&self) -> &'static str { + match (self.timeline_event_filter, self.output_time_filter) { + (TimelineEventFilter::All, OutputTimeFilter::AllTime) => { + "No timeline events for this session yet." + } + (TimelineEventFilter::Lifecycle, OutputTimeFilter::AllTime) => { + "No lifecycle events for this session yet." + } + (TimelineEventFilter::Messages, OutputTimeFilter::AllTime) => { + "No message events for this session yet." + } + (TimelineEventFilter::ToolCalls, OutputTimeFilter::AllTime) => { + "No tool-call events for this session yet." + } + (TimelineEventFilter::FileChanges, OutputTimeFilter::AllTime) => { + "No file-change events for this session yet." + } + (TimelineEventFilter::All, _) => "No timeline events in the selected time range.", + (TimelineEventFilter::Lifecycle, _) => { + "No lifecycle events in the selected time range." + } + (TimelineEventFilter::Messages, _) => "No message events in the selected time range.", + (TimelineEventFilter::ToolCalls, _) => { + "No tool-call events in the selected time range." + } + (TimelineEventFilter::FileChanges, _) => { + "No file-change events in the selected time range." + } + } + } + fn render_searchable_output(&self, lines: &[&OutputLine]) -> Text<'static> { let Some(query) = self.search_query.as_deref() else { return Text::from( @@ -738,7 +813,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -824,10 +899,12 @@ impl Dashboard { " G Dispatch then rebalance backlog across lead teams", " h Collapse the focused non-session pane", " H Restore all collapsed panes", + " y Toggle selected-session timeline view", + " E Cycle timeline event filter", " v Toggle selected worktree diff in output pane", " c Show conflict-resolution protocol for selected conflicted worktree", " e Cycle output content filter: all/errors/tool calls/file changes", - " f Cycle output time filter between all/15m/1h/24h", + " f Cycle output or timeline time range between all/15m/1h/24h", " A Toggle search scope between selected session and all sessions", " o Toggle search agent filter between all agents and selected agent type", " m Merge selected ready worktree into base and clean it up", @@ -1350,6 +1427,11 @@ impl Dashboard { self.reset_output_view(); self.set_operator_note("showing session output".to_string()); } + OutputMode::Timeline => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } OutputMode::ConflictProtocol => { self.output_mode = OutputMode::SessionOutput; self.reset_output_view(); @@ -1358,6 +1440,27 @@ impl Dashboard { } } + pub fn toggle_timeline_mode(&mut self) { + match self.output_mode { + OutputMode::Timeline => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + _ => { + if self.sessions.get(self.selected_session).is_some() { + self.output_mode = OutputMode::Timeline; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = 0; + self.set_operator_note("showing selected session timeline".to_string()); + } else { + self.set_operator_note("no session selected for timeline view".to_string()); + } + } + } + } + pub fn toggle_conflict_protocol_mode(&mut self) { match self.output_mode { OutputMode::ConflictProtocol => { @@ -2237,19 +2340,45 @@ impl Dashboard { } pub fn cycle_output_time_filter(&mut self) { - if self.output_mode != OutputMode::SessionOutput { + if !matches!( + self.output_mode, + OutputMode::SessionOutput | OutputMode::Timeline + ) { self.set_operator_note( - "output time filters are only available in session output view".to_string(), + "time filters are only available in session output or timeline view".to_string(), ); return; } self.output_time_filter = self.output_time_filter.next(); - self.recompute_search_matches(); + if self.output_mode == OutputMode::SessionOutput { + self.recompute_search_matches(); + } + self.sync_output_scroll(self.last_output_height.max(1)); + let note_prefix = if self.output_mode == OutputMode::Timeline { + "timeline range" + } else { + "output time filter" + }; + self.set_operator_note(format!( + "{note_prefix} set to {}", + self.output_time_filter.label() + )); + } + + pub fn cycle_timeline_event_filter(&mut self) { + if self.output_mode != OutputMode::Timeline { + self.set_operator_note( + "timeline event filters are only available in timeline view".to_string(), + ); + return; + } + + self.timeline_event_filter = self.timeline_event_filter.next(); self.sync_output_scroll(self.last_output_height.max(1)); self.set_operator_note(format!( - "output time filter set to {}", - self.output_time_filter.label() + "timeline filter set to {}", + self.timeline_event_filter.label() )); } @@ -2846,6 +2975,111 @@ impl Dashboard { .unwrap_or_default() } + fn visible_timeline_lines(&self) -> Vec> { + self.selected_timeline_events() + .into_iter() + .filter(|event| self.timeline_event_filter.matches(event.event_type)) + .filter(|event| self.output_time_filter.matches_timestamp(event.occurred_at)) + .map(|event| { + Line::from(format!( + "[{}] {:<11} {}", + event.occurred_at.format("%H:%M:%S"), + event.event_type.label(), + event.summary + )) + }) + .collect() + } + + fn selected_timeline_events(&self) -> Vec { + let Some(session) = self.sessions.get(self.selected_session) else { + return Vec::new(); + }; + + let mut events = vec![TimelineEvent { + occurred_at: session.created_at, + event_type: TimelineEventType::Lifecycle, + summary: format!( + "created session as {} for {}", + session.agent_type, + truncate_for_dashboard(&session.task, 64) + ), + }]; + + if session.updated_at > session.created_at { + events.push(TimelineEvent { + occurred_at: session.updated_at, + event_type: TimelineEventType::Lifecycle, + summary: format!("state {} | updated session metadata", session.state), + }); + } + + if let Some(worktree) = session.worktree.as_ref() { + events.push(TimelineEvent { + occurred_at: session.updated_at, + event_type: TimelineEventType::Lifecycle, + summary: format!( + "attached worktree {} from {}", + worktree.branch, worktree.base_branch + ), + }); + } + + if session.metrics.files_changed > 0 { + events.push(TimelineEvent { + occurred_at: session.updated_at, + event_type: TimelineEventType::FileChange, + summary: format!("files changed {}", session.metrics.files_changed), + }); + } + + let messages = self + .db + .list_messages_for_session(&session.id, 128) + .unwrap_or_default(); + events.extend(messages.into_iter().map(|message| { + let (direction, counterpart) = if message.from_session == session.id { + ("sent", format_session_id(&message.to_session)) + } else { + ("received", format_session_id(&message.from_session)) + }; + TimelineEvent { + occurred_at: message.timestamp, + event_type: TimelineEventType::Message, + summary: format!( + "{direction} {} {} | {}", + message.msg_type, + counterpart, + truncate_for_dashboard( + &comms::preview(&message.msg_type, &message.content), + 64 + ) + ), + } + })); + + let tool_logs = self + .db + .query_tool_logs(&session.id, 1, 128) + .map(|page| page.entries) + .unwrap_or_default(); + events.extend(tool_logs.into_iter().filter_map(|entry| { + parse_rfc3339_to_utc(&entry.timestamp).map(|occurred_at| TimelineEvent { + occurred_at, + event_type: TimelineEventType::ToolCall, + summary: format!( + "tool {} | {}ms | {}", + entry.tool_name, + entry.duration_ms, + truncate_for_dashboard(&entry.input_summary, 56) + ), + }) + })); + + events.sort_by_key(|event| event.occurred_at); + events + } + fn recompute_search_matches(&mut self) { let Some(query) = self.search_query.clone() else { self.search_matches.clear(); @@ -4048,19 +4282,28 @@ impl OutputTimeFilter { Self::AllTime => true, Self::Last15Minutes => line .occurred_at() - .map(|timestamp| timestamp >= Utc::now() - Duration::minutes(15)) + .map(|timestamp| self.matches_timestamp(timestamp)) .unwrap_or(false), Self::LastHour => line .occurred_at() - .map(|timestamp| timestamp >= Utc::now() - Duration::hours(1)) + .map(|timestamp| self.matches_timestamp(timestamp)) .unwrap_or(false), Self::Last24Hours => line .occurred_at() - .map(|timestamp| timestamp >= Utc::now() - Duration::hours(24)) + .map(|timestamp| self.matches_timestamp(timestamp)) .unwrap_or(false), } } + fn matches_timestamp(self, timestamp: chrono::DateTime) -> bool { + match self { + Self::AllTime => true, + Self::Last15Minutes => timestamp >= Utc::now() - Duration::minutes(15), + Self::LastHour => timestamp >= Utc::now() - Duration::hours(1), + Self::Last24Hours => timestamp >= Utc::now() - Duration::hours(24), + } + } + fn label(self) -> &'static str { match self { Self::AllTime => "all time", @@ -4080,6 +4323,65 @@ impl OutputTimeFilter { } } +impl TimelineEventFilter { + fn next(self) -> Self { + match self { + Self::All => Self::Lifecycle, + Self::Lifecycle => Self::Messages, + Self::Messages => Self::ToolCalls, + Self::ToolCalls => Self::FileChanges, + Self::FileChanges => Self::All, + } + } + + fn matches(self, event_type: TimelineEventType) -> bool { + match self { + Self::All => true, + Self::Lifecycle => event_type == TimelineEventType::Lifecycle, + Self::Messages => event_type == TimelineEventType::Message, + Self::ToolCalls => event_type == TimelineEventType::ToolCall, + Self::FileChanges => event_type == TimelineEventType::FileChange, + } + } + + fn label(self) -> &'static str { + match self { + Self::All => "all events", + Self::Lifecycle => "lifecycle", + Self::Messages => "messages", + Self::ToolCalls => "tool calls", + Self::FileChanges => "file changes", + } + } + + fn title_suffix(self) -> &'static str { + match self { + Self::All => "", + Self::Lifecycle => " lifecycle", + Self::Messages => " messages", + Self::ToolCalls => " tool calls", + Self::FileChanges => " file changes", + } + } +} + +impl TimelineEventType { + fn label(self) -> &'static str { + match self { + Self::Lifecycle => "lifecycle", + Self::Message => "message", + Self::ToolCall => "tool", + Self::FileChange => "file-change", + } + } +} + +fn parse_rfc3339_to_utc(value: &str) -> Option> { + chrono::DateTime::parse_from_rfc3339(value) + .ok() + .map(|timestamp| timestamp.with_timezone(&Utc)) +} + impl SearchScope { fn next(self) -> Self { match self { @@ -5042,6 +5344,165 @@ mod tests { assert!(rendered.contains("+new line")); } + #[test] + fn toggle_timeline_mode_renders_selected_session_events() { + let now = Utc::now(); + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.created_at = now - chrono::Duration::hours(2); + session.updated_at = now - chrono::Duration::minutes(5); + session.metrics.files_changed = 3; + + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session).unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "focus-12345678", + "{\"question\":\"Need review\"}", + "query", + ) + .unwrap(); + dashboard + .db + .insert_tool_log( + "focus-12345678", + "bash", + "cargo test -q", + "ok", + 240, + 0.2, + &(now - chrono::Duration::minutes(3)).to_rfc3339(), + ) + .unwrap(); + + dashboard.toggle_timeline_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::Timeline); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected session timeline") + ); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Timeline")); + assert!(rendered.contains("created session as planner")); + assert!(rendered.contains("received query lead-123")); + assert!(rendered.contains("tool bash")); + assert!(rendered.contains("files changed 3")); + } + + #[test] + fn cycle_timeline_event_filter_limits_rendered_events() { + let now = Utc::now(); + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.created_at = now - chrono::Duration::hours(2); + session.updated_at = now - chrono::Duration::minutes(5); + session.metrics.files_changed = 1; + + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session).unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "focus-12345678", + "{\"question\":\"Need review\"}", + "query", + ) + .unwrap(); + dashboard + .db + .insert_tool_log( + "focus-12345678", + "bash", + "cargo test -q", + "ok", + 240, + 0.2, + &(now - chrono::Duration::minutes(3)).to_rfc3339(), + ) + .unwrap(); + dashboard.toggle_timeline_mode(); + + dashboard.cycle_timeline_event_filter(); + dashboard.cycle_timeline_event_filter(); + + assert_eq!( + dashboard.timeline_event_filter, + TimelineEventFilter::Messages + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("timeline filter set to messages") + ); + assert_eq!(dashboard.output_title(), " Timeline messages "); + + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("received query lead-123")); + assert!(!rendered.contains("tool bash")); + assert!(!rendered.contains("files changed 1")); + } + + #[test] + fn timeline_time_filter_hides_old_events() { + let now = Utc::now(); + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.created_at = now - chrono::Duration::hours(3); + session.updated_at = now - chrono::Duration::hours(2); + + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session).unwrap(); + dashboard + .db + .insert_tool_log( + "focus-12345678", + "bash", + "cargo test -q", + "ok", + 240, + 0.2, + &(now - chrono::Duration::minutes(3)).to_rfc3339(), + ) + .unwrap(); + dashboard.toggle_timeline_mode(); + + dashboard.cycle_output_time_filter(); + dashboard.cycle_output_time_filter(); + + assert_eq!(dashboard.output_time_filter, OutputTimeFilter::LastHour); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("timeline range set to last 1h") + ); + assert_eq!(dashboard.output_title(), " Timeline last 1h "); + + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("tool bash")); + assert!(!rendered.contains("created session as planner")); + assert!(!rendered.contains("state running")); + } + #[test] fn worktree_diff_columns_split_removed_and_added_lines() { let patch = "\ @@ -7875,6 +8336,7 @@ diff --git a/src/next.rs b/src/next.rs output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, + timeline_event_filter: TimelineEventFilter::All, selected_pane: Pane::Sessions, selected_session, show_help: false, From 3c16c85a7581fe0a9f5d2665de89444f3123e080 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:48:58 -0700 Subject: [PATCH 080/459] feat: add ecc2 global timeline scope --- ecc2/src/tui/dashboard.rs | 227 +++++++++++++++++++++++++++++++++----- 1 file changed, 198 insertions(+), 29 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index e285e4e2..f339d57e 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -78,6 +78,7 @@ pub struct Dashboard { output_filter: OutputFilter, output_time_filter: OutputTimeFilter, timeline_event_filter: TimelineEventFilter, + timeline_scope: SearchScope, selected_pane: Pane, selected_session: usize, show_help: bool, @@ -184,6 +185,7 @@ enum TimelineEventType { #[derive(Debug, Clone)] struct TimelineEvent { occurred_at: chrono::DateTime, + session_id: String, event_type: TimelineEventType, summary: String, } @@ -303,6 +305,7 @@ impl Dashboard { output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, timeline_event_filter: TimelineEventFilter::All, + timeline_scope: SearchScope::SelectedSession, selected_pane: Pane::Sessions, selected_session: 0, show_help: false, @@ -612,7 +615,8 @@ impl Dashboard { fn output_title(&self) -> String { if self.output_mode == OutputMode::Timeline { return format!( - " Timeline{}{} ", + " Timeline{}{}{} ", + self.timeline_scope.title_suffix(), self.timeline_event_filter.title_suffix(), self.output_time_filter.title_suffix() ); @@ -664,31 +668,85 @@ impl Dashboard { } fn empty_timeline_message(&self) -> &'static str { - match (self.timeline_event_filter, self.output_time_filter) { - (TimelineEventFilter::All, OutputTimeFilter::AllTime) => { + match ( + self.timeline_scope, + self.timeline_event_filter, + self.output_time_filter, + ) { + (SearchScope::AllSessions, TimelineEventFilter::All, OutputTimeFilter::AllTime) => { + "No timeline events across all sessions yet." + } + ( + SearchScope::AllSessions, + TimelineEventFilter::Lifecycle, + OutputTimeFilter::AllTime, + ) => "No lifecycle events across all sessions yet.", + ( + SearchScope::AllSessions, + TimelineEventFilter::Messages, + OutputTimeFilter::AllTime, + ) => "No message events across all sessions yet.", + ( + SearchScope::AllSessions, + TimelineEventFilter::ToolCalls, + OutputTimeFilter::AllTime, + ) => "No tool-call events across all sessions yet.", + ( + SearchScope::AllSessions, + TimelineEventFilter::FileChanges, + OutputTimeFilter::AllTime, + ) => "No file-change events across all sessions yet.", + (SearchScope::AllSessions, TimelineEventFilter::All, _) => { + "No timeline events across all sessions in the selected time range." + } + (SearchScope::AllSessions, TimelineEventFilter::Lifecycle, _) => { + "No lifecycle events across all sessions in the selected time range." + } + (SearchScope::AllSessions, TimelineEventFilter::Messages, _) => { + "No message events across all sessions in the selected time range." + } + (SearchScope::AllSessions, TimelineEventFilter::ToolCalls, _) => { + "No tool-call events across all sessions in the selected time range." + } + (SearchScope::AllSessions, TimelineEventFilter::FileChanges, _) => { + "No file-change events across all sessions in the selected time range." + } + (SearchScope::SelectedSession, TimelineEventFilter::All, OutputTimeFilter::AllTime) => { "No timeline events for this session yet." } - (TimelineEventFilter::Lifecycle, OutputTimeFilter::AllTime) => { - "No lifecycle events for this session yet." + ( + SearchScope::SelectedSession, + TimelineEventFilter::Lifecycle, + OutputTimeFilter::AllTime, + ) => "No lifecycle events for this session yet.", + ( + SearchScope::SelectedSession, + TimelineEventFilter::Messages, + OutputTimeFilter::AllTime, + ) => "No message events for this session yet.", + ( + SearchScope::SelectedSession, + TimelineEventFilter::ToolCalls, + OutputTimeFilter::AllTime, + ) => "No tool-call events for this session yet.", + ( + SearchScope::SelectedSession, + TimelineEventFilter::FileChanges, + OutputTimeFilter::AllTime, + ) => "No file-change events for this session yet.", + (SearchScope::SelectedSession, TimelineEventFilter::All, _) => { + "No timeline events in the selected time range." } - (TimelineEventFilter::Messages, OutputTimeFilter::AllTime) => { - "No message events for this session yet." - } - (TimelineEventFilter::ToolCalls, OutputTimeFilter::AllTime) => { - "No tool-call events for this session yet." - } - (TimelineEventFilter::FileChanges, OutputTimeFilter::AllTime) => { - "No file-change events for this session yet." - } - (TimelineEventFilter::All, _) => "No timeline events in the selected time range.", - (TimelineEventFilter::Lifecycle, _) => { + (SearchScope::SelectedSession, TimelineEventFilter::Lifecycle, _) => { "No lifecycle events in the selected time range." } - (TimelineEventFilter::Messages, _) => "No message events in the selected time range.", - (TimelineEventFilter::ToolCalls, _) => { + (SearchScope::SelectedSession, TimelineEventFilter::Messages, _) => { + "No message events in the selected time range." + } + (SearchScope::SelectedSession, TimelineEventFilter::ToolCalls, _) => { "No tool-call events in the selected time range." } - (TimelineEventFilter::FileChanges, _) => { + (SearchScope::SelectedSession, TimelineEventFilter::FileChanges, _) => { "No file-change events in the selected time range." } } @@ -813,7 +871,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter search scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -905,7 +963,7 @@ impl Dashboard { " c Show conflict-resolution protocol for selected conflicted worktree", " e Cycle output content filter: all/errors/tool calls/file changes", " f Cycle output or timeline time range between all/15m/1h/24h", - " A Toggle search scope between selected session and all sessions", + " A Toggle search or timeline scope between selected session and all sessions", " o Toggle search agent filter between all agents and selected agent type", " m Merge selected ready worktree into base and clean it up", " M Merge all ready inactive worktrees and clean them up", @@ -2046,9 +2104,19 @@ impl Dashboard { } pub fn toggle_search_scope(&mut self) { + if self.output_mode == OutputMode::Timeline { + self.timeline_scope = self.timeline_scope.next(); + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note(format!( + "timeline scope set to {}", + self.timeline_scope.label() + )); + return; + } + if self.output_mode != OutputMode::SessionOutput { self.set_operator_note( - "search scope is only available in session output view".to_string(), + "scope toggle is only available in session output or timeline view".to_string(), ); return; } @@ -2976,14 +3044,21 @@ impl Dashboard { } fn visible_timeline_lines(&self) -> Vec> { - self.selected_timeline_events() + let show_session_label = self.timeline_scope == SearchScope::AllSessions; + self.timeline_events() .into_iter() .filter(|event| self.timeline_event_filter.matches(event.event_type)) .filter(|event| self.output_time_filter.matches_timestamp(event.occurred_at)) .map(|event| { + let prefix = if show_session_label { + format!("{} ", format_session_id(&event.session_id)) + } else { + String::new() + }; Line::from(format!( - "[{}] {:<11} {}", + "[{}] {}{:<11} {}", event.occurred_at.format("%H:%M:%S"), + prefix, event.event_type.label(), event.summary )) @@ -2991,13 +3066,32 @@ impl Dashboard { .collect() } - fn selected_timeline_events(&self) -> Vec { - let Some(session) = self.sessions.get(self.selected_session) else { - return Vec::new(); + fn timeline_events(&self) -> Vec { + let mut events = match self.timeline_scope { + SearchScope::SelectedSession => self + .sessions + .get(self.selected_session) + .map(|session| self.session_timeline_events(session)) + .unwrap_or_default(), + SearchScope::AllSessions => self + .sessions + .iter() + .flat_map(|session| self.session_timeline_events(session)) + .collect(), }; + events.sort_by(|left, right| { + left.occurred_at + .cmp(&right.occurred_at) + .then_with(|| left.session_id.cmp(&right.session_id)) + .then_with(|| left.summary.cmp(&right.summary)) + }); + events + } + fn session_timeline_events(&self, session: &Session) -> Vec { let mut events = vec![TimelineEvent { occurred_at: session.created_at, + session_id: session.id.clone(), event_type: TimelineEventType::Lifecycle, summary: format!( "created session as {} for {}", @@ -3009,6 +3103,7 @@ impl Dashboard { if session.updated_at > session.created_at { events.push(TimelineEvent { occurred_at: session.updated_at, + session_id: session.id.clone(), event_type: TimelineEventType::Lifecycle, summary: format!("state {} | updated session metadata", session.state), }); @@ -3017,6 +3112,7 @@ impl Dashboard { if let Some(worktree) = session.worktree.as_ref() { events.push(TimelineEvent { occurred_at: session.updated_at, + session_id: session.id.clone(), event_type: TimelineEventType::Lifecycle, summary: format!( "attached worktree {} from {}", @@ -3028,6 +3124,7 @@ impl Dashboard { if session.metrics.files_changed > 0 { events.push(TimelineEvent { occurred_at: session.updated_at, + session_id: session.id.clone(), event_type: TimelineEventType::FileChange, summary: format!("files changed {}", session.metrics.files_changed), }); @@ -3045,6 +3142,7 @@ impl Dashboard { }; TimelineEvent { occurred_at: message.timestamp, + session_id: session.id.clone(), event_type: TimelineEventType::Message, summary: format!( "{direction} {} {} | {}", @@ -3066,6 +3164,7 @@ impl Dashboard { events.extend(tool_logs.into_iter().filter_map(|entry| { parse_rfc3339_to_utc(&entry.timestamp).map(|occurred_at| TimelineEvent { occurred_at, + session_id: session.id.clone(), event_type: TimelineEventType::ToolCall, summary: format!( "tool {} | {}ms | {}", @@ -3075,8 +3174,6 @@ impl Dashboard { ), }) })); - - events.sort_by_key(|event| event.occurred_at); events } @@ -5503,6 +5600,77 @@ mod tests { assert!(!rendered.contains("state running")); } + #[test] + fn timeline_scope_all_sessions_renders_cross_session_events() { + let now = Utc::now(); + let mut focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + focus.created_at = now - chrono::Duration::hours(2); + focus.updated_at = now - chrono::Duration::minutes(5); + + let mut review = sample_session( + "review-87654321", + "reviewer", + SessionState::Idle, + Some("ecc/review"), + 256, + 12, + ); + review.created_at = now - chrono::Duration::hours(1); + review.updated_at = now - chrono::Duration::minutes(3); + review.metrics.files_changed = 2; + + let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0); + dashboard.db.insert_session(&focus).unwrap(); + dashboard.db.insert_session(&review).unwrap(); + dashboard + .db + .insert_tool_log( + "focus-12345678", + "bash", + "cargo test -q", + "ok", + 240, + 0.2, + &(now - chrono::Duration::minutes(4)).to_rfc3339(), + ) + .unwrap(); + dashboard + .db + .insert_tool_log( + "review-87654321", + "git", + "git status --short", + "ok", + 120, + 0.1, + &(now - chrono::Duration::minutes(2)).to_rfc3339(), + ) + .unwrap(); + dashboard.toggle_timeline_mode(); + + dashboard.toggle_search_scope(); + + assert_eq!(dashboard.timeline_scope, SearchScope::AllSessions); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("timeline scope set to all sessions") + ); + assert_eq!(dashboard.output_title(), " Timeline all sessions "); + + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("focus-12")); + assert!(rendered.contains("review-8")); + assert!(rendered.contains("tool bash")); + assert!(rendered.contains("tool git")); + } + #[test] fn worktree_diff_columns_split_removed_and_added_lines() { let patch = "\ @@ -8337,6 +8505,7 @@ diff --git a/src/next.rs b/src/next.rs output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, timeline_event_filter: TimelineEventFilter::All, + timeline_scope: SearchScope::SelectedSession, selected_pane: Pane::Sessions, selected_session, show_help: false, From f136a4e0d63b7ab5cc8f60135355b20751a2b711 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:53:55 -0700 Subject: [PATCH 081/459] feat: add ecc2 direct pane focus shortcuts --- ecc2/src/tui/app.rs | 8 ++ ecc2/src/tui/dashboard.rs | 168 +++++++++++++++++++++++++++++++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index d3daedfb..179a8d95 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -47,7 +47,15 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + (KeyModifiers::CONTROL, KeyCode::Char('h')) => dashboard.focus_pane_left(), + (KeyModifiers::CONTROL, KeyCode::Char('j')) => dashboard.focus_pane_down(), + (KeyModifiers::CONTROL, KeyCode::Char('k')) => dashboard.focus_pane_up(), + (KeyModifiers::CONTROL, KeyCode::Char('l')) => dashboard.focus_pane_right(), (_, KeyCode::Char('q')) => break, + (_, KeyCode::Char('1')) => dashboard.focus_pane_number(1), + (_, KeyCode::Char('2')) => dashboard.focus_pane_number(2), + (_, KeyCode::Char('3')) => dashboard.focus_pane_number(3), + (_, KeyCode::Char('4')) => dashboard.focus_pane_number(4), (_, KeyCode::Tab) => dashboard.next_pane(), (KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_pane(), (_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => { diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f339d57e..8c726340 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -168,6 +168,14 @@ enum SearchAgentFilter { SelectedAgentType, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaneDirection { + Left, + Right, + Up, + Down, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SearchMatch { session_id: String, @@ -871,7 +879,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] 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 delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [1-4] focus pane [Tab] cycle pane [Ctrl+h/j/k/l] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.layout_label(), self.theme_label() ); @@ -978,8 +986,10 @@ impl Dashboard { " x Cleanup selected worktree", " X Prune inactive worktrees globally", " d Delete selected inactive session", + " 1-4 Focus Sessions/Output/Metrics/Log directly", " Tab Next pane", " S-Tab Previous pane", + " C-hjkl Move pane focus left/down/up/right", " j/↓ Scroll down", " k/↑ Scroll up", " [ or ] Focus previous/next delegate in lead Metrics board", @@ -1025,6 +1035,39 @@ impl Dashboard { self.selected_pane = visible_panes[previous_index]; } + pub fn focus_pane_number(&mut self, slot: usize) { + let Some(target) = Pane::from_shortcut(slot) else { + self.set_operator_note(format!("pane {slot} is not available")); + return; + }; + + if !self.visible_panes().contains(&target) { + self.set_operator_note(format!( + "{} pane is not visible", + target.title().to_lowercase() + )); + return; + } + + self.focus_pane(target); + } + + pub fn focus_pane_left(&mut self) { + self.move_pane_focus(PaneDirection::Left); + } + + pub fn focus_pane_right(&mut self) { + self.move_pane_focus(PaneDirection::Right); + } + + pub fn focus_pane_up(&mut self) { + self.move_pane_focus(PaneDirection::Up); + } + + pub fn focus_pane_down(&mut self) { + self.move_pane_focus(PaneDirection::Down); + } + pub fn collapse_selected_pane(&mut self) { if self.selected_pane == Pane::Sessions { self.set_operator_note("cannot collapse sessions pane".to_string()); @@ -2635,6 +2678,50 @@ impl Dashboard { } } + fn focus_pane(&mut self, pane: Pane) { + self.selected_pane = pane; + self.ensure_selected_pane_visible(); + self.set_operator_note(format!("focused {} pane", pane.title().to_lowercase())); + } + + fn move_pane_focus(&mut self, direction: PaneDirection) { + let visible_panes = self.visible_panes(); + if visible_panes.len() <= 1 { + return; + } + + let pane_areas = self.pane_areas(Rect::new(0, 0, 100, 40)); + let Some(current_rect) = pane_rect(&pane_areas, self.selected_pane) else { + return; + }; + let current_center = pane_center(current_rect); + + let candidate = visible_panes + .into_iter() + .filter(|pane| *pane != self.selected_pane) + .filter_map(|pane| { + let rect = pane_rect(&pane_areas, pane)?; + let center = pane_center(rect); + let dx = center.0 - current_center.0; + let dy = center.1 - current_center.1; + + let (primary, secondary) = match direction { + PaneDirection::Left if dx < 0 => ((-dx) as u16, dy.unsigned_abs()), + PaneDirection::Right if dx > 0 => (dx as u16, dy.unsigned_abs()), + PaneDirection::Up if dy < 0 => ((-dy) as u16, dx.unsigned_abs()), + PaneDirection::Down if dy > 0 => (dy as u16, dx.unsigned_abs()), + _ => return None, + }; + + Some((pane, primary, secondary)) + }) + .min_by_key(|(pane, primary, secondary)| (*primary, *secondary, pane.sort_key())); + + if let Some((pane, _, _)) = candidate { + self.focus_pane(pane); + } + } + fn sync_global_handoff_backlog(&mut self) { let limit = self.sessions.len().max(1); match self.db.unread_task_handoff_targets(limit) { @@ -4154,6 +4241,41 @@ impl Pane { Pane::Log => "Log", } } + + fn from_shortcut(slot: usize) -> Option { + match slot { + 1 => Some(Self::Sessions), + 2 => Some(Self::Output), + 3 => Some(Self::Metrics), + 4 => Some(Self::Log), + _ => None, + } + } + + fn sort_key(self) -> u8 { + match self { + Self::Sessions => 1, + Self::Output => 2, + Self::Metrics => 3, + Self::Log => 4, + } + } +} + +fn pane_rect(pane_areas: &PaneAreas, pane: Pane) -> Option { + match pane { + Pane::Sessions => Some(pane_areas.sessions), + Pane::Output => pane_areas.output, + Pane::Metrics => pane_areas.metrics, + Pane::Log => pane_areas.log, + } +} + +fn pane_center(rect: Rect) -> (i16, i16) { + ( + rect.x as i16 + rect.width as i16 / 2, + rect.y as i16 + rect.height as i16 / 2, + ) } impl OutputFilter { @@ -8220,6 +8342,50 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.selected_pane, Pane::Log); } + #[test] + fn focus_pane_number_selects_visible_panes_and_rejects_hidden_targets() { + let mut dashboard = test_dashboard(Vec::new(), 0); + + dashboard.focus_pane_number(3); + + assert_eq!(dashboard.selected_pane, Pane::Metrics); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("focused metrics pane") + ); + + dashboard.focus_pane_number(4); + + assert_eq!(dashboard.selected_pane, Pane::Metrics); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("log pane is not visible") + ); + } + + #[test] + fn directional_pane_focus_uses_grid_neighbors() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Grid; + dashboard.pane_size_percent = DEFAULT_GRID_SIZE_PERCENT; + + dashboard.focus_pane_right(); + assert_eq!(dashboard.selected_pane, Pane::Output); + + dashboard.focus_pane_down(); + assert_eq!(dashboard.selected_pane, Pane::Log); + + dashboard.focus_pane_left(); + assert_eq!(dashboard.selected_pane, Pane::Metrics); + + dashboard.focus_pane_up(); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("focused sessions pane") + ); + } + #[test] fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { let mut dashboard = test_dashboard(Vec::new(), 0); From c6e26ddea46f065aab19bee6d4b3b70fad5bd7cd Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 05:58:54 -0700 Subject: [PATCH 082/459] feat: surface ecc2 tool and file metrics in sessions pane --- ecc2/src/tui/dashboard.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 8c726340..32e9543c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -470,6 +470,8 @@ impl Dashboard { "Approvals", "Backlog", "Tokens", + "Tools", + "Files", "Duration", ]) .style(Style::default().add_modifier(Modifier::BOLD)); @@ -481,6 +483,8 @@ impl Dashboard { Constraint::Length(10), Constraint::Length(7), Constraint::Length(8), + Constraint::Length(7), + Constraint::Length(7), Constraint::Length(8), ]; @@ -4742,6 +4746,8 @@ fn session_row( .add_modifier(Modifier::BOLD) }), Cell::from(session.metrics.tokens_used.to_string()), + Cell::from(session.metrics.tool_calls.to_string()), + Cell::from(session.metrics.files_changed.to_string()), Cell::from(format_duration(session.metrics.duration_secs)), ]) } @@ -5253,9 +5259,10 @@ mod tests { timestamp: Utc::now(), }]; - let rendered = render_dashboard_text(dashboard, 180, 24); + let rendered = render_dashboard_text(dashboard, 220, 24); assert!(rendered.contains("ID")); assert!(rendered.contains("Branch")); + assert!(rendered.contains("Tool Files")); assert!(rendered.contains("Total 2")); assert!(rendered.contains("Running 1")); assert!(rendered.contains("Completed 1")); From a54799127cfdd7cfec6eadb433ac92b8a53cac61 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 06:05:27 -0700 Subject: [PATCH 083/459] feat: make ecc2 pane navigation shortcuts configurable --- ecc2/src/config/mod.rs | 206 ++++++++++++++++++++++++++++++++++++ ecc2/src/session/manager.rs | 1 + ecc2/src/tui/app.rs | 9 +- ecc2/src/tui/dashboard.rs | 183 +++++++++++++++++++++++--------- 4 files changed, 339 insertions(+), 60 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 75275f81..aaa0500f 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -37,11 +38,34 @@ pub struct Config { pub token_budget: u64, pub theme: Theme, pub pane_layout: PaneLayout, + pub pane_navigation: PaneNavigationConfig, pub linear_pane_size_percent: u16, pub grid_pane_size_percent: u16, pub risk_thresholds: RiskThresholds, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct PaneNavigationConfig { + pub focus_sessions: String, + pub focus_output: String, + pub focus_metrics: String, + pub focus_log: String, + pub move_left: String, + pub move_down: String, + pub move_up: String, + pub move_right: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaneNavigationAction { + FocusSlot(usize), + MoveLeft, + MoveDown, + MoveUp, + MoveRight, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum Theme { Dark, @@ -67,6 +91,7 @@ impl Default for Config { token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + pane_navigation: PaneNavigationConfig::default(), linear_pane_size_percent: 35, grid_pane_size_percent: 50, risk_thresholds: Self::RISK_THRESHOLDS, @@ -115,6 +140,117 @@ impl Config { } } +impl Default for PaneNavigationConfig { + fn default() -> Self { + Self { + focus_sessions: "1".to_string(), + focus_output: "2".to_string(), + focus_metrics: "3".to_string(), + focus_log: "4".to_string(), + move_left: "ctrl-h".to_string(), + move_down: "ctrl-j".to_string(), + move_up: "ctrl-k".to_string(), + move_right: "ctrl-l".to_string(), + } + } +} + +impl PaneNavigationConfig { + pub fn action_for_key(&self, key: KeyEvent) -> Option { + [ + (&self.focus_sessions, PaneNavigationAction::FocusSlot(1)), + (&self.focus_output, PaneNavigationAction::FocusSlot(2)), + (&self.focus_metrics, PaneNavigationAction::FocusSlot(3)), + (&self.focus_log, PaneNavigationAction::FocusSlot(4)), + (&self.move_left, PaneNavigationAction::MoveLeft), + (&self.move_down, PaneNavigationAction::MoveDown), + (&self.move_up, PaneNavigationAction::MoveUp), + (&self.move_right, PaneNavigationAction::MoveRight), + ] + .into_iter() + .find_map(|(binding, action)| shortcut_matches(binding, key).then_some(action)) + } + + pub fn focus_shortcuts_label(&self) -> String { + [ + self.focus_sessions.as_str(), + self.focus_output.as_str(), + self.focus_metrics.as_str(), + self.focus_log.as_str(), + ] + .into_iter() + .map(shortcut_label) + .collect::>() + .join("/") + } + + pub fn movement_shortcuts_label(&self) -> String { + [ + self.move_left.as_str(), + self.move_down.as_str(), + self.move_up.as_str(), + self.move_right.as_str(), + ] + .into_iter() + .map(shortcut_label) + .collect::>() + .join("/") + } +} + +fn shortcut_matches(spec: &str, key: KeyEvent) -> bool { + parse_shortcut(spec).is_some_and(|(modifiers, code)| key.modifiers == modifiers && key.code == code) +} + +fn parse_shortcut(spec: &str) -> Option<(KeyModifiers, KeyCode)> { + let normalized = spec.trim().to_ascii_lowercase().replace('+', "-"); + if normalized.is_empty() { + return None; + } + + if normalized == "tab" { + return Some((KeyModifiers::NONE, KeyCode::Tab)); + } + + if normalized == "shift-tab" || normalized == "s-tab" { + return Some((KeyModifiers::SHIFT, KeyCode::BackTab)); + } + + if let Some(rest) = normalized + .strip_prefix("ctrl-") + .or_else(|| normalized.strip_prefix("c-")) + { + return parse_single_char(rest).map(|ch| (KeyModifiers::CONTROL, KeyCode::Char(ch))); + } + + parse_single_char(&normalized).map(|ch| (KeyModifiers::NONE, KeyCode::Char(ch))) +} + +fn parse_single_char(value: &str) -> Option { + let mut chars = value.chars(); + let ch = chars.next()?; + (chars.next().is_none()).then_some(ch) +} + +fn shortcut_label(spec: &str) -> String { + let normalized = spec.trim().to_ascii_lowercase().replace('+', "-"); + if normalized == "tab" { + return "Tab".to_string(); + } + if normalized == "shift-tab" || normalized == "s-tab" { + return "S-Tab".to_string(); + } + if let Some(rest) = normalized + .strip_prefix("ctrl-") + .or_else(|| normalized.strip_prefix("c-")) + { + if let Some(ch) = parse_single_char(rest) { + return format!("Ctrl+{ch}"); + } + } + normalized +} + impl Default for RiskThresholds { fn default() -> Self { Config::RISK_THRESHOLDS @@ -124,6 +260,7 @@ impl Default for RiskThresholds { #[cfg(test)] mod tests { use super::{Config, PaneLayout}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use uuid::Uuid; #[test] @@ -153,6 +290,7 @@ theme = "Dark" assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); assert_eq!(config.pane_layout, defaults.pane_layout); + assert_eq!(config.pane_navigation, defaults.pane_navigation); assert_eq!( config.linear_pane_size_percent, defaults.linear_pane_size_percent @@ -197,6 +335,70 @@ theme = "Dark" assert_eq!(config.pane_layout, PaneLayout::Grid); } + #[test] + fn pane_navigation_deserializes_from_toml() { + let config: Config = toml::from_str( + r#" +[pane_navigation] +focus_sessions = "q" +focus_output = "w" +focus_metrics = "e" +focus_log = "r" +move_left = "a" +move_down = "s" +move_up = "w" +move_right = "d" +"#, + ) + .unwrap(); + + assert_eq!(config.pane_navigation.focus_sessions, "q"); + assert_eq!(config.pane_navigation.focus_output, "w"); + assert_eq!(config.pane_navigation.focus_metrics, "e"); + assert_eq!(config.pane_navigation.focus_log, "r"); + assert_eq!(config.pane_navigation.move_left, "a"); + assert_eq!(config.pane_navigation.move_down, "s"); + assert_eq!(config.pane_navigation.move_up, "w"); + assert_eq!(config.pane_navigation.move_right, "d"); + } + + #[test] + fn pane_navigation_matches_default_shortcuts() { + let navigation = Config::default().pane_navigation; + + assert_eq!( + navigation.action_for_key(KeyEvent::new(KeyCode::Char('1'), KeyModifiers::NONE)), + Some(super::PaneNavigationAction::FocusSlot(1)) + ); + assert_eq!( + navigation.action_for_key(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL)), + Some(super::PaneNavigationAction::MoveRight) + ); + } + + #[test] + fn pane_navigation_matches_custom_shortcuts() { + let navigation = super::PaneNavigationConfig { + focus_sessions: "q".to_string(), + focus_output: "w".to_string(), + focus_metrics: "e".to_string(), + focus_log: "r".to_string(), + move_left: "a".to_string(), + move_down: "s".to_string(), + move_up: "w".to_string(), + move_right: "d".to_string(), + }; + + assert_eq!( + navigation.action_for_key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), + Some(super::PaneNavigationAction::FocusSlot(3)) + ); + assert_eq!( + navigation.action_for_key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)), + Some(super::PaneNavigationAction::MoveRight) + ); + } + #[test] fn default_risk_thresholds_are_applied() { assert_eq!(Config::default().risk_thresholds, Config::RISK_THRESHOLDS); @@ -210,6 +412,8 @@ theme = "Dark" config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; + config.pane_navigation.focus_metrics = "e".to_string(); + config.pane_navigation.move_right = "d".to_string(); config.linear_pane_size_percent = 42; config.grid_pane_size_percent = 55; @@ -221,6 +425,8 @@ theme = "Dark" assert_eq!(loaded.auto_dispatch_limit_per_session, 9); assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); + assert_eq!(loaded.pane_navigation.focus_metrics, "e"); + assert_eq!(loaded.pane_navigation.move_right, "d"); assert_eq!(loaded.linear_pane_size_percent, 42); assert_eq!(loaded.grid_pane_size_percent, 55); diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 61eb07f2..e83e838f 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1664,6 +1664,7 @@ mod tests { token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + pane_navigation: Default::default(), linear_pane_size_percent: 35, grid_pane_size_percent: 50, risk_thresholds: Config::RISK_THRESHOLDS, diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 179a8d95..0abcc8b3 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -47,15 +47,8 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, - (KeyModifiers::CONTROL, KeyCode::Char('h')) => dashboard.focus_pane_left(), - (KeyModifiers::CONTROL, KeyCode::Char('j')) => dashboard.focus_pane_down(), - (KeyModifiers::CONTROL, KeyCode::Char('k')) => dashboard.focus_pane_up(), - (KeyModifiers::CONTROL, KeyCode::Char('l')) => dashboard.focus_pane_right(), (_, KeyCode::Char('q')) => break, - (_, KeyCode::Char('1')) => dashboard.focus_pane_number(1), - (_, KeyCode::Char('2')) => dashboard.focus_pane_number(2), - (_, KeyCode::Char('3')) => dashboard.focus_pane_number(3), - (_, KeyCode::Char('4')) => dashboard.focus_pane_number(4), + _ if dashboard.handle_pane_navigation_key(key) => {} (_, KeyCode::Tab) => dashboard.next_pane(), (KeyModifiers::SHIFT, KeyCode::BackTab) => dashboard.prev_pane(), (_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => { diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 32e9543c..e3cd5508 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1,4 +1,5 @@ use chrono::{Duration, Utc}; +use crossterm::event::KeyEvent; use ratatui::{ prelude::*, widgets::{ @@ -11,7 +12,7 @@ use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; -use crate::config::{Config, PaneLayout, Theme}; +use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{ @@ -883,7 +884,9 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [1-4] focus pane [Tab] cycle pane [Ctrl+h/j/k/l] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + self.pane_focus_shortcuts_label(), + self.pane_move_shortcuts_label(), self.layout_label(), self.theme_label() ); @@ -956,56 +959,62 @@ impl Dashboard { fn render_help(&self, frame: &mut Frame, area: Rect) { let help = vec![ - "Keyboard Shortcuts:", - "", - " n New session", - " N Natural-language multi-agent spawn prompt", - " a Assign follow-up work from selected session", - " b Rebalance backed-up delegate handoff backlog for selected lead", - " B Rebalance backed-up delegate handoff backlog across lead teams", - " i Drain unread task handoffs from selected lead", - " I Jump to the next unread approval/conflict target session", - " g Auto-dispatch unread handoffs across lead sessions", - " G Dispatch then rebalance backlog across lead teams", - " h Collapse the focused non-session pane", - " H Restore all collapsed panes", - " y Toggle selected-session timeline view", - " E Cycle timeline event filter", - " v Toggle selected worktree diff in output pane", - " c Show conflict-resolution protocol for selected conflicted worktree", - " e Cycle output content filter: all/errors/tool calls/file changes", - " f Cycle output or timeline time range between all/15m/1h/24h", - " A Toggle search or timeline scope between selected session and all sessions", - " o Toggle search agent filter between all agents and selected agent type", - " m Merge selected ready worktree into base and clean it up", - " M Merge all ready inactive worktrees and clean them up", - " l Cycle pane layout and persist it", - " T Toggle theme and persist it", - " t Toggle default worktree creation for new sessions and delegated work", - " 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", - " x Cleanup selected worktree", - " X Prune inactive worktrees globally", - " d Delete selected inactive session", - " 1-4 Focus Sessions/Output/Metrics/Log directly", - " Tab Next pane", - " S-Tab Previous pane", - " C-hjkl Move pane focus left/down/up/right", - " j/↓ Scroll down", - " k/↑ Scroll up", - " [ or ] Focus previous/next delegate in lead Metrics board", - " Enter Open focused delegate from lead Metrics board", - " / Search current session output", - " n/N Next/previous search match when search is active", - " Esc Clear active search or cancel search input", - " +/= Increase pane size and persist it", - " - Decrease pane size and persist it", - " r Refresh", - " ? Toggle help", - " q/C-c Quit", + "Keyboard Shortcuts:".to_string(), + "".to_string(), + " n New session".to_string(), + " N Natural-language multi-agent spawn prompt".to_string(), + " a Assign follow-up work from selected session".to_string(), + " b Rebalance backed-up delegate handoff backlog for selected lead".to_string(), + " B Rebalance backed-up delegate handoff backlog across lead teams".to_string(), + " i Drain unread task handoffs from selected lead".to_string(), + " I Jump to the next unread approval/conflict target session".to_string(), + " g Auto-dispatch unread handoffs across lead sessions".to_string(), + " G Dispatch then rebalance backlog across lead teams".to_string(), + " h Collapse the focused non-session pane".to_string(), + " H Restore all collapsed panes".to_string(), + " y Toggle selected-session timeline view".to_string(), + " E Cycle timeline event filter".to_string(), + " v Toggle selected worktree diff in output pane".to_string(), + " c Show conflict-resolution protocol for selected conflicted worktree".to_string(), + " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), + " f Cycle output or timeline time range between all/15m/1h/24h".to_string(), + " A Toggle search or timeline scope between selected session and all sessions".to_string(), + " o Toggle search agent filter between all agents and selected agent type".to_string(), + " m Merge selected ready worktree into base and clean it up".to_string(), + " M Merge all ready inactive worktrees and clean them up".to_string(), + " l Cycle pane layout and persist it".to_string(), + " T Toggle theme and persist it".to_string(), + " t Toggle default worktree creation for new sessions and delegated work".to_string(), + " p Toggle daemon auto-dispatch policy and persist config".to_string(), + " w Toggle daemon auto-merge for ready inactive worktrees".to_string(), + " ,/. Decrease/increase auto-dispatch limit per lead".to_string(), + " s Stop selected session".to_string(), + " u Resume selected session".to_string(), + " x Cleanup selected worktree".to_string(), + " X Prune inactive worktrees globally".to_string(), + " d Delete selected inactive session".to_string(), + format!( + " {:<7} Focus Sessions/Output/Metrics/Log directly", + self.pane_focus_shortcuts_label() + ), + " Tab Next pane".to_string(), + " S-Tab Previous pane".to_string(), + format!( + " {:<7} Move pane focus left/down/up/right", + self.pane_move_shortcuts_label() + ), + " j/↓ Scroll down".to_string(), + " k/↑ Scroll up".to_string(), + " [ or ] Focus previous/next delegate in lead Metrics board".to_string(), + " Enter Open focused delegate from lead Metrics board".to_string(), + " / Search current session output".to_string(), + " n/N Next/previous search match when search is active".to_string(), + " Esc Clear active search or cancel search input".to_string(), + " +/= Increase pane size and persist it".to_string(), + " - Decrease pane size and persist it".to_string(), + " r Refresh".to_string(), + " ? Toggle help".to_string(), + " q/C-c Quit".to_string(), ]; let paragraph = Paragraph::new(help.join("\n")).block( @@ -1072,6 +1081,32 @@ impl Dashboard { self.move_pane_focus(PaneDirection::Down); } + pub fn handle_pane_navigation_key(&mut self, key: KeyEvent) -> bool { + match self.cfg.pane_navigation.action_for_key(key) { + Some(PaneNavigationAction::FocusSlot(slot)) => { + self.focus_pane_number(slot); + true + } + Some(PaneNavigationAction::MoveLeft) => { + self.focus_pane_left(); + true + } + Some(PaneNavigationAction::MoveDown) => { + self.focus_pane_down(); + true + } + Some(PaneNavigationAction::MoveUp) => { + self.focus_pane_up(); + true + } + Some(PaneNavigationAction::MoveRight) => { + self.focus_pane_right(); + true + } + None => false, + } + } + pub fn collapse_selected_pane(&mut self) { if self.selected_pane == Pane::Sessions { self.set_operator_note("cannot collapse sessions pane".to_string()); @@ -2726,6 +2761,14 @@ impl Dashboard { } } + fn pane_focus_shortcuts_label(&self) -> String { + self.cfg.pane_navigation.focus_shortcuts_label() + } + + fn pane_move_shortcuts_label(&self) -> String { + self.cfg.pane_navigation.movement_shortcuts_label() + } + fn sync_global_handoff_backlog(&mut self) { let limit = self.sessions.len().max(1); match self.db.unread_task_handoff_targets(limit) { @@ -8393,6 +8436,41 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn configured_pane_navigation_keys_override_defaults() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_navigation.focus_metrics = "e".to_string(); + dashboard.cfg.pane_navigation.move_left = "a".to_string(); + + assert!(dashboard.handle_pane_navigation_key(KeyEvent::new( + crossterm::event::KeyCode::Char('e'), + crossterm::event::KeyModifiers::NONE, + ))); + assert_eq!(dashboard.selected_pane, Pane::Metrics); + + assert!(dashboard.handle_pane_navigation_key(KeyEvent::new( + crossterm::event::KeyCode::Char('a'), + crossterm::event::KeyModifiers::NONE, + ))); + assert_eq!(dashboard.selected_pane, Pane::Sessions); + } + + #[test] + fn pane_navigation_labels_use_configured_bindings() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_navigation.focus_sessions = "q".to_string(); + dashboard.cfg.pane_navigation.focus_output = "w".to_string(); + dashboard.cfg.pane_navigation.focus_metrics = "e".to_string(); + dashboard.cfg.pane_navigation.focus_log = "r".to_string(); + dashboard.cfg.pane_navigation.move_left = "a".to_string(); + dashboard.cfg.pane_navigation.move_down = "s".to_string(); + dashboard.cfg.pane_navigation.move_up = "w".to_string(); + dashboard.cfg.pane_navigation.move_right = "d".to_string(); + + assert_eq!(dashboard.pane_focus_shortcuts_label(), "q/w/e/r"); + assert_eq!(dashboard.pane_move_shortcuts_label(), "a/s/w/d"); + } + #[test] fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { let mut dashboard = test_dashboard(Vec::new(), 0); @@ -8717,6 +8795,7 @@ diff --git a/src/next.rs b/src/next.rs token_budget: 500_000, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, + pane_navigation: Default::default(), linear_pane_size_percent: 35, grid_pane_size_percent: 50, risk_thresholds: Config::RISK_THRESHOLDS, From cf9c68846cbaa9cc66177f5c3b9dd23c15f69bd6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 06:08:59 -0700 Subject: [PATCH 084/459] feat: add ecc2 ctrl-w pane commands --- ecc2/src/tui/app.rs | 7 ++ ecc2/src/tui/dashboard.rs | 140 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 0abcc8b3..91248342 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -45,8 +45,15 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { continue; } + if dashboard.is_pane_command_mode() { + if dashboard.handle_pane_command_key(key) { + continue; + } + } + match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + (KeyModifiers::CONTROL, KeyCode::Char('w')) => dashboard.begin_pane_command_mode(), (_, KeyCode::Char('q')) => break, _ if dashboard.handle_pane_navigation_key(key) => {} (_, KeyCode::Tab) => dashboard.next_pane(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index e3cd5508..40f583f2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -84,6 +84,7 @@ pub struct Dashboard { selected_session: usize, show_help: bool, operator_note: Option, + pane_command_mode: bool, output_follow: bool, output_scroll_offset: usize, last_output_height: usize, @@ -319,6 +320,7 @@ impl Dashboard { selected_session: 0, show_help: false, operator_note: None, + pane_command_mode: false, output_follow: true, output_scroll_offset: 0, last_output_height: 0, @@ -911,6 +913,9 @@ impl Dashboard { self.search_scope.label(), self.search_agent_filter_label() ) + } else if self.pane_command_mode { + " Ctrl+w | [h/j/k/l] move [1-4] focus [s/v/g] layout [+/-] resize [Esc] cancel |" + .to_string() } else { String::new() }; @@ -918,6 +923,7 @@ impl Dashboard { let text = if self.spawn_input.is_some() || self.search_input.is_some() || self.search_query.is_some() + || self.pane_command_mode { format!(" {search_prefix}") } else if let Some(note) = self.operator_note.as_ref() { @@ -997,6 +1003,8 @@ impl Dashboard { " {:<7} Focus Sessions/Output/Metrics/Log directly", self.pane_focus_shortcuts_label() ), + " Ctrl+w Pane command mode: h/j/k/l move, s/v/g layout, 1-4 focus, +/- resize" + .to_string(), " Tab Next pane".to_string(), " S-Tab Previous pane".to_string(), format!( @@ -1081,6 +1089,18 @@ impl Dashboard { self.move_pane_focus(PaneDirection::Down); } + pub fn begin_pane_command_mode(&mut self) { + self.pane_command_mode = true; + self.set_operator_note( + "pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize" + .to_string(), + ); + } + + pub fn is_pane_command_mode(&self) -> bool { + self.pane_command_mode + } + pub fn handle_pane_navigation_key(&mut self, key: KeyEvent) -> bool { match self.cfg.pane_navigation.action_for_key(key) { Some(PaneNavigationAction::FocusSlot(slot)) => { @@ -1107,6 +1127,37 @@ impl Dashboard { } } + pub fn handle_pane_command_key(&mut self, key: KeyEvent) -> bool { + if !self.pane_command_mode { + return false; + } + + self.pane_command_mode = false; + match key.code { + crossterm::event::KeyCode::Esc => { + self.set_operator_note("pane command cancelled".to_string()); + } + crossterm::event::KeyCode::Char('h') => self.focus_pane_left(), + crossterm::event::KeyCode::Char('j') => self.focus_pane_down(), + crossterm::event::KeyCode::Char('k') => self.focus_pane_up(), + crossterm::event::KeyCode::Char('l') => self.focus_pane_right(), + crossterm::event::KeyCode::Char('1') => self.focus_pane_number(1), + crossterm::event::KeyCode::Char('2') => self.focus_pane_number(2), + crossterm::event::KeyCode::Char('3') => self.focus_pane_number(3), + crossterm::event::KeyCode::Char('4') => self.focus_pane_number(4), + crossterm::event::KeyCode::Char('+') | crossterm::event::KeyCode::Char('=') => { + self.increase_pane_size() + } + crossterm::event::KeyCode::Char('-') => self.decrease_pane_size(), + crossterm::event::KeyCode::Char('s') => self.set_pane_layout(PaneLayout::Horizontal), + crossterm::event::KeyCode::Char('v') => self.set_pane_layout(PaneLayout::Vertical), + crossterm::event::KeyCode::Char('g') => self.set_pane_layout(PaneLayout::Grid), + _ => self.set_operator_note("unknown pane command".to_string()), + } + true + } + + pub fn collapse_selected_pane(&mut self) { if self.selected_pane == Pane::Sessions { self.set_operator_note("cannot collapse sessions pane".to_string()); @@ -1144,6 +1195,11 @@ impl Dashboard { self.cycle_pane_layout_with_save(&config_path, |cfg| cfg.save()); } + pub fn set_pane_layout(&mut self, layout: PaneLayout) { + let config_path = crate::config::Config::config_path(); + self.set_pane_layout_with_save(layout, &config_path, |cfg| cfg.save()); + } + fn cycle_pane_layout_with_save(&mut self, config_path: &std::path::Path, save: F) where F: FnOnce(&Config) -> anyhow::Result<()>, @@ -1176,6 +1232,43 @@ impl Dashboard { } } + fn set_pane_layout_with_save( + &mut self, + layout: PaneLayout, + config_path: &std::path::Path, + save: F, + ) where + F: FnOnce(&Config) -> anyhow::Result<()>, + { + if self.cfg.pane_layout == layout { + self.set_operator_note(format!("pane layout already {}", self.layout_label())); + return; + } + + let previous_layout = self.cfg.pane_layout; + let previous_pane_size = self.pane_size_percent; + let previous_selected_pane = self.selected_pane; + + self.cfg.pane_layout = layout; + self.pane_size_percent = configured_pane_size(&self.cfg, self.cfg.pane_layout); + self.persist_current_pane_size(); + self.ensure_selected_pane_visible(); + + match save(&self.cfg) { + Ok(()) => self.set_operator_note(format!( + "pane layout set to {} | saved to {}", + self.layout_label(), + config_path.display() + )), + Err(error) => { + self.cfg.pane_layout = previous_layout; + self.pane_size_percent = previous_pane_size; + self.selected_pane = previous_selected_pane; + self.set_operator_note(format!("failed to persist pane layout: {error}")); + } + } + } + fn auto_split_layout_after_spawn(&mut self, spawned_count: usize) -> Option { let config_path = crate::config::Config::config_path(); self.auto_split_layout_after_spawn_with_save(spawned_count, &config_path, |cfg| cfg.save()) @@ -8471,6 +8564,52 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.pane_move_shortcuts_label(), "a/s/w/d"); } + #[test] + fn pane_command_mode_handles_focus_and_cancel() { + let mut dashboard = test_dashboard(Vec::new(), 0); + + dashboard.begin_pane_command_mode(); + assert!(dashboard.is_pane_command_mode()); + + assert!(dashboard.handle_pane_command_key(KeyEvent::new( + crossterm::event::KeyCode::Char('3'), + crossterm::event::KeyModifiers::NONE, + ))); + assert_eq!(dashboard.selected_pane, Pane::Metrics); + assert!(!dashboard.is_pane_command_mode()); + + dashboard.begin_pane_command_mode(); + assert!(dashboard.handle_pane_command_key(KeyEvent::new( + crossterm::event::KeyCode::Esc, + crossterm::event::KeyModifiers::NONE, + ))); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("pane command cancelled") + ); + assert!(!dashboard.is_pane_command_mode()); + } + + #[test] + fn pane_command_mode_sets_layout() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.pane_layout = PaneLayout::Horizontal; + + dashboard.begin_pane_command_mode(); + assert!(dashboard.handle_pane_command_key(KeyEvent::new( + crossterm::event::KeyCode::Char('g'), + crossterm::event::KeyModifiers::NONE, + ))); + + assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid); + assert!( + dashboard + .operator_note + .as_deref() + .is_some_and(|note| note.contains("pane layout set to grid | saved to ")) + ); + } + #[test] fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { let mut dashboard = test_dashboard(Vec::new(), 0); @@ -8761,6 +8900,7 @@ diff --git a/src/next.rs b/src/next.rs selected_session, show_help: false, operator_note: None, + pane_command_mode: false, output_follow: true, output_scroll_offset: 0, last_output_height: 0, From 08f61f667df38f17be641f50fa564dee1cadb9ce Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 06:22:20 -0700 Subject: [PATCH 085/459] feat: sync ecc2 cost tracker metrics --- ecc2/src/config/mod.rs | 8 + ecc2/src/main.rs | 12 ++ ecc2/src/session/manager.rs | 12 +- ecc2/src/session/mod.rs | 2 + ecc2/src/session/store.rs | 256 +++++++++++++++++++++++++++++-- ecc2/src/tui/dashboard.rs | 52 ++++++- scripts/hooks/cost-tracker.js | 2 +- tests/hooks/cost-tracker.test.js | 21 +++ 8 files changed, 352 insertions(+), 13 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index aaa0500f..86b542dc 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -113,6 +113,14 @@ impl Config { .join("ecc2.toml") } + pub fn cost_metrics_path(&self) -> PathBuf { + self.db_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("metrics") + .join("costs.jsonl") + } + pub fn load() -> Result { let config_path = Self::config_path(); diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 2c16abe9..c52b0096 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -673,17 +673,20 @@ async fn main() -> Result<()> { } } Some(Commands::Sessions) => { + sync_runtime_session_metrics(&db, &cfg)?; let sessions = session::manager::list_sessions(&db)?; for s in sessions { println!("{} [{}] {}", s.id, s.state, s.task); } } Some(Commands::Status { session_id }) => { + sync_runtime_session_metrics(&db, &cfg)?; let id = session_id.unwrap_or_else(|| "latest".to_string()); let status = session::manager::get_status(&db, &id)?; println!("{status}"); } Some(Commands::Team { session_id, depth }) => { + sync_runtime_session_metrics(&db, &cfg)?; let id = session_id.unwrap_or_else(|| "latest".to_string()); let team = session::manager::get_team_status(&db, &id, depth)?; println!("{team}"); @@ -890,6 +893,15 @@ fn resolve_session_id(db: &session::store::StateStore, value: &str) -> Result Result<()> { + db.refresh_session_durations()?; + db.sync_cost_tracker_metrics(&cfg.cost_metrics_path())?; + Ok(()) +} + fn build_message( kind: MessageKindArg, text: String, diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index e83e838f..0bdff6db 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1136,6 +1136,7 @@ fn build_agent_command( ) -> Command { let mut command = Command::new(agent_program); command + .env("ECC_SESSION_ID", session_id) .arg("--print") .arg("--name") .arg(format!("ecc-{session_id}")) @@ -1412,7 +1413,13 @@ impl fmt::Display for SessionStatus { writeln!(f, "Branch: {}", wt.branch)?; writeln!(f, "Worktree: {}", wt.path.display())?; } - writeln!(f, "Tokens: {}", s.metrics.tokens_used)?; + writeln!( + f, + "Tokens: {} total (in {} / out {})", + s.metrics.tokens_used, + s.metrics.input_tokens, + s.metrics.output_tokens + )?; writeln!(f, "Tools: {}", s.metrics.tool_calls)?; writeln!(f, "Files: {}", s.metrics.files_changed)?; writeln!(f, "Cost: ${:.4}", s.metrics.cost_usd)?; @@ -1741,7 +1748,7 @@ mod tests { let script_path = root.join("fake-claude.sh"); let log_path = root.join("fake-claude.log"); let script = format!( - "#!/usr/bin/env python3\nimport os\nimport pathlib\nimport signal\nimport sys\nimport time\n\nlog_path = pathlib.Path(r\"{}\")\nlog_path.write_text(os.getcwd() + \"\\n\", encoding=\"utf-8\")\nwith log_path.open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(\" \".join(sys.argv[1:]) + \"\\n\")\n\ndef handle_term(signum, frame):\n raise SystemExit(0)\n\nsignal.signal(signal.SIGTERM, handle_term)\nwhile True:\n time.sleep(0.1)\n", + "#!/usr/bin/env python3\nimport os\nimport pathlib\nimport signal\nimport sys\nimport time\n\nlog_path = pathlib.Path(r\"{}\")\nlog_path.write_text(os.getcwd() + \"\\n\", encoding=\"utf-8\")\nwith log_path.open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(\" \".join(sys.argv[1:]) + \"\\n\")\n handle.write(\"ECC_SESSION_ID=\" + os.environ.get(\"ECC_SESSION_ID\", \"\") + \"\\n\")\n\ndef handle_term(signum, frame):\n raise SystemExit(0)\n\nsignal.signal(signal.SIGTERM, handle_term)\nwhile True:\n time.sleep(0.1)\n", log_path.display() ); @@ -1803,6 +1810,7 @@ mod tests { assert!(log.contains(repo_root.to_string_lossy().as_ref())); assert!(log.contains("--print")); assert!(log.contains("implement lifecycle")); + assert!(log.contains(&format!("ECC_SESSION_ID={session_id}"))); stop_session_with_options(&db, &session_id, false).await?; Ok(()) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 8ee2668e..6d243858 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -95,6 +95,8 @@ pub struct WorktreeInfo { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SessionMetrics { + pub input_tokens: u64, + pub output_tokens: u64, pub tokens_used: u64, pub tool_calls: u64, pub files_changed: u32, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 22362723..d3f9da9c 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -2,6 +2,8 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; use serde::Serialize; use std::collections::HashMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -121,6 +123,8 @@ impl StateStore { worktree_path TEXT, worktree_branch TEXT, worktree_base TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, tokens_used INTEGER DEFAULT 0, tool_calls INTEGER DEFAULT 0, files_changed INTEGER DEFAULT 0, @@ -212,6 +216,24 @@ impl StateStore { .context("Failed to add pid column to sessions table")?; } + if !self.has_column("sessions", "input_tokens")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN input_tokens INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add input_tokens column to sessions table")?; + } + + if !self.has_column("sessions", "output_tokens")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN output_tokens INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add output_tokens column to sessions table")?; + } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn .execute( @@ -470,8 +492,19 @@ impl StateStore { pub fn update_metrics(&self, session_id: &str, metrics: &SessionMetrics) -> Result<()> { self.conn.execute( - "UPDATE sessions SET tokens_used = ?1, tool_calls = ?2, files_changed = ?3, duration_secs = ?4, cost_usd = ?5, updated_at = ?6 WHERE id = ?7", + "UPDATE sessions + SET input_tokens = ?1, + output_tokens = ?2, + tokens_used = ?3, + tool_calls = ?4, + files_changed = ?5, + duration_secs = ?6, + cost_usd = ?7, + updated_at = ?8 + WHERE id = ?9", rusqlite::params![ + metrics.input_tokens, + metrics.output_tokens, metrics.tokens_used, metrics.tool_calls, metrics.files_changed, @@ -484,6 +517,121 @@ impl StateStore { Ok(()) } + pub fn refresh_session_durations(&self) -> Result<()> { + let now = chrono::Utc::now(); + let mut stmt = self.conn.prepare( + "SELECT id, state, created_at, updated_at, duration_secs + FROM sessions", + )?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, u64>(4)?, + )) + })? + .collect::, _>>()?; + + for (session_id, state_raw, created_raw, updated_raw, current_duration) in rows { + let state = SessionState::from_db_value(&state_raw); + let created_at = chrono::DateTime::parse_from_rfc3339(&created_raw) + .unwrap_or_default() + .with_timezone(&chrono::Utc); + let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_raw) + .unwrap_or_default() + .with_timezone(&chrono::Utc); + let effective_end = match state { + SessionState::Pending | SessionState::Running | SessionState::Idle => now, + SessionState::Completed | SessionState::Failed | SessionState::Stopped => updated_at, + }; + let duration_secs = effective_end + .signed_duration_since(created_at) + .num_seconds() + .max(0) as u64; + + if duration_secs != current_duration { + self.conn.execute( + "UPDATE sessions SET duration_secs = ?1 WHERE id = ?2", + rusqlite::params![duration_secs, session_id], + )?; + } + } + + Ok(()) + } + + pub fn sync_cost_tracker_metrics(&self, metrics_path: &Path) -> Result<()> { + if !metrics_path.exists() { + return Ok(()); + } + + #[derive(Default)] + struct UsageAggregate { + input_tokens: u64, + output_tokens: u64, + cost_usd: f64, + } + + #[derive(serde::Deserialize)] + struct CostTrackerRow { + session_id: String, + #[serde(default)] + input_tokens: u64, + #[serde(default)] + output_tokens: u64, + #[serde(default)] + estimated_cost_usd: f64, + } + + let file = File::open(metrics_path) + .with_context(|| format!("Failed to open {}", metrics_path.display()))?; + let reader = BufReader::new(file); + let mut aggregates: HashMap = HashMap::new(); + + for line in reader.lines() { + let line = line?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let Ok(row) = serde_json::from_str::(trimmed) else { + continue; + }; + if row.session_id.trim().is_empty() { + continue; + } + + let aggregate = aggregates.entry(row.session_id).or_default(); + aggregate.input_tokens = aggregate.input_tokens.saturating_add(row.input_tokens); + aggregate.output_tokens = aggregate.output_tokens.saturating_add(row.output_tokens); + aggregate.cost_usd += row.estimated_cost_usd; + } + + for (session_id, aggregate) in aggregates { + self.conn.execute( + "UPDATE sessions + SET input_tokens = ?1, + output_tokens = ?2, + tokens_used = ?3, + cost_usd = ?4 + WHERE id = ?5", + rusqlite::params![ + aggregate.input_tokens, + aggregate.output_tokens, + aggregate.input_tokens.saturating_add(aggregate.output_tokens), + aggregate.cost_usd, + session_id, + ], + )?; + } + + Ok(()) + } + pub fn increment_tool_calls(&self, session_id: &str) -> Result<()> { self.conn.execute( "UPDATE sessions SET tool_calls = tool_calls + 1, updated_at = ?1 WHERE id = ?2", @@ -495,7 +643,7 @@ impl StateStore { pub fn list_sessions(&self) -> Result> { let mut stmt = self.conn.prepare( "SELECT id, task, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, - tokens_used, tool_calls, files_changed, duration_secs, cost_usd, + input_tokens, output_tokens, tokens_used, tool_calls, files_changed, duration_secs, cost_usd, created_at, updated_at FROM sessions ORDER BY updated_at DESC", )?; @@ -512,8 +660,8 @@ impl StateStore { base_branch: row.get::<_, String>(8).unwrap_or_default(), }); - let created_str: String = row.get(14)?; - let updated_str: String = row.get(15)?; + let created_str: String = row.get(16)?; + let updated_str: String = row.get(17)?; Ok(Session { id: row.get(0)?, @@ -530,11 +678,13 @@ impl StateStore { .unwrap_or_default() .with_timezone(&chrono::Utc), metrics: SessionMetrics { - tokens_used: row.get(9)?, - tool_calls: row.get(10)?, - files_changed: row.get(11)?, - duration_secs: row.get(12)?, - cost_usd: row.get(13)?, + input_tokens: row.get(9)?, + output_tokens: row.get(10)?, + tokens_used: row.get(11)?, + tool_calls: row.get(12)?, + files_changed: row.get(13)?, + duration_secs: row.get(14)?, + cost_usd: row.get(15)?, }, }) })? @@ -1216,6 +1366,94 @@ mod tests { assert!(column_names.iter().any(|column| column == "working_dir")); assert!(column_names.iter().any(|column| column == "pid")); + assert!(column_names.iter().any(|column| column == "input_tokens")); + assert!(column_names.iter().any(|column| column == "output_tokens")); + Ok(()) + } + + #[test] + fn sync_cost_tracker_metrics_aggregates_usage_into_sessions() -> Result<()> { + let tempdir = TestDir::new("store-cost-metrics")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "sync usage".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join("metrics"); + fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("costs.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"session_id\":\"session-1\",\"input_tokens\":100,\"output_tokens\":25,\"estimated_cost_usd\":0.11}\n", + "{\"session_id\":\"session-1\",\"input_tokens\":40,\"output_tokens\":10,\"estimated_cost_usd\":0.05}\n", + "{\"session_id\":\"other-session\",\"input_tokens\":999,\"output_tokens\":1,\"estimated_cost_usd\":9.99}\n" + ), + )?; + + db.sync_cost_tracker_metrics(&metrics_path)?; + + let session = db + .get_session("session-1")? + .expect("session should still exist"); + assert_eq!(session.metrics.input_tokens, 140); + assert_eq!(session.metrics.output_tokens, 35); + assert_eq!(session.metrics.tokens_used, 175); + assert!((session.metrics.cost_usd - 0.16).abs() < f64::EPSILON); + + Ok(()) + } + + #[test] + fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { + let tempdir = TestDir::new("store-duration-metrics")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "running-1".to_string(), + task: "live run".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: Some(1234), + worktree: None, + created_at: now - ChronoDuration::seconds(95), + updated_at: now - ChronoDuration::seconds(1), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "done-1".to_string(), + task: "finished run".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Completed, + pid: None, + worktree: None, + created_at: now - ChronoDuration::seconds(80), + updated_at: now - ChronoDuration::seconds(5), + metrics: SessionMetrics::default(), + })?; + + db.refresh_session_durations()?; + + let running = db.get_session("running-1")?.expect("running session should exist"); + let completed = db.get_session("done-1")?.expect("completed session should exist"); + + assert!(running.metrics.duration_secs >= 95); + assert!(completed.metrics.duration_secs >= 75); + Ok(()) } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 40f583f2..842d820b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -8,6 +8,7 @@ use ratatui::{ }; use regex::Regex; use std::collections::{HashMap, HashSet}; +use std::time::UNIX_EPOCH; use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; @@ -100,6 +101,7 @@ pub struct Dashboard { search_matches: Vec, selected_search_match: usize, session_table_state: TableState, + last_cost_metrics_signature: Option<(u64, u128)>, } #[derive(Debug, Default, PartialEq, Eq)] @@ -277,6 +279,11 @@ impl Dashboard { output_store: SessionOutputStore, ) -> Self { let pane_size_percent = configured_pane_size(&cfg, cfg.pane_layout); + let initial_cost_metrics_signature = cost_metrics_signature(&cfg.cost_metrics_path()); + let _ = db.refresh_session_durations(); + if initial_cost_metrics_signature.is_some() { + let _ = db.sync_cost_tracker_metrics(&cfg.cost_metrics_path()); + } let sessions = db.list_sessions().unwrap_or_default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -336,6 +343,7 @@ impl Dashboard { search_matches: Vec::new(), selected_search_match: 0, session_table_state, + last_cost_metrics_signature: initial_cost_metrics_signature, }; dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); dashboard.sync_handoff_backlog_counts(); @@ -2729,7 +2737,27 @@ impl Dashboard { self.sync_from_store(); } + fn sync_runtime_metrics(&mut self) { + if let Err(error) = self.db.refresh_session_durations() { + tracing::warn!("Failed to refresh session durations: {error}"); + } + + let metrics_path = self.cfg.cost_metrics_path(); + let signature = cost_metrics_signature(&metrics_path); + if signature == self.last_cost_metrics_signature { + return; + } + + self.last_cost_metrics_signature = signature; + if signature.is_some() { + if let Err(error) = self.db.sync_cost_tracker_metrics(&metrics_path) { + tracing::warn!("Failed to sync cost tracker metrics: {error}"); + } + } + } + fn sync_from_store(&mut self) { + self.sync_runtime_metrics(); let selected_id = self.selected_session_id().map(ToOwned::to_owned); self.sessions = match self.db.list_sessions() { Ok(sessions) => sessions, @@ -3977,8 +4005,13 @@ impl Dashboard { } lines.push(format!( - "Tokens {} | Tools {} | Files {}", + "Tokens {} total | In {} | Out {}", format_token_count(metrics.tokens_used), + format_token_count(metrics.input_tokens), + format_token_count(metrics.output_tokens), + )); + lines.push(format!( + "Tools {} | Files {}", metrics.tool_calls, metrics.files_changed, )); @@ -5348,6 +5381,17 @@ fn format_duration(duration_secs: u64) -> String { format!("{hours:02}:{minutes:02}:{seconds:02}") } +fn cost_metrics_signature(path: &std::path::Path) -> Option<(u64, u128)> { + let metadata = std::fs::metadata(path).ok()?; + let modified = metadata + .modified() + .ok()? + .duration_since(UNIX_EPOCH) + .ok()? + .as_nanos(); + Some((metadata.len(), modified)) +} + #[cfg(test)] mod tests { use anyhow::{Context, Result}; @@ -5668,6 +5712,7 @@ mod tests { assert!(text.contains("- Working ?? notes.txt")); assert!(text.contains("Merge blocked by 1 conflict(s): src/main.rs")); assert!(text.contains("- conflict src/main.rs")); + assert!(text.contains("Tokens 512 total | In 384 | Out 128")); assert!(text.contains("Last output last useful output")); assert!(text.contains("Needs attention:")); assert!(text.contains("Failed failed-8 | Render dashboard rows")); @@ -8915,6 +8960,7 @@ diff --git a/src/next.rs b/src/next.rs search_matches: Vec::new(), selected_search_match: 0, session_table_state, + last_cost_metrics_signature: None, } } @@ -8990,6 +9036,8 @@ diff --git a/src/next.rs b/src/next.rs created_at: Utc::now(), updated_at: Utc::now(), metrics: SessionMetrics { + input_tokens: tokens_used.saturating_mul(3) / 4, + output_tokens: tokens_used / 4, tokens_used, tool_calls: 4, files_changed: 2, @@ -9012,6 +9060,8 @@ diff --git a/src/next.rs b/src/next.rs created_at: now, updated_at: now, metrics: SessionMetrics { + input_tokens: tokens_used.saturating_mul(3) / 4, + output_tokens: tokens_used / 4, tokens_used, tool_calls: 0, files_changed: 0, diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index d3b90f9b..817ff77a 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -55,7 +55,7 @@ process.stdin.on('end', () => { const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0); const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown'); - const sessionId = String(process.env.CLAUDE_SESSION_ID || 'default'); + const sessionId = String(process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || 'default'); const metricsDir = path.join(getClaudeDir(), 'metrics'); ensureDir(metricsDir); diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index 3b474912..ee834465 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -131,6 +131,27 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + // 6. Prefers ECC_SESSION_ID for ECC2 session correlation + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID when both are present', () => { + const tmpHome = makeTempDir(); + const input = { + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 120, output_tokens: 30 }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + ECC_SESSION_ID: 'ecc-session-1234', + CLAUDE_SESSION_ID: 'claude-session-9999', + }); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.strictEqual(row.session_id, 'ecc-session-1234', 'Expected ECC_SESSION_ID to win'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From 95c33d3c04780df5082a88471d64b15a40fb0140 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 06:31:54 -0700 Subject: [PATCH 086/459] feat: add ecc2 budget alert thresholds --- ecc2/src/tui/dashboard.rs | 135 ++++++++++++++++++++++++++++++++------ ecc2/src/tui/widgets.rs | 69 +++++++++++++------ 2 files changed, 165 insertions(+), 39 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 842d820b..beff4af2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -102,6 +102,7 @@ pub struct Dashboard { selected_search_match: usize, session_table_state: TableState, last_cost_metrics_signature: Option<(u64, u128)>, + last_budget_alert_state: BudgetState, } #[derive(Debug, Default, PartialEq, Eq)] @@ -344,6 +345,7 @@ impl Dashboard { selected_search_match: 0, session_table_state, last_cost_metrics_signature: initial_cost_metrics_signature, + last_budget_alert_state: BudgetState::Normal, }; dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); dashboard.sync_handoff_backlog_counts(); @@ -353,6 +355,7 @@ impl Dashboard { dashboard.sync_selected_messages(); dashboard.sync_selected_lineage(); dashboard.refresh_logs(); + dashboard.last_budget_alert_state = dashboard.aggregate_usage().overall_state; dashboard } @@ -989,16 +992,20 @@ impl Dashboard { " y Toggle selected-session timeline view".to_string(), " E Cycle timeline event filter".to_string(), " v Toggle selected worktree diff in output pane".to_string(), - " c Show conflict-resolution protocol for selected conflicted worktree".to_string(), + " c Show conflict-resolution protocol for selected conflicted worktree" + .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), " f Cycle output or timeline time range between all/15m/1h/24h".to_string(), - " A Toggle search or timeline scope between selected session and all sessions".to_string(), - " o Toggle search agent filter between all agents and selected agent type".to_string(), + " A Toggle search or timeline scope between selected session and all sessions" + .to_string(), + " o Toggle search agent filter between all agents and selected agent type" + .to_string(), " m Merge selected ready worktree into base and clean it up".to_string(), " M Merge all ready inactive worktrees and clean them up".to_string(), " l Cycle pane layout and persist it".to_string(), " T Toggle theme and persist it".to_string(), - " t Toggle default worktree creation for new sessions and delegated work".to_string(), + " t Toggle default worktree creation for new sessions and delegated work" + .to_string(), " p Toggle daemon auto-dispatch policy and persist config".to_string(), " w Toggle daemon auto-merge for ready inactive worktrees".to_string(), " ,/. Decrease/increase auto-dispatch limit per lead".to_string(), @@ -1100,8 +1107,7 @@ impl Dashboard { pub fn begin_pane_command_mode(&mut self) { self.pane_command_mode = true; self.set_operator_note( - "pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize" - .to_string(), + "pane command mode | h/j/k/l move | s/v/g layout | 1-4 focus | +/- resize".to_string(), ); } @@ -1165,7 +1171,6 @@ impl Dashboard { true } - pub fn collapse_selected_pane(&mut self) { if self.selected_pane == Pane::Sessions { self.set_operator_note("cannot collapse sessions pane".to_string()); @@ -1648,6 +1653,7 @@ impl Dashboard { self.sync_selected_messages(); self.sync_selected_lineage(); self.refresh_logs(); + self.sync_budget_alerts(); } pub fn toggle_output_mode(&mut self) { @@ -4012,8 +4018,7 @@ impl Dashboard { )); lines.push(format!( "Tools {} | Files {}", - metrics.tool_calls, - metrics.files_changed, + metrics.tool_calls, metrics.files_changed, )); lines.push(format!( "Cost ${:.4} | Duration {}s", @@ -4080,15 +4085,56 @@ impl Dashboard { ) }; - match aggregate.overall_state { - BudgetState::Warning => text.push_str(" | Budget warning"), - BudgetState::OverBudget => text.push_str(" | Budget exceeded"), - _ => {} + if let Some(summary_suffix) = aggregate.overall_state.summary_suffix() { + text.push_str(" | "); + text.push_str(summary_suffix); } (text, aggregate.overall_state.style()) } + fn sync_budget_alerts(&mut self) { + let aggregate = self.aggregate_usage(); + let current_state = aggregate.overall_state; + if current_state == self.last_budget_alert_state { + return; + } + + let previous_state = self.last_budget_alert_state; + self.last_budget_alert_state = current_state; + + if current_state <= previous_state { + return; + } + + let Some(summary_suffix) = current_state.summary_suffix() else { + return; + }; + + let token_budget = if self.cfg.token_budget > 0 { + format!( + "{} / {}", + format_token_count(aggregate.total_tokens), + format_token_count(self.cfg.token_budget) + ) + } else { + format!("{} / no budget", format_token_count(aggregate.total_tokens)) + }; + let cost_budget = if self.cfg.cost_budget_usd > 0.0 { + format!( + "{} / {}", + format_currency(aggregate.total_cost_usd), + format_currency(self.cfg.cost_budget_usd) + ) + } else { + format!("{} / no budget", format_currency(aggregate.total_cost_usd)) + }; + + self.set_operator_note(format!( + "{summary_suffix} | tokens {token_budget} | cost {cost_budget}" + )); + } + fn attention_queue_items(&self, limit: usize) -> Vec { let mut items = Vec::new(); let suppress_inbox_attention = self @@ -7033,10 +7079,60 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!( dashboard.aggregate_cost_summary_text(), - "Aggregate cost $8.25 / $10.00 | Budget warning" + "Aggregate cost $8.25 / $10.00 | Budget alert 75%" ); } + #[test] + fn aggregate_cost_summary_mentions_fifty_percent_alert() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.cost_budget_usd = 10.0; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sessions = vec![budget_session("sess-1", 1_000, 5.0)]; + + assert_eq!( + dashboard.aggregate_cost_summary_text(), + "Aggregate cost $5.00 / $10.00 | Budget alert 50%" + ); + } + + #[test] + fn aggregate_cost_summary_mentions_ninety_percent_alert() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.cost_budget_usd = 10.0; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sessions = vec![budget_session("sess-1", 1_000, 9.0)]; + + assert_eq!( + dashboard.aggregate_cost_summary_text(), + "Aggregate cost $9.00 / $10.00 | Budget alert 90%" + ); + } + + #[test] + fn sync_budget_alerts_sets_operator_note_when_threshold_is_crossed() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.token_budget = 1_000; + cfg.cost_budget_usd = 10.0; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sessions = vec![budget_session("sess-1", 760, 2.0)]; + dashboard.last_budget_alert_state = BudgetState::Alert50; + + dashboard.sync_budget_alerts(); + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("Budget alert 75% | tokens 760 / 1,000 | cost $2.00 / $10.00") + ); + assert_eq!(dashboard.last_budget_alert_state, BudgetState::Alert75); + } + #[test] fn new_session_task_uses_selected_session_context() { let dashboard = test_dashboard( @@ -8647,12 +8743,10 @@ diff --git a/src/next.rs b/src/next.rs ))); assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Grid); - assert!( - dashboard - .operator_note - .as_deref() - .is_some_and(|note| note.contains("pane layout set to grid | saved to ")) - ); + assert!(dashboard + .operator_note + .as_deref() + .is_some_and(|note| note.contains("pane layout set to grid | saved to "))); } #[test] @@ -8961,6 +9055,7 @@ diff --git a/src/next.rs b/src/next.rs selected_search_match: 0, session_table_state, last_cost_metrics_signature: None, + last_budget_alert_state: BudgetState::Normal, } } diff --git a/ecc2/src/tui/widgets.rs b/ecc2/src/tui/widgets.rs index 784e4b50..370011b4 100644 --- a/ecc2/src/tui/widgets.rs +++ b/ecc2/src/tui/widgets.rs @@ -4,39 +4,53 @@ use ratatui::{ widgets::{Gauge, Paragraph, Widget}, }; -pub(crate) const WARNING_THRESHOLD: f64 = 0.8; +pub(crate) const ALERT_THRESHOLD_50: f64 = 0.50; +pub(crate) const ALERT_THRESHOLD_75: f64 = 0.75; +pub(crate) const ALERT_THRESHOLD_90: f64 = 0.90; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum BudgetState { Unconfigured, Normal, - Warning, + Alert50, + Alert75, + Alert90, OverBudget, } impl BudgetState { - pub(crate) const fn is_warning(self) -> bool { - matches!(self, Self::Warning | Self::OverBudget) - } - fn badge(self) -> Option<&'static str> { match self { - Self::Warning => Some("warning"), + Self::Alert50 => Some("50%"), + Self::Alert75 => Some("75%"), + Self::Alert90 => Some("90%"), Self::OverBudget => Some("over budget"), Self::Unconfigured => Some("no budget"), Self::Normal => None, } } + pub(crate) const fn summary_suffix(self) -> Option<&'static str> { + match self { + Self::Alert50 => Some("Budget alert 50%"), + Self::Alert75 => Some("Budget alert 75%"), + Self::Alert90 => Some("Budget alert 90%"), + Self::OverBudget => Some("Budget exceeded"), + Self::Unconfigured | Self::Normal => None, + } + } + pub(crate) fn style(self) -> Style { let base = Style::default().fg(match self { Self::Unconfigured => Color::DarkGray, Self::Normal => Color::DarkGray, - Self::Warning => Color::Yellow, + Self::Alert50 => Color::Cyan, + Self::Alert75 => Color::Yellow, + Self::Alert90 => Color::LightRed, Self::OverBudget => Color::Red, }); - if self.is_warning() { + if matches!(self, Self::Alert75 | Self::Alert90 | Self::OverBudget) { base.add_modifier(Modifier::BOLD) } else { base @@ -187,8 +201,12 @@ pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState { BudgetState::Unconfigured } else if used / budget >= 1.0 { BudgetState::OverBudget - } else if used / budget >= WARNING_THRESHOLD { - BudgetState::Warning + } else if used / budget >= ALERT_THRESHOLD_90 { + BudgetState::Alert90 + } else if used / budget >= ALERT_THRESHOLD_75 { + BudgetState::Alert75 + } else if used / budget >= ALERT_THRESHOLD_50 { + BudgetState::Alert50 } else { BudgetState::Normal } @@ -200,13 +218,13 @@ pub(crate) fn gradient_color(ratio: f64) -> Color { const RED: (u8, u8, u8) = (239, 68, 68); let clamped = ratio.clamp(0.0, 1.0); - if clamped <= WARNING_THRESHOLD { - interpolate_rgb(GREEN, YELLOW, clamped / WARNING_THRESHOLD) + if clamped <= ALERT_THRESHOLD_75 { + interpolate_rgb(GREEN, YELLOW, clamped / ALERT_THRESHOLD_75) } else { interpolate_rgb( YELLOW, RED, - (clamped - WARNING_THRESHOLD) / (1.0 - WARNING_THRESHOLD), + (clamped - ALERT_THRESHOLD_75) / (1.0 - ALERT_THRESHOLD_75), ) } } @@ -249,16 +267,29 @@ mod tests { use super::{gradient_color, BudgetState, TokenMeter}; #[test] - fn warning_state_starts_at_eighty_percent() { - let meter = TokenMeter::tokens("Token Budget", 80, 100); - - assert_eq!(meter.state(), BudgetState::Warning); + fn budget_state_uses_alert_threshold_ladder() { + assert_eq!( + TokenMeter::tokens("Token Budget", 50, 100).state(), + BudgetState::Alert50 + ); + assert_eq!( + TokenMeter::tokens("Token Budget", 75, 100).state(), + BudgetState::Alert75 + ); + assert_eq!( + TokenMeter::tokens("Token Budget", 90, 100).state(), + BudgetState::Alert90 + ); + assert_eq!( + TokenMeter::tokens("Token Budget", 100, 100).state(), + BudgetState::OverBudget + ); } #[test] fn gradient_runs_from_green_to_yellow_to_red() { assert_eq!(gradient_color(0.0), Color::Rgb(34, 197, 94)); - assert_eq!(gradient_color(0.8), Color::Rgb(234, 179, 8)); + assert_eq!(gradient_color(0.75), Color::Rgb(234, 179, 8)); assert_eq!(gradient_color(1.0), Color::Rgb(239, 68, 68)); } From 67d06687a057d521c89e342a3cb3ae6dbb4840af Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 06:36:22 -0700 Subject: [PATCH 087/459] feat: add ecc2 configurable budget thresholds --- ecc2/src/config/mod.rs | 114 +++++++++++++++++++++++++++- ecc2/src/session/manager.rs | 1 + ecc2/src/tui/dashboard.rs | 66 +++++++++++++++-- ecc2/src/tui/widgets.rs | 144 +++++++++++++++++++++++++++--------- 4 files changed, 282 insertions(+), 43 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 86b542dc..694e6a6d 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -20,6 +20,14 @@ pub struct RiskThresholds { pub block: f64, } +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BudgetAlertThresholds { + pub advisory: f64, + pub warning: f64, + pub critical: f64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { @@ -36,6 +44,7 @@ pub struct Config { pub auto_merge_ready_worktrees: bool, pub cost_budget_usd: f64, pub token_budget: u64, + pub budget_alert_thresholds: BudgetAlertThresholds, pub theme: Theme, pub pane_layout: PaneLayout, pub pane_navigation: PaneNavigationConfig, @@ -89,6 +98,7 @@ impl Default for Config { auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, + budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, pane_navigation: PaneNavigationConfig::default(), @@ -106,6 +116,12 @@ impl Config { block: 0.85, }; + pub const BUDGET_ALERT_THRESHOLDS: BudgetAlertThresholds = BudgetAlertThresholds { + advisory: 0.50, + warning: 0.75, + critical: 0.90, + }; + pub fn config_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -121,6 +137,10 @@ impl Config { .join("costs.jsonl") } + pub fn effective_budget_alert_thresholds(&self) -> BudgetAlertThresholds { + self.budget_alert_thresholds.sanitized() + } + pub fn load() -> Result { let config_path = Self::config_path(); @@ -265,9 +285,32 @@ impl Default for RiskThresholds { } } +impl Default for BudgetAlertThresholds { + fn default() -> Self { + Config::BUDGET_ALERT_THRESHOLDS + } +} + +impl BudgetAlertThresholds { + pub fn sanitized(self) -> Self { + let values = [self.advisory, self.warning, self.critical]; + let valid = values.into_iter().all(f64::is_finite) + && self.advisory > 0.0 + && self.advisory < self.warning + && self.warning < self.critical + && self.critical < 1.0; + + if valid { + self + } else { + Self::default() + } + } +} + #[cfg(test)] mod tests { - use super::{Config, PaneLayout}; + use super::{BudgetAlertThresholds, Config, PaneLayout}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use uuid::Uuid; @@ -297,6 +340,10 @@ theme = "Dark" assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); + assert_eq!( + config.budget_alert_thresholds, + defaults.budget_alert_thresholds + ); assert_eq!(config.pane_layout, defaults.pane_layout); assert_eq!(config.pane_navigation, defaults.pane_navigation); assert_eq!( @@ -412,6 +459,58 @@ move_right = "d" assert_eq!(Config::default().risk_thresholds, Config::RISK_THRESHOLDS); } + #[test] + fn default_budget_alert_thresholds_are_applied() { + assert_eq!( + Config::default().budget_alert_thresholds, + Config::BUDGET_ALERT_THRESHOLDS + ); + } + + #[test] + fn budget_alert_thresholds_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[budget_alert_thresholds] +advisory = 0.40 +warning = 0.70 +critical = 0.85 +"#, + ) + .unwrap(); + + assert_eq!( + config.budget_alert_thresholds, + BudgetAlertThresholds { + advisory: 0.40, + warning: 0.70, + critical: 0.85, + } + ); + assert_eq!( + config.effective_budget_alert_thresholds(), + config.budget_alert_thresholds + ); + } + + #[test] + fn invalid_budget_alert_thresholds_fall_back_to_defaults() { + let config: Config = toml::from_str( + r#" +[budget_alert_thresholds] +advisory = 0.80 +warning = 0.70 +critical = 1.10 +"#, + ) + .unwrap(); + + assert_eq!( + config.effective_budget_alert_thresholds(), + Config::BUDGET_ALERT_THRESHOLDS + ); + } + #[test] fn save_round_trips_automation_settings() { let path = std::env::temp_dir().join(format!("ecc2-config-{}.toml", Uuid::new_v4())); @@ -420,6 +519,11 @@ move_right = "d" config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; + config.budget_alert_thresholds = BudgetAlertThresholds { + advisory: 0.45, + warning: 0.70, + critical: 0.88, + }; config.pane_navigation.focus_metrics = "e".to_string(); config.pane_navigation.move_right = "d".to_string(); config.linear_pane_size_percent = 42; @@ -433,6 +537,14 @@ move_right = "d" assert_eq!(loaded.auto_dispatch_limit_per_session, 9); assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); + assert_eq!( + loaded.budget_alert_thresholds, + BudgetAlertThresholds { + advisory: 0.45, + warning: 0.70, + critical: 0.88, + } + ); assert_eq!(loaded.pane_navigation.focus_metrics, "e"); assert_eq!(loaded.pane_navigation.move_right, "d"); assert_eq!(loaded.linear_pane_size_percent, 42); diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 0bdff6db..1562d796 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1669,6 +1669,7 @@ mod tests { auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, + budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, pane_navigation: Default::default(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index beff4af2..5f92bf5b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -835,11 +835,13 @@ impl Dashboard { .split(inner); let aggregate = self.aggregate_usage(); + let thresholds = self.cfg.effective_budget_alert_thresholds(); frame.render_widget( TokenMeter::tokens( "Token Budget", aggregate.total_tokens, self.cfg.token_budget, + thresholds, ), chunks[0], ); @@ -848,6 +850,7 @@ impl Dashboard { "Cost Budget", aggregate.total_cost_usd, self.cfg.cost_budget_usd, + thresholds, ), chunks[1], ); @@ -3774,6 +3777,7 @@ impl Dashboard { } fn aggregate_usage(&self) -> AggregateUsage { + let thresholds = self.cfg.effective_budget_alert_thresholds(); let total_tokens = self .sessions .iter() @@ -3784,8 +3788,12 @@ impl Dashboard { .iter() .map(|session| session.metrics.cost_usd) .sum::(); - let token_state = budget_state(total_tokens as f64, self.cfg.token_budget as f64); - let cost_state = budget_state(total_cost_usd, self.cfg.cost_budget_usd); + let token_state = budget_state( + total_tokens as f64, + self.cfg.token_budget as f64, + thresholds, + ); + let cost_state = budget_state(total_cost_usd, self.cfg.cost_budget_usd, thresholds); AggregateUsage { total_tokens, @@ -4072,6 +4080,7 @@ impl Dashboard { fn aggregate_cost_summary(&self) -> (String, Style) { let aggregate = self.aggregate_usage(); + let thresholds = self.cfg.effective_budget_alert_thresholds(); let mut text = if self.cfg.cost_budget_usd > 0.0 { format!( "Aggregate cost {} / {}", @@ -4085,9 +4094,9 @@ impl Dashboard { ) }; - if let Some(summary_suffix) = aggregate.overall_state.summary_suffix() { + if let Some(summary_suffix) = aggregate.overall_state.summary_suffix(thresholds) { text.push_str(" | "); - text.push_str(summary_suffix); + text.push_str(&summary_suffix); } (text, aggregate.overall_state.style()) @@ -4095,6 +4104,7 @@ impl Dashboard { fn sync_budget_alerts(&mut self) { let aggregate = self.aggregate_usage(); + let thresholds = self.cfg.effective_budget_alert_thresholds(); let current_state = aggregate.overall_state; if current_state == self.last_budget_alert_state { return; @@ -4107,7 +4117,7 @@ impl Dashboard { return; } - let Some(summary_suffix) = current_state.summary_suffix() else { + let Some(summary_suffix) = current_state.summary_suffix(thresholds) else { return; }; @@ -7098,6 +7108,26 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn aggregate_cost_summary_uses_custom_threshold_labels() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.cost_budget_usd = 10.0; + cfg.budget_alert_thresholds = crate::config::BudgetAlertThresholds { + advisory: 0.40, + warning: 0.70, + critical: 0.85, + }; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sessions = vec![budget_session("sess-1", 1_000, 7.0)]; + + assert_eq!( + dashboard.aggregate_cost_summary_text(), + "Aggregate cost $7.00 / $10.00 | Budget alert 70%" + ); + } + #[test] fn aggregate_cost_summary_mentions_ninety_percent_alert() { let db = StateStore::open(Path::new(":memory:")).unwrap(); @@ -7133,6 +7163,31 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.last_budget_alert_state, BudgetState::Alert75); } + #[test] + fn sync_budget_alerts_uses_custom_threshold_labels() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.token_budget = 1_000; + cfg.cost_budget_usd = 10.0; + cfg.budget_alert_thresholds = crate::config::BudgetAlertThresholds { + advisory: 0.40, + warning: 0.70, + critical: 0.85, + }; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.sessions = vec![budget_session("sess-1", 710, 2.0)]; + dashboard.last_budget_alert_state = BudgetState::Alert50; + + dashboard.sync_budget_alerts(); + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("Budget alert 70% | tokens 710 / 1,000 | cost $2.00 / $10.00") + ); + assert_eq!(dashboard.last_budget_alert_state, BudgetState::Alert75); + } + #[test] fn new_session_task_uses_selected_session_context() { let dashboard = test_dashboard( @@ -9074,6 +9129,7 @@ diff --git a/src/next.rs b/src/next.rs auto_merge_ready_worktrees: false, cost_budget_usd: 10.0, token_budget: 500_000, + budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS, theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, pane_navigation: Default::default(), diff --git a/ecc2/src/tui/widgets.rs b/ecc2/src/tui/widgets.rs index 370011b4..1f30fcaa 100644 --- a/ecc2/src/tui/widgets.rs +++ b/ecc2/src/tui/widgets.rs @@ -1,13 +1,11 @@ +use crate::config::BudgetAlertThresholds; + use ratatui::{ prelude::*, text::{Line, Span}, widgets::{Gauge, Paragraph, Widget}, }; -pub(crate) const ALERT_THRESHOLD_50: f64 = 0.50; -pub(crate) const ALERT_THRESHOLD_75: f64 = 0.75; -pub(crate) const ALERT_THRESHOLD_90: f64 = 0.90; - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub(crate) enum BudgetState { Unconfigured, @@ -19,23 +17,32 @@ pub(crate) enum BudgetState { } impl BudgetState { - fn badge(self) -> Option<&'static str> { + fn badge(self, thresholds: BudgetAlertThresholds) -> Option { match self { - Self::Alert50 => Some("50%"), - Self::Alert75 => Some("75%"), - Self::Alert90 => Some("90%"), - Self::OverBudget => Some("over budget"), - Self::Unconfigured => Some("no budget"), + Self::Alert50 => Some(threshold_label(thresholds.advisory)), + Self::Alert75 => Some(threshold_label(thresholds.warning)), + Self::Alert90 => Some(threshold_label(thresholds.critical)), + Self::OverBudget => Some("over budget".to_string()), + Self::Unconfigured => Some("no budget".to_string()), Self::Normal => None, } } - pub(crate) const fn summary_suffix(self) -> Option<&'static str> { + pub(crate) fn summary_suffix(self, thresholds: BudgetAlertThresholds) -> Option { match self { - Self::Alert50 => Some("Budget alert 50%"), - Self::Alert75 => Some("Budget alert 75%"), - Self::Alert90 => Some("Budget alert 90%"), - Self::OverBudget => Some("Budget exceeded"), + Self::Alert50 => Some(format!( + "Budget alert {}", + threshold_label(thresholds.advisory) + )), + Self::Alert75 => Some(format!( + "Budget alert {}", + threshold_label(thresholds.warning) + )), + Self::Alert90 => Some(format!( + "Budget alert {}", + threshold_label(thresholds.critical) + )), + Self::OverBudget => Some("Budget exceeded".to_string()), Self::Unconfigured | Self::Normal => None, } } @@ -69,30 +76,43 @@ pub(crate) struct TokenMeter<'a> { title: &'a str, used: f64, budget: f64, + thresholds: BudgetAlertThresholds, format: MeterFormat, } impl<'a> TokenMeter<'a> { - pub(crate) fn tokens(title: &'a str, used: u64, budget: u64) -> Self { + pub(crate) fn tokens( + title: &'a str, + used: u64, + budget: u64, + thresholds: BudgetAlertThresholds, + ) -> Self { Self { title, used: used as f64, budget: budget as f64, + thresholds, format: MeterFormat::Tokens, } } - pub(crate) fn currency(title: &'a str, used: f64, budget: f64) -> Self { + pub(crate) fn currency( + title: &'a str, + used: f64, + budget: f64, + thresholds: BudgetAlertThresholds, + ) -> Self { Self { title, used, budget, + thresholds, format: MeterFormat::Currency, } } pub(crate) fn state(&self) -> BudgetState { - budget_state(self.used, self.budget) + budget_state(self.used, self.budget, self.thresholds) } fn ratio(&self) -> f64 { @@ -111,7 +131,7 @@ impl<'a> TokenMeter<'a> { .add_modifier(Modifier::BOLD), )]; - if let Some(badge) = self.state().badge() { + if let Some(badge) = self.state().badge(self.thresholds) { spans.push(Span::raw(" ")); spans.push(Span::styled(format!("[{badge}]"), self.state().style())); } @@ -179,7 +199,7 @@ impl Widget for TokenMeter<'_> { .label(self.display_label()) .gauge_style( Style::default() - .fg(gradient_color(self.ratio())) + .fg(gradient_color(self.ratio(), self.thresholds)) .add_modifier(Modifier::BOLD), ) .style(Style::default().fg(Color::DarkGray)) @@ -196,39 +216,51 @@ pub(crate) fn budget_ratio(used: f64, budget: f64) -> f64 { } } -pub(crate) fn budget_state(used: f64, budget: f64) -> BudgetState { +pub(crate) fn budget_state( + used: f64, + budget: f64, + thresholds: BudgetAlertThresholds, +) -> BudgetState { if budget <= 0.0 { BudgetState::Unconfigured } else if used / budget >= 1.0 { BudgetState::OverBudget - } else if used / budget >= ALERT_THRESHOLD_90 { + } else if used / budget >= thresholds.critical { BudgetState::Alert90 - } else if used / budget >= ALERT_THRESHOLD_75 { + } else if used / budget >= thresholds.warning { BudgetState::Alert75 - } else if used / budget >= ALERT_THRESHOLD_50 { + } else if used / budget >= thresholds.advisory { BudgetState::Alert50 } else { BudgetState::Normal } } -pub(crate) fn gradient_color(ratio: f64) -> Color { +pub(crate) fn gradient_color(ratio: f64, thresholds: BudgetAlertThresholds) -> Color { const GREEN: (u8, u8, u8) = (34, 197, 94); const YELLOW: (u8, u8, u8) = (234, 179, 8); const RED: (u8, u8, u8) = (239, 68, 68); let clamped = ratio.clamp(0.0, 1.0); - if clamped <= ALERT_THRESHOLD_75 { - interpolate_rgb(GREEN, YELLOW, clamped / ALERT_THRESHOLD_75) + if clamped <= thresholds.warning { + interpolate_rgb( + GREEN, + YELLOW, + clamped / thresholds.warning.max(f64::EPSILON), + ) } else { interpolate_rgb( YELLOW, RED, - (clamped - ALERT_THRESHOLD_75) / (1.0 - ALERT_THRESHOLD_75), + (clamped - thresholds.warning) / (1.0 - thresholds.warning), ) } } +fn threshold_label(value: f64) -> String { + format!("{}%", (value * 100.0).round() as u64) +} + pub(crate) fn format_currency(value: f64) -> String { format!("${value:.2}") } @@ -264,38 +296,76 @@ fn interpolate_rgb(from: (u8, u8, u8), to: (u8, u8, u8), ratio: f64) -> Color { mod tests { use ratatui::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget}; - use super::{gradient_color, BudgetState, TokenMeter}; + use crate::config::{BudgetAlertThresholds, Config}; + + use super::{gradient_color, threshold_label, BudgetState, TokenMeter}; #[test] fn budget_state_uses_alert_threshold_ladder() { assert_eq!( - TokenMeter::tokens("Token Budget", 50, 100).state(), + TokenMeter::tokens("Token Budget", 50, 100, Config::BUDGET_ALERT_THRESHOLDS).state(), BudgetState::Alert50 ); assert_eq!( - TokenMeter::tokens("Token Budget", 75, 100).state(), + TokenMeter::tokens("Token Budget", 75, 100, Config::BUDGET_ALERT_THRESHOLDS).state(), BudgetState::Alert75 ); assert_eq!( - TokenMeter::tokens("Token Budget", 90, 100).state(), + TokenMeter::tokens("Token Budget", 90, 100, Config::BUDGET_ALERT_THRESHOLDS).state(), BudgetState::Alert90 ); assert_eq!( - TokenMeter::tokens("Token Budget", 100, 100).state(), + TokenMeter::tokens("Token Budget", 100, 100, Config::BUDGET_ALERT_THRESHOLDS).state(), BudgetState::OverBudget ); } #[test] fn gradient_runs_from_green_to_yellow_to_red() { - assert_eq!(gradient_color(0.0), Color::Rgb(34, 197, 94)); - assert_eq!(gradient_color(0.75), Color::Rgb(234, 179, 8)); - assert_eq!(gradient_color(1.0), Color::Rgb(239, 68, 68)); + assert_eq!( + gradient_color(0.0, Config::BUDGET_ALERT_THRESHOLDS), + Color::Rgb(34, 197, 94) + ); + assert_eq!( + gradient_color(0.75, Config::BUDGET_ALERT_THRESHOLDS), + Color::Rgb(234, 179, 8) + ); + assert_eq!( + gradient_color(1.0, Config::BUDGET_ALERT_THRESHOLDS), + Color::Rgb(239, 68, 68) + ); + } + + #[test] + fn token_meter_uses_custom_budget_thresholds() { + let meter = TokenMeter::tokens( + "Token Budget", + 45, + 100, + BudgetAlertThresholds { + advisory: 0.40, + warning: 0.70, + critical: 0.85, + }, + ); + + assert_eq!(meter.state(), BudgetState::Alert50); + } + + #[test] + fn threshold_label_rounds_to_percent() { + assert_eq!(threshold_label(0.4), "40%"); + assert_eq!(threshold_label(0.875), "88%"); } #[test] fn token_meter_renders_compact_usage_label() { - let meter = TokenMeter::tokens("Token Budget", 4_000, 10_000); + let meter = TokenMeter::tokens( + "Token Budget", + 4_000, + 10_000, + Config::BUDGET_ALERT_THRESHOLDS, + ); let area = Rect::new(0, 0, 48, 2); let mut buffer = Buffer::empty(area); From 6f08e78456227c7f223e5ed9b5defa495bf48c9a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 06:47:28 -0700 Subject: [PATCH 088/459] feat: auto-pause ecc2 sessions when budgets are exceeded --- ecc2/src/config/mod.rs | 3 +- ecc2/src/main.rs | 1 + ecc2/src/session/manager.rs | 186 ++++++++++++++++++++++++++++++++++-- ecc2/src/session/store.rs | 16 +++- ecc2/src/tui/app.rs | 4 +- ecc2/src/tui/dashboard.rs | 163 +++++++++++++++++++++---------- 6 files changed, 310 insertions(+), 63 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 694e6a6d..58449d0a 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -227,7 +227,8 @@ impl PaneNavigationConfig { } fn shortcut_matches(spec: &str, key: KeyEvent) -> bool { - parse_shortcut(spec).is_some_and(|(modifiers, code)| key.modifiers == modifiers && key.code == code) + parse_shortcut(spec) + .is_some_and(|(modifiers, code)| key.modifiers == modifiers && key.code == code) } fn parse_shortcut(spec: &str) -> Option<(KeyModifiers, KeyCode)> { diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index c52b0096..c2398a56 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -899,6 +899,7 @@ fn sync_runtime_session_metrics( ) -> Result<()> { db.refresh_session_durations()?; db.sync_cost_tracker_metrics(&cfg.cost_metrics_path())?; + let _ = session::manager::enforce_budget_hard_limits(db, cfg)?; Ok(()) } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 1562d796..d64f5398 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -353,6 +353,56 @@ pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> { stop_session_with_options(db, id, true).await } +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct BudgetEnforcementOutcome { + pub token_budget_exceeded: bool, + pub cost_budget_exceeded: bool, + pub paused_sessions: Vec, +} + +impl BudgetEnforcementOutcome { + pub fn hard_limit_exceeded(&self) -> bool { + self.token_budget_exceeded || self.cost_budget_exceeded + } +} + +pub fn enforce_budget_hard_limits( + db: &StateStore, + cfg: &Config, +) -> Result { + let sessions = db.list_sessions()?; + let total_tokens = sessions + .iter() + .map(|session| session.metrics.tokens_used) + .sum::(); + let total_cost = sessions + .iter() + .map(|session| session.metrics.cost_usd) + .sum::(); + + let mut outcome = BudgetEnforcementOutcome { + token_budget_exceeded: cfg.token_budget > 0 && total_tokens >= cfg.token_budget, + cost_budget_exceeded: cfg.cost_budget_usd > 0.0 && total_cost >= cfg.cost_budget_usd, + paused_sessions: Vec::new(), + }; + + if !outcome.hard_limit_exceeded() { + return Ok(outcome); + } + + for session in sessions.into_iter().filter(|session| { + matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) + }) { + stop_session_recorded(db, &session, false)?; + outcome.paused_sessions.push(session.id); + } + + Ok(outcome) +} + pub fn record_tool_call( db: &StateStore, session_id: &str, @@ -1175,9 +1225,12 @@ async fn stop_session_with_options( cleanup_worktree: bool, ) -> Result<()> { let session = resolve_session(db, id)?; + stop_session_recorded(db, &session, cleanup_worktree) +} +fn stop_session_recorded(db: &StateStore, session: &Session, cleanup_worktree: bool) -> Result<()> { if let Some(pid) = session.pid { - kill_process(pid).await?; + kill_process(pid)?; } db.update_pid(&session.id, None)?; @@ -1193,13 +1246,27 @@ async fn stop_session_with_options( } #[cfg(unix)] -async fn kill_process(pid: u32) -> Result<()> { +fn kill_process(pid: u32) -> Result<()> { send_signal(pid, libc::SIGTERM)?; - tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + std::thread::sleep(std::time::Duration::from_millis(1200)); send_signal(pid, libc::SIGKILL)?; Ok(()) } +#[cfg(windows)] +fn kill_process(pid: u32) -> Result<()> { + let status = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .status() + .with_context(|| format!("Failed to invoke taskkill for process {pid}"))?; + + if status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!("taskkill exited with status {status}")) + } +} + #[cfg(unix)] fn send_signal(pid: u32, signal: i32) -> Result<()> { let outcome = unsafe { libc::kill(pid as i32, signal) }; @@ -1416,9 +1483,7 @@ impl fmt::Display for SessionStatus { writeln!( f, "Tokens: {} total (in {} / out {})", - s.metrics.tokens_used, - s.metrics.input_tokens, - s.metrics.output_tokens + s.metrics.tokens_used, s.metrics.input_tokens, s.metrics.output_tokens )?; writeln!(f, "Tools: {}", s.metrics.tool_calls)?; writeln!(f, "Files: {}", s.metrics.files_changed)?; @@ -1885,6 +1950,115 @@ mod tests { Ok(()) } + #[test] + fn enforce_budget_hard_limits_stops_active_sessions_without_cleaning_worktrees() -> Result<()> { + let tempdir = TestDir::new("manager-budget-pause")?; + let mut cfg = build_config(tempdir.path()); + cfg.token_budget = 100; + cfg.cost_budget_usd = 0.0; + + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + let worktree_path = tempdir.path().join("keep-worktree"); + fs::create_dir_all(&worktree_path)?; + + db.insert_session(&Session { + id: "active-over-budget".to_string(), + task: "pause on hard limit".to_string(), + agent_type: "claude".to_string(), + working_dir: tempdir.path().to_path_buf(), + state: SessionState::Running, + pid: Some(999_999), + worktree: Some(crate::session::WorktreeInfo { + path: worktree_path.clone(), + branch: "ecc/active-over-budget".to_string(), + base_branch: "main".to_string(), + }), + created_at: now - Duration::minutes(1), + updated_at: now, + metrics: SessionMetrics::default(), + })?; + db.update_metrics( + "active-over-budget", + &SessionMetrics { + input_tokens: 90, + output_tokens: 30, + tokens_used: 120, + tool_calls: 0, + files_changed: 0, + duration_secs: 60, + cost_usd: 0.0, + }, + )?; + + let outcome = enforce_budget_hard_limits(&db, &cfg)?; + assert!(outcome.token_budget_exceeded); + assert!(!outcome.cost_budget_exceeded); + assert_eq!( + outcome.paused_sessions, + vec!["active-over-budget".to_string()] + ); + + let session = db + .get_session("active-over-budget")? + .context("session should still exist")?; + assert_eq!(session.state, SessionState::Stopped); + assert_eq!(session.pid, None); + assert!( + worktree_path.exists(), + "hard-limit pauses should preserve worktrees for resume" + ); + + Ok(()) + } + + #[test] + fn enforce_budget_hard_limits_ignores_inactive_sessions() -> Result<()> { + let tempdir = TestDir::new("manager-budget-ignore-inactive")?; + let mut cfg = build_config(tempdir.path()); + cfg.token_budget = 100; + cfg.cost_budget_usd = 0.0; + + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "completed-over-budget".to_string(), + task: "already done".to_string(), + agent_type: "claude".to_string(), + working_dir: tempdir.path().to_path_buf(), + state: SessionState::Completed, + pid: None, + worktree: None, + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + })?; + db.update_metrics( + "completed-over-budget", + &SessionMetrics { + input_tokens: 90, + output_tokens: 30, + tokens_used: 120, + tool_calls: 0, + files_changed: 0, + duration_secs: 60, + cost_usd: 0.0, + }, + )?; + + let outcome = enforce_budget_hard_limits(&db, &cfg)?; + assert!(outcome.token_budget_exceeded); + assert!(outcome.paused_sessions.is_empty()); + + let session = db + .get_session("completed-over-budget")? + .context("completed session should still exist")?; + assert_eq!(session.state, SessionState::Completed); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn resume_session_requeues_failed_session() -> Result<()> { let tempdir = TestDir::new("manager-resume-session")?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index d3f9da9c..f25c8f4b 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -545,7 +545,9 @@ impl StateStore { .with_timezone(&chrono::Utc); let effective_end = match state { SessionState::Pending | SessionState::Running | SessionState::Idle => now, - SessionState::Completed | SessionState::Failed | SessionState::Stopped => updated_at, + SessionState::Completed | SessionState::Failed | SessionState::Stopped => { + updated_at + } }; let duration_secs = effective_end .signed_duration_since(created_at) @@ -622,7 +624,9 @@ impl StateStore { rusqlite::params![ aggregate.input_tokens, aggregate.output_tokens, - aggregate.input_tokens.saturating_add(aggregate.output_tokens), + aggregate + .input_tokens + .saturating_add(aggregate.output_tokens), aggregate.cost_usd, session_id, ], @@ -1448,8 +1452,12 @@ mod tests { db.refresh_session_durations()?; - let running = db.get_session("running-1")?.expect("running session should exist"); - let completed = db.get_session("done-1")?.expect("completed session should exist"); + let running = db + .get_session("running-1")? + .expect("running session should exist"); + let completed = db + .get_session("done-1")? + .expect("completed session should exist"); assert!(running.metrics.duration_secs >= 95); assert!(completed.metrics.duration_secs >= 75); diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 91248342..b1d936ee 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -53,7 +53,9 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, - (KeyModifiers::CONTROL, KeyCode::Char('w')) => dashboard.begin_pane_command_mode(), + (KeyModifiers::CONTROL, KeyCode::Char('w')) => { + dashboard.begin_pane_command_mode() + } (_, KeyCode::Char('q')) => break, _ if dashboard.handle_pane_navigation_key(key) => {} (_, KeyCode::Tab) => dashboard.next_pane(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 5f92bf5b..c3186fa6 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -2746,27 +2746,33 @@ impl Dashboard { self.sync_from_store(); } - fn sync_runtime_metrics(&mut self) { + fn sync_runtime_metrics(&mut self) -> Option { if let Err(error) = self.db.refresh_session_durations() { tracing::warn!("Failed to refresh session durations: {error}"); } let metrics_path = self.cfg.cost_metrics_path(); let signature = cost_metrics_signature(&metrics_path); - if signature == self.last_cost_metrics_signature { - return; + if signature != self.last_cost_metrics_signature { + self.last_cost_metrics_signature = signature; + if signature.is_some() { + if let Err(error) = self.db.sync_cost_tracker_metrics(&metrics_path) { + tracing::warn!("Failed to sync cost tracker metrics: {error}"); + } + } } - self.last_cost_metrics_signature = signature; - if signature.is_some() { - if let Err(error) = self.db.sync_cost_tracker_metrics(&metrics_path) { - tracing::warn!("Failed to sync cost tracker metrics: {error}"); + match manager::enforce_budget_hard_limits(&self.db, &self.cfg) { + Ok(outcome) => Some(outcome), + Err(error) => { + tracing::warn!("Failed to enforce budget hard limits: {error}"); + None } } } fn sync_from_store(&mut self) { - self.sync_runtime_metrics(); + let budget_enforcement = self.sync_runtime_metrics(); let selected_id = self.selected_session_id().map(ToOwned::to_owned); self.sessions = match self.db.list_sessions() { Ok(sessions) => sessions, @@ -2794,6 +2800,56 @@ impl Dashboard { self.sync_selected_messages(); self.sync_selected_lineage(); self.refresh_logs(); + self.sync_budget_alerts(); + + if let Some(outcome) = + budget_enforcement.filter(|outcome| !outcome.paused_sessions.is_empty()) + { + self.set_operator_note(budget_auto_pause_note(&outcome)); + } + } + + fn sync_budget_alerts(&mut self) { + let aggregate = self.aggregate_usage(); + let thresholds = self.cfg.effective_budget_alert_thresholds(); + let current_state = aggregate.overall_state; + if current_state == self.last_budget_alert_state { + return; + } + + let previous_state = self.last_budget_alert_state; + self.last_budget_alert_state = current_state; + + if current_state <= previous_state { + return; + } + + let Some(summary_suffix) = current_state.summary_suffix(thresholds) else { + return; + }; + + let token_budget = if self.cfg.token_budget > 0 { + format!( + "{} / {}", + format_token_count(aggregate.total_tokens), + format_token_count(self.cfg.token_budget) + ) + } else { + format!("{} / no budget", format_token_count(aggregate.total_tokens)) + }; + let cost_budget = if self.cfg.cost_budget_usd > 0.0 { + format!( + "{} / {}", + format_currency(aggregate.total_cost_usd), + format_currency(self.cfg.cost_budget_usd) + ) + } else { + format!("{} / no budget", format_currency(aggregate.total_cost_usd)) + }; + + self.set_operator_note(format!( + "{summary_suffix} | tokens {token_budget} | cost {cost_budget}" + )); } fn sync_selection(&mut self) { @@ -4102,49 +4158,6 @@ impl Dashboard { (text, aggregate.overall_state.style()) } - fn sync_budget_alerts(&mut self) { - let aggregate = self.aggregate_usage(); - let thresholds = self.cfg.effective_budget_alert_thresholds(); - let current_state = aggregate.overall_state; - if current_state == self.last_budget_alert_state { - return; - } - - let previous_state = self.last_budget_alert_state; - self.last_budget_alert_state = current_state; - - if current_state <= previous_state { - return; - } - - let Some(summary_suffix) = current_state.summary_suffix(thresholds) else { - return; - }; - - let token_budget = if self.cfg.token_budget > 0 { - format!( - "{} / {}", - format_token_count(aggregate.total_tokens), - format_token_count(self.cfg.token_budget) - ) - } else { - format!("{} / no budget", format_token_count(aggregate.total_tokens)) - }; - let cost_budget = if self.cfg.cost_budget_usd > 0.0 { - format!( - "{} / {}", - format_currency(aggregate.total_cost_usd), - format_currency(self.cfg.cost_budget_usd) - ) - } else { - format!("{} / no budget", format_currency(aggregate.total_cost_usd)) - }; - - self.set_operator_note(format!( - "{summary_suffix} | tokens {token_budget} | cost {cost_budget}" - )); - } - fn attention_queue_items(&self, limit: usize) -> Vec { let mut items = Vec::new(); let suppress_inbox_attention = self @@ -5307,6 +5320,20 @@ fn session_state_color(state: &SessionState) -> Color { } } +fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String { + let cause = match (outcome.token_budget_exceeded, outcome.cost_budget_exceeded) { + (true, true) => "token and cost budgets exceeded", + (true, false) => "token budget exceeded", + (false, true) => "cost budget exceeded", + (false, false) => "budget exceeded", + }; + + format!( + "{cause} | auto-paused {} active session(s)", + outcome.paused_sessions.len() + ) +} + fn format_session_id(id: &str) -> String { id.chars().take(8).collect() } @@ -7188,6 +7215,40 @@ diff --git a/src/next.rs b/src/next.rs assert_eq!(dashboard.last_budget_alert_state, BudgetState::Alert75); } + #[test] + fn refresh_auto_pauses_over_budget_sessions_and_sets_operator_note() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.token_budget = 100; + cfg.cost_budget_usd = 0.0; + + db.insert_session(&budget_session("sess-1", 120, 0.0)) + .expect("insert session"); + db.update_metrics( + "sess-1", + &SessionMetrics { + input_tokens: 90, + output_tokens: 30, + tokens_used: 120, + tool_calls: 0, + files_changed: 0, + duration_secs: 0, + cost_usd: 0.0, + }, + ) + .expect("persist metrics"); + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.refresh(); + + assert_eq!(dashboard.sessions.len(), 1); + assert_eq!(dashboard.sessions[0].state, SessionState::Stopped); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("token budget exceeded | auto-paused 1 active session(s)") + ); + } + #[test] fn new_session_task_uses_selected_session_context() { let dashboard = test_dashboard( From 48fd68115ea9e13ad7d88e3f0460212eea13cf31 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:02:24 -0700 Subject: [PATCH 089/459] feat(ecc2): sync hook activity into session metrics --- ecc2/src/config/mod.rs | 8 + ecc2/src/main.rs | 1 + ecc2/src/session/store.rs | 219 ++++++++++++++++++- ecc2/src/tui/dashboard.rs | 72 +++++- hooks/hooks.json | 12 + scripts/hooks/session-activity-tracker.js | 212 ++++++++++++++++++ tests/hooks/session-activity-tracker.test.js | 149 +++++++++++++ 7 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 scripts/hooks/session-activity-tracker.js create mode 100644 tests/hooks/session-activity-tracker.test.js diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 58449d0a..2f184caa 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -137,6 +137,14 @@ impl Config { .join("costs.jsonl") } + pub fn tool_activity_metrics_path(&self) -> PathBuf { + self.db_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("metrics") + .join("tool-usage.jsonl") + } + pub fn effective_budget_alert_thresholds(&self) -> BudgetAlertThresholds { self.budget_alert_thresholds.sanitized() } diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index c2398a56..21ca4b6c 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -899,6 +899,7 @@ fn sync_runtime_session_metrics( ) -> Result<()> { db.refresh_session_durations()?; db.sync_cost_tracker_metrics(&cfg.cost_metrics_path())?; + db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path())?; let _ = session::manager::enforce_budget_hard_limits(db, cfg)?; Ok(()) } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index f25c8f4b..33ab6407 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1,13 +1,14 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; use serde::Serialize; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::time::Duration; -use crate::observability::{ToolLogEntry, ToolLogPage}; +use crate::config::Config; +use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{Session, SessionMessage, SessionMetrics, SessionState}; @@ -136,13 +137,15 @@ impl StateStore { CREATE TABLE IF NOT EXISTS tool_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_event_id TEXT UNIQUE, session_id TEXT NOT NULL REFERENCES sessions(id), tool_name TEXT NOT NULL, input_summary TEXT, output_summary TEXT, duration_ms INTEGER, risk_score REAL DEFAULT 0.0, - timestamp TEXT NOT NULL + timestamp TEXT NOT NULL, + file_paths_json TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS messages ( @@ -189,6 +192,9 @@ impl StateStore { CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state); CREATE INDEX IF NOT EXISTS idx_tool_log_session ON tool_log(session_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_log_hook_event + ON tool_log(hook_event_id) + WHERE hook_event_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session, read); CREATE INDEX IF NOT EXISTS idx_session_output_session ON session_output(session_id, id); @@ -234,6 +240,21 @@ impl StateStore { .context("Failed to add output_tokens column to sessions table")?; } + if !self.has_column("tool_log", "hook_event_id")? { + self.conn + .execute("ALTER TABLE tool_log ADD COLUMN hook_event_id TEXT", []) + .context("Failed to add hook_event_id column to tool_log table")?; + } + + if !self.has_column("tool_log", "file_paths_json")? { + self.conn + .execute( + "ALTER TABLE tool_log ADD COLUMN file_paths_json TEXT NOT NULL DEFAULT '[]'", + [], + ) + .context("Failed to add file_paths_json column to tool_log table")?; + } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn .execute( @@ -362,6 +383,12 @@ impl StateStore { .context("Failed to add last_auto_prune_active_skipped column to daemon_activity table")?; } + self.conn.execute_batch( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_log_hook_event + ON tool_log(hook_event_id) + WHERE hook_event_id IS NOT NULL;", + )?; + Ok(()) } @@ -636,6 +663,127 @@ impl StateStore { Ok(()) } + pub fn sync_tool_activity_metrics(&self, metrics_path: &Path) -> Result<()> { + if !metrics_path.exists() { + return Ok(()); + } + + #[derive(Default)] + struct ActivityAggregate { + tool_calls: u64, + file_paths: HashSet, + } + + #[derive(serde::Deserialize)] + struct ToolActivityRow { + id: String, + session_id: String, + tool_name: String, + #[serde(default)] + input_summary: String, + #[serde(default)] + output_summary: String, + #[serde(default)] + duration_ms: u64, + #[serde(default)] + file_paths: Vec, + #[serde(default)] + timestamp: String, + } + + let file = File::open(metrics_path) + .with_context(|| format!("Failed to open {}", metrics_path.display()))?; + let reader = BufReader::new(file); + let mut aggregates: HashMap = HashMap::new(); + let mut seen_event_ids = HashSet::new(); + + for line in reader.lines() { + let line = line?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let Ok(row) = serde_json::from_str::(trimmed) else { + continue; + }; + if row.id.trim().is_empty() + || row.session_id.trim().is_empty() + || row.tool_name.trim().is_empty() + { + continue; + } + if !seen_event_ids.insert(row.id.clone()) { + continue; + } + + let file_paths: Vec = row + .file_paths + .into_iter() + .map(|path| path.trim().to_string()) + .filter(|path| !path.is_empty()) + .collect(); + let file_paths_json = + serde_json::to_string(&file_paths).unwrap_or_else(|_| "[]".to_string()); + let timestamp = if row.timestamp.trim().is_empty() { + chrono::Utc::now().to_rfc3339() + } else { + row.timestamp + }; + let risk_score = ToolCallEvent::compute_risk( + &row.tool_name, + &row.input_summary, + &Config::RISK_THRESHOLDS, + ) + .score; + let session_id = row.session_id.clone(); + + self.conn.execute( + "INSERT OR IGNORE INTO tool_log ( + hook_event_id, + session_id, + tool_name, + input_summary, + output_summary, + duration_ms, + risk_score, + timestamp, + file_paths_json + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + rusqlite::params![ + row.id, + row.session_id, + row.tool_name, + row.input_summary, + row.output_summary, + row.duration_ms, + risk_score, + timestamp, + file_paths_json, + ], + )?; + + let aggregate = aggregates.entry(session_id).or_default(); + aggregate.tool_calls = aggregate.tool_calls.saturating_add(1); + for file_path in file_paths { + aggregate.file_paths.insert(file_path); + } + } + + for session in self.list_sessions()? { + let mut metrics = session.metrics.clone(); + let aggregate = aggregates.get(&session.id); + metrics.tool_calls = aggregate.map(|item| item.tool_calls).unwrap_or(0); + metrics.files_changed = aggregate + .map(|item| item.file_paths.len().min(u32::MAX as usize) as u32) + .unwrap_or(0); + self.update_metrics(&session.id, &metrics)?; + } + + Ok(()) + } + pub fn increment_tool_calls(&self, session_id: &str) -> Result<()> { self.conn.execute( "UPDATE sessions SET tool_calls = tool_calls + 1, updated_at = ?1 WHERE id = ?2", @@ -1419,6 +1567,71 @@ mod tests { Ok(()) } + #[test] + fn sync_tool_activity_metrics_aggregates_usage_and_logs() -> Result<()> { + let tempdir = TestDir::new("store-tool-activity")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "sync tools".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "session-2".to_string(), + task: "no activity".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Pending, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join("metrics"); + fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-1\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\",\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + + db.sync_tool_activity_metrics(&metrics_path)?; + + let session = db + .get_session("session-1")? + .expect("session should still exist"); + assert_eq!(session.metrics.tool_calls, 2); + assert_eq!(session.metrics.files_changed, 2); + + let inactive = db + .get_session("session-2")? + .expect("session should still exist"); + assert_eq!(inactive.metrics.tool_calls, 0); + assert_eq!(inactive.metrics.files_changed, 0); + + let logs = db.query_tool_logs("session-1", 1, 10)?; + assert_eq!(logs.total, 2); + assert_eq!(logs.entries[0].tool_name, "Write"); + assert_eq!(logs.entries[1].tool_name, "Read"); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c3186fa6..68f97b1a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -102,6 +102,7 @@ pub struct Dashboard { selected_search_match: usize, session_table_state: TableState, last_cost_metrics_signature: Option<(u64, u128)>, + last_tool_activity_signature: Option<(u64, u128)>, last_budget_alert_state: BudgetState, } @@ -280,11 +281,16 @@ impl Dashboard { output_store: SessionOutputStore, ) -> Self { let pane_size_percent = configured_pane_size(&cfg, cfg.pane_layout); - let initial_cost_metrics_signature = cost_metrics_signature(&cfg.cost_metrics_path()); + let initial_cost_metrics_signature = metrics_file_signature(&cfg.cost_metrics_path()); + let initial_tool_activity_signature = + metrics_file_signature(&cfg.tool_activity_metrics_path()); let _ = db.refresh_session_durations(); if initial_cost_metrics_signature.is_some() { let _ = db.sync_cost_tracker_metrics(&cfg.cost_metrics_path()); } + if initial_tool_activity_signature.is_some() { + let _ = db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path()); + } let sessions = db.list_sessions().unwrap_or_default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -345,6 +351,7 @@ impl Dashboard { selected_search_match: 0, session_table_state, last_cost_metrics_signature: initial_cost_metrics_signature, + last_tool_activity_signature: initial_tool_activity_signature, last_budget_alert_state: BudgetState::Normal, }; dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); @@ -2752,7 +2759,7 @@ impl Dashboard { } let metrics_path = self.cfg.cost_metrics_path(); - let signature = cost_metrics_signature(&metrics_path); + let signature = metrics_file_signature(&metrics_path); if signature != self.last_cost_metrics_signature { self.last_cost_metrics_signature = signature; if signature.is_some() { @@ -2762,6 +2769,17 @@ impl Dashboard { } } + let activity_path = self.cfg.tool_activity_metrics_path(); + let activity_signature = metrics_file_signature(&activity_path); + if activity_signature != self.last_tool_activity_signature { + self.last_tool_activity_signature = activity_signature; + if activity_signature.is_some() { + if let Err(error) = self.db.sync_tool_activity_metrics(&activity_path) { + tracing::warn!("Failed to sync tool activity metrics: {error}"); + } + } + } + match manager::enforce_budget_hard_limits(&self.db, &self.cfg) { Ok(outcome) => Some(outcome), Err(error) => { @@ -3446,7 +3464,7 @@ impl Dashboard { occurred_at: session.updated_at, session_id: session.id.clone(), event_type: TimelineEventType::FileChange, - summary: format!("files changed {}", session.metrics.files_changed), + summary: format!("files touched {}", session.metrics.files_changed), }); } @@ -5464,7 +5482,7 @@ fn format_duration(duration_secs: u64) -> String { format!("{hours:02}:{minutes:02}:{seconds:02}") } -fn cost_metrics_signature(path: &std::path::Path) -> Option<(u64, u128)> { +fn metrics_file_signature(path: &std::path::Path) -> Option<(u64, u128)> { let metadata = std::fs::metadata(path).ok()?; let modified = metadata .modified() @@ -5885,7 +5903,7 @@ mod tests { assert!(rendered.contains("created session as planner")); assert!(rendered.contains("received query lead-123")); assert!(rendered.contains("tool bash")); - assert!(rendered.contains("files changed 3")); + assert!(rendered.contains("files touched 3")); } #[test] @@ -5944,7 +5962,7 @@ mod tests { let rendered = dashboard.rendered_output_text(180, 30); assert!(rendered.contains("received query lead-123")); assert!(!rendered.contains("tool bash")); - assert!(!rendered.contains("files changed 1")); + assert!(!rendered.contains("files touched 1")); } #[test] @@ -7249,6 +7267,47 @@ diff --git a/src/next.rs b/src/next.rs ); } + #[test] + fn refresh_syncs_tool_activity_metrics_from_hook_file() { + let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4())); + fs::create_dir_all(tempdir.join("metrics")).unwrap(); + let db_path = tempdir.join("state.db"); + let db = StateStore::open(&db_path).unwrap(); + let now = Utc::now(); + + db.insert_session(&Session { + id: "sess-1".to_string(), + task: "sync activity".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + metrics: SessionMetrics::default(), + }) + .unwrap(); + + let mut cfg = Config::default(); + cfg.db_path = db_path; + + let mut dashboard = Dashboard::new(db, cfg); + fs::write( + tempdir.join("metrics").join("tool-usage.jsonl"), + "{\"id\":\"evt-1\",\"session_id\":\"sess-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read README.md\",\"output_summary\":\"ok\",\"file_paths\":[\"README.md\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + ) + .unwrap(); + + dashboard.refresh(); + + assert_eq!(dashboard.sessions.len(), 1); + assert_eq!(dashboard.sessions[0].metrics.tool_calls, 1); + assert_eq!(dashboard.sessions[0].metrics.files_changed, 1); + + let _ = fs::remove_dir_all(tempdir); + } + #[test] fn new_session_task_uses_selected_session_context() { let dashboard = test_dashboard( @@ -9171,6 +9230,7 @@ diff --git a/src/next.rs b/src/next.rs selected_search_match: 0, session_table_state, last_cost_metrics_signature: None, + last_tool_activity_signature: None, last_budget_alert_state: BudgetState::Normal, } } diff --git a/hooks/hooks.json b/hooks/hooks.json index 3da90a22..528b03f8 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -260,6 +260,18 @@ "description": "Capture governance events from tool outputs. Enable with ECC_GOVERNANCE_CAPTURE=1", "id": "post:governance-capture" }, + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js\" \"post:session-activity-tracker\" \"scripts/hooks/session-activity-tracker.js\" \"standard,strict\"", + "timeout": 10 + } + ], + "description": "Track per-session tool calls and file activity for ECC2 metrics", + "id": "post:session-activity-tracker" + }, { "matcher": "*", "hooks": [ diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js new file mode 100644 index 00000000..b3b75728 --- /dev/null +++ b/scripts/hooks/session-activity-tracker.js @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * Session Activity Tracker Hook + * + * PostToolUse hook that records sanitized per-tool activity to + * ~/.claude/metrics/tool-usage.jsonl for ECC2 metric sync. + */ + +'use strict'; + +const crypto = require('crypto'); +const path = require('path'); +const { + appendFile, + getClaudeDir, + stripAnsi, +} = require('../lib/utils'); + +const MAX_STDIN = 1024 * 1024; +const METRICS_FILE_NAME = 'tool-usage.jsonl'; +const FILE_PATH_KEYS = new Set([ + 'file_path', + 'file_paths', + 'source_path', + 'destination_path', + 'old_file_path', + 'new_file_path', +]); + +function redactSecrets(value) { + return String(value || '') + .replace(/\n/g, ' ') + .replace(/--token[= ][^ ]*/g, '--token=') + .replace(/Authorization:[: ]*[^ ]*[: ]*[^ ]*/gi, 'Authorization:') + .replace(/\bAKIA[A-Z0-9]{16}\b/g, '') + .replace(/\bASIA[A-Z0-9]{16}\b/g, '') + .replace(/password[= ][^ ]*/gi, 'password=') + .replace(/\bghp_[A-Za-z0-9_]+\b/g, '') + .replace(/\bgho_[A-Za-z0-9_]+\b/g, '') + .replace(/\bghs_[A-Za-z0-9_]+\b/g, '') + .replace(/\bgithub_pat_[A-Za-z0-9_]+\b/g, ''); +} + +function truncateSummary(value, maxLength = 220) { + const normalized = stripAnsi(redactSecrets(value)).trim().replace(/\s+/g, ' '); + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, maxLength - 3)}...`; +} + +function pushPathCandidate(paths, value) { + const candidate = String(value || '').trim(); + if (!candidate) { + return; + } + if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) { + return; + } + if (!paths.includes(candidate)) { + paths.push(candidate); + } +} + +function collectFilePaths(value, paths) { + if (!value) { + return; + } + + if (Array.isArray(value)) { + for (const entry of value) { + collectFilePaths(entry, paths); + } + return; + } + + if (typeof value === 'string') { + pushPathCandidate(paths, value); + return; + } + + if (typeof value !== 'object') { + return; + } + + for (const [key, nested] of Object.entries(value)) { + if (FILE_PATH_KEYS.has(key)) { + collectFilePaths(nested, paths); + } + } +} + +function extractFilePaths(toolInput) { + const paths = []; + if (!toolInput || typeof toolInput !== 'object') { + return paths; + } + collectFilePaths(toolInput, paths); + return paths; +} + +function summarizeInput(toolName, toolInput, filePaths) { + if (toolName === 'Bash') { + return truncateSummary(toolInput?.command || 'bash'); + } + + if (filePaths.length > 0) { + return truncateSummary(`${toolName} ${filePaths.join(', ')}`); + } + + if (toolInput && typeof toolInput === 'object') { + const shallow = {}; + for (const [key, value] of Object.entries(toolInput)) { + if (value == null) { + continue; + } + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + shallow[key] = value; + } + } + const serialized = Object.keys(shallow).length > 0 ? JSON.stringify(shallow) : toolName; + return truncateSummary(serialized); + } + + return truncateSummary(toolName); +} + +function summarizeOutput(toolOutput) { + if (toolOutput == null) { + return ''; + } + + if (typeof toolOutput === 'string') { + return truncateSummary(toolOutput); + } + + if (typeof toolOutput === 'object' && typeof toolOutput.output === 'string') { + return truncateSummary(toolOutput.output); + } + + return truncateSummary(JSON.stringify(toolOutput)); +} + +function buildActivityRow(input, env = process.env) { + const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim(); + if (hookEvent && hookEvent !== 'PostToolUse') { + return null; + } + + const toolName = String(input?.tool_name || '').trim(); + const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim(); + if (!toolName || !sessionId) { + return null; + } + + const toolInput = input?.tool_input || {}; + const filePaths = extractFilePaths(toolInput); + + return { + id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`, + timestamp: new Date().toISOString(), + session_id: sessionId, + tool_name: toolName, + input_summary: summarizeInput(toolName, toolInput, filePaths), + output_summary: summarizeOutput(input?.tool_output), + duration_ms: 0, + file_paths: filePaths, + }; +} + +function run(rawInput) { + try { + const input = rawInput.trim() ? JSON.parse(rawInput) : {}; + const row = buildActivityRow(input); + if (row) { + appendFile( + path.join(getClaudeDir(), 'metrics', METRICS_FILE_NAME), + `${JSON.stringify(row)}\n` + ); + } + } catch { + // Keep hook non-blocking. + } + + return rawInput; +} + +function main() { + let raw = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + if (raw.length < MAX_STDIN) { + const remaining = MAX_STDIN - raw.length; + raw += chunk.substring(0, remaining); + } + }); + process.stdin.on('end', () => { + process.stdout.write(run(raw)); + }); +} + +if (require.main === module) { + main(); +} + +module.exports = { + buildActivityRow, + extractFilePaths, + summarizeInput, + summarizeOutput, + run, +}; diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js new file mode 100644 index 00000000..7c265f96 --- /dev/null +++ b/tests/hooks/session-activity-tracker.test.js @@ -0,0 +1,149 @@ +/** + * Tests for session-activity-tracker.js hook. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const script = path.join( + __dirname, + '..', + '..', + 'scripts', + 'hooks', + 'session-activity-tracker.js' +); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-test-')); +} + +function withTempHome(homeDir) { + return { + HOME: homeDir, + USERPROFILE: homeDir, + }; +} + +function runScript(input, envOverrides = {}) { + const inputStr = typeof input === 'string' ? input : JSON.stringify(input); + const result = spawnSync('node', [script], { + encoding: 'utf8', + input: inputStr, + timeout: 10000, + env: { ...process.env, ...envOverrides }, + }); + return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; +} + +function runTests() { + console.log('\n=== Testing session-activity-tracker.js ===\n'); + + let passed = 0; + let failed = 0; + + (test('passes through input on stdout', () => { + const input = { + tool_name: 'Read', + tool_input: { file_path: 'README.md' }, + tool_output: { output: 'ok' }, + }; + const inputStr = JSON.stringify(input); + const result = runScript(input, { + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'sess-123', + }); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, inputStr); + }) ? passed++ : failed++); + + (test('creates tool activity metrics rows with file paths', () => { + const tmpHome = makeTempDir(); + const input = { + tool_name: 'Write', + tool_input: { + file_path: 'src/app.rs', + }, + tool_output: { output: 'wrote src/app.rs' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-1234', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + assert.ok(fs.existsSync(metricsFile), `Expected metrics file at ${metricsFile}`); + + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.strictEqual(row.session_id, 'ecc-session-1234'); + assert.strictEqual(row.tool_name, 'Write'); + assert.deepStrictEqual(row.file_paths, ['src/app.rs']); + assert.ok(row.id, 'Expected stable event id'); + assert.ok(row.timestamp, 'Expected timestamp'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { + const tmpHome = makeTempDir(); + const input = { + tool_name: 'Bash', + tool_input: { + command: 'curl --token abc123 -H "Authorization: Bearer topsecret" https://example.com', + }, + tool_output: { output: 'done' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-1', + CLAUDE_SESSION_ID: 'claude-session-2', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.strictEqual(row.session_id, 'ecc-session-1'); + assert.ok(row.input_summary.includes('')); + assert.ok(!row.input_summary.includes('abc123')); + assert.ok(!row.input_summary.includes('topsecret')); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + (test('handles invalid JSON gracefully', () => { + const tmpHome = makeTempDir(); + const invalidInput = 'not valid json {{{'; + const result = runScript(invalidInput, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'sess-123', + }); + assert.strictEqual(result.code, 0); + assert.strictEqual(result.stdout, invalidInput); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); From 24a3ffa234165d7f60885f3fc95682f7e8fafde6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:20:40 -0700 Subject: [PATCH 090/459] feat(ecc2): add session heartbeat stale detection --- ecc2/src/config/mod.rs | 7 ++ ecc2/src/main.rs | 1 + ecc2/src/observability/mod.rs | 1 + ecc2/src/session/daemon.rs | 26 +---- ecc2/src/session/manager.rs | 176 +++++++++++++++++++++++++++++++++- ecc2/src/session/mod.rs | 13 +++ ecc2/src/session/runtime.rs | 92 +++++++++++++++++- ecc2/src/session/store.rs | 125 +++++++++++++++++++++--- ecc2/src/tui/dashboard.rs | 126 ++++++++++++++++++++++-- 9 files changed, 520 insertions(+), 47 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 2f184caa..d24f61a2 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -37,6 +37,7 @@ pub struct Config { pub max_parallel_worktrees: usize, pub session_timeout_secs: u64, pub heartbeat_interval_secs: u64, + pub auto_terminate_stale_sessions: bool, pub default_agent: String, pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_limit_per_session: usize, @@ -91,6 +92,7 @@ impl Default for Config { max_parallel_worktrees: 6, session_timeout_secs: 3600, heartbeat_interval_secs: 30, + auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, @@ -340,6 +342,7 @@ max_parallel_sessions = 8 max_parallel_worktrees = 6 session_timeout_secs = 3600 heartbeat_interval_secs = 30 +auto_terminate_stale_sessions = false default_agent = "claude" theme = "Dark" "#; @@ -377,6 +380,10 @@ theme = "Dark" config.auto_merge_ready_worktrees, defaults.auto_merge_ready_worktrees ); + assert_eq!( + config.auto_terminate_stale_sessions, + defaults.auto_terminate_stale_sessions + ); } #[test] diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 21ca4b6c..7966369a 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -900,6 +900,7 @@ fn sync_runtime_session_metrics( db.refresh_session_durations()?; db.sync_cost_tracker_metrics(&cfg.cost_metrics_path())?; db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path())?; + let _ = session::manager::enforce_session_heartbeats(db, cfg)?; let _ = session::manager::enforce_budget_hard_limits(db, cfg)?; Ok(()) } diff --git a/ecc2/src/observability/mod.rs b/ecc2/src/observability/mod.rs index 13e43657..586c4431 100644 --- a/ecc2/src/observability/mod.rs +++ b/ecc2/src/observability/mod.rs @@ -313,6 +313,7 @@ mod tests { worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), } } diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index c2783322..b8e4d7a3 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -22,10 +22,8 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { resume_crashed_sessions(&db)?; let heartbeat_interval = Duration::from_secs(cfg.heartbeat_interval_secs); - let timeout = Duration::from_secs(cfg.session_timeout_secs); - loop { - if let Err(e) = check_sessions(&db, timeout) { + if let Err(e) = check_sessions(&db, &cfg) { tracing::error!("Session check failed: {e}"); } @@ -82,25 +80,8 @@ where Ok(failed_sessions) } -fn check_sessions(db: &StateStore, timeout: Duration) -> Result<()> { - let sessions = db.list_sessions()?; - - for session in sessions { - if session.state != SessionState::Running { - continue; - } - - let elapsed = chrono::Utc::now() - .signed_duration_since(session.updated_at) - .to_std() - .unwrap_or(Duration::ZERO); - - if elapsed > timeout { - tracing::warn!("Session {} timed out after {:?}", session.id, elapsed); - db.update_state_and_pid(&session.id, &SessionState::Failed, None)?; - } - } - +fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> { + let _ = manager::enforce_session_heartbeats(db, cfg)?; Ok(()) } @@ -498,6 +479,7 @@ mod tests { worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index d64f5398..58ed4dfe 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -68,6 +68,58 @@ pub fn get_team_status(db: &StateStore, id: &str, depth: usize) -> Result, + pub auto_terminated_sessions: Vec, +} + +pub fn enforce_session_heartbeats( + db: &StateStore, + cfg: &Config, +) -> Result { + enforce_session_heartbeats_with(db, cfg, kill_process) +} + +fn enforce_session_heartbeats_with( + db: &StateStore, + cfg: &Config, + terminate_pid: F, +) -> Result +where + F: Fn(u32) -> Result<()>, +{ + let timeout = chrono::Duration::seconds(cfg.session_timeout_secs as i64); + let now = chrono::Utc::now(); + let mut outcome = HeartbeatEnforcementOutcome::default(); + + for session in db.list_sessions()? { + if !matches!(session.state, SessionState::Running | SessionState::Stale) { + continue; + } + + if now.signed_duration_since(session.last_heartbeat_at) <= timeout { + continue; + } + + if cfg.auto_terminate_stale_sessions { + if let Some(pid) = session.pid { + let _ = terminate_pid(pid); + } + db.update_state_and_pid(&session.id, &SessionState::Failed, None)?; + outcome.auto_terminated_sessions.push(session.id); + continue; + } + + if session.state != SessionState::Stale { + db.update_state(&session.id, &SessionState::Stale)?; + outcome.stale_sessions.push(session.id); + } + } + + Ok(outcome) +} + pub async fn assign_session( db: &StateStore, cfg: &Config, @@ -685,7 +737,7 @@ pub async fn merge_session_worktree( if matches!( session.state, - SessionState::Pending | SessionState::Running | SessionState::Idle + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale ) { anyhow::bail!( "Cannot merge active session {} while it is {}", @@ -747,7 +799,10 @@ pub async fn merge_ready_worktrees( if matches!( session.state, - SessionState::Pending | SessionState::Running | SessionState::Idle + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale ) { active_with_worktree_ids.push(session.id); continue; @@ -902,6 +957,7 @@ pub async fn run_session( session_id.to_string(), command, SessionOutputStore::default(), + std::time::Duration::from_secs(cfg.heartbeat_interval_secs), ) .await?; Ok(()) @@ -997,6 +1053,7 @@ fn build_session_record( worktree, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), }) } @@ -1488,6 +1545,15 @@ impl fmt::Display for SessionStatus { writeln!(f, "Tools: {}", s.metrics.tool_calls)?; writeln!(f, "Files: {}", s.metrics.files_changed)?; writeln!(f, "Cost: ${:.4}", s.metrics.cost_usd)?; + writeln!( + f, + "Heartbeat: {} ({}s ago)", + s.last_heartbeat_at, + chrono::Utc::now() + .signed_duration_since(s.last_heartbeat_at) + .num_seconds() + .max(0) + )?; if !self.delegated_children.is_empty() { writeln!(f, "Children: {}", self.delegated_children.join(", "))?; } @@ -1528,6 +1594,7 @@ impl fmt::Display for TeamStatus { for lane in [ "Running", "Idle", + "Stale", "Pending", "Failed", "Stopped", @@ -1676,6 +1743,7 @@ fn session_state_label(state: &SessionState) -> &'static str { SessionState::Pending => "Pending", SessionState::Running => "Running", SessionState::Idle => "Idle", + SessionState::Stale => "Stale", SessionState::Completed => "Completed", SessionState::Failed => "Failed", SessionState::Stopped => "Stopped", @@ -1727,6 +1795,7 @@ mod tests { max_parallel_worktrees: 4, session_timeout_secs: 60, heartbeat_interval_secs: 5, + auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, @@ -1755,10 +1824,85 @@ mod tests { worktree: None, created_at: updated_at - Duration::minutes(1), updated_at, + last_heartbeat_at: updated_at, metrics: SessionMetrics::default(), } } + #[test] + fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> { + let tempdir = TestDir::new("manager-heartbeat-stale")?; + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "stale-1".to_string(), + task: "heartbeat overdue".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: Some(4242), + worktree: None, + created_at: now - Duration::minutes(5), + updated_at: now - Duration::minutes(5), + last_heartbeat_at: now - Duration::minutes(5), + metrics: SessionMetrics::default(), + })?; + + let outcome = enforce_session_heartbeats(&db, &cfg)?; + let session = db.get_session("stale-1")?.expect("session should exist"); + + assert_eq!(outcome.stale_sessions, vec!["stale-1".to_string()]); + assert!(outcome.auto_terminated_sessions.is_empty()); + assert_eq!(session.state, SessionState::Stale); + assert_eq!(session.pid, Some(4242)); + + Ok(()) + } + + #[test] + fn enforce_session_heartbeats_auto_terminates_when_enabled() -> Result<()> { + let tempdir = TestDir::new("manager-heartbeat-terminate")?; + let mut cfg = build_config(tempdir.path()); + cfg.auto_terminate_stale_sessions = true; + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + let killed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let killed_clone = killed.clone(); + + db.insert_session(&Session { + id: "stale-2".to_string(), + task: "terminate overdue".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: Some(7777), + worktree: None, + created_at: now - Duration::minutes(5), + updated_at: now - Duration::minutes(5), + last_heartbeat_at: now - Duration::minutes(5), + metrics: SessionMetrics::default(), + })?; + + let outcome = enforce_session_heartbeats_with(&db, &cfg, move |pid| { + killed_clone.lock().unwrap().push(pid); + Ok(()) + })?; + let session = db.get_session("stale-2")?.expect("session should exist"); + + assert!(outcome.stale_sessions.is_empty()); + assert_eq!( + outcome.auto_terminated_sessions, + vec!["stale-2".to_string()] + ); + assert_eq!(*killed.lock().unwrap(), vec![7777]); + assert_eq!(session.state, SessionState::Failed); + assert_eq!(session.pid, None); + + Ok(()) + } + fn build_daemon_activity() -> super::super::store::DaemonActivity { let now = Utc::now(); super::super::store::DaemonActivity { @@ -1976,6 +2120,7 @@ mod tests { }), created_at: now - Duration::minutes(1), updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; db.update_metrics( @@ -2032,6 +2177,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), metrics: SessionMetrics::default(), })?; db.update_metrics( @@ -2076,6 +2222,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(1), updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -2328,6 +2475,7 @@ mod tests { worktree: Some(merged_worktree.clone()), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -2343,6 +2491,7 @@ mod tests { worktree: Some(active_worktree.clone()), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -2359,6 +2508,7 @@ mod tests { worktree: Some(dirty_worktree.clone()), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -2584,6 +2734,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -2596,6 +2747,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(1), updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), metrics: SessionMetrics::default(), })?; db.send_message( @@ -2651,6 +2803,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -2663,6 +2816,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), metrics: SessionMetrics::default(), })?; db.send_message( @@ -2727,6 +2881,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -2739,6 +2894,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), metrics: SessionMetrics::default(), })?; db.send_message( @@ -2794,6 +2950,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -2806,6 +2963,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), metrics: SessionMetrics::default(), })?; db.send_message( @@ -2865,6 +3023,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -2877,6 +3036,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), metrics: SessionMetrics::default(), })?; db.send_message( @@ -2930,6 +3090,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; @@ -2977,6 +3138,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -2989,6 +3151,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), metrics: SessionMetrics::default(), })?; db.send_message( @@ -3044,6 +3207,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; } @@ -3103,6 +3267,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; } @@ -3154,6 +3319,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; @@ -3167,6 +3333,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), metrics: SessionMetrics::default(), })?; @@ -3222,6 +3389,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(4), updated_at: now - Duration::minutes(4), + last_heartbeat_at: now - Duration::minutes(4), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -3234,6 +3402,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -3246,6 +3415,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(2), updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), metrics: SessionMetrics::default(), })?; @@ -3307,6 +3477,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(4), updated_at: now - Duration::minutes(4), + last_heartbeat_at: now - Duration::minutes(4), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -3319,6 +3490,7 @@ mod tests { worktree: None, created_at: now - Duration::minutes(3), updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), metrics: SessionMetrics::default(), })?; diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 6d243858..653ca1bc 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -20,6 +20,7 @@ pub struct Session { pub worktree: Option, pub created_at: DateTime, pub updated_at: DateTime, + pub last_heartbeat_at: DateTime, pub metrics: SessionMetrics, } @@ -28,6 +29,7 @@ pub enum SessionState { Pending, Running, Idle, + Stale, Completed, Failed, Stopped, @@ -39,6 +41,7 @@ impl fmt::Display for SessionState { SessionState::Pending => write!(f, "pending"), SessionState::Running => write!(f, "running"), SessionState::Idle => write!(f, "idle"), + SessionState::Stale => write!(f, "stale"), SessionState::Completed => write!(f, "completed"), SessionState::Failed => write!(f, "failed"), SessionState::Stopped => write!(f, "stopped"), @@ -60,12 +63,21 @@ impl SessionState { ) | ( SessionState::Running, SessionState::Idle + | SessionState::Stale | SessionState::Completed | SessionState::Failed | SessionState::Stopped ) | ( SessionState::Idle, SessionState::Running + | SessionState::Stale + | SessionState::Completed + | SessionState::Failed + | SessionState::Stopped + ) | ( + SessionState::Stale, + SessionState::Running + | SessionState::Idle | SessionState::Completed | SessionState::Failed | SessionState::Stopped @@ -78,6 +90,7 @@ impl SessionState { match value { "running" => SessionState::Running, "idle" => SessionState::Idle, + "stale" => SessionState::Stale, "completed" => SessionState::Completed, "failed" => SessionState::Failed, "stopped" => SessionState::Stopped, diff --git a/ecc2/src/session/runtime.rs b/ecc2/src/session/runtime.rs index 3c75fe6d..8310a7e1 100644 --- a/ecc2/src/session/runtime.rs +++ b/ecc2/src/session/runtime.rs @@ -5,6 +5,7 @@ use anyhow::{Context, Result}; use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; use tokio::process::Command; use tokio::sync::{mpsc, oneshot}; +use tokio::time::{self, MissedTickBehavior}; use super::output::{OutputStream, SessionOutputStore}; use super::store::StateStore; @@ -26,6 +27,9 @@ enum DbMessage { line: String, ack: oneshot::Sender, }, + TouchHeartbeat { + ack: oneshot::Sender, + }, } #[derive(Clone)] @@ -53,6 +57,10 @@ impl DbWriter { .await } + async fn touch_heartbeat(&self) -> Result<()> { + self.send(|ack| DbMessage::TouchHeartbeat { ack }).await + } + async fn send(&self, build: F) -> Result<()> where F: FnOnce(oneshot::Sender) -> DbMessage, @@ -111,6 +119,17 @@ fn run_db_writer(db_path: PathBuf, session_id: String, mut rx: mpsc::UnboundedRe }; let _ = ack.send(result); } + DbMessage::TouchHeartbeat { ack } => { + let result = match opened.as_ref() { + Some(db) => db + .touch_heartbeat(&session_id) + .map_err(|error| error.to_string()), + None => Err(open_error + .clone() + .unwrap_or_else(|| "Failed to open state store".to_string())), + }; + let _ = ack.send(result); + } } } } @@ -120,6 +139,7 @@ pub async fn capture_command_output( session_id: String, mut command: Command, output_store: SessionOutputStore, + heartbeat_interval: std::time::Duration, ) -> Result { let db_writer = DbWriter::start(db_path, session_id.clone()); @@ -152,6 +172,19 @@ pub async fn capture_command_output( .ok_or_else(|| anyhow::anyhow!("Spawned process did not expose a process id"))?; db_writer.update_pid(Some(pid)).await?; db_writer.update_state(SessionState::Running).await?; + db_writer.touch_heartbeat().await?; + + let heartbeat_writer = db_writer.clone(); + let heartbeat_task = tokio::spawn(async move { + let mut ticker = time::interval(heartbeat_interval); + ticker.set_missed_tick_behavior(MissedTickBehavior::Delay); + loop { + ticker.tick().await; + if heartbeat_writer.touch_heartbeat().await.is_err() { + break; + } + } + }); let stdout_task = tokio::spawn(capture_stream( session_id.clone(), @@ -169,6 +202,8 @@ pub async fn capture_command_output( )); let status = child.wait().await?; + heartbeat_task.abort(); + let _ = heartbeat_task.await; stdout_task.await??; stderr_task.await??; @@ -244,6 +279,7 @@ mod tests { worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -254,9 +290,14 @@ mod tests { .arg("-c") .arg("printf 'alpha\\n'; printf 'beta\\n' >&2"); - let status = - capture_command_output(db_path.clone(), session_id.clone(), command, output_store) - .await?; + let status = capture_command_output( + db_path.clone(), + session_id.clone(), + command, + output_store, + std::time::Duration::from_millis(10), + ) + .await?; assert!(status.success()); @@ -286,4 +327,49 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn capture_command_output_updates_heartbeat_for_quiet_processes() -> Result<()> { + let db_path = env::temp_dir().join(format!("ecc2-runtime-heartbeat-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let session_id = "session-heartbeat".to_string(); + let now = Utc::now(); + + db.insert_session(&Session { + id: session_id.clone(), + task: "quiet process".to_string(), + agent_type: "test".to_string(), + working_dir: env::temp_dir(), + state: SessionState::Pending, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("sleep 0.05"); + + let _ = capture_command_output( + db_path.clone(), + session_id.clone(), + command, + SessionOutputStore::default(), + std::time::Duration::from_millis(10), + ) + .await?; + + let db = StateStore::open(&db_path)?; + let session = db + .get_session(&session_id)? + .expect("session should still exist"); + + assert!(session.last_heartbeat_at > now); + assert_eq!(session.state, SessionState::Completed); + + let _ = std::fs::remove_file(db_path); + Ok(()) + } } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 33ab6407..aaf2e38e 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -132,7 +132,8 @@ impl StateStore { duration_secs INTEGER DEFAULT 0, cost_usd REAL DEFAULT 0.0, created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + last_heartbeat_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS tool_log ( @@ -240,6 +241,20 @@ impl StateStore { .context("Failed to add output_tokens column to sessions table")?; } + if !self.has_column("sessions", "last_heartbeat_at")? { + self.conn + .execute("ALTER TABLE sessions ADD COLUMN last_heartbeat_at TEXT", []) + .context("Failed to add last_heartbeat_at column to sessions table")?; + self.conn + .execute( + "UPDATE sessions + SET last_heartbeat_at = updated_at + WHERE last_heartbeat_at IS NULL", + [], + ) + .context("Failed to backfill last_heartbeat_at column")?; + } + if !self.has_column("tool_log", "hook_event_id")? { self.conn .execute("ALTER TABLE tool_log ADD COLUMN hook_event_id TEXT", []) @@ -404,8 +419,8 @@ impl StateStore { pub fn insert_session(&self, session: &Session) -> Result<()> { self.conn.execute( - "INSERT INTO sessions (id, task, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT INTO sessions (id, task, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, created_at, updated_at, last_heartbeat_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", rusqlite::params![ session.id, session.task, @@ -421,6 +436,7 @@ impl StateStore { session.worktree.as_ref().map(|w| w.base_branch.clone()), session.created_at.to_rfc3339(), session.updated_at.to_rfc3339(), + session.last_heartbeat_at.to_rfc3339(), ], )?; Ok(()) @@ -433,7 +449,12 @@ impl StateStore { pid: Option, ) -> Result<()> { let updated = self.conn.execute( - "UPDATE sessions SET state = ?1, pid = ?2, updated_at = ?3 WHERE id = ?4", + "UPDATE sessions + SET state = ?1, + pid = ?2, + updated_at = ?3, + last_heartbeat_at = ?3 + WHERE id = ?4", rusqlite::params![ state.to_string(), pid.map(i64::from), @@ -470,7 +491,11 @@ impl StateStore { } let updated = self.conn.execute( - "UPDATE sessions SET state = ?1, updated_at = ?2 WHERE id = ?3", + "UPDATE sessions + SET state = ?1, + updated_at = ?2, + last_heartbeat_at = ?2 + WHERE id = ?3", rusqlite::params![ state.to_string(), chrono::Utc::now().to_rfc3339(), @@ -487,7 +512,11 @@ impl StateStore { pub fn update_pid(&self, session_id: &str, pid: Option) -> Result<()> { let updated = self.conn.execute( - "UPDATE sessions SET pid = ?1, updated_at = ?2 WHERE id = ?3", + "UPDATE sessions + SET pid = ?1, + updated_at = ?2, + last_heartbeat_at = ?2 + WHERE id = ?3", rusqlite::params![ pid.map(i64::from), chrono::Utc::now().to_rfc3339(), @@ -505,7 +534,11 @@ impl StateStore { pub fn clear_worktree(&self, session_id: &str) -> Result<()> { let updated = self.conn.execute( "UPDATE sessions - SET worktree_path = NULL, worktree_branch = NULL, worktree_base = NULL, updated_at = ?1 + SET worktree_path = NULL, + worktree_branch = NULL, + worktree_base = NULL, + updated_at = ?1, + last_heartbeat_at = ?1 WHERE id = ?2", rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id], )?; @@ -571,7 +604,10 @@ impl StateStore { .unwrap_or_default() .with_timezone(&chrono::Utc); let effective_end = match state { - SessionState::Pending | SessionState::Running | SessionState::Idle => now, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale => now, SessionState::Completed | SessionState::Failed | SessionState::Stopped => { updated_at } @@ -592,6 +628,20 @@ impl StateStore { Ok(()) } + pub fn touch_heartbeat(&self, session_id: &str) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + let updated = self.conn.execute( + "UPDATE sessions SET last_heartbeat_at = ?1 WHERE id = ?2", + rusqlite::params![now, session_id], + )?; + + if updated == 0 { + anyhow::bail!("Session not found: {session_id}"); + } + + Ok(()) + } + pub fn sync_cost_tracker_metrics(&self, metrics_path: &Path) -> Result<()> { if !metrics_path.exists() { return Ok(()); @@ -786,7 +836,11 @@ impl StateStore { pub fn increment_tool_calls(&self, session_id: &str) -> Result<()> { self.conn.execute( - "UPDATE sessions SET tool_calls = tool_calls + 1, updated_at = ?1 WHERE id = ?2", + "UPDATE sessions + SET tool_calls = tool_calls + 1, + updated_at = ?1, + last_heartbeat_at = ?1 + WHERE id = ?2", rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id], )?; Ok(()) @@ -796,7 +850,7 @@ impl StateStore { let mut stmt = self.conn.prepare( "SELECT id, task, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, input_tokens, output_tokens, tokens_used, tool_calls, files_changed, duration_secs, cost_usd, - created_at, updated_at + created_at, updated_at, last_heartbeat_at FROM sessions ORDER BY updated_at DESC", )?; @@ -814,6 +868,7 @@ impl StateStore { let created_str: String = row.get(16)?; let updated_str: String = row.get(17)?; + let heartbeat_str: String = row.get(18)?; Ok(Session { id: row.get(0)?, @@ -829,6 +884,11 @@ impl StateStore { updated_at: chrono::DateTime::parse_from_rfc3339(&updated_str) .unwrap_or_default() .with_timezone(&chrono::Utc), + last_heartbeat_at: chrono::DateTime::parse_from_rfc3339(&heartbeat_str) + .unwrap_or_else(|_| { + chrono::DateTime::parse_from_rfc3339(&updated_str).unwrap_or_default() + }) + .with_timezone(&chrono::Utc), metrics: SessionMetrics { input_tokens: row.get(9)?, output_tokens: row.get(10)?, @@ -1299,7 +1359,10 @@ impl StateStore { )?; self.conn.execute( - "UPDATE sessions SET updated_at = ?1 WHERE id = ?2", + "UPDATE sessions + SET updated_at = ?1, + last_heartbeat_at = ?1 + WHERE id = ?2", rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id], )?; @@ -1460,6 +1523,7 @@ mod tests { worktree: None, created_at: now - ChronoDuration::minutes(1), updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), } } @@ -1520,6 +1584,9 @@ mod tests { assert!(column_names.iter().any(|column| column == "pid")); assert!(column_names.iter().any(|column| column == "input_tokens")); assert!(column_names.iter().any(|column| column == "output_tokens")); + assert!(column_names + .iter() + .any(|column| column == "last_heartbeat_at")); Ok(()) } @@ -1539,6 +1606,7 @@ mod tests { worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -1583,6 +1651,7 @@ mod tests { worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -1595,6 +1664,7 @@ mod tests { worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -1648,6 +1718,7 @@ mod tests { worktree: None, created_at: now - ChronoDuration::seconds(95), updated_at: now - ChronoDuration::seconds(1), + last_heartbeat_at: now - ChronoDuration::seconds(1), metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -1660,6 +1731,7 @@ mod tests { worktree: None, created_at: now - ChronoDuration::seconds(80), updated_at: now - ChronoDuration::seconds(5), + last_heartbeat_at: now - ChronoDuration::seconds(5), metrics: SessionMetrics::default(), })?; @@ -1678,6 +1750,36 @@ mod tests { Ok(()) } + #[test] + fn touch_heartbeat_updates_last_heartbeat_timestamp() -> Result<()> { + let tempdir = TestDir::new("store-touch-heartbeat")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now() - ChronoDuration::seconds(30); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "heartbeat".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: Some(1234), + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + db.touch_heartbeat("session-1")?; + + let session = db + .get_session("session-1")? + .expect("session should still exist"); + assert!(session.last_heartbeat_at > now); + + Ok(()) + } + #[test] fn append_output_line_keeps_latest_buffer_window() -> Result<()> { let tempdir = TestDir::new("store-output")?; @@ -1694,6 +1796,7 @@ mod tests { worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 68f97b1a..63ba8984 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -112,6 +112,7 @@ struct SessionSummary { pending: usize, running: usize, idle: usize, + stale: usize, completed: usize, failed: usize, stopped: usize, @@ -266,6 +267,7 @@ struct TeamSummary { idle: usize, running: usize, pending: usize, + stale: usize, failed: usize, stopped: usize, } @@ -2753,7 +2755,12 @@ impl Dashboard { self.sync_from_store(); } - fn sync_runtime_metrics(&mut self) -> Option { + fn sync_runtime_metrics( + &mut self, + ) -> ( + Option, + Option, + ) { if let Err(error) = self.db.refresh_session_durations() { tracing::warn!("Failed to refresh session durations: {error}"); } @@ -2780,17 +2787,27 @@ impl Dashboard { } } - match manager::enforce_budget_hard_limits(&self.db, &self.cfg) { + let heartbeat_enforcement = match manager::enforce_session_heartbeats(&self.db, &self.cfg) { + Ok(outcome) => Some(outcome), + Err(error) => { + tracing::warn!("Failed to enforce session heartbeats: {error}"); + None + } + }; + + let budget_enforcement = match manager::enforce_budget_hard_limits(&self.db, &self.cfg) { Ok(outcome) => Some(outcome), Err(error) => { tracing::warn!("Failed to enforce budget hard limits: {error}"); None } - } + }; + + (heartbeat_enforcement, budget_enforcement) } fn sync_from_store(&mut self) { - let budget_enforcement = self.sync_runtime_metrics(); + let (heartbeat_enforcement, budget_enforcement) = self.sync_runtime_metrics(); let selected_id = self.selected_session_id().map(ToOwned::to_owned); self.sessions = match self.db.list_sessions() { Ok(sessions) => sessions, @@ -2825,6 +2842,11 @@ impl Dashboard { { self.set_operator_note(budget_auto_pause_note(&outcome)); } + if let Some(outcome) = heartbeat_enforcement.filter(|outcome| { + !outcome.stale_sessions.is_empty() || !outcome.auto_terminated_sessions.is_empty() + }) { + self.set_operator_note(heartbeat_enforcement_note(&outcome)); + } } fn sync_budget_alerts(&mut self) { @@ -3183,6 +3205,7 @@ impl Dashboard { SessionState::Pending => team.pending += 1, SessionState::Failed => team.failed += 1, SessionState::Stopped => team.stopped += 1, + SessionState::Stale => team.stale += 1, SessionState::Completed => {} } @@ -4239,7 +4262,10 @@ impl Dashboard { .filter(|session| { matches!( session.state, - SessionState::Pending | SessionState::Running | SessionState::Idle + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale ) }) .count() @@ -4944,6 +4970,7 @@ impl SessionSummary { SessionState::Pending => summary.pending += 1, SessionState::Running => summary.running += 1, SessionState::Idle => summary.idle += 1, + SessionState::Stale => summary.stale += 1, SessionState::Completed => summary.completed += 1, SessionState::Failed => summary.failed += 1, SessionState::Stopped => summary.stopped += 1, @@ -4968,12 +4995,14 @@ fn session_row( approval_requests: usize, unread_messages: usize, ) -> Row<'static> { + let state_label = session_state_label(&session.state); + let state_color = session_state_color(&session.state); Row::new(vec![ Cell::from(format_session_id(&session.id)), Cell::from(session.agent_type.clone()), - Cell::from(session_state_label(&session.state)).style( + Cell::from(state_label).style( Style::default() - .fg(session_state_color(&session.state)) + .fg(state_color) .add_modifier(Modifier::BOLD), ), Cell::from(session_branch(session)), @@ -5016,6 +5045,7 @@ fn summary_line(summary: &SessionSummary) -> Line<'static> { ), summary_span("Running", summary.running, Color::Green), summary_span("Idle", summary.idle, Color::Yellow), + summary_span("Stale", summary.stale, Color::LightRed), summary_span("Completed", summary.completed, Color::Blue), summary_span("Failed", summary.failed, Color::Red), summary_span("Stopped", summary.stopped, Color::DarkGray), @@ -5052,6 +5082,7 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta if summary.failed == 0 && summary.stopped == 0 && summary.pending == 0 + && summary.stale == 0 && summary.unread_messages == 0 && summary.conflicted_worktrees == 0 { @@ -5086,6 +5117,7 @@ fn attention_queue_line(summary: &SessionSummary, stabilized: bool) -> Line<'sta } spans.extend([ + summary_span("Stale", summary.stale, Color::LightRed), summary_span("Backlog", summary.unread_messages, Color::Magenta), summary_span("Failed", summary.failed, Color::Red), summary_span("Stopped", summary.stopped, Color::DarkGray), @@ -5321,6 +5353,7 @@ fn session_state_label(state: &SessionState) -> &'static str { SessionState::Pending => "Pending", SessionState::Running => "Running", SessionState::Idle => "Idle", + SessionState::Stale => "Stale", SessionState::Completed => "Completed", SessionState::Failed => "Failed", SessionState::Stopped => "Stopped", @@ -5331,6 +5364,7 @@ fn session_state_color(state: &SessionState) -> Color { match state { SessionState::Running => Color::Green, SessionState::Idle => Color::Yellow, + SessionState::Stale => Color::LightRed, SessionState::Failed => Color::Red, SessionState::Stopped => Color::DarkGray, SessionState::Completed => Color::Blue, @@ -5338,6 +5372,20 @@ fn session_state_color(state: &SessionState) -> Color { } } +fn heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) -> String { + if !outcome.auto_terminated_sessions.is_empty() { + return format!( + "stale heartbeat detected | auto-terminated {} session(s)", + outcome.auto_terminated_sessions.len() + ); + } + + format!( + "stale heartbeat detected | flagged {} session(s) for attention", + outcome.stale_sessions.len() + ) +} + fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String { let cause = match (outcome.token_budget_exceeded, outcome.cost_budget_exceeded) { (true, true) => "token and cost budgets exceeded", @@ -5436,6 +5484,7 @@ fn delegate_next_action(delegate: &DelegatedChildSummary) -> &'static str { SessionState::Pending => "wait for startup", SessionState::Running => "let it run", SessionState::Idle => "assign next task", + SessionState::Stale => "inspect stale heartbeat", SessionState::Failed => "inspect failure", SessionState::Stopped => "resume or reassign", SessionState::Completed => "merge or cleanup", @@ -5449,7 +5498,10 @@ fn delegate_attention_priority(delegate: &DelegatedChildSummary) -> u8 { if delegate.approval_backlog > 0 { return 1; } - if matches!(delegate.state, SessionState::Failed | SessionState::Stopped) { + if matches!( + delegate.state, + SessionState::Stale | SessionState::Failed | SessionState::Stopped + ) { return 2; } if delegate.handoff_backlog > 0 { @@ -5463,7 +5515,7 @@ fn delegate_attention_priority(delegate: &DelegatedChildSummary) -> u8 { SessionState::Running => 6, SessionState::Idle => 7, SessionState::Completed => 8, - SessionState::Failed | SessionState::Stopped => unreachable!(), + SessionState::Stale | SessionState::Failed | SessionState::Stopped => unreachable!(), } } @@ -6160,6 +6212,7 @@ diff --git a/src/next.rs b/src/next.rs idle: 1, running: 1, pending: 1, + stale: 0, failed: 0, stopped: 0, }); @@ -7285,6 +7338,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), }) .unwrap(); @@ -7308,6 +7362,39 @@ diff --git a/src/next.rs b/src/next.rs let _ = fs::remove_dir_all(tempdir); } + #[test] + fn refresh_flags_stale_sessions_and_sets_operator_note() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let mut cfg = Config::default(); + cfg.session_timeout_secs = 60; + let now = Utc::now(); + + db.insert_session(&Session { + id: "stale-1".to_string(), + task: "stale session".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: Some(4242), + worktree: None, + created_at: now - Duration::minutes(5), + updated_at: now - Duration::minutes(5), + last_heartbeat_at: now - Duration::minutes(5), + metrics: SessionMetrics::default(), + }) + .unwrap(); + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.refresh(); + + assert_eq!(dashboard.sessions.len(), 1); + assert_eq!(dashboard.sessions[0].state, SessionState::Stale); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("stale heartbeat detected | flagged 1 session(s) for attention") + ); + } + #[test] fn new_session_task_uses_selected_session_context() { let dashboard = test_dashboard( @@ -7445,6 +7532,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -7458,6 +7546,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now + chrono::Duration::seconds(1), + last_heartbeat_at: now + chrono::Duration::seconds(1), metrics: SessionMetrics::default(), })?; @@ -7487,6 +7576,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -7529,6 +7619,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8163,6 +8254,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8200,6 +8292,7 @@ diff --git a/src/next.rs b/src/next.rs }), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8239,6 +8332,7 @@ diff --git a/src/next.rs b/src/next.rs }), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8275,6 +8369,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8315,6 +8410,7 @@ diff --git a/src/next.rs b/src/next.rs }), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; db.insert_session(&Session { @@ -8331,6 +8427,7 @@ diff --git a/src/next.rs b/src/next.rs }), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8380,6 +8477,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: Some(worktree.clone()), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8461,6 +8559,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: Some(merged_worktree.clone()), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8476,6 +8575,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: Some(active_worktree.clone()), created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8519,6 +8619,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8551,6 +8652,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8583,6 +8685,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8615,6 +8718,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -8647,6 +8751,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics::default(), })?; @@ -9243,6 +9348,7 @@ diff --git a/src/next.rs b/src/next.rs max_parallel_worktrees: 4, session_timeout_secs: 60, heartbeat_interval_secs: 5, + auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, @@ -9307,6 +9413,7 @@ diff --git a/src/next.rs b/src/next.rs }), created_at: Utc::now(), updated_at: Utc::now(), + last_heartbeat_at: Utc::now(), metrics: SessionMetrics { input_tokens: tokens_used.saturating_mul(3) / 4, output_tokens: tokens_used / 4, @@ -9331,6 +9438,7 @@ diff --git a/src/next.rs b/src/next.rs worktree: None, created_at: now, updated_at: now, + last_heartbeat_at: now, metrics: SessionMetrics { input_tokens: tokens_used.saturating_mul(3) / 4, output_tokens: tokens_used / 4, From a0f69cec9231aa61474101b26903a85b5dade09e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:27:17 -0700 Subject: [PATCH 091/459] feat(ecc2): surface per-file session activity --- ecc2/src/session/mod.rs | 9 +++ ecc2/src/session/store.rs | 112 +++++++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 99 ++++++++++++++++++++++++++++++++- 3 files changed, 217 insertions(+), 3 deletions(-) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 653ca1bc..086a7d53 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -127,3 +127,12 @@ pub struct SessionMessage { pub read: bool, pub timestamp: DateTime, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FileActivityEntry { + pub session_id: String, + pub tool_name: String, + pub path: String, + pub summary: String, + pub timestamp: DateTime, +} diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index aaf2e38e..061c96d6 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -11,7 +11,7 @@ use crate::config::Config; use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; -use super::{Session, SessionMessage, SessionMetrics, SessionState}; +use super::{FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState}; pub struct StateStore { conn: Connection, @@ -1480,6 +1480,72 @@ impl StateStore { total, }) } + + pub fn list_file_activity( + &self, + session_id: &str, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json + FROM tool_log + WHERE session_id = ?1 + AND file_paths_json IS NOT NULL + AND file_paths_json != '[]' + ORDER BY timestamp DESC, id DESC", + )?; + + let rows = stmt + .query_map(rusqlite::params![session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?.unwrap_or_default(), + row.get::<_, Option>(3)?.unwrap_or_default(), + row.get::<_, String>(4)?, + row.get::<_, Option>(5)? + .unwrap_or_else(|| "[]".to_string()), + )) + })? + .collect::, _>>()?; + + let mut events = Vec::new(); + for (session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json) in + rows + { + let Ok(paths) = serde_json::from_str::>(&file_paths_json) else { + continue; + }; + let occurred_at = chrono::DateTime::parse_from_rfc3339(×tamp) + .unwrap_or_default() + .with_timezone(&chrono::Utc); + let summary = if output_summary.trim().is_empty() { + input_summary + } else { + output_summary + }; + + for path in paths { + let path = path.trim().to_string(); + if path.is_empty() { + continue; + } + + events.push(FileActivityEntry { + session_id: session_id.clone(), + tool_name: tool_name.clone(), + path, + summary: summary.clone(), + timestamp: occurred_at, + }); + if events.len() >= limit { + return Ok(events); + } + } + } + + Ok(events) + } } #[cfg(test)] @@ -1702,6 +1768,50 @@ mod tests { Ok(()) } + #[test] + fn list_file_activity_expands_logged_file_paths() -> Result<()> { + let tempdir = TestDir::new("store-file-activity")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "sync tools".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join("metrics"); + fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-1\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\",\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + + db.sync_tool_activity_metrics(&metrics_path)?; + + let activity = db.list_file_activity("session-1", 10)?; + assert_eq!(activity.len(), 3); + assert_eq!(activity[0].tool_name, "Write"); + assert_eq!(activity[0].path, "README.md"); + assert_eq!(activity[1].path, "src/lib.rs"); + assert_eq!(activity[2].tool_name, "Read"); + assert_eq!(activity[2].path, "src/lib.rs"); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 63ba8984..9bf315b9 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -20,7 +20,7 @@ use crate::session::output::{ OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT, }; use crate::session::store::{DaemonActivity, StateStore}; -use crate::session::{Session, SessionMessage, SessionState}; +use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState}; use crate::worktree; #[cfg(test)] @@ -3482,13 +3482,24 @@ impl Dashboard { }); } - if session.metrics.files_changed > 0 { + let file_activity = self + .db + .list_file_activity(&session.id, 64) + .unwrap_or_default(); + if file_activity.is_empty() && session.metrics.files_changed > 0 { events.push(TimelineEvent { occurred_at: session.updated_at, session_id: session.id.clone(), event_type: TimelineEventType::FileChange, summary: format!("files touched {}", session.metrics.files_changed), }); + } else { + events.extend(file_activity.into_iter().map(|entry| TimelineEvent { + occurred_at: entry.timestamp, + session_id: session.id.clone(), + event_type: TimelineEventType::FileChange, + summary: file_activity_summary(&entry), + })); } let messages = self @@ -4125,6 +4136,20 @@ impl Dashboard { "Tools {} | Files {}", metrics.tool_calls, metrics.files_changed, )); + let recent_file_activity = self + .db + .list_file_activity(&session.id, 5) + .unwrap_or_default(); + if !recent_file_activity.is_empty() { + lines.push("Recent file activity".to_string()); + for entry in recent_file_activity { + lines.push(format!( + "- {} {}", + self.short_timestamp(&entry.timestamp.to_rfc3339()), + file_activity_summary(&entry) + )); + } + } lines.push(format!( "Cost ${:.4} | Duration {}s", metrics.cost_usd, metrics.duration_secs @@ -5372,6 +5397,31 @@ fn session_state_color(state: &SessionState) -> Color { } } +fn file_activity_summary(entry: &FileActivityEntry) -> String { + format!( + "{} {}", + file_activity_verb(&entry.tool_name), + truncate_for_dashboard(&entry.path, 72) + ) +} + +fn file_activity_verb(tool_name: &str) -> &'static str { + let tool_name = tool_name.trim().to_ascii_lowercase(); + if tool_name.contains("read") { + "read" + } else if tool_name.contains("write") { + "write" + } else if tool_name.contains("edit") { + "edit" + } else if tool_name.contains("delete") || tool_name.contains("remove") { + "delete" + } else if tool_name.contains("move") || tool_name.contains("rename") { + "move" + } else { + "touch" + } +} + fn heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) -> String { if !outcome.auto_terminated_sessions.is_empty() { return format!( @@ -6017,6 +6067,51 @@ mod tests { assert!(!rendered.contains("files touched 1")); } + #[test] + fn timeline_and_metrics_render_recent_file_activity_details() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-file-activity-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let now = Utc::now(); + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.created_at = now - chrono::Duration::hours(2); + session.updated_at = now - chrono::Duration::minutes(5); + + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session)?; + + let metrics_path = root.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + dashboard.db.sync_tool_activity_metrics(&metrics_path)?; + dashboard.sync_from_store(); + + dashboard.toggle_timeline_mode(); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("read src/lib.rs")); + assert!(rendered.contains("write README.md")); + assert!(!rendered.contains("files touched 2")); + + let metrics_text = dashboard.selected_session_metrics_text(); + assert!(metrics_text.contains("Recent file activity")); + assert!(metrics_text.contains("write README.md")); + assert!(metrics_text.contains("read src/lib.rs")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn timeline_time_filter_hides_old_events() { let now = Utc::now(); From edd027edd45b7080bfbcc755546c75a8c2586299 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:33:42 -0700 Subject: [PATCH 092/459] feat(ecc2): classify typed file activity --- ecc2/src/session/mod.rs | 13 +- ecc2/src/session/store.rs | 168 ++++++++++++++++--- ecc2/src/tui/dashboard.rs | 28 ++-- scripts/hooks/session-activity-tracker.js | 83 +++++++++ tests/hooks/session-activity-tracker.test.js | 29 ++++ 5 files changed, 282 insertions(+), 39 deletions(-) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 086a7d53..188324ed 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -131,8 +131,19 @@ pub struct SessionMessage { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileActivityEntry { pub session_id: String, - pub tool_name: String, + pub action: FileActivityAction, pub path: String, pub summary: String, pub timestamp: DateTime, } + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FileActivityAction { + Read, + Create, + Modify, + Move, + Delete, + Touch, +} diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 061c96d6..a237aa87 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -11,7 +11,9 @@ use crate::config::Config; use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; -use super::{FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState}; +use super::{ + FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, +}; pub struct StateStore { conn: Connection, @@ -146,7 +148,8 @@ impl StateStore { duration_ms INTEGER, risk_score REAL DEFAULT 0.0, timestamp TEXT NOT NULL, - file_paths_json TEXT NOT NULL DEFAULT '[]' + file_paths_json TEXT NOT NULL DEFAULT '[]', + file_events_json TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS messages ( @@ -270,6 +273,15 @@ impl StateStore { .context("Failed to add file_paths_json column to tool_log table")?; } + if !self.has_column("tool_log", "file_events_json")? { + self.conn + .execute( + "ALTER TABLE tool_log ADD COLUMN file_events_json TEXT NOT NULL DEFAULT '[]'", + [], + ) + .context("Failed to add file_events_json column to tool_log table")?; + } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn .execute( @@ -738,9 +750,17 @@ impl StateStore { #[serde(default)] file_paths: Vec, #[serde(default)] + file_events: Vec, + #[serde(default)] timestamp: String, } + #[derive(serde::Deserialize)] + struct ToolActivityFileEvent { + path: String, + action: String, + } + let file = File::open(metrics_path) .with_context(|| format!("Failed to open {}", metrics_path.display()))?; let reader = BufReader::new(file); @@ -773,8 +793,35 @@ impl StateStore { .map(|path| path.trim().to_string()) .filter(|path| !path.is_empty()) .collect(); + let file_events: Vec = if row.file_events.is_empty() { + file_paths + .iter() + .cloned() + .map(|path| PersistedFileEvent { + path, + action: infer_file_activity_action(&row.tool_name), + }) + .collect() + } else { + row.file_events + .into_iter() + .filter_map(|event| { + let path = event.path.trim().to_string(); + if path.is_empty() { + return None; + } + Some(PersistedFileEvent { + path, + action: parse_file_activity_action(&event.action) + .unwrap_or_else(|| infer_file_activity_action(&row.tool_name)), + }) + }) + .collect() + }; let file_paths_json = serde_json::to_string(&file_paths).unwrap_or_else(|_| "[]".to_string()); + let file_events_json = + serde_json::to_string(&file_events).unwrap_or_else(|_| "[]".to_string()); let timestamp = if row.timestamp.trim().is_empty() { chrono::Utc::now().to_rfc3339() } else { @@ -798,9 +845,10 @@ impl StateStore { duration_ms, risk_score, timestamp, - file_paths_json + file_paths_json, + file_events_json ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", rusqlite::params![ row.id, row.session_id, @@ -811,6 +859,7 @@ impl StateStore { risk_score, timestamp, file_paths_json, + file_events_json, ], )?; @@ -1487,11 +1536,13 @@ impl StateStore { limit: usize, ) -> Result> { let mut stmt = self.conn.prepare( - "SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json + "SELECT session_id, tool_name, input_summary, output_summary, timestamp, file_events_json, file_paths_json FROM tool_log WHERE session_id = ?1 - AND file_paths_json IS NOT NULL - AND file_paths_json != '[]' + AND ( + (file_events_json IS NOT NULL AND file_events_json != '[]') + OR (file_paths_json IS NOT NULL AND file_paths_json != '[]') + ) ORDER BY timestamp DESC, id DESC", )?; @@ -1505,17 +1556,23 @@ impl StateStore { row.get::<_, String>(4)?, row.get::<_, Option>(5)? .unwrap_or_else(|| "[]".to_string()), + row.get::<_, Option>(6)? + .unwrap_or_else(|| "[]".to_string()), )) })? .collect::, _>>()?; let mut events = Vec::new(); - for (session_id, tool_name, input_summary, output_summary, timestamp, file_paths_json) in - rows + for ( + session_id, + tool_name, + input_summary, + output_summary, + timestamp, + file_events_json, + file_paths_json, + ) in rows { - let Ok(paths) = serde_json::from_str::>(&file_paths_json) else { - continue; - }; let occurred_at = chrono::DateTime::parse_from_rfc3339(×tamp) .unwrap_or_default() .with_timezone(&chrono::Utc); @@ -1525,16 +1582,28 @@ impl StateStore { output_summary }; - for path in paths { - let path = path.trim().to_string(); - if path.is_empty() { - continue; - } + let persisted = parse_persisted_file_events(&file_events_json).unwrap_or_else(|| { + serde_json::from_str::>(&file_paths_json) + .unwrap_or_default() + .into_iter() + .filter_map(|path| { + let path = path.trim().to_string(); + if path.is_empty() { + return None; + } + Some(PersistedFileEvent { + path, + action: infer_file_activity_action(&tool_name), + }) + }) + .collect() + }); + for event in persisted { events.push(FileActivityEntry { session_id: session_id.clone(), - tool_name: tool_name.clone(), - path, + action: event.action, + path: event.path, summary: summary.clone(), timestamp: occurred_at, }); @@ -1548,6 +1617,62 @@ impl StateStore { } } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct PersistedFileEvent { + path: String, + action: FileActivityAction, +} + +fn parse_persisted_file_events(value: &str) -> Option> { + let events = serde_json::from_str::>(value).ok()?; + let events: Vec = events + .into_iter() + .filter_map(|event| { + let path = event.path.trim().to_string(); + if path.is_empty() { + return None; + } + Some(PersistedFileEvent { + path, + action: event.action, + }) + }) + .collect(); + if events.is_empty() { + return None; + } + Some(events) +} + +fn parse_file_activity_action(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "read" => Some(FileActivityAction::Read), + "create" => Some(FileActivityAction::Create), + "modify" | "edit" | "write" => Some(FileActivityAction::Modify), + "move" | "rename" => Some(FileActivityAction::Move), + "delete" | "remove" => Some(FileActivityAction::Delete), + "touch" => Some(FileActivityAction::Touch), + _ => None, + } +} + +fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { + let tool_name = tool_name.trim().to_ascii_lowercase(); + if tool_name.contains("read") { + FileActivityAction::Read + } else if tool_name.contains("write") { + FileActivityAction::Create + } else if tool_name.contains("edit") { + FileActivityAction::Modify + } else if tool_name.contains("delete") || tool_name.contains("remove") { + FileActivityAction::Delete + } else if tool_name.contains("move") || tool_name.contains("rename") { + FileActivityAction::Move + } else { + FileActivityAction::Touch + } +} + #[cfg(test)] mod tests { use super::*; @@ -1803,10 +1928,11 @@ mod tests { let activity = db.list_file_activity("session-1", 10)?; assert_eq!(activity.len(), 3); - assert_eq!(activity[0].tool_name, "Write"); + assert_eq!(activity[0].action, FileActivityAction::Create); assert_eq!(activity[0].path, "README.md"); + assert_eq!(activity[1].action, FileActivityAction::Create); assert_eq!(activity[1].path, "src/lib.rs"); - assert_eq!(activity[2].tool_name, "Read"); + assert_eq!(activity[2].action, FileActivityAction::Read); assert_eq!(activity[2].path, "src/lib.rs"); Ok(()) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 9bf315b9..a708a8a0 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -5400,25 +5400,19 @@ fn session_state_color(state: &SessionState) -> Color { fn file_activity_summary(entry: &FileActivityEntry) -> String { format!( "{} {}", - file_activity_verb(&entry.tool_name), + file_activity_verb(entry.action.clone()), truncate_for_dashboard(&entry.path, 72) ) } -fn file_activity_verb(tool_name: &str) -> &'static str { - let tool_name = tool_name.trim().to_ascii_lowercase(); - if tool_name.contains("read") { - "read" - } else if tool_name.contains("write") { - "write" - } else if tool_name.contains("edit") { - "edit" - } else if tool_name.contains("delete") || tool_name.contains("remove") { - "delete" - } else if tool_name.contains("move") || tool_name.contains("rename") { - "move" - } else { - "touch" +fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { + match action { + crate::session::FileActivityAction::Read => "read", + crate::session::FileActivityAction::Create => "create", + crate::session::FileActivityAction::Modify => "modify", + crate::session::FileActivityAction::Move => "move", + crate::session::FileActivityAction::Delete => "delete", + crate::session::FileActivityAction::Touch => "touch", } } @@ -6100,12 +6094,12 @@ mod tests { dashboard.toggle_timeline_mode(); let rendered = dashboard.rendered_output_text(180, 30); assert!(rendered.contains("read src/lib.rs")); - assert!(rendered.contains("write README.md")); + assert!(rendered.contains("create README.md")); assert!(!rendered.contains("files touched 2")); let metrics_text = dashboard.selected_session_metrics_text(); assert!(metrics_text.contains("Recent file activity")); - assert!(metrics_text.contains("write README.md")); + assert!(metrics_text.contains("create README.md")); assert!(metrics_text.contains("read src/lib.rs")); let _ = fs::remove_dir_all(root); diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index b3b75728..8627de00 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -62,6 +62,49 @@ function pushPathCandidate(paths, value) { } } +function pushFileEvent(events, value, action) { + const candidate = String(value || '').trim(); + if (!candidate) { + return; + } + if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) { + return; + } + if (!events.some(event => event.path === candidate && event.action === action)) { + events.push({ path: candidate, action }); + } +} + +function inferDefaultFileAction(toolName) { + const normalized = String(toolName || '').trim().toLowerCase(); + if (normalized.includes('read')) { + return 'read'; + } + if (normalized.includes('write')) { + return 'create'; + } + if (normalized.includes('edit')) { + return 'modify'; + } + if (normalized.includes('delete') || normalized.includes('remove')) { + return 'delete'; + } + if (normalized.includes('move') || normalized.includes('rename')) { + return 'move'; + } + return 'touch'; +} + +function actionForFileKey(toolName, key) { + if (key === 'source_path' || key === 'old_file_path') { + return 'move'; + } + if (key === 'destination_path' || key === 'new_file_path') { + return 'move'; + } + return inferDefaultFileAction(toolName); +} + function collectFilePaths(value, paths) { if (!value) { return; @@ -99,6 +142,43 @@ function extractFilePaths(toolInput) { return paths; } +function collectFileEvents(toolName, value, events, key = null) { + if (!value) { + return; + } + + if (Array.isArray(value)) { + for (const entry of value) { + collectFileEvents(toolName, entry, events, key); + } + return; + } + + if (typeof value === 'string') { + pushFileEvent(events, value, actionForFileKey(toolName, key)); + return; + } + + if (typeof value !== 'object') { + return; + } + + for (const [nestedKey, nested] of Object.entries(value)) { + if (FILE_PATH_KEYS.has(nestedKey)) { + collectFileEvents(toolName, nested, events, nestedKey); + } + } +} + +function extractFileEvents(toolName, toolInput) { + const events = []; + if (!toolInput || typeof toolInput !== 'object') { + return events; + } + collectFileEvents(toolName, toolInput, events); + return events; +} + function summarizeInput(toolName, toolInput, filePaths) { if (toolName === 'Bash') { return truncateSummary(toolInput?.command || 'bash'); @@ -155,6 +235,7 @@ function buildActivityRow(input, env = process.env) { const toolInput = input?.tool_input || {}; const filePaths = extractFilePaths(toolInput); + const fileEvents = extractFileEvents(toolName, toolInput); return { id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`, @@ -165,6 +246,7 @@ function buildActivityRow(input, env = process.env) { output_summary: summarizeOutput(input?.tool_output), duration_ms: 0, file_paths: filePaths, + file_events: fileEvents, }; } @@ -205,6 +287,7 @@ if (require.main === module) { module.exports = { buildActivityRow, + extractFileEvents, extractFilePaths, summarizeInput, summarizeOutput, diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index 7c265f96..c2f01507 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -95,12 +95,41 @@ function runTests() { assert.strictEqual(row.session_id, 'ecc-session-1234'); assert.strictEqual(row.tool_name, 'Write'); assert.deepStrictEqual(row.file_paths, ['src/app.rs']); + assert.deepStrictEqual(row.file_events, [{ path: 'src/app.rs', action: 'create' }]); assert.ok(row.id, 'Expected stable event id'); assert.ok(row.timestamp, 'Expected timestamp'); fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('captures typed move file events from source/destination inputs', () => { + const tmpHome = makeTempDir(); + const input = { + tool_name: 'Move', + tool_input: { + source_path: 'src/old.rs', + destination_path: 'src/new.rs', + }, + tool_output: { output: 'moved file' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-5678', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_paths, ['src/old.rs', 'src/new.rs']); + assert.deepStrictEqual(row.file_events, [ + { path: 'src/old.rs', action: 'move' }, + { path: 'src/new.rs', action: 'move' }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { const tmpHome = makeTempDir(); const input = { From c395b42d2c967cc09e3957c2fb986e7d147ff090 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:40:28 -0700 Subject: [PATCH 093/459] feat(ecc2): persist file activity diff previews --- ecc2/src/session/mod.rs | 1 + ecc2/src/session/store.rs | 64 ++++++++++++++ ecc2/src/tui/dashboard.rs | 15 +++- scripts/hooks/session-activity-tracker.js | 88 ++++++++++++++++++-- tests/hooks/session-activity-tracker.test.js | 77 +++++++++++++++++ 5 files changed, 234 insertions(+), 11 deletions(-) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 188324ed..0482e057 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -134,6 +134,7 @@ pub struct FileActivityEntry { pub action: FileActivityAction, pub path: String, pub summary: String, + pub diff_preview: Option, pub timestamp: DateTime, } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index a237aa87..b800ca65 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -759,6 +759,8 @@ impl StateStore { struct ToolActivityFileEvent { path: String, action: String, + #[serde(default)] + diff_preview: Option, } let file = File::open(metrics_path) @@ -800,6 +802,7 @@ impl StateStore { .map(|path| PersistedFileEvent { path, action: infer_file_activity_action(&row.tool_name), + diff_preview: None, }) .collect() } else { @@ -814,6 +817,7 @@ impl StateStore { path, action: parse_file_activity_action(&event.action) .unwrap_or_else(|| infer_file_activity_action(&row.tool_name)), + diff_preview: normalize_optional_string(event.diff_preview), }) }) .collect() @@ -1594,6 +1598,7 @@ impl StateStore { Some(PersistedFileEvent { path, action: infer_file_activity_action(&tool_name), + diff_preview: None, }) }) .collect() @@ -1605,6 +1610,7 @@ impl StateStore { action: event.action, path: event.path, summary: summary.clone(), + diff_preview: event.diff_preview, timestamp: occurred_at, }); if events.len() >= limit { @@ -1621,6 +1627,8 @@ impl StateStore { struct PersistedFileEvent { path: String, action: FileActivityAction, + #[serde(default, skip_serializing_if = "Option::is_none")] + diff_preview: Option, } fn parse_persisted_file_events(value: &str) -> Option> { @@ -1635,6 +1643,7 @@ fn parse_persisted_file_events(value: &str) -> Option> { Some(PersistedFileEvent { path, action: event.action, + diff_preview: normalize_optional_string(event.diff_preview), }) }) .collect(); @@ -1656,6 +1665,17 @@ fn parse_file_activity_action(value: &str) -> Option { } } +fn normalize_optional_string(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { let tool_name = tool_name.trim().to_ascii_lowercase(); if tool_name.contains("read") { @@ -1938,6 +1958,50 @@ mod tests { Ok(()) } + #[test] + fn list_file_activity_preserves_diff_previews() -> Result<()> { + let tempdir = TestDir::new("store-file-activity-diffs")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "sync tools".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join("metrics"); + fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/config.ts\",\"output_summary\":\"updated config\",\"file_paths\":[\"src/config.ts\"],\"file_events\":[{\"path\":\"src/config.ts\",\"action\":\"modify\",\"diff_preview\":\"API_URL=http://localhost:3000 -> API_URL=https://api.example.com\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n" + ), + )?; + + db.sync_tool_activity_metrics(&metrics_path)?; + + let activity = db.list_file_activity("session-1", 10)?; + assert_eq!(activity.len(), 1); + assert_eq!(activity[0].action, FileActivityAction::Modify); + assert_eq!(activity[0].path, "src/config.ts"); + assert_eq!( + activity[0].diff_preview.as_deref(), + Some("API_URL=http://localhost:3000 -> API_URL=https://api.example.com") + ); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a708a8a0..809ae926 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -5398,11 +5398,18 @@ fn session_state_color(state: &SessionState) -> Color { } fn file_activity_summary(entry: &FileActivityEntry) -> String { - format!( + let mut summary = format!( "{} {}", file_activity_verb(entry.action.clone()), truncate_for_dashboard(&entry.path, 72) - ) + ); + + if let Some(diff_preview) = entry.diff_preview.as_ref() { + summary.push_str(" | "); + summary.push_str(&truncate_for_dashboard(diff_preview, 56)); + } + + summary } fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { @@ -6085,7 +6092,7 @@ mod tests { &metrics_path, concat!( "{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", - "{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + "{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ # ECC 2.0\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" ), )?; dashboard.db.sync_tool_activity_metrics(&metrics_path)?; @@ -6095,11 +6102,13 @@ mod tests { let rendered = dashboard.rendered_output_text(180, 30); assert!(rendered.contains("read src/lib.rs")); assert!(rendered.contains("create README.md")); + assert!(rendered.contains("+ # ECC 2.0")); assert!(!rendered.contains("files touched 2")); let metrics_text = dashboard.selected_session_metrics_text(); assert!(metrics_text.contains("Recent file activity")); assert!(metrics_text.contains("create README.md")); + assert!(metrics_text.contains("+ # ECC 2.0")); assert!(metrics_text.contains("read src/lib.rs")); let _ = fs::remove_dir_all(root); diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index 8627de00..917217c0 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -62,7 +62,7 @@ function pushPathCandidate(paths, value) { } } -function pushFileEvent(events, value, action) { +function pushFileEvent(events, value, action, diffPreview) { const candidate = String(value || '').trim(); if (!candidate) { return; @@ -70,11 +70,52 @@ function pushFileEvent(events, value, action) { if (/^(https?:\/\/|app:\/\/|plugin:\/\/|mcp:\/\/)/i.test(candidate)) { return; } - if (!events.some(event => event.path === candidate && event.action === action)) { - events.push({ path: candidate, action }); + const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim() + ? diffPreview.trim() + : undefined; + if (!events.some(event => + event.path === candidate + && event.action === action + && (event.diff_preview || undefined) === normalizedDiffPreview + )) { + const event = { path: candidate, action }; + if (normalizedDiffPreview) { + event.diff_preview = normalizedDiffPreview; + } + events.push(event); } } +function sanitizeDiffText(value, maxLength = 96) { + if (typeof value !== 'string' || !value.trim()) { + return ''; + } + return truncateSummary(value, maxLength); +} + +function buildReplacementPreview(oldValue, newValue) { + const before = sanitizeDiffText(oldValue); + const after = sanitizeDiffText(newValue); + if (!before && !after) { + return undefined; + } + if (!before) { + return `-> ${after}`; + } + if (!after) { + return `${before} ->`; + } + return `${before} -> ${after}`; +} + +function buildCreationPreview(content) { + const normalized = sanitizeDiffText(content); + if (!normalized) { + return undefined; + } + return `+ ${normalized}`; +} + function inferDefaultFileAction(toolName) { const normalized = String(toolName || '').trim().toLowerCase(); if (normalized.includes('read')) { @@ -129,6 +170,11 @@ function collectFilePaths(value, paths) { for (const [key, nested] of Object.entries(value)) { if (FILE_PATH_KEYS.has(key)) { collectFilePaths(nested, paths); + continue; + } + + if (nested && (Array.isArray(nested) || typeof nested === 'object')) { + collectFilePaths(nested, paths); } } } @@ -142,20 +188,39 @@ function extractFilePaths(toolInput) { return paths; } -function collectFileEvents(toolName, value, events, key = null) { +function fileEventDiffPreview(toolName, value, action) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + if (typeof value.old_string === 'string' || typeof value.new_string === 'string') { + return buildReplacementPreview(value.old_string, value.new_string); + } + + if (action === 'create') { + return buildCreationPreview(value.content || value.file_text || value.text); + } + + return undefined; +} + +function collectFileEvents(toolName, value, events, key = null, parentValue = null) { if (!value) { return; } if (Array.isArray(value)) { for (const entry of value) { - collectFileEvents(toolName, entry, events, key); + collectFileEvents(toolName, entry, events, key, parentValue); } return; } if (typeof value === 'string') { - pushFileEvent(events, value, actionForFileKey(toolName, key)); + if (key && FILE_PATH_KEYS.has(key)) { + const action = actionForFileKey(toolName, key); + pushFileEvent(events, value, action, fileEventDiffPreview(toolName, parentValue, action)); + } return; } @@ -165,7 +230,12 @@ function collectFileEvents(toolName, value, events, key = null) { for (const [nestedKey, nested] of Object.entries(value)) { if (FILE_PATH_KEYS.has(nestedKey)) { - collectFileEvents(toolName, nested, events, nestedKey); + collectFileEvents(toolName, nested, events, nestedKey, value); + continue; + } + + if (nested && (Array.isArray(nested) || typeof nested === 'object')) { + collectFileEvents(toolName, nested, events, null, nested); } } } @@ -234,8 +304,10 @@ function buildActivityRow(input, env = process.env) { } const toolInput = input?.tool_input || {}; - const filePaths = extractFilePaths(toolInput); const fileEvents = extractFileEvents(toolName, toolInput); + const filePaths = fileEvents.length > 0 + ? [...new Set(fileEvents.map(event => event.path))] + : extractFilePaths(toolInput); return { id: `tool-${Date.now()}-${crypto.randomBytes(6).toString('hex')}`, diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index c2f01507..d1af7b3d 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -130,6 +130,83 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('captures replacement diff previews for edit tool input', () => { + const tmpHome = makeTempDir(); + const input = { + tool_name: 'Edit', + tool_input: { + file_path: 'src/config.ts', + old_string: 'API_URL=http://localhost:3000', + new_string: 'API_URL=https://api.example.com', + }, + tool_output: { output: 'updated config' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-edit', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_events, [ + { + path: 'src/config.ts', + action: 'modify', + diff_preview: 'API_URL=http://localhost:3000 -> API_URL=https://api.example.com', + }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + + (test('captures MultiEdit nested edits with typed diff previews', () => { + const tmpHome = makeTempDir(); + const input = { + tool_name: 'MultiEdit', + tool_input: { + edits: [ + { + file_path: 'src/a.ts', + old_string: 'const a = 1;', + new_string: 'const a = 2;', + }, + { + file_path: 'src/b.ts', + old_string: 'old name', + new_string: 'new name', + }, + ], + }, + tool_output: { output: 'updated two files' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-multiedit', + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_paths, ['src/a.ts', 'src/b.ts']); + assert.deepStrictEqual(row.file_events, [ + { + path: 'src/a.ts', + action: 'modify', + diff_preview: 'const a = 1; -> const a = 2;', + }, + { + path: 'src/b.ts', + action: 'modify', + diff_preview: 'old name -> new name', + }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { const tmpHome = makeTempDir(); const input = { From eee9768cd88cda6543482a549c4f8de407ed3a77 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:45:37 -0700 Subject: [PATCH 094/459] feat(ecc2): persist file activity patch previews --- ecc2/src/session/mod.rs | 1 + ecc2/src/session/store.rs | 17 ++++- ecc2/src/tui/dashboard.rs | 45 ++++++++++-- scripts/hooks/session-activity-tracker.js | 75 +++++++++++++++++++- tests/hooks/session-activity-tracker.test.js | 3 + 5 files changed, 133 insertions(+), 8 deletions(-) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 0482e057..21a10e79 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -135,6 +135,7 @@ pub struct FileActivityEntry { pub path: String, pub summary: String, pub diff_preview: Option, + pub patch_preview: Option, pub timestamp: DateTime, } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index b800ca65..b6de51b7 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -761,6 +761,8 @@ impl StateStore { action: String, #[serde(default)] diff_preview: Option, + #[serde(default)] + patch_preview: Option, } let file = File::open(metrics_path) @@ -803,6 +805,7 @@ impl StateStore { path, action: infer_file_activity_action(&row.tool_name), diff_preview: None, + patch_preview: None, }) .collect() } else { @@ -818,6 +821,7 @@ impl StateStore { action: parse_file_activity_action(&event.action) .unwrap_or_else(|| infer_file_activity_action(&row.tool_name)), diff_preview: normalize_optional_string(event.diff_preview), + patch_preview: normalize_optional_string(event.patch_preview), }) }) .collect() @@ -1599,6 +1603,7 @@ impl StateStore { path, action: infer_file_activity_action(&tool_name), diff_preview: None, + patch_preview: None, }) }) .collect() @@ -1611,6 +1616,7 @@ impl StateStore { path: event.path, summary: summary.clone(), diff_preview: event.diff_preview, + patch_preview: event.patch_preview, timestamp: occurred_at, }); if events.len() >= limit { @@ -1629,6 +1635,8 @@ struct PersistedFileEvent { action: FileActivityAction, #[serde(default, skip_serializing_if = "Option::is_none")] diff_preview: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + patch_preview: Option, } fn parse_persisted_file_events(value: &str) -> Option> { @@ -1644,6 +1652,7 @@ fn parse_persisted_file_events(value: &str) -> Option> { path, action: event.action, diff_preview: normalize_optional_string(event.diff_preview), + patch_preview: normalize_optional_string(event.patch_preview), }) }) .collect(); @@ -1959,7 +1968,7 @@ mod tests { } #[test] - fn list_file_activity_preserves_diff_previews() -> Result<()> { + fn list_file_activity_preserves_diff_and_patch_previews() -> Result<()> { let tempdir = TestDir::new("store-file-activity-diffs")?; let db = StateStore::open(&tempdir.path().join("state.db"))?; let now = Utc::now(); @@ -1984,7 +1993,7 @@ mod tests { fs::write( &metrics_path, concat!( - "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/config.ts\",\"output_summary\":\"updated config\",\"file_paths\":[\"src/config.ts\"],\"file_events\":[{\"path\":\"src/config.ts\",\"action\":\"modify\",\"diff_preview\":\"API_URL=http://localhost:3000 -> API_URL=https://api.example.com\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n" + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/config.ts\",\"output_summary\":\"updated config\",\"file_paths\":[\"src/config.ts\"],\"file_events\":[{\"path\":\"src/config.ts\",\"action\":\"modify\",\"diff_preview\":\"API_URL=http://localhost:3000 -> API_URL=https://api.example.com\",\"patch_preview\":\"@@\\n- API_URL=http://localhost:3000\\n+ API_URL=https://api.example.com\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n" ), )?; @@ -1998,6 +2007,10 @@ mod tests { activity[0].diff_preview.as_deref(), Some("API_URL=http://localhost:3000 -> API_URL=https://api.example.com") ); + assert_eq!( + activity[0].patch_preview.as_deref(), + Some("@@\n- API_URL=http://localhost:3000\n+ API_URL=https://api.example.com") + ); Ok(()) } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 809ae926..cdb2271b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -34,6 +34,7 @@ const PANE_RESIZE_STEP_PERCENT: u16 = 5; const MAX_LOG_ENTRIES: u64 = 12; const MAX_DIFF_PREVIEW_LINES: usize = 6; const MAX_DIFF_PATCH_LINES: usize = 80; +const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3; #[derive(Debug, Clone, PartialEq, Eq)] struct WorktreeDiffColumns { @@ -203,6 +204,7 @@ struct TimelineEvent { session_id: String, event_type: TimelineEventType, summary: String, + detail_lines: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -3410,19 +3412,26 @@ impl Dashboard { .into_iter() .filter(|event| self.timeline_event_filter.matches(event.event_type)) .filter(|event| self.output_time_filter.matches_timestamp(event.occurred_at)) - .map(|event| { + .flat_map(|event| { let prefix = if show_session_label { format!("{} ", format_session_id(&event.session_id)) } else { String::new() }; - Line::from(format!( + let mut lines = vec![Line::from(format!( "[{}] {}{:<11} {}", event.occurred_at.format("%H:%M:%S"), prefix, event.event_type.label(), event.summary - )) + ))]; + lines.extend( + event + .detail_lines + .into_iter() + .map(|line| Line::from(format!(" {}", line))), + ); + lines }) .collect() } @@ -3459,6 +3468,7 @@ impl Dashboard { session.agent_type, truncate_for_dashboard(&session.task, 64) ), + detail_lines: Vec::new(), }]; if session.updated_at > session.created_at { @@ -3467,6 +3477,7 @@ impl Dashboard { session_id: session.id.clone(), event_type: TimelineEventType::Lifecycle, summary: format!("state {} | updated session metadata", session.state), + detail_lines: Vec::new(), }); } @@ -3479,6 +3490,7 @@ impl Dashboard { "attached worktree {} from {}", worktree.branch, worktree.base_branch ), + detail_lines: Vec::new(), }); } @@ -3492,6 +3504,7 @@ impl Dashboard { session_id: session.id.clone(), event_type: TimelineEventType::FileChange, summary: format!("files touched {}", session.metrics.files_changed), + detail_lines: Vec::new(), }); } else { events.extend(file_activity.into_iter().map(|entry| TimelineEvent { @@ -3499,6 +3512,7 @@ impl Dashboard { session_id: session.id.clone(), event_type: TimelineEventType::FileChange, summary: file_activity_summary(&entry), + detail_lines: file_activity_patch_lines(&entry, MAX_FILE_ACTIVITY_PATCH_LINES), })); } @@ -3525,6 +3539,7 @@ impl Dashboard { 64 ) ), + detail_lines: Vec::new(), } })); @@ -3544,6 +3559,7 @@ impl Dashboard { entry.duration_ms, truncate_for_dashboard(&entry.input_summary, 56) ), + detail_lines: Vec::new(), }) })); events @@ -4148,6 +4164,9 @@ impl Dashboard { self.short_timestamp(&entry.timestamp.to_rfc3339()), file_activity_summary(&entry) )); + for detail in file_activity_patch_lines(&entry, 2) { + lines.push(format!(" {}", detail)); + } } } lines.push(format!( @@ -5412,6 +5431,22 @@ fn file_activity_summary(entry: &FileActivityEntry) -> String { summary } +fn file_activity_patch_lines(entry: &FileActivityEntry, max_lines: usize) -> Vec { + entry + .patch_preview + .as_deref() + .map(|patch| { + patch + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && *line != "@@" && *line != "+" && *line != "-") + .take(max_lines) + .map(|line| truncate_for_dashboard(line, 72)) + .collect() + }) + .unwrap_or_default() +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -6092,7 +6127,7 @@ mod tests { &metrics_path, concat!( "{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", - "{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ # ECC 2.0\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + "{\"id\":\"evt-2\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_paths\":[\"README.md\"],\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ # ECC 2.0\",\"patch_preview\":\"+ # ECC 2.0\\n+ \\n+ A richer dashboard\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" ), )?; dashboard.db.sync_tool_activity_metrics(&metrics_path)?; @@ -6103,12 +6138,14 @@ mod tests { assert!(rendered.contains("read src/lib.rs")); assert!(rendered.contains("create README.md")); assert!(rendered.contains("+ # ECC 2.0")); + assert!(rendered.contains("+ A richer dashboard")); assert!(!rendered.contains("files touched 2")); let metrics_text = dashboard.selected_session_metrics_text(); assert!(metrics_text.contains("Recent file activity")); assert!(metrics_text.contains("create README.md")); assert!(metrics_text.contains("+ # ECC 2.0")); + assert!(metrics_text.contains("+ A richer dashboard")); assert!(metrics_text.contains("read src/lib.rs")); let _ = fs::remove_dir_all(root); diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index 917217c0..554700f7 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -62,7 +62,7 @@ function pushPathCandidate(paths, value) { } } -function pushFileEvent(events, value, action, diffPreview) { +function pushFileEvent(events, value, action, diffPreview, patchPreview) { const candidate = String(value || '').trim(); if (!candidate) { return; @@ -73,15 +73,22 @@ function pushFileEvent(events, value, action, diffPreview) { const normalizedDiffPreview = typeof diffPreview === 'string' && diffPreview.trim() ? diffPreview.trim() : undefined; + const normalizedPatchPreview = typeof patchPreview === 'string' && patchPreview.trim() + ? patchPreview.trim() + : undefined; if (!events.some(event => event.path === candidate && event.action === action && (event.diff_preview || undefined) === normalizedDiffPreview + && (event.patch_preview || undefined) === normalizedPatchPreview )) { const event = { path: candidate, action }; if (normalizedDiffPreview) { event.diff_preview = normalizedDiffPreview; } + if (normalizedPatchPreview) { + event.patch_preview = normalizedPatchPreview; + } events.push(event); } } @@ -93,6 +100,19 @@ function sanitizeDiffText(value, maxLength = 96) { return truncateSummary(value, maxLength); } +function sanitizePatchLines(value, maxLines = 4, maxLineLength = 120) { + if (typeof value !== 'string' || !value.trim()) { + return []; + } + + return stripAnsi(redactSecrets(value)) + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean) + .slice(0, maxLines) + .map(line => line.length <= maxLineLength ? line : `${line.slice(0, maxLineLength - 3)}...`); +} + function buildReplacementPreview(oldValue, newValue) { const before = sanitizeDiffText(oldValue); const after = sanitizeDiffText(newValue); @@ -116,6 +136,31 @@ function buildCreationPreview(content) { return `+ ${normalized}`; } +function buildPatchPreviewFromReplacement(oldValue, newValue) { + const beforeLines = sanitizePatchLines(oldValue); + const afterLines = sanitizePatchLines(newValue); + if (beforeLines.length === 0 && afterLines.length === 0) { + return undefined; + } + + const lines = ['@@']; + for (const line of beforeLines) { + lines.push(`- ${line}`); + } + for (const line of afterLines) { + lines.push(`+ ${line}`); + } + return lines.join('\n'); +} + +function buildPatchPreviewFromContent(content, prefix) { + const lines = sanitizePatchLines(content); + if (lines.length === 0) { + return undefined; + } + return lines.map(line => `${prefix} ${line}`).join('\n'); +} + function inferDefaultFileAction(toolName) { const normalized = String(toolName || '').trim().toLowerCase(); if (normalized.includes('read')) { @@ -204,6 +249,26 @@ function fileEventDiffPreview(toolName, value, action) { return undefined; } +function fileEventPatchPreview(value, action) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + if (typeof value.old_string === 'string' || typeof value.new_string === 'string') { + return buildPatchPreviewFromReplacement(value.old_string, value.new_string); + } + + if (action === 'create') { + return buildPatchPreviewFromContent(value.content || value.file_text || value.text, '+'); + } + + if (action === 'delete') { + return buildPatchPreviewFromContent(value.content || value.old_string || value.file_text, '-'); + } + + return undefined; +} + function collectFileEvents(toolName, value, events, key = null, parentValue = null) { if (!value) { return; @@ -219,7 +284,13 @@ function collectFileEvents(toolName, value, events, key = null, parentValue = nu if (typeof value === 'string') { if (key && FILE_PATH_KEYS.has(key)) { const action = actionForFileKey(toolName, key); - pushFileEvent(events, value, action, fileEventDiffPreview(toolName, parentValue, action)); + pushFileEvent( + events, + value, + action, + fileEventDiffPreview(toolName, parentValue, action), + fileEventPatchPreview(parentValue, action) + ); } return; } diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index d1af7b3d..84620c7f 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -155,6 +155,7 @@ function runTests() { path: 'src/config.ts', action: 'modify', diff_preview: 'API_URL=http://localhost:3000 -> API_URL=https://api.example.com', + patch_preview: '@@\n- API_URL=http://localhost:3000\n+ API_URL=https://api.example.com', }, ]); @@ -196,11 +197,13 @@ function runTests() { path: 'src/a.ts', action: 'modify', diff_preview: 'const a = 1; -> const a = 2;', + patch_preview: '@@\n- const a = 1;\n+ const a = 2;', }, { path: 'src/b.ts', action: 'modify', diff_preview: 'old name -> new name', + patch_preview: '@@\n- old name\n+ new name', }, ]); From 31f672275ed52d1249a13a07a8b854042b99f99a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:48:29 -0700 Subject: [PATCH 095/459] feat(ecc2): infer tracked write modifications --- scripts/hooks/session-activity-tracker.js | 130 ++++++++++++++++++- tests/hooks/session-activity-tracker.test.js | 52 +++++++- 2 files changed, 180 insertions(+), 2 deletions(-) diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index 554700f7..e802d5d0 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -10,6 +10,7 @@ const crypto = require('crypto'); const path = require('path'); +const { spawnSync } = require('child_process'); const { appendFile, getClaudeDir, @@ -161,6 +162,33 @@ function buildPatchPreviewFromContent(content, prefix) { return lines.map(line => `${prefix} ${line}`).join('\n'); } +function buildDiffPreviewFromPatchPreview(patchPreview) { + if (typeof patchPreview !== 'string' || !patchPreview.trim()) { + return undefined; + } + + const lines = patchPreview + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); + const removed = lines.find(line => line.startsWith('- ') || line.startsWith('-')); + const added = lines.find(line => line.startsWith('+ ') || line.startsWith('+')); + + if (!removed && !added) { + return undefined; + } + + const before = removed ? removed.replace(/^- ?/, '') : ''; + const after = added ? added.replace(/^\+ ?/, '') : ''; + if (before && after) { + return `${before} -> ${after}`; + } + if (before) { + return `${before} ->`; + } + return `-> ${after}`; +} + function inferDefaultFileAction(toolName) { const normalized = String(toolName || '').trim().toLowerCase(); if (normalized.includes('read')) { @@ -269,6 +297,104 @@ function fileEventPatchPreview(value, action) { return undefined; } +function runGit(args, cwd) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + timeout: 2500, + }); + + if (result.error || result.status !== 0) { + return null; + } + + return String(result.stdout || '').trim(); +} + +function gitRepoRoot(cwd) { + return runGit(['rev-parse', '--show-toplevel'], cwd); +} + +function repoRelativePath(repoRoot, filePath) { + const absolute = path.isAbsolute(filePath) + ? path.resolve(filePath) + : path.resolve(process.cwd(), filePath); + const relative = path.relative(repoRoot, absolute); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + return relative.split(path.sep).join('/'); +} + +function patchPreviewFromGitDiff(repoRoot, repoRelative) { + const patch = runGit( + ['diff', '--no-ext-diff', '--no-color', '--unified=1', '--', repoRelative], + repoRoot + ); + if (!patch) { + return undefined; + } + + const relevant = patch + .split(/\r?\n/) + .filter(line => + line.startsWith('@@') + || (line.startsWith('+') && !line.startsWith('+++')) + || (line.startsWith('-') && !line.startsWith('---')) + ) + .slice(0, 6); + + if (relevant.length === 0) { + return undefined; + } + + return relevant.join('\n'); +} + +function trackedInGit(repoRoot, repoRelative) { + return runGit(['ls-files', '--error-unmatch', '--', repoRelative], repoRoot) !== null; +} + +function enrichFileEventFromWorkingTree(toolName, event) { + if (!event || typeof event !== 'object' || !event.path) { + return event; + } + + const repoRoot = gitRepoRoot(process.cwd()); + if (!repoRoot) { + return event; + } + + const repoRelative = repoRelativePath(repoRoot, event.path); + if (!repoRelative) { + return event; + } + + const tool = String(toolName || '').trim().toLowerCase(); + const tracked = trackedInGit(repoRoot, repoRelative); + const patchPreview = patchPreviewFromGitDiff(repoRoot, repoRelative) || event.patch_preview; + const diffPreview = buildDiffPreviewFromPatchPreview(patchPreview) || event.diff_preview; + + if (tool.includes('write')) { + return { + ...event, + action: tracked ? 'modify' : event.action, + diff_preview: diffPreview, + patch_preview: patchPreview, + }; + } + + if (tracked && patchPreview) { + return { + ...event, + diff_preview: diffPreview, + patch_preview: patchPreview, + }; + } + + return event; +} + function collectFileEvents(toolName, value, events, key = null, parentValue = null) { if (!value) { return; @@ -375,7 +501,9 @@ function buildActivityRow(input, env = process.env) { } const toolInput = input?.tool_input || {}; - const fileEvents = extractFileEvents(toolName, toolInput); + const fileEvents = extractFileEvents(toolName, toolInput).map(event => + enrichFileEventFromWorkingTree(toolName, event) + ); const filePaths = fileEvents.length > 0 ? [...new Set(fileEvents.map(event => event.path))] : extractFilePaths(toolInput); diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index 84620c7f..0b15c2fd 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -40,13 +40,14 @@ function withTempHome(homeDir) { }; } -function runScript(input, envOverrides = {}) { +function runScript(input, envOverrides = {}, options = {}) { const inputStr = typeof input === 'string' ? input : JSON.stringify(input); const result = spawnSync('node', [script], { encoding: 'utf8', input: inputStr, timeout: 10000, env: { ...process.env, ...envOverrides }, + cwd: options.cwd, }); return { code: result.status || 0, stdout: result.stdout || '', stderr: result.stderr || '' }; } @@ -210,6 +211,55 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('reclassifies tracked Write activity as modify using git diff context', () => { + const tmpHome = makeTempDir(); + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-repo-')); + + spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' }); + + const srcDir = path.join(repoDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + const trackedFile = path.join(srcDir, 'app.ts'); + fs.writeFileSync(trackedFile, 'const count = 1;\n', 'utf8'); + spawnSync('git', ['add', 'src/app.ts'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' }); + + fs.writeFileSync(trackedFile, 'const count = 2;\n', 'utf8'); + + const input = { + tool_name: 'Write', + tool_input: { + file_path: 'src/app.ts', + content: 'const count = 2;\n', + }, + tool_output: { output: 'updated src/app.ts' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-write-modify', + }, { + cwd: repoDir, + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_events, [ + { + path: 'src/app.ts', + action: 'modify', + diff_preview: 'const count = 1; -> const count = 2;', + patch_preview: '@@ -1 +1 @@\n-const count = 1;\n+const count = 2;', + }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { const tmpHome = makeTempDir(); const input = { From f28f55c41eb815599e2c5eb0b405ba1fab337d2e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 07:54:27 -0700 Subject: [PATCH 096/459] feat(ecc2): surface overlapping file activity --- ecc2/src/session/store.rs | 168 +++++++++++++++++++ ecc2/src/tui/dashboard.rs | 82 ++++++++- tests/hooks/session-activity-tracker.test.js | 48 ++++++ 3 files changed, 297 insertions(+), 1 deletion(-) diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index b6de51b7..1fa31298 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; use serde::Serialize; +use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::{BufRead, BufReader}; @@ -19,6 +20,16 @@ pub struct StateStore { conn: Connection, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FileActivityOverlap { + pub path: String, + pub current_action: FileActivityAction, + pub other_action: FileActivityAction, + pub other_session_id: String, + pub other_session_state: SessionState, + pub timestamp: chrono::DateTime, +} + #[derive(Debug, Clone, Default, Serialize)] pub struct DaemonActivity { pub last_dispatch_at: Option>, @@ -1627,6 +1638,67 @@ impl StateStore { Ok(events) } + + pub fn list_file_overlaps( + &self, + session_id: &str, + limit: usize, + ) -> Result> { + if limit == 0 { + return Ok(Vec::new()); + } + + let current_activity = self.list_file_activity(session_id, 64)?; + if current_activity.is_empty() { + return Ok(Vec::new()); + } + + let mut current_by_path = HashMap::new(); + for entry in current_activity { + current_by_path.entry(entry.path.clone()).or_insert(entry); + } + + let mut overlaps = Vec::new(); + let mut seen = HashSet::new(); + + for session in self.list_sessions()? { + if session.id == session_id || !session_state_supports_overlap(&session.state) { + continue; + } + + for entry in self.list_file_activity(&session.id, 32)? { + let Some(current) = current_by_path.get(&entry.path) else { + continue; + }; + if !file_overlap_is_relevant(current, &entry) { + continue; + } + if !seen.insert((session.id.clone(), entry.path.clone())) { + continue; + } + + overlaps.push(FileActivityOverlap { + path: entry.path.clone(), + current_action: current.action.clone(), + other_action: entry.action.clone(), + other_session_id: session.id.clone(), + other_session_state: session.state.clone(), + timestamp: entry.timestamp, + }); + } + } + + overlaps.sort_by_key(|entry| { + ( + overlap_state_priority(&entry.other_session_state), + Reverse(entry.timestamp), + entry.other_session_id.clone(), + entry.path.clone(), + ) + }); + overlaps.truncate(limit); + Ok(overlaps) + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -1702,6 +1774,31 @@ fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { } } +fn session_state_supports_overlap(state: &SessionState) -> bool { + matches!( + state, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + ) +} + +fn file_overlap_is_relevant(current: &FileActivityEntry, other: &FileActivityEntry) -> bool { + current.path == other.path + && !(matches!(current.action, FileActivityAction::Read) + && matches!(other.action, FileActivityAction::Read)) +} + +fn overlap_state_priority(state: &SessionState) -> u8 { + match state { + SessionState::Running => 0, + SessionState::Idle => 1, + SessionState::Pending => 2, + SessionState::Stale => 3, + SessionState::Completed => 4, + SessionState::Failed => 5, + SessionState::Stopped => 6, + } +} + #[cfg(test)] mod tests { use super::*; @@ -2015,6 +2112,77 @@ mod tests { Ok(()) } + #[test] + fn list_file_overlaps_reports_other_active_sessions_sharing_paths() -> Result<()> { + let tempdir = TestDir::new("store-file-overlaps")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "focus".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "session-2".to_string(), + task: "delegate".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Idle, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "session-3".to_string(), + task: "done".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Completed, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join("metrics"); + fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"updated lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:02:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-2\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"touched lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:03:00Z\"}\n", + "{\"id\":\"evt-3\",\"session_id\":\"session-3\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"completed overlap\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:04:00Z\"}\n" + ), + )?; + + db.sync_tool_activity_metrics(&metrics_path)?; + + let overlaps = db.list_file_overlaps("session-1", 10)?; + assert_eq!(overlaps.len(), 1); + assert_eq!(overlaps[0].path, "src/lib.rs"); + assert_eq!(overlaps[0].current_action, FileActivityAction::Modify); + assert_eq!(overlaps[0].other_action, FileActivityAction::Modify); + assert_eq!(overlaps[0].other_session_id, "session-2"); + assert_eq!(overlaps[0].other_session_state, SessionState::Idle); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index cdb2271b..53d8c23a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -19,7 +19,7 @@ use crate::session::manager; use crate::session::output::{ OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT, }; -use crate::session::store::{DaemonActivity, StateStore}; +use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState}; use crate::worktree; @@ -4169,6 +4169,22 @@ impl Dashboard { } } } + let file_overlaps = self + .db + .list_file_overlaps(&session.id, 3) + .unwrap_or_default(); + if !file_overlaps.is_empty() { + lines.push("Potential overlaps".to_string()); + for overlap in file_overlaps { + lines.push(format!( + "- {}", + file_overlap_summary( + &overlap, + &self.short_timestamp(&overlap.timestamp.to_rfc3339()) + ) + )); + } + } lines.push(format!( "Cost ${:.4} | Duration {}s", metrics.cost_usd, metrics.duration_secs @@ -5447,6 +5463,18 @@ fn file_activity_patch_lines(entry: &FileActivityEntry, max_lines: usize) -> Vec .unwrap_or_default() } +fn file_overlap_summary(entry: &FileActivityOverlap, timestamp: &str) -> String { + format!( + "{} {} | {} {} as {} | {}", + file_activity_verb(entry.current_action.clone()), + truncate_for_dashboard(&entry.path, 48), + entry.other_session_state, + format_session_id(&entry.other_session_id), + file_activity_verb(entry.other_action.clone()), + timestamp + ) +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -6152,6 +6180,58 @@ mod tests { Ok(()) } + #[test] + fn metrics_text_surfaces_file_activity_overlaps() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-file-overlaps-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let now = Utc::now(); + let mut focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + focus.created_at = now - chrono::Duration::hours(1); + focus.updated_at = now - chrono::Duration::minutes(3); + + let mut delegate = sample_session( + "delegate-87654321", + "coder", + SessionState::Idle, + Some("ecc/delegate"), + 256, + 12, + ); + delegate.created_at = now - chrono::Duration::minutes(50); + delegate.updated_at = now - chrono::Duration::minutes(2); + + let mut dashboard = test_dashboard(vec![focus.clone(), delegate.clone()], 0); + dashboard.db.insert_session(&focus)?; + dashboard.db.insert_session(&delegate)?; + + let metrics_path = root.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"updated lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"delegate-87654321\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"touched lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + dashboard.db.sync_tool_activity_metrics(&metrics_path)?; + dashboard.sync_from_store(); + + let metrics_text = dashboard.selected_session_metrics_text(); + assert!(metrics_text.contains("Potential overlaps")); + assert!(metrics_text.contains("modify src/lib.rs")); + assert!(metrics_text.contains("idle delegate")); + assert!(metrics_text.contains("as modify")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn timeline_time_filter_hides_old_events() { let now = Utc::now(); diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index 0b15c2fd..da56c677 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -260,6 +260,54 @@ function runTests() { fs.rmSync(repoDir, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('captures tracked Delete activity using git diff context', () => { + const tmpHome = makeTempDir(); + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-delete-repo-')); + + spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' }); + + const srcDir = path.join(repoDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + const trackedFile = path.join(srcDir, 'obsolete.ts'); + fs.writeFileSync(trackedFile, 'export const obsolete = true;\n', 'utf8'); + spawnSync('git', ['add', 'src/obsolete.ts'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' }); + + fs.rmSync(trackedFile, { force: true }); + + const input = { + tool_name: 'Delete', + tool_input: { + file_path: 'src/obsolete.ts', + }, + tool_output: { output: 'deleted src/obsolete.ts' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-delete', + }, { + cwd: repoDir, + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_events, [ + { + path: 'src/obsolete.ts', + action: 'delete', + diff_preview: 'export const obsolete = true; ->', + patch_preview: '@@ -1 +0,0 @@\n-export const obsolete = true;', + }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { const tmpHome = makeTempDir(); const input = { From b01a300c3176a1ba2f6a05cd2a4e9a055886da28 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 08:04:18 -0700 Subject: [PATCH 097/459] feat(ecc2): persist tool log params and trigger context --- ecc2/src/observability/mod.rs | 10 +++ ecc2/src/session/store.rs | 78 +++++++++++++++++--- ecc2/src/tui/dashboard.rs | 53 ++++++++++++- scripts/hooks/session-activity-tracker.js | 45 +++++++++++ tests/hooks/session-activity-tracker.test.js | 4 + 5 files changed, 175 insertions(+), 15 deletions(-) diff --git a/ecc2/src/observability/mod.rs b/ecc2/src/observability/mod.rs index 586c4431..fae8ddd0 100644 --- a/ecc2/src/observability/mod.rs +++ b/ecc2/src/observability/mod.rs @@ -9,7 +9,9 @@ pub struct ToolCallEvent { pub session_id: String, pub tool_name: String, pub input_summary: String, + pub input_params_json: String, pub output_summary: String, + pub trigger_summary: String, pub duration_ms: u64, pub risk_score: f64, } @@ -47,7 +49,9 @@ impl ToolCallEvent { .score, tool_name, input_summary, + input_params_json: "{}".to_string(), output_summary: output_summary.into(), + trigger_summary: String::new(), duration_ms, } } @@ -238,7 +242,9 @@ pub struct ToolLogEntry { pub session_id: String, pub tool_name: String, pub input_summary: String, + pub input_params_json: String, pub output_summary: String, + pub trigger_summary: String, pub duration_ms: u64, pub risk_score: f64, pub timestamp: String, @@ -268,7 +274,9 @@ impl<'a> ToolLogger<'a> { &event.session_id, &event.tool_name, &event.input_summary, + &event.input_params_json, &event.output_summary, + &event.trigger_summary, event.duration_ms, event.risk_score, ×tamp, @@ -398,6 +406,8 @@ mod tests { assert_eq!(first_page.entries.len(), 2); assert_eq!(first_page.entries[0].tool_name, "Bash"); assert_eq!(first_page.entries[1].tool_name, "Write"); + assert_eq!(first_page.entries[0].input_params_json, "{}"); + assert_eq!(first_page.entries[0].trigger_summary, ""); let second_page = logger.query("sess-1", 2, 2)?; assert_eq!(second_page.total, 3); diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 1fa31298..18d6610c 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -155,7 +155,9 @@ impl StateStore { session_id TEXT NOT NULL REFERENCES sessions(id), tool_name TEXT NOT NULL, input_summary TEXT, + input_params_json TEXT NOT NULL DEFAULT '{}', output_summary TEXT, + trigger_summary TEXT NOT NULL DEFAULT '', duration_ms INTEGER, risk_score REAL DEFAULT 0.0, timestamp TEXT NOT NULL, @@ -293,6 +295,24 @@ impl StateStore { .context("Failed to add file_events_json column to tool_log table")?; } + if !self.has_column("tool_log", "input_params_json")? { + self.conn + .execute( + "ALTER TABLE tool_log ADD COLUMN input_params_json TEXT NOT NULL DEFAULT '{}'", + [], + ) + .context("Failed to add input_params_json column to tool_log table")?; + } + + if !self.has_column("tool_log", "trigger_summary")? { + self.conn + .execute( + "ALTER TABLE tool_log ADD COLUMN trigger_summary TEXT NOT NULL DEFAULT ''", + [], + ) + .context("Failed to add trigger_summary column to tool_log table")?; + } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn .execute( @@ -754,6 +774,8 @@ impl StateStore { tool_name: String, #[serde(default)] input_summary: String, + #[serde(default = "default_input_params_json")] + input_params_json: String, #[serde(default)] output_summary: String, #[serde(default)] @@ -781,6 +803,11 @@ impl StateStore { let reader = BufReader::new(file); let mut aggregates: HashMap = HashMap::new(); let mut seen_event_ids = HashSet::new(); + let session_tasks = self + .list_sessions()? + .into_iter() + .map(|session| (session.id, session.task)) + .collect::>(); for line in reader.lines() { let line = line?; @@ -853,6 +880,7 @@ impl StateStore { ) .score; let session_id = row.session_id.clone(); + let trigger_summary = session_tasks.get(&session_id).cloned().unwrap_or_default(); self.conn.execute( "INSERT OR IGNORE INTO tool_log ( @@ -860,20 +888,24 @@ impl StateStore { session_id, tool_name, input_summary, + input_params_json, output_summary, + trigger_summary, duration_ms, risk_score, timestamp, file_paths_json, file_events_json ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", rusqlite::params![ row.id, row.session_id, row.tool_name, row.input_summary, + row.input_params_json, row.output_summary, + trigger_summary, row.duration_ms, risk_score, timestamp, @@ -1472,19 +1504,23 @@ impl StateStore { session_id: &str, tool_name: &str, input_summary: &str, + input_params_json: &str, output_summary: &str, + trigger_summary: &str, duration_ms: u64, risk_score: f64, timestamp: &str, ) -> Result { self.conn.execute( - "INSERT INTO tool_log (session_id, tool_name, input_summary, output_summary, duration_ms, risk_score, timestamp) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + "INSERT INTO tool_log (session_id, tool_name, input_summary, input_params_json, output_summary, trigger_summary, duration_ms, risk_score, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", rusqlite::params![ session_id, tool_name, input_summary, + input_params_json, output_summary, + trigger_summary, duration_ms, risk_score, timestamp, @@ -1496,7 +1532,9 @@ impl StateStore { session_id: session_id.to_string(), tool_name: tool_name.to_string(), input_summary: input_summary.to_string(), + input_params_json: input_params_json.to_string(), output_summary: output_summary.to_string(), + trigger_summary: trigger_summary.to_string(), duration_ms, risk_score, timestamp: timestamp.to_string(), @@ -1519,7 +1557,7 @@ impl StateStore { )?; let mut stmt = self.conn.prepare( - "SELECT id, session_id, tool_name, input_summary, output_summary, duration_ms, risk_score, timestamp + "SELECT id, session_id, tool_name, input_summary, input_params_json, output_summary, trigger_summary, duration_ms, risk_score, timestamp FROM tool_log WHERE session_id = ?1 ORDER BY timestamp DESC, id DESC @@ -1533,10 +1571,14 @@ impl StateStore { session_id: row.get(1)?, tool_name: row.get(2)?, input_summary: row.get::<_, Option>(3)?.unwrap_or_default(), - output_summary: row.get::<_, Option>(4)?.unwrap_or_default(), - duration_ms: row.get::<_, Option>(5)?.unwrap_or_default(), - risk_score: row.get::<_, Option>(6)?.unwrap_or_default(), - timestamp: row.get(7)?, + input_params_json: row + .get::<_, Option>(4)? + .unwrap_or_else(|| "{}".to_string()), + output_summary: row.get::<_, Option>(5)?.unwrap_or_default(), + trigger_summary: row.get::<_, Option>(6)?.unwrap_or_default(), + duration_ms: row.get::<_, Option>(7)?.unwrap_or_default(), + risk_score: row.get::<_, Option>(8)?.unwrap_or_default(), + timestamp: row.get(9)?, }) })? .collect::, _>>()?; @@ -1757,6 +1799,10 @@ fn normalize_optional_string(value: Option) -> Option { }) } +fn default_input_params_json() -> String { + "{}".to_string() +} + fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { let tool_name = tool_name.trim().to_ascii_lowercase(); if tool_name.contains("read") { @@ -1991,9 +2037,9 @@ mod tests { fs::write( &metrics_path, concat!( - "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", - "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", - "{\"id\":\"evt-2\",\"session_id\":\"session-1\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\",\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"input_params_json\":\"{\\\"file_path\\\":\\\"src/lib.rs\\\"}\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Read\",\"input_summary\":\"Read src/lib.rs\",\"input_params_json\":\"{\\\"file_path\\\":\\\"src/lib.rs\\\"}\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\"],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-1\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"input_params_json\":\"{\\\"file_path\\\":\\\"README.md\\\",\\\"content\\\":\\\"hello\\\"}\",\"output_summary\":\"ok\",\"file_paths\":[\"src/lib.rs\",\"README.md\"],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" ), )?; @@ -2015,6 +2061,16 @@ mod tests { assert_eq!(logs.total, 2); assert_eq!(logs.entries[0].tool_name, "Write"); assert_eq!(logs.entries[1].tool_name, "Read"); + assert_eq!( + logs.entries[0].input_params_json, + "{\"file_path\":\"README.md\",\"content\":\"hello\"}" + ); + assert_eq!(logs.entries[0].trigger_summary, "sync tools"); + assert_eq!( + logs.entries[1].input_params_json, + "{\"file_path\":\"src/lib.rs\"}" + ); + assert_eq!(logs.entries[1].trigger_summary, "sync tools"); Ok(()) } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 53d8c23a..df8b60fd 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -883,15 +883,31 @@ impl Dashboard { self.logs .iter() .map(|entry| { - format!( - "[{}] {} | {}ms | risk {:.0}%\ninput: {}\noutput: {}", + let mut block = format!( + "[{}] {} | {}ms | risk {:.0}%", self.short_timestamp(&entry.timestamp), entry.tool_name, entry.duration_ms, entry.risk_score * 100.0, + ); + if !entry.trigger_summary.trim().is_empty() { + block.push_str(&format!( + "\nwhy: {}", + self.log_field(&entry.trigger_summary) + )); + } + if entry.input_params_json.trim() != "{}" { + block.push_str(&format!( + "\nparams: {}", + self.log_field(&entry.input_params_json) + )); + } + block.push_str(&format!( + "\ninput: {}\noutput: {}", self.log_field(&entry.input_summary), self.log_field(&entry.output_summary) - ) + )); + block }) .collect::>() .join("\n\n") @@ -3559,7 +3575,7 @@ impl Dashboard { entry.duration_ms, truncate_for_dashboard(&entry.input_summary, 56) ), - detail_lines: Vec::new(), + detail_lines: tool_log_detail_lines(&entry), }) })); events @@ -5475,6 +5491,23 @@ fn file_overlap_summary(entry: &FileActivityOverlap, timestamp: &str) -> String ) } +fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec { + let mut lines = Vec::new(); + if !entry.trigger_summary.trim().is_empty() { + lines.push(format!( + "why {}", + truncate_for_dashboard(&entry.trigger_summary, 72) + )); + } + if entry.input_params_json.trim() != "{}" { + lines.push(format!( + "params {}", + truncate_for_dashboard(&entry.input_params_json, 72) + )); + } + lines +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -6050,7 +6083,9 @@ mod tests { "focus-12345678", "bash", "cargo test -q", + "{\"command\":\"cargo test -q\"}", "ok", + "stabilize planner session", 240, 0.2, &(now - chrono::Duration::minutes(3)).to_rfc3339(), @@ -6069,6 +6104,8 @@ mod tests { assert!(rendered.contains("created session as planner")); assert!(rendered.contains("received query lead-123")); assert!(rendered.contains("tool bash")); + assert!(rendered.contains("why stabilize planner session")); + assert!(rendered.contains("params {\"command\":\"cargo test -q\"}")); assert!(rendered.contains("files touched 3")); } @@ -6104,7 +6141,9 @@ mod tests { "focus-12345678", "bash", "cargo test -q", + "{}", "ok", + "", 240, 0.2, &(now - chrono::Duration::minutes(3)).to_rfc3339(), @@ -6254,7 +6293,9 @@ mod tests { "focus-12345678", "bash", "cargo test -q", + "{}", "ok", + "", 240, 0.2, &(now - chrono::Duration::minutes(3)).to_rfc3339(), @@ -6313,7 +6354,9 @@ mod tests { "focus-12345678", "bash", "cargo test -q", + "{}", "ok", + "", 240, 0.2, &(now - chrono::Duration::minutes(4)).to_rfc3339(), @@ -6325,7 +6368,9 @@ mod tests { "review-87654321", "git", "git status --short", + "{}", "ok", + "", 120, 0.1, &(now - chrono::Duration::minutes(2)).to_rfc3339(), diff --git a/scripts/hooks/session-activity-tracker.js b/scripts/hooks/session-activity-tracker.js index e802d5d0..9d4716a1 100644 --- a/scripts/hooks/session-activity-tracker.js +++ b/scripts/hooks/session-activity-tracker.js @@ -50,6 +50,50 @@ function truncateSummary(value, maxLength = 220) { return `${normalized.slice(0, maxLength - 3)}...`; } +function sanitizeParamValue(value, depth = 0) { + if (depth >= 4) { + return '[Truncated]'; + } + + if (value == null) { + return value; + } + + if (typeof value === 'string') { + return truncateSummary(value, 160); + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + if (Array.isArray(value)) { + return value.slice(0, 8).map(entry => sanitizeParamValue(entry, depth + 1)); + } + + if (typeof value === 'object') { + const output = {}; + for (const [key, nested] of Object.entries(value).slice(0, 20)) { + output[key] = sanitizeParamValue(nested, depth + 1); + } + return output; + } + + return truncateSummary(String(value), 160); +} + +function sanitizeInputParams(toolInput) { + if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) { + return '{}'; + } + + try { + return JSON.stringify(sanitizeParamValue(toolInput)); + } catch { + return '{}'; + } +} + function pushPathCandidate(paths, value) { const candidate = String(value || '').trim(); if (!candidate) { @@ -514,6 +558,7 @@ function buildActivityRow(input, env = process.env) { session_id: sessionId, tool_name: toolName, input_summary: summarizeInput(toolName, toolInput, filePaths), + input_params_json: sanitizeInputParams(toolInput), output_summary: summarizeOutput(input?.tool_output), duration_ms: 0, file_paths: filePaths, diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index da56c677..99eb7c6f 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -95,6 +95,7 @@ function runTests() { const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); assert.strictEqual(row.session_id, 'ecc-session-1234'); assert.strictEqual(row.tool_name, 'Write'); + assert.strictEqual(row.input_params_json, '{"file_path":"src/app.rs"}'); assert.deepStrictEqual(row.file_paths, ['src/app.rs']); assert.deepStrictEqual(row.file_events, [{ path: 'src/app.rs', action: 'create' }]); assert.ok(row.id, 'Expected stable event id'); @@ -331,6 +332,9 @@ function runTests() { assert.ok(row.input_summary.includes('')); assert.ok(!row.input_summary.includes('abc123')); assert.ok(!row.input_summary.includes('topsecret')); + assert.ok(row.input_params_json.includes('')); + assert.ok(!row.input_params_json.includes('abc123')); + assert.ok(!row.input_params_json.includes('topsecret')); fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); From 941d4e6172f71a3b1c166ed7f9de3ea283394c60 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 08:08:42 -0700 Subject: [PATCH 098/459] feat(ecc2): enforce configurable worktree branch prefixes --- ecc2/src/config/mod.rs | 15 ++++ ecc2/src/session/manager.rs | 1 + ecc2/src/tui/dashboard.rs | 1 + ecc2/src/worktree/mod.rs | 141 ++++++++++++++++++++++++++---------- 4 files changed, 118 insertions(+), 40 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index d24f61a2..a60f07f1 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -33,6 +33,7 @@ pub struct BudgetAlertThresholds { pub struct Config { pub db_path: PathBuf, pub worktree_root: PathBuf, + pub worktree_branch_prefix: String, pub max_parallel_sessions: usize, pub max_parallel_worktrees: usize, pub session_timeout_secs: u64, @@ -88,6 +89,7 @@ impl Default for Config { Self { db_path: home.join(".claude").join("ecc2.db"), worktree_root: PathBuf::from("/tmp/ecc-worktrees"), + worktree_branch_prefix: "ecc".to_string(), max_parallel_sessions: 8, max_parallel_worktrees: 6, session_timeout_secs: 3600, @@ -350,6 +352,10 @@ theme = "Dark" let config: Config = toml::from_str(legacy_config).unwrap(); let defaults = Config::default(); + assert_eq!( + config.worktree_branch_prefix, + defaults.worktree_branch_prefix + ); assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); assert_eq!( @@ -406,6 +412,13 @@ theme = "Dark" assert_eq!(config.pane_layout, PaneLayout::Grid); } + #[test] + fn worktree_branch_prefix_deserializes_from_toml() { + let config: Config = toml::from_str(r#"worktree_branch_prefix = "bots/ecc""#).unwrap(); + + assert_eq!(config.worktree_branch_prefix, "bots/ecc"); + } + #[test] fn pane_navigation_deserializes_from_toml() { let config: Config = toml::from_str( @@ -535,6 +548,7 @@ critical = 1.10 config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; + config.worktree_branch_prefix = "bots/ecc".to_string(); config.budget_alert_thresholds = BudgetAlertThresholds { advisory: 0.45, warning: 0.70, @@ -553,6 +567,7 @@ critical = 1.10 assert_eq!(loaded.auto_dispatch_limit_per_session, 9); assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); + assert_eq!(loaded.worktree_branch_prefix, "bots/ecc"); assert_eq!( loaded.budget_alert_thresholds, BudgetAlertThresholds { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 58ed4dfe..016abde9 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1791,6 +1791,7 @@ mod tests { Config { db_path: root.join("state.db"), worktree_root: root.join("worktrees"), + worktree_branch_prefix: "ecc".to_string(), max_parallel_sessions: 4, max_parallel_worktrees: 4, session_timeout_secs: 60, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index df8b60fd..7b06afb0 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -9604,6 +9604,7 @@ diff --git a/src/next.rs b/src/next.rs Config { db_path: root.join("state.db"), worktree_root: root.join("worktrees"), + worktree_branch_prefix: "ecc".to_string(), max_parallel_sessions: 4, max_parallel_worktrees: 4, session_timeout_secs: 60, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 95c93c29..38f43add 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -44,7 +44,7 @@ pub(crate) fn create_for_session_in_repo( cfg: &Config, repo_root: &Path, ) -> Result { - let branch = format!("ecc/{session_id}"); + let branch = branch_name_for_session(session_id, cfg, repo_root)?; let path = cfg.worktree_root.join(session_id); // Get current branch as base @@ -80,6 +80,27 @@ pub(crate) fn create_for_session_in_repo( }) } +pub(crate) fn branch_name_for_session( + session_id: &str, + cfg: &Config, + repo_root: &Path, +) -> Result { + let prefix = cfg.worktree_branch_prefix.trim().trim_matches('/'); + if prefix.is_empty() { + anyhow::bail!("worktree_branch_prefix cannot be empty"); + } + + let branch = format!("{prefix}/{session_id}"); + validate_branch_name(repo_root, &branch).with_context(|| { + format!( + "Invalid worktree branch '{branch}' derived from prefix '{}' and session id '{session_id}'", + cfg.worktree_branch_prefix + ) + })?; + + Ok(branch) +} + /// Remove a worktree and its branch. pub fn remove(worktree: &WorktreeInfo) -> Result<()> { let repo_root = match base_checkout_path(worktree) { @@ -461,6 +482,26 @@ fn git_status_short(worktree_path: &Path) -> Result> { Ok(parse_nonempty_lines(&output.stdout)) } +fn validate_branch_name(repo_root: &Path, branch: &str) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["check-ref-format", "--branch", branch]) + .output() + .context("Failed to validate worktree branch name")?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + anyhow::bail!("branch name is not a valid git ref"); + } else { + anyhow::bail!("{stderr}"); + } + } +} + fn parse_nonempty_lines(stdout: &[u8]) -> Vec { String::from_utf8_lossy(stdout) .lines() @@ -576,9 +617,7 @@ mod tests { Ok(()) } - #[test] - fn diff_summary_reports_clean_and_dirty_worktrees() -> Result<()> { - let root = std::env::temp_dir().join(format!("ecc2-worktree-{}", Uuid::new_v4())); + fn init_repo(root: &Path) -> Result { let repo = root.join("repo"); fs::create_dir_all(&repo)?; @@ -589,6 +628,60 @@ mod tests { run_git(&repo, &["add", "README.md"])?; run_git(&repo, &["commit", "-m", "init"])?; + Ok(repo) + } + + #[test] + fn create_for_session_uses_configured_branch_prefix() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-prefix-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let mut cfg = Config::default(); + cfg.worktree_root = root.join("worktrees"); + cfg.worktree_branch_prefix = "bots/ecc".to_string(); + + let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; + assert_eq!(worktree.branch, "bots/ecc/worker-123"); + + let branch = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["rev-parse", "--abbrev-ref", "bots/ecc/worker-123"]) + .output()?; + assert!(branch.status.success()); + assert_eq!( + String::from_utf8_lossy(&branch.stdout).trim(), + "bots/ecc/worker-123" + ); + + remove(&worktree)?; + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn create_for_session_rejects_invalid_branch_prefix() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-worktree-invalid-prefix-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let mut cfg = Config::default(); + cfg.worktree_root = root.join("worktrees"); + cfg.worktree_branch_prefix = "bad prefix".to_string(); + + let error = create_for_session_in_repo("worker-123", &cfg, &repo).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("Invalid worktree branch")); + assert!(message.contains("bad prefix")); + assert!(!cfg.worktree_root.join("worker-123").exists()); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn diff_summary_reports_clean_and_dirty_worktrees() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let worktree_dir = root.join("wt-1"); run_git( &repo, @@ -631,15 +724,7 @@ mod tests { #[test] fn diff_file_preview_reports_branch_and_working_tree_files() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-worktree-preview-{}", Uuid::new_v4())); - let repo = root.join("repo"); - fs::create_dir_all(&repo)?; - - run_git(&repo, &["init", "-b", "main"])?; - run_git(&repo, &["config", "user.email", "ecc@example.com"])?; - run_git(&repo, &["config", "user.name", "ECC"])?; - fs::write(repo.join("README.md"), "hello\n")?; - run_git(&repo, &["add", "README.md"])?; - run_git(&repo, &["commit", "-m", "init"])?; + let repo = init_repo(&root)?; let worktree_dir = root.join("wt-1"); run_git( @@ -686,15 +771,7 @@ mod tests { #[test] fn diff_patch_preview_reports_branch_and_working_tree_sections() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-worktree-patch-{}", Uuid::new_v4())); - let repo = root.join("repo"); - fs::create_dir_all(&repo)?; - - run_git(&repo, &["init", "-b", "main"])?; - run_git(&repo, &["config", "user.email", "ecc@example.com"])?; - run_git(&repo, &["config", "user.name", "ECC"])?; - fs::write(repo.join("README.md"), "hello\n")?; - run_git(&repo, &["add", "README.md"])?; - run_git(&repo, &["commit", "-m", "init"])?; + let repo = init_repo(&root)?; let worktree_dir = root.join("wt-1"); run_git( @@ -740,15 +817,7 @@ mod tests { fn merge_readiness_reports_ready_worktree() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-worktree-merge-ready-{}", Uuid::new_v4())); - let repo = root.join("repo"); - fs::create_dir_all(&repo)?; - - run_git(&repo, &["init", "-b", "main"])?; - run_git(&repo, &["config", "user.email", "ecc@example.com"])?; - run_git(&repo, &["config", "user.name", "ECC"])?; - fs::write(repo.join("README.md"), "hello\n")?; - run_git(&repo, &["add", "README.md"])?; - run_git(&repo, &["commit", "-m", "init"])?; + let repo = init_repo(&root)?; let worktree_dir = root.join("wt-1"); run_git( @@ -792,15 +861,7 @@ mod tests { fn merge_readiness_reports_conflicted_worktree() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-worktree-merge-conflict-{}", Uuid::new_v4())); - let repo = root.join("repo"); - fs::create_dir_all(&repo)?; - - run_git(&repo, &["init", "-b", "main"])?; - run_git(&repo, &["config", "user.email", "ecc@example.com"])?; - run_git(&repo, &["config", "user.name", "ECC"])?; - fs::write(repo.join("README.md"), "hello\n")?; - run_git(&repo, &["add", "README.md"])?; - run_git(&repo, &["commit", "-m", "init"])?; + let repo = init_repo(&root)?; let worktree_dir = root.join("wt-1"); run_git( From 491f213fbd2fb6fba52faa5854fd4d0e4bc093e1 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 08:23:01 -0700 Subject: [PATCH 099/459] feat: enforce queued parallel worktree limits --- ecc2/src/config/mod.rs | 63 ++++++++- ecc2/src/session/daemon.rs | 4 + ecc2/src/session/manager.rs | 259 +++++++++++++++++++++++++++++++++++- ecc2/src/session/store.rs | 124 ++++++++++++++++- ecc2/src/tui/dashboard.rs | 43 +++++- 5 files changed, 473 insertions(+), 20 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index a60f07f1..bc692582 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -68,6 +68,11 @@ pub struct PaneNavigationConfig { pub move_right: String, } +#[derive(Debug, Default, Deserialize)] +struct ProjectWorktreeConfigOverride { + max_parallel_worktrees: Option, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaneNavigationAction { FocusSlot(usize), @@ -155,14 +160,47 @@ impl Config { pub fn load() -> Result { let config_path = Self::config_path(); + let project_path = std::env::current_dir() + .ok() + .and_then(|cwd| Self::project_config_path_from(&cwd)); + Self::load_from_paths(&config_path, project_path.as_deref()) + } - if config_path.exists() { - let content = std::fs::read_to_string(&config_path)?; - let config: Config = toml::from_str(&content)?; - Ok(config) + fn load_from_paths( + config_path: &std::path::Path, + project_override_path: Option<&std::path::Path>, + ) -> Result { + let mut config = if config_path.exists() { + let content = std::fs::read_to_string(config_path)?; + toml::from_str(&content)? } else { - Ok(Config::default()) + Config::default() + }; + + if let Some(project_path) = project_override_path.filter(|path| path.exists()) { + let content = std::fs::read_to_string(project_path)?; + let overrides: ProjectWorktreeConfigOverride = toml::from_str(&content)?; + if let Some(limit) = overrides.max_parallel_worktrees { + config.max_parallel_worktrees = limit; + } } + + Ok(config) + } + + fn project_config_path_from(start: &std::path::Path) -> Option { + let global = Self::config_path(); + let mut current = Some(start); + + while let Some(path) = current { + let candidate = path.join(".claude").join("ecc2.toml"); + if candidate.exists() && candidate != global { + return Some(candidate); + } + current = path.parent(); + } + + None } pub fn save(&self) -> Result<()> { @@ -419,6 +457,21 @@ theme = "Dark" assert_eq!(config.worktree_branch_prefix, "bots/ecc"); } + #[test] + fn project_worktree_limit_override_replaces_global_limit() { + let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4())); + let global_path = tempdir.join("global.toml"); + let project_path = tempdir.join("project.toml"); + std::fs::create_dir_all(&tempdir).unwrap(); + std::fs::write(&global_path, "max_parallel_worktrees = 6\n").unwrap(); + std::fs::write(&project_path, "max_parallel_worktrees = 2\n").unwrap(); + + let config = Config::load_from_paths(&global_path, Some(&project_path)).unwrap(); + assert_eq!(config.max_parallel_worktrees, 2); + + let _ = std::fs::remove_dir_all(tempdir); + } + #[test] fn pane_navigation_deserializes_from_toml() { let config: Config = toml::from_str( diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index b8e4d7a3..e88a92bb 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -39,6 +39,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Worktree auto-prune pass failed: {e}"); } + if let Err(e) = manager::activate_pending_worktree_sessions(&db, &cfg).await { + tracing::error!("Queued worktree activation pass failed: {e}"); + } + time::sleep(heartbeat_interval).await; } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 016abde9..964e16d2 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -963,6 +963,114 @@ pub async fn run_session( Ok(()) } +pub async fn activate_pending_worktree_sessions( + db: &StateStore, + cfg: &Config, +) -> Result> { + activate_pending_worktree_sessions_with( + db, + cfg, + |cfg, session_id, task, agent_type, cwd| async move { + tokio::spawn(async move { + if let Err(error) = run_session(&cfg, &session_id, &task, &agent_type, &cwd).await { + tracing::error!( + "Failed to start queued worktree session {}: {error}", + session_id + ); + } + }); + Ok(()) + }, + ) + .await +} + +async fn activate_pending_worktree_sessions_with( + db: &StateStore, + cfg: &Config, + spawn: F, +) -> Result> +where + F: Fn(Config, String, String, String, PathBuf) -> Fut, + Fut: std::future::Future>, +{ + let mut available_slots = cfg + .max_parallel_worktrees + .saturating_sub(attached_worktree_count(db)?); + if available_slots == 0 { + return Ok(Vec::new()); + } + + let mut started = Vec::new(); + for request in db.pending_worktree_queue(available_slots)? { + let Some(session) = db.get_session(&request.session_id)? else { + db.dequeue_pending_worktree(&request.session_id)?; + continue; + }; + + if session.worktree.is_some() + || session.pid.is_some() + || session.state != SessionState::Pending + { + db.dequeue_pending_worktree(&session.id)?; + continue; + } + + let worktree = + match worktree::create_for_session_in_repo(&session.id, cfg, &request.repo_root) { + Ok(worktree) => worktree, + Err(error) => { + db.dequeue_pending_worktree(&session.id)?; + db.update_state(&session.id, &SessionState::Failed)?; + tracing::warn!( + "Failed to create queued worktree for session {}: {error}", + session.id + ); + continue; + } + }; + + if let Err(error) = db.attach_worktree(&session.id, &worktree) { + let _ = worktree::remove(&worktree); + db.dequeue_pending_worktree(&session.id)?; + db.update_state(&session.id, &SessionState::Failed)?; + return Err(error.context(format!( + "Failed to attach queued worktree for session {}", + session.id + ))); + } + + if let Err(error) = spawn( + cfg.clone(), + session.id.clone(), + session.task.clone(), + session.agent_type.clone(), + worktree.path.clone(), + ) + .await + { + let _ = worktree::remove(&worktree); + let _ = db.clear_worktree_to_dir(&session.id, &request.repo_root); + db.dequeue_pending_worktree(&session.id)?; + db.update_state(&session.id, &SessionState::Failed)?; + tracing::warn!( + "Failed to start queued worktree session {}: {error}", + session.id + ); + continue; + } + + db.dequeue_pending_worktree(&session.id)?; + started.push(session.id); + available_slots = available_slots.saturating_sub(1); + if available_slots == 0 { + break; + } + } + + Ok(started) +} + async fn queue_session_in_dir( db: &StateStore, cfg: &Config, @@ -992,9 +1100,14 @@ async fn queue_session_in_dir_with_runner_program( repo_root: &Path, runner_program: &Path, ) -> Result { - let session = build_session_record(task, agent_type, use_worktree, cfg, repo_root)?; + let session = build_session_record(db, task, agent_type, use_worktree, cfg, repo_root)?; db.insert_session(&session)?; + if use_worktree && session.worktree.is_none() { + db.enqueue_pending_worktree(&session.id, repo_root)?; + return Ok(session.id); + } + let working_dir = session .worktree .as_ref() @@ -1024,6 +1137,7 @@ async fn queue_session_in_dir_with_runner_program( } fn build_session_record( + db: &StateStore, task: &str, agent_type: &str, use_worktree: bool, @@ -1033,7 +1147,7 @@ fn build_session_record( let id = uuid::Uuid::new_v4().to_string()[..8].to_string(); let now = chrono::Utc::now(); - let worktree = if use_worktree { + let worktree = if use_worktree && attached_worktree_count(db)? < cfg.max_parallel_worktrees { Some(worktree::create_for_session_in_repo(&id, cfg, repo_root)?) } else { None @@ -1067,10 +1181,15 @@ async fn create_session_in_dir( repo_root: &Path, agent_program: &Path, ) -> Result { - let session = build_session_record(task, agent_type, use_worktree, cfg, repo_root)?; + let session = build_session_record(db, task, agent_type, use_worktree, cfg, repo_root)?; db.insert_session(&session)?; + if use_worktree && session.worktree.is_none() { + db.enqueue_pending_worktree(&session.id, repo_root)?; + return Ok(session.id); + } + let working_dir = session .worktree .as_ref() @@ -1095,6 +1214,14 @@ async fn create_session_in_dir( } } +fn attached_worktree_count(db: &StateStore) -> Result { + Ok(db + .list_sessions()? + .into_iter() + .filter(|session| session.worktree.is_some()) + .count()) +} + async fn spawn_session_runner( task: &str, session_id: &str, @@ -1296,6 +1423,7 @@ fn stop_session_recorded(db: &StateStore, session: &Session, cleanup_worktree: b if cleanup_worktree { if let Some(worktree) = session.worktree.as_ref() { crate::worktree::remove(worktree)?; + db.clear_worktree_to_dir(&session.id, &session.working_dir)?; } } @@ -2095,6 +2223,131 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn create_session_with_worktree_limit_queues_without_starting_runner() -> Result<()> { + let tempdir = TestDir::new("manager-worktree-limit-queue")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.max_parallel_worktrees = 1; + let db = StateStore::open(&cfg.db_path)?; + let (fake_claude, log_path) = write_fake_claude(tempdir.path())?; + + let first_id = create_session_in_dir( + &db, + &cfg, + "active worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + let second_id = create_session_in_dir( + &db, + &cfg, + "queued worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + + let first = db + .get_session(&first_id)? + .context("first session missing")?; + assert_eq!(first.state, SessionState::Running); + assert!(first.worktree.is_some()); + + let second = db + .get_session(&second_id)? + .context("second session missing")?; + assert_eq!(second.state, SessionState::Pending); + assert!(second.pid.is_none()); + assert!(second.worktree.is_none()); + assert!(db.pending_worktree_queue_contains(&second_id)?); + + let log = wait_for_file(&log_path)?; + assert!(log.contains("active worktree")); + assert!(!log.contains("queued worktree")); + + stop_session_with_options(&db, &first_id, true).await?; + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn activate_pending_worktree_sessions_starts_queued_session_when_slot_opens() -> Result<()> + { + let tempdir = TestDir::new("manager-worktree-limit-activate")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.max_parallel_worktrees = 1; + let db = StateStore::open(&cfg.db_path)?; + let (fake_claude, _) = write_fake_claude(tempdir.path())?; + + let first_id = create_session_in_dir( + &db, + &cfg, + "active worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + let second_id = create_session_in_dir( + &db, + &cfg, + "queued worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + + stop_session_with_options(&db, &first_id, true).await?; + + let launch_log = tempdir.path().join("queued-launch.log"); + let started = + activate_pending_worktree_sessions_with(&db, &cfg, |_, session_id, task, _, cwd| { + let launch_log = launch_log.clone(); + async move { + fs::write( + &launch_log, + format!("{session_id}\n{task}\n{}\n", cwd.display()), + )?; + Ok(()) + } + }) + .await?; + + assert_eq!(started, vec![second_id.clone()]); + assert!(!db.pending_worktree_queue_contains(&second_id)?); + + let second = db + .get_session(&second_id)? + .context("queued session missing")?; + let worktree = second + .worktree + .context("queued session should gain worktree")?; + assert_eq!(second.state, SessionState::Pending); + assert!(worktree.path.exists()); + + let launch = fs::read_to_string(&launch_log)?; + assert!(launch.contains(&second_id)); + assert!(launch.contains("queued worktree")); + assert!(launch.contains(worktree.path.to_string_lossy().as_ref())); + + crate::worktree::remove(&worktree)?; + db.clear_worktree_to_dir(&second_id, &repo_root)?; + Ok(()) + } + #[test] fn enforce_budget_hard_limits_stops_active_sessions_without_cleaning_worktrees() -> Result<()> { let tempdir = TestDir::new("manager-budget-pause")?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 18d6610c..3b3244ca 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -14,12 +14,20 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, + WorktreeInfo, }; pub struct StateStore { conn: Connection, } +#[derive(Debug, Clone)] +pub struct PendingWorktreeRequest { + pub session_id: String, + pub repo_root: PathBuf, + pub _requested_at: chrono::DateTime, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct FileActivityOverlap { pub path: String, @@ -183,6 +191,12 @@ impl StateStore { timestamp TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS pending_worktree_queue ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + repo_root TEXT NOT NULL, + requested_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS daemon_activity ( id INTEGER PRIMARY KEY CHECK(id = 1), last_dispatch_at TEXT, @@ -215,6 +229,8 @@ impl StateStore { CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session, read); CREATE INDEX IF NOT EXISTS idx_session_output_session ON session_output(session_id, id); + CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at + ON pending_worktree_queue(requested_at, session_id); INSERT OR IGNORE INTO daemon_activity (id) VALUES (1); ", @@ -575,15 +591,29 @@ impl StateStore { } pub fn clear_worktree(&self, session_id: &str) -> Result<()> { + let working_dir: String = self.conn.query_row( + "SELECT working_dir FROM sessions WHERE id = ?1", + [session_id], + |row| row.get(0), + )?; + self.clear_worktree_to_dir(session_id, Path::new(&working_dir)) + } + + pub fn clear_worktree_to_dir(&self, session_id: &str, working_dir: &Path) -> Result<()> { let updated = self.conn.execute( "UPDATE sessions - SET worktree_path = NULL, + SET working_dir = ?1, + worktree_path = NULL, worktree_branch = NULL, worktree_base = NULL, - updated_at = ?1, - last_heartbeat_at = ?1 - WHERE id = ?2", - rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id], + updated_at = ?2, + last_heartbeat_at = ?2 + WHERE id = ?3", + rusqlite::params![ + working_dir.to_string_lossy().to_string(), + chrono::Utc::now().to_rfc3339(), + session_id + ], )?; if updated == 0 { @@ -593,6 +623,90 @@ impl StateStore { Ok(()) } + pub fn attach_worktree(&self, session_id: &str, worktree: &WorktreeInfo) -> Result<()> { + let updated = self.conn.execute( + "UPDATE sessions + SET working_dir = ?1, + worktree_path = ?2, + worktree_branch = ?3, + worktree_base = ?4, + updated_at = ?5, + last_heartbeat_at = ?5 + WHERE id = ?6", + rusqlite::params![ + worktree.path.to_string_lossy().to_string(), + worktree.path.to_string_lossy().to_string(), + worktree.branch, + worktree.base_branch, + chrono::Utc::now().to_rfc3339(), + session_id + ], + )?; + + if updated == 0 { + anyhow::bail!("Session not found: {session_id}"); + } + + Ok(()) + } + + pub fn enqueue_pending_worktree(&self, session_id: &str, repo_root: &Path) -> Result<()> { + self.conn.execute( + "INSERT OR REPLACE INTO pending_worktree_queue (session_id, repo_root, requested_at) + VALUES (?1, ?2, ?3)", + rusqlite::params![ + session_id, + repo_root.to_string_lossy().to_string(), + chrono::Utc::now().to_rfc3339() + ], + )?; + Ok(()) + } + + pub fn dequeue_pending_worktree(&self, session_id: &str) -> Result<()> { + self.conn.execute( + "DELETE FROM pending_worktree_queue WHERE session_id = ?1", + [session_id], + )?; + Ok(()) + } + + pub fn pending_worktree_queue_contains(&self, session_id: &str) -> Result { + Ok(self + .conn + .query_row( + "SELECT 1 FROM pending_worktree_queue WHERE session_id = ?1", + [session_id], + |_| Ok(()), + ) + .optional()? + .is_some()) + } + + pub fn pending_worktree_queue(&self, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT session_id, repo_root, requested_at + FROM pending_worktree_queue + ORDER BY requested_at ASC, session_id ASC + LIMIT ?1", + )?; + + let rows = stmt + .query_map([limit as i64], |row| { + let requested_at: String = row.get(2)?; + Ok(PendingWorktreeRequest { + session_id: row.get(0)?, + repo_root: PathBuf::from(row.get::<_, String>(1)?), + _requested_at: chrono::DateTime::parse_from_rfc3339(&requested_at) + .unwrap_or_default() + .with_timezone(&chrono::Utc), + }) + })? + .collect::, _>>()?; + + Ok(rows) + } + pub fn update_metrics(&self, session_id: &str, metrics: &SessionMetrics) -> Result<()> { self.conn.execute( "UPDATE sessions diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 7b06afb0..eda90b22 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -1673,10 +1673,21 @@ impl Dashboard { self.refresh(); self.sync_selection_by_id(Some(&session_id)); - self.set_operator_note(format!( - "spawned session {}", - format_session_id(&session_id) - )); + let queued_for_worktree = self + .db + .pending_worktree_queue_contains(&session_id) + .unwrap_or(false); + if queued_for_worktree { + self.set_operator_note(format!( + "queued session {} pending worktree slot", + format_session_id(&session_id) + )); + } else { + self.set_operator_note(format!( + "spawned session {}", + format_session_id(&session_id) + )); + } self.reset_output_view(); self.sync_selected_output(); self.sync_selected_diff(); @@ -2565,7 +2576,15 @@ impl Dashboard { let preferred_selection = post_spawn_selection_id(source_session_id.as_deref(), &created_ids); self.refresh_after_spawn(preferred_selection.as_deref()); - let mut note = build_spawn_note(&plan, created_ids.len()); + let queued_count = created_ids + .iter() + .filter(|session_id| { + self.db + .pending_worktree_queue_contains(session_id) + .unwrap_or(false) + }) + .count(); + let mut note = build_spawn_note(&plan, created_ids.len(), queued_count); if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len()) { note.push_str(" | "); note.push_str(&layout_note); @@ -2770,6 +2789,10 @@ impl Dashboard { } } + if let Err(error) = manager::activate_pending_worktree_sessions(&self.db, &self.cfg).await { + tracing::warn!("Failed to activate queued worktree sessions: {error}"); + } + self.sync_from_store(); } @@ -4768,16 +4791,22 @@ fn expand_spawn_tasks(task: &str, count: usize) -> Vec { .collect() } -fn build_spawn_note(plan: &SpawnPlan, created_count: usize) -> String { +fn build_spawn_note(plan: &SpawnPlan, created_count: usize, queued_count: usize) -> String { let task = truncate_for_dashboard(&plan.task, 72); - if plan.spawn_count < plan.requested_count { + let mut note = if plan.spawn_count < plan.requested_count { format!( "spawned {created_count} session(s) for {task} (requested {}, capped at {})", plan.requested_count, plan.spawn_count ) } else { format!("spawned {created_count} session(s) for {task}") + }; + + if queued_count > 0 { + note.push_str(&format!(" | {queued_count} pending worktree slot")); } + + note } fn post_spawn_selection_id( From 13f99cbf1c56ff35473b3cf925752b01458af4d7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 08:29:21 -0700 Subject: [PATCH 100/459] feat: add worktree retention cleanup policy --- ecc2/src/config/mod.rs | 7 +++ ecc2/src/main.rs | 17 +++++++- ecc2/src/session/daemon.rs | 11 +++-- ecc2/src/session/manager.rs | 79 +++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 85 ++++++++++++++++++++++++++++++++++--- 5 files changed, 186 insertions(+), 13 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index bc692582..165d8363 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -36,6 +36,7 @@ pub struct Config { pub worktree_branch_prefix: String, pub max_parallel_sessions: usize, pub max_parallel_worktrees: usize, + pub worktree_retention_secs: u64, pub session_timeout_secs: u64, pub heartbeat_interval_secs: u64, pub auto_terminate_stale_sessions: bool, @@ -97,6 +98,7 @@ impl Default for Config { worktree_branch_prefix: "ecc".to_string(), max_parallel_sessions: 8, max_parallel_worktrees: 6, + worktree_retention_secs: 0, session_timeout_secs: 3600, heartbeat_interval_secs: 30, auto_terminate_stale_sessions: false, @@ -380,6 +382,7 @@ db_path = "/tmp/ecc2.db" worktree_root = "/tmp/ecc-worktrees" max_parallel_sessions = 8 max_parallel_worktrees = 6 +worktree_retention_secs = 0 session_timeout_secs = 3600 heartbeat_interval_secs = 30 auto_terminate_stale_sessions = false @@ -394,6 +397,10 @@ theme = "Dark" config.worktree_branch_prefix, defaults.worktree_branch_prefix ); + assert_eq!( + config.worktree_retention_secs, + defaults.worktree_retention_secs + ); assert_eq!(config.cost_budget_usd, defaults.cost_budget_usd); assert_eq!(config.token_budget, defaults.token_budget); assert_eq!( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 7966369a..699f22dd 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -801,7 +801,7 @@ async fn main() -> Result<()> { } } Some(Commands::PruneWorktrees { json }) => { - let outcome = session::manager::prune_inactive_worktrees(&db).await?; + let outcome = session::manager::prune_inactive_worktrees(&db, &cfg).await?; if json { println!("{}", serde_json::to_string_pretty(&outcome)?); } else { @@ -1434,6 +1434,18 @@ fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome } } + if outcome.retained_session_ids.is_empty() { + lines.push("No inactive worktrees are being retained".to_string()); + } else { + lines.push(format!( + "Deferred {} inactive worktree(s) still within retention", + outcome.retained_session_ids.len() + )); + for session_id in &outcome.retained_session_ids { + lines.push(format!("- retained {}", short_session(session_id))); + } + } + lines.join("\n") } @@ -2105,12 +2117,15 @@ mod tests { let text = format_prune_worktrees_human(&session::manager::WorktreePruneOutcome { cleaned_session_ids: vec!["deadbeefcafefeed".to_string()], active_with_worktree_ids: vec!["facefeed12345678".to_string()], + retained_session_ids: vec!["retain1234567890".to_string()], }); assert!(text.contains("Pruned 1 inactive worktree(s)")); assert!(text.contains("- cleaned deadbeef")); assert!(text.contains("Skipped 1 active session(s) still holding worktrees")); assert!(text.contains("- active facefeed")); + assert!(text.contains("Deferred 1 inactive worktree(s) still within retention")); + assert!(text.contains("- retained retain12")); } #[test] diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index e88a92bb..f8fc7c6d 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -35,7 +35,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Worktree auto-merge pass failed: {e}"); } - if let Err(e) = maybe_auto_prune_inactive_worktrees(&db).await { + if let Err(e) = maybe_auto_prune_inactive_worktrees(&db, &cfg).await { tracing::error!("Worktree auto-prune pass failed: {e}"); } @@ -393,9 +393,9 @@ where Ok(merged) } -async fn maybe_auto_prune_inactive_worktrees(db: &StateStore) -> Result { +async fn maybe_auto_prune_inactive_worktrees(db: &StateStore, cfg: &Config) -> Result { maybe_auto_prune_inactive_worktrees_with_recorder( - || manager::prune_inactive_worktrees(db), + || manager::prune_inactive_worktrees(db, cfg), |pruned, active| db.record_daemon_auto_prune_pass(pruned, active), ) .await @@ -421,6 +421,7 @@ where let outcome = prune().await?; let pruned = outcome.cleaned_session_ids.len(); let active = outcome.active_with_worktree_ids.len(); + let retained = outcome.retained_session_ids.len(); record(pruned, active)?; if pruned > 0 { @@ -429,6 +430,9 @@ where if active > 0 { tracing::info!("Skipped {active} active worktree(s) during auto-prune"); } + if retained > 0 { + tracing::info!("Deferred {retained} inactive worktree(s) within retention"); + } Ok(pruned) } @@ -1255,6 +1259,7 @@ mod tests { Ok(manager::WorktreePruneOutcome { cleaned_session_ids: vec!["stopped-a".to_string(), "stopped-b".to_string()], active_with_worktree_ids: vec!["running-a".to_string()], + retained_session_ids: vec!["retained-a".to_string()], }) }, move |pruned, active| { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 964e16d2..aeb903c4 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -862,12 +862,19 @@ pub async fn merge_ready_worktrees( pub struct WorktreePruneOutcome { pub cleaned_session_ids: Vec, pub active_with_worktree_ids: Vec, + pub retained_session_ids: Vec, } -pub async fn prune_inactive_worktrees(db: &StateStore) -> Result { +pub async fn prune_inactive_worktrees( + db: &StateStore, + cfg: &Config, +) -> Result { let sessions = db.list_sessions()?; let mut cleaned_session_ids = Vec::new(); let mut active_with_worktree_ids = Vec::new(); + let mut retained_session_ids = Vec::new(); + let retention = chrono::Duration::seconds(cfg.worktree_retention_secs as i64); + let now = chrono::Utc::now(); for session in sessions { let Some(_) = session.worktree.as_ref() else { @@ -882,6 +889,13 @@ pub async fn prune_inactive_worktrees(db: &StateStore) -> Result chrono::Duration::zero() + && now.signed_duration_since(session.last_heartbeat_at) < retention + { + retained_session_ids.push(session.id); + continue; + } + cleanup_session_worktree(db, &session.id).await?; cleaned_session_ids.push(session.id); } @@ -889,6 +903,7 @@ pub async fn prune_inactive_worktrees(db: &StateStore) -> Result Result<()> { + let tempdir = TestDir::new("manager-prune-worktree-retention")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.worktree_retention_secs = 3600; + let db = StateStore::open(&cfg.db_path)?; + let (fake_claude, _) = write_fake_claude(tempdir.path())?; + + let session_id = create_session_in_dir( + &db, + &cfg, + "recently completed worktree", + "claude", + true, + &repo_root, + &fake_claude, + ) + .await?; + + stop_session_with_options(&db, &session_id, false).await?; + + let before = db + .get_session(&session_id)? + .context("retained session should exist")?; + let worktree_path = before + .worktree + .clone() + .context("retained session worktree missing")? + .path; + + let outcome = prune_inactive_worktrees(&db, &cfg).await?; + + assert!(outcome.cleaned_session_ids.is_empty()); + assert!(outcome.active_with_worktree_ids.is_empty()); + assert_eq!(outcome.retained_session_ids, vec![session_id.clone()]); + assert!(worktree_path.exists(), "retained worktree should remain"); + assert!( + db.get_session(&session_id)? + .context("retained session should still exist")? + .worktree + .is_some(), + "retained session should keep worktree metadata" + ); + + crate::worktree::remove( + &db.get_session(&session_id)? + .context("retained session should still exist")? + .worktree + .context("retained session should still have worktree")?, + )?; + db.clear_worktree_to_dir(&session_id, &repo_root)?; + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn merge_session_worktree_merges_branch_and_cleans_worktree() -> Result<()> { let tempdir = TestDir::new("manager-merge-worktree")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index eda90b22..10e9168c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -2256,22 +2256,43 @@ impl Dashboard { } pub async fn prune_inactive_worktrees(&mut self) { - match manager::prune_inactive_worktrees(&self.db).await { + match manager::prune_inactive_worktrees(&self.db, &self.cfg).await { Ok(outcome) => { self.refresh(); - if outcome.cleaned_session_ids.is_empty() { + if outcome.cleaned_session_ids.is_empty() && outcome.retained_session_ids.is_empty() + { self.set_operator_note("no inactive worktrees to prune".to_string()); - } else if outcome.active_with_worktree_ids.is_empty() { + } else if outcome.cleaned_session_ids.is_empty() { self.set_operator_note(format!( - "pruned {} inactive worktree(s)", - outcome.cleaned_session_ids.len() + "deferred {} inactive worktree(s) within retention", + outcome.retained_session_ids.len() )); + } else if outcome.active_with_worktree_ids.is_empty() { + if outcome.retained_session_ids.is_empty() { + self.set_operator_note(format!( + "pruned {} inactive worktree(s)", + outcome.cleaned_session_ids.len() + )); + } else { + self.set_operator_note(format!( + "pruned {} inactive worktree(s); deferred {} within retention", + outcome.cleaned_session_ids.len(), + outcome.retained_session_ids.len() + )); + } } else { - self.set_operator_note(format!( + let mut note = format!( "pruned {} inactive worktree(s); skipped {} active session(s)", outcome.cleaned_session_ids.len(), outcome.active_with_worktree_ids.len() - )); + ); + if !outcome.retained_session_ids.is_empty() { + note.push_str(&format!( + "; deferred {} within retention", + outcome.retained_session_ids.len() + )); + } + self.set_operator_note(note); } } Err(error) => { @@ -8745,6 +8766,55 @@ diff --git a/src/next.rs b/src/next.rs Ok(()) } + #[tokio::test] + async fn prune_inactive_worktrees_reports_retained_sessions_within_retention() -> Result<()> { + let db_path = std::env::temp_dir().join(format!("ecc2-dashboard-{}.db", Uuid::new_v4())); + let db = StateStore::open(&db_path)?; + let now = Utc::now(); + let retained_path = std::env::temp_dir().join(format!("ecc2-retained-{}", Uuid::new_v4())); + std::fs::create_dir_all(&retained_path)?; + + db.insert_session(&Session { + id: "stopped-1".to_string(), + task: "retain me".to_string(), + agent_type: "claude".to_string(), + working_dir: retained_path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(WorktreeInfo { + path: retained_path.clone(), + branch: "ecc/stopped-1".to_string(), + base_branch: "main".to_string(), + }), + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let mut cfg = Config::default(); + cfg.db_path = db_path.clone(); + cfg.worktree_retention_secs = 3600; + + let dashboard_store = StateStore::open(&db_path)?; + let mut dashboard = Dashboard::new(dashboard_store, cfg); + dashboard.prune_inactive_worktrees().await; + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("deferred 1 inactive worktree(s) within retention") + ); + assert!(db + .get_session("stopped-1")? + .expect("stopped session should exist") + .worktree + .is_some()); + + let _ = std::fs::remove_dir_all(retained_path); + let _ = std::fs::remove_file(db_path); + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn merge_selected_worktree_sets_operator_note_when_ready() -> Result<()> { let tempdir = std::env::temp_dir().join(format!("dashboard-merge-{}", Uuid::new_v4())); @@ -9636,6 +9706,7 @@ diff --git a/src/next.rs b/src/next.rs worktree_branch_prefix: "ecc".to_string(), max_parallel_sessions: 4, max_parallel_worktrees: 4, + worktree_retention_secs: 0, session_timeout_secs: 60, heartbeat_interval_secs: 5, auto_terminate_stale_sessions: false, From f5437078e1a8ff9597470ff4ef949887800a802c Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 08:41:10 -0700 Subject: [PATCH 101/459] feat: add diff view modes and hunk navigation --- ecc2/src/tui/app.rs | 3 + ecc2/src/tui/dashboard.rs | 238 +++++++++++++++++++++++++++++++++++++- 2 files changed, 238 insertions(+), 3 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index b1d936ee..d7b4e6ea 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -91,6 +91,9 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(), (_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), + (_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(), + (_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(), + (_, KeyCode::Char('}')) => dashboard.next_diff_hunk(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), (_, KeyCode::Char('e')) => dashboard.toggle_output_filter(), (_, KeyCode::Char('f')) => dashboard.cycle_output_time_filter(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 10e9168c..f742f85d 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -40,6 +40,7 @@ const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3; struct WorktreeDiffColumns { removals: String, additions: String, + hunk_offsets: Vec, } #[derive(Debug, Clone, Copy)] @@ -75,6 +76,10 @@ pub struct Dashboard { selected_diff_summary: Option, selected_diff_preview: Vec, selected_diff_patch: Option, + selected_diff_hunk_offsets_unified: Vec, + selected_diff_hunk_offsets_split: Vec, + selected_diff_hunk: usize, + diff_view_mode: DiffViewMode, selected_conflict_protocol: Option, selected_merge_readiness: Option, output_mode: OutputMode, @@ -139,6 +144,12 @@ enum OutputMode { ConflictProtocol, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DiffViewMode { + Split, + Unified, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OutputFilter { All, @@ -327,6 +338,10 @@ impl Dashboard { selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_diff_hunk_offsets_unified: Vec::new(), + selected_diff_hunk_offsets_split: Vec::new(), + selected_diff_hunk: 0, + diff_view_mode: DiffViewMode::Split, selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, @@ -542,6 +557,7 @@ impl Dashboard { if self.sessions.get(self.selected_session).is_some() && self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_some() + && self.diff_view_mode == DiffViewMode::Split { self.render_split_diff_output(frame, area); return; @@ -588,7 +604,7 @@ impl Dashboard { .unwrap_or_else(|| { "No worktree diff available for the selected session.".to_string() }); - (" Diff ".to_string(), Text::from(content)) + (self.output_title(), Text::from(content)) } OutputMode::ConflictProtocol => { let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| { @@ -618,7 +634,7 @@ impl Dashboard { fn render_split_diff_output(&mut self, frame: &mut Frame, area: Rect) { let block = Block::default() .borders(Borders::ALL) - .title(" Diff ") + .title(self.output_title()) .border_style(self.pane_border_style(Pane::Output)); let inner_area = block.inner(area); frame.render_widget(block, area); @@ -659,6 +675,14 @@ impl Dashboard { ); } + if self.output_mode == OutputMode::WorktreeDiff { + return format!( + " Diff{}{} ", + self.diff_view_mode.title_suffix(), + self.diff_hunk_title_suffix() + ); + } + let filter = format!( "{}{}", self.output_filter.title_suffix(), @@ -1022,6 +1046,8 @@ impl Dashboard { " y Toggle selected-session timeline view".to_string(), " E Cycle timeline event filter".to_string(), " v Toggle selected worktree diff in output pane".to_string(), + " V Toggle diff view mode between split and unified".to_string(), + " {/} Jump to previous/next diff hunk in the active diff view".to_string(), " c Show conflict-resolution protocol for selected conflicted worktree" .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), @@ -1704,7 +1730,7 @@ impl Dashboard { self.output_mode = OutputMode::WorktreeDiff; self.selected_pane = Pane::Output; self.output_follow = false; - self.output_scroll_offset = 0; + self.output_scroll_offset = self.current_diff_hunk_offset(); self.set_operator_note("showing selected worktree diff".to_string()); } else { self.set_operator_note("no worktree diff for selected session".to_string()); @@ -1728,6 +1754,54 @@ impl Dashboard { } } + pub fn toggle_diff_view_mode(&mut self) { + if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { + self.set_operator_note("no active worktree diff view to toggle".to_string()); + return; + } + + self.diff_view_mode = match self.diff_view_mode { + DiffViewMode::Split => DiffViewMode::Unified, + DiffViewMode::Unified => DiffViewMode::Split, + }; + self.output_follow = false; + self.output_scroll_offset = self.current_diff_hunk_offset(); + self.set_operator_note(format!("diff view set to {}", self.diff_view_mode.label())); + } + + pub fn next_diff_hunk(&mut self) { + self.move_diff_hunk(1); + } + + pub fn prev_diff_hunk(&mut self) { + self.move_diff_hunk(-1); + } + + fn move_diff_hunk(&mut self, delta: isize) { + if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { + self.set_operator_note("no active worktree diff to navigate".to_string()); + return; + } + + let (len, next_offset) = { + let offsets = self.current_diff_hunk_offsets(); + if offsets.is_empty() { + self.set_operator_note("no diff hunks in bounded preview".to_string()); + return; + } + + let len = offsets.len(); + let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize; + (len, offsets[next]) + }; + + let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize; + self.selected_diff_hunk = next; + self.output_follow = false; + self.output_scroll_offset = next_offset; + self.set_operator_note(format!("diff hunk {}/{}", next + 1, len)); + } + pub fn toggle_timeline_mode(&mut self) { match self.output_mode { OutputMode::Timeline => { @@ -3161,6 +3235,19 @@ impl Dashboard { .ok() .flatten() }); + self.selected_diff_hunk_offsets_unified = self + .selected_diff_patch + .as_deref() + .map(build_unified_diff_hunk_offsets) + .unwrap_or_default(); + self.selected_diff_hunk_offsets_split = self + .selected_diff_patch + .as_deref() + .map(|patch| build_worktree_diff_columns(patch).hunk_offsets) + .unwrap_or_default(); + if self.selected_diff_hunk >= self.current_diff_hunk_offsets().len() { + self.selected_diff_hunk = 0; + } self.selected_merge_readiness = worktree.and_then(|worktree| worktree::merge_readiness(worktree).ok()); self.selected_conflict_protocol = session @@ -3179,6 +3266,29 @@ impl Dashboard { } } + fn current_diff_hunk_offsets(&self) -> &[usize] { + match self.diff_view_mode { + DiffViewMode::Split => &self.selected_diff_hunk_offsets_split, + DiffViewMode::Unified => &self.selected_diff_hunk_offsets_unified, + } + } + + fn current_diff_hunk_offset(&self) -> usize { + self.current_diff_hunk_offsets() + .get(self.selected_diff_hunk) + .copied() + .unwrap_or(0) + } + + fn diff_hunk_title_suffix(&self) -> String { + let total = self.current_diff_hunk_offsets().len(); + if total == 0 { + String::new() + } else { + format!(" {}/{}", self.selected_diff_hunk + 1, total) + } + } + fn sync_selected_messages(&mut self) { let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { self.selected_messages.clear(); @@ -4954,6 +5064,22 @@ impl OutputTimeFilter { } } +impl DiffViewMode { + fn label(self) -> &'static str { + match self { + Self::Split => "split", + Self::Unified => "unified", + } + } + + fn title_suffix(self) -> &'static str { + match self { + Self::Split => " split", + Self::Unified => " unified", + } + } +} + impl TimelineEventFilter { fn next(self) -> Self { match self { @@ -5421,6 +5547,7 @@ fn highlight_output_line( fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { let mut removals = Vec::new(); let mut additions = Vec::new(); + let mut hunk_offsets = Vec::new(); for line in patch.lines() { if line.is_empty() { @@ -5444,6 +5571,9 @@ fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { } if line.starts_with("diff --git ") || line.starts_with("@@") { + if line.starts_with("@@") { + hunk_offsets.push(removals.len().max(additions.len())); + } removals.push(line.to_string()); additions.push(line.to_string()); continue; @@ -5471,9 +5601,18 @@ fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { } else { additions.join("\n") }, + hunk_offsets, } } +fn build_unified_diff_hunk_offsets(patch: &str) -> Vec { + patch + .lines() + .enumerate() + .filter_map(|(index, line)| line.starts_with("@@").then_some(index)) + .collect() +} + fn session_state_label(state: &SessionState) -> &'static str { match state { SessionState::Pending => "Pending", @@ -6101,6 +6240,95 @@ mod tests { assert!(rendered.contains("+new line")); } + #[test] + fn toggle_diff_view_mode_switches_to_unified_rendering() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + let patch = "--- Branch diff vs main ---\n\ +diff --git a/src/lib.rs b/src/lib.rs\n\ +@@ -1 +1 @@\n\ +-old line\n\ ++new line" + .to_string(); + dashboard.selected_diff_summary = Some("1 file changed".to_string()); + dashboard.selected_diff_patch = Some(patch.clone()); + dashboard.selected_diff_hunk_offsets_split = + build_worktree_diff_columns(&patch).hunk_offsets; + dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch); + dashboard.toggle_output_mode(); + + dashboard.toggle_diff_view_mode(); + + assert_eq!(dashboard.diff_view_mode, DiffViewMode::Unified); + assert_eq!(dashboard.output_title(), " Diff unified 1/1 "); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("diff view set to unified") + ); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Diff unified 1/1")); + assert!(rendered.contains("@@ -1 +1 @@")); + assert!(rendered.contains("-old line")); + assert!(rendered.contains("+new line")); + assert!(!rendered.contains("Removals")); + assert!(!rendered.contains("Additions")); + } + + #[test] + fn diff_hunk_navigation_updates_scroll_offset_and_wraps() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + let patch = "--- Branch diff vs main ---\n\ +diff --git a/src/lib.rs b/src/lib.rs\n\ +@@ -1 +1 @@\n\ +-old line\n\ ++new line\n\ +@@ -5 +5 @@\n\ +-second old\n\ ++second new" + .to_string(); + dashboard.selected_diff_patch = Some(patch.clone()); + let split_offsets = build_worktree_diff_columns(&patch).hunk_offsets; + dashboard.selected_diff_hunk_offsets_split = split_offsets.clone(); + dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch); + dashboard.output_mode = OutputMode::WorktreeDiff; + + dashboard.next_diff_hunk(); + assert_eq!(dashboard.selected_diff_hunk, 1); + assert_eq!(dashboard.output_scroll_offset, split_offsets[1]); + assert_eq!(dashboard.output_title(), " Diff split 2/2 "); + assert_eq!(dashboard.operator_note.as_deref(), Some("diff hunk 2/2")); + + dashboard.next_diff_hunk(); + assert_eq!(dashboard.selected_diff_hunk, 0); + assert_eq!(dashboard.output_scroll_offset, split_offsets[0]); + assert_eq!(dashboard.output_title(), " Diff split 1/2 "); + assert_eq!(dashboard.operator_note.as_deref(), Some("diff hunk 1/2")); + + dashboard.prev_diff_hunk(); + assert_eq!(dashboard.selected_diff_hunk, 1); + assert_eq!(dashboard.output_scroll_offset, split_offsets[1]); + assert_eq!(dashboard.operator_note.as_deref(), Some("diff hunk 2/2")); + } + #[test] fn toggle_timeline_mode_renders_selected_session_events() { let now = Utc::now(); @@ -9667,6 +9895,10 @@ diff --git a/src/next.rs b/src/next.rs selected_diff_summary: None, selected_diff_preview: Vec::new(), selected_diff_patch: None, + selected_diff_hunk_offsets_unified: Vec::new(), + selected_diff_hunk_offsets_split: Vec::new(), + selected_diff_hunk: 0, + diff_view_mode: DiffViewMode::Split, selected_conflict_protocol: None, selected_merge_readiness: None, output_mode: OutputMode::SessionOutput, From 2048f0d6f5dbeac428b2c5286d3659d2cecf2259 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 08:55:53 -0700 Subject: [PATCH 102/459] feat: add word diff highlighting to tui diffs --- ecc2/src/tui/dashboard.rs | 569 +++++++++++++++++++++++++++++++++----- 1 file changed, 500 insertions(+), 69 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f742f85d..dda97a92 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -38,8 +38,8 @@ const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3; #[derive(Debug, Clone, PartialEq, Eq)] struct WorktreeDiffColumns { - removals: String, - additions: String, + removals: Text<'static>, + additions: Text<'static>, hunk_offsets: Vec, } @@ -591,20 +591,24 @@ impl Dashboard { (self.output_title(), content) } OutputMode::WorktreeDiff => { - let content = self - .selected_diff_patch - .clone() - .or_else(|| { - self.selected_diff_summary.as_ref().map(|summary| { - format!( - "{summary}\n\nNo patch content to preview yet. The worktree may be clean or only have summary-level changes." - ) - }) - }) - .unwrap_or_else(|| { - "No worktree diff available for the selected session.".to_string() - }); - (self.output_title(), Text::from(content)) + let content = if let Some(patch) = self.selected_diff_patch.as_ref() { + build_unified_diff_text(patch, self.theme_palette()) + } else { + Text::from( + self.selected_diff_summary + .as_ref() + .map(|summary| { + format!( + "{summary}\n\nNo patch content to preview yet. The worktree may be clean or only have summary-level changes." + ) + }) + .unwrap_or_else(|| { + "No worktree diff available for the selected session." + .to_string() + }), + ) + }; + (self.output_title(), content) } OutputMode::ConflictProtocol => { let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| { @@ -646,7 +650,7 @@ impl Dashboard { let Some(patch) = self.selected_diff_patch.as_ref() else { return; }; - let columns = build_worktree_diff_columns(patch); + let columns = build_worktree_diff_columns(patch, self.theme_palette()); let column_chunks = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) @@ -3243,7 +3247,7 @@ impl Dashboard { self.selected_diff_hunk_offsets_split = self .selected_diff_patch .as_deref() - .map(|patch| build_worktree_diff_columns(patch).hunk_offsets) + .map(|patch| build_worktree_diff_columns(patch, self.theme_palette()).hunk_offsets) .unwrap_or_default(); if self.selected_diff_hunk >= self.current_diff_hunk_offsets().len() { self.selected_diff_hunk = 0; @@ -5544,73 +5548,423 @@ fn highlight_output_line( } } -fn build_worktree_diff_columns(patch: &str) -> WorktreeDiffColumns { +fn build_worktree_diff_columns(patch: &str, palette: ThemePalette) -> WorktreeDiffColumns { let mut removals = Vec::new(); let mut additions = Vec::new(); let mut hunk_offsets = Vec::new(); + let mut pending_removals = Vec::new(); + let mut pending_additions = Vec::new(); for line in patch.lines() { + if is_diff_removal_line(line) { + pending_removals.push(line[1..].to_string()); + continue; + } + + if is_diff_addition_line(line) { + pending_additions.push(line[1..].to_string()); + continue; + } + + flush_split_diff_change_block( + &mut removals, + &mut additions, + &mut pending_removals, + &mut pending_additions, + palette, + ); + if line.is_empty() { continue; } - if line.starts_with("--- ") && !line.starts_with("--- a/") { - removals.push(line.to_string()); - additions.push(line.to_string()); - continue; + if line.starts_with("@@") { + hunk_offsets.push(removals.len().max(additions.len())); } - if let Some(path) = line.strip_prefix("--- a/") { - removals.push(format!("File {path}")); - continue; - } - - if let Some(path) = line.strip_prefix("+++ b/") { - additions.push(format!("File {path}")); - continue; - } - - if line.starts_with("diff --git ") || line.starts_with("@@") { - if line.starts_with("@@") { - hunk_offsets.push(removals.len().max(additions.len())); - } - removals.push(line.to_string()); - additions.push(line.to_string()); - continue; - } - - if line.starts_with('-') { - removals.push(line.to_string()); - continue; - } - - if line.starts_with('+') { - additions.push(line.to_string()); - continue; - } + let styled_line = if line.starts_with(' ') { + styled_diff_context_line(line, palette) + } else { + styled_diff_meta_line(split_diff_display_line(line), palette) + }; + removals.push(styled_line.clone()); + additions.push(styled_line); } + flush_split_diff_change_block( + &mut removals, + &mut additions, + &mut pending_removals, + &mut pending_additions, + palette, + ); + WorktreeDiffColumns { removals: if removals.is_empty() { - "No removals in this bounded preview.".to_string() + Text::from("No removals in this bounded preview.") } else { - removals.join("\n") + Text::from(removals) }, additions: if additions.is_empty() { - "No additions in this bounded preview.".to_string() + Text::from("No additions in this bounded preview.") } else { - additions.join("\n") + Text::from(additions) }, hunk_offsets, } } +fn build_unified_diff_text(patch: &str, palette: ThemePalette) -> Text<'static> { + let mut lines = Vec::new(); + let mut pending_removals = Vec::new(); + let mut pending_additions = Vec::new(); + + for line in patch.lines() { + if is_diff_removal_line(line) { + pending_removals.push(line[1..].to_string()); + continue; + } + + if is_diff_addition_line(line) { + pending_additions.push(line[1..].to_string()); + continue; + } + + flush_unified_diff_change_block( + &mut lines, + &mut pending_removals, + &mut pending_additions, + palette, + ); + + if line.is_empty() { + continue; + } + + lines.push(if line.starts_with(' ') { + styled_diff_context_line(line, palette) + } else { + styled_diff_meta_line(line, palette) + }); + } + + flush_unified_diff_change_block( + &mut lines, + &mut pending_removals, + &mut pending_additions, + palette, + ); + + Text::from(lines) +} + fn build_unified_diff_hunk_offsets(patch: &str) -> Vec { - patch - .lines() - .enumerate() - .filter_map(|(index, line)| line.starts_with("@@").then_some(index)) - .collect() + let mut offsets = Vec::new(); + let mut rendered_index = 0usize; + let mut pending_removals = 0usize; + let mut pending_additions = 0usize; + + for line in patch.lines() { + if is_diff_removal_line(line) { + pending_removals += 1; + continue; + } + + if is_diff_addition_line(line) { + pending_additions += 1; + continue; + } + + if pending_removals > 0 || pending_additions > 0 { + rendered_index += pending_removals + pending_additions; + pending_removals = 0; + pending_additions = 0; + } + + if line.is_empty() { + continue; + } + + if line.starts_with("@@") { + offsets.push(rendered_index); + } + rendered_index += 1; + } + + offsets +} + +fn flush_split_diff_change_block( + removals: &mut Vec>, + additions: &mut Vec>, + pending_removals: &mut Vec, + pending_additions: &mut Vec, + palette: ThemePalette, +) { + let pair_count = pending_removals.len().max(pending_additions.len()); + for index in 0..pair_count { + match (pending_removals.get(index), pending_additions.get(index)) { + (Some(removal), Some(addition)) => { + let (removal_mask, addition_mask) = + diff_word_change_masks(removal.as_str(), addition.as_str()); + removals.push(styled_diff_change_line( + '-', + removal, + &removal_mask, + diff_removal_style(palette), + diff_removal_word_style(), + )); + additions.push(styled_diff_change_line( + '+', + addition, + &addition_mask, + diff_addition_style(palette), + diff_addition_word_style(), + )); + } + (Some(removal), None) => { + removals.push(styled_diff_change_line( + '-', + removal, + &vec![false; tokenize_diff_words(removal).len()], + diff_removal_style(palette), + diff_removal_word_style(), + )); + additions.push(Line::from("")); + } + (None, Some(addition)) => { + removals.push(Line::from("")); + additions.push(styled_diff_change_line( + '+', + addition, + &vec![false; tokenize_diff_words(addition).len()], + diff_addition_style(palette), + diff_addition_word_style(), + )); + } + (None, None) => {} + } + } + + pending_removals.clear(); + pending_additions.clear(); +} + +fn flush_unified_diff_change_block( + lines: &mut Vec>, + pending_removals: &mut Vec, + pending_additions: &mut Vec, + palette: ThemePalette, +) { + let pair_count = pending_removals.len().max(pending_additions.len()); + for index in 0..pair_count { + match (pending_removals.get(index), pending_additions.get(index)) { + (Some(removal), Some(addition)) => { + let (removal_mask, addition_mask) = + diff_word_change_masks(removal.as_str(), addition.as_str()); + lines.push(styled_diff_change_line( + '-', + removal, + &removal_mask, + diff_removal_style(palette), + diff_removal_word_style(), + )); + lines.push(styled_diff_change_line( + '+', + addition, + &addition_mask, + diff_addition_style(palette), + diff_addition_word_style(), + )); + } + (Some(removal), None) => lines.push(styled_diff_change_line( + '-', + removal, + &vec![false; tokenize_diff_words(removal).len()], + diff_removal_style(palette), + diff_removal_word_style(), + )), + (None, Some(addition)) => lines.push(styled_diff_change_line( + '+', + addition, + &vec![false; tokenize_diff_words(addition).len()], + diff_addition_style(palette), + diff_addition_word_style(), + )), + (None, None) => {} + } + } + + pending_removals.clear(); + pending_additions.clear(); +} + +fn split_diff_display_line(line: &str) -> String { + if line.starts_with("--- ") && !line.starts_with("--- a/") { + return line.to_string(); + } + + if let Some(path) = line.strip_prefix("--- a/") { + return format!("File {path}"); + } + + if let Some(path) = line.strip_prefix("+++ b/") { + return format!("File {path}"); + } + + line.to_string() +} + +fn is_diff_removal_line(line: &str) -> bool { + line.starts_with('-') && !line.starts_with("--- ") +} + +fn is_diff_addition_line(line: &str) -> bool { + line.starts_with('+') && !line.starts_with("+++ ") +} + +fn styled_diff_meta_line(text: impl Into, palette: ThemePalette) -> Line<'static> { + Line::from(vec![Span::styled(text.into(), diff_meta_style(palette))]) +} + +fn styled_diff_context_line(text: &str, palette: ThemePalette) -> Line<'static> { + Line::from(vec![Span::styled( + text.to_string(), + diff_context_style(palette), + )]) +} + +fn styled_diff_change_line( + prefix: char, + body: &str, + change_mask: &[bool], + base_style: Style, + changed_style: Style, +) -> Line<'static> { + let tokens = tokenize_diff_words(body); + let mut spans = vec![Span::styled( + prefix.to_string(), + base_style.add_modifier(Modifier::BOLD), + )]; + + for (index, token) in tokens.into_iter().enumerate() { + let style = if change_mask.get(index).copied().unwrap_or(false) { + changed_style + } else { + base_style + }; + spans.push(Span::styled(token, style)); + } + + Line::from(spans) +} + +fn tokenize_diff_words(text: &str) -> Vec { + if text.is_empty() { + return Vec::new(); + } + + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut current_is_whitespace: Option = None; + + for ch in text.chars() { + let is_whitespace = ch.is_whitespace(); + match current_is_whitespace { + Some(state) if state == is_whitespace => current.push(ch), + Some(_) => { + tokens.push(std::mem::take(&mut current)); + current.push(ch); + current_is_whitespace = Some(is_whitespace); + } + None => { + current.push(ch); + current_is_whitespace = Some(is_whitespace); + } + } + } + + if !current.is_empty() { + tokens.push(current); + } + + tokens +} + +fn diff_word_change_masks(left: &str, right: &str) -> (Vec, Vec) { + let left_tokens = tokenize_diff_words(left); + let right_tokens = tokenize_diff_words(right); + let left_len = left_tokens.len(); + let right_len = right_tokens.len(); + let mut lcs = vec![vec![0usize; right_len + 1]; left_len + 1]; + + for left_index in (0..left_len).rev() { + for right_index in (0..right_len).rev() { + lcs[left_index][right_index] = if left_tokens[left_index] == right_tokens[right_index] { + lcs[left_index + 1][right_index + 1] + 1 + } else { + lcs[left_index + 1][right_index].max(lcs[left_index][right_index + 1]) + }; + } + } + + let mut left_changed = vec![true; left_len]; + let mut right_changed = vec![true; right_len]; + let (mut left_index, mut right_index) = (0usize, 0usize); + while left_index < left_len && right_index < right_len { + if left_tokens[left_index] == right_tokens[right_index] { + left_changed[left_index] = false; + right_changed[right_index] = false; + left_index += 1; + right_index += 1; + } else if lcs[left_index + 1][right_index] >= lcs[left_index][right_index + 1] { + left_index += 1; + } else { + right_index += 1; + } + } + + (left_changed, right_changed) +} + +fn diff_meta_style(palette: ThemePalette) -> Style { + Style::default() + .fg(palette.accent) + .add_modifier(Modifier::BOLD) +} + +fn diff_context_style(palette: ThemePalette) -> Style { + Style::default().fg(palette.muted) +} + +fn diff_removal_style(palette: ThemePalette) -> Style { + let color = match palette.accent { + Color::Blue => Color::Red, + _ => Color::LightRed, + }; + Style::default().fg(color) +} + +fn diff_addition_style(palette: ThemePalette) -> Style { + let color = match palette.accent { + Color::Blue => Color::Green, + _ => Color::LightGreen, + }; + Style::default().fg(color) +} + +fn diff_removal_word_style() -> Style { + Style::default() + .bg(Color::Red) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) +} + +fn diff_addition_word_style() -> Style { + Style::default() + .bg(Color::Green) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) } fn session_state_label(state: &SessionState) -> &'static str { @@ -6262,7 +6616,7 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ dashboard.selected_diff_summary = Some("1 file changed".to_string()); dashboard.selected_diff_patch = Some(patch.clone()); dashboard.selected_diff_hunk_offsets_split = - build_worktree_diff_columns(&patch).hunk_offsets; + build_worktree_diff_columns(&patch, dashboard.theme_palette()).hunk_offsets; dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch); dashboard.toggle_output_mode(); @@ -6306,7 +6660,8 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ +second new" .to_string(); dashboard.selected_diff_patch = Some(patch.clone()); - let split_offsets = build_worktree_diff_columns(&patch).hunk_offsets; + let split_offsets = + build_worktree_diff_columns(&patch, dashboard.theme_palette()).hunk_offsets; dashboard.selected_diff_hunk_offsets_split = split_offsets.clone(); dashboard.selected_diff_hunk_offsets_unified = build_unified_diff_hunk_offsets(&patch); dashboard.output_mode = OutputMode::WorktreeDiff; @@ -6688,13 +7043,74 @@ diff --git a/src/next.rs b/src/next.rs -bye +hello"; - let columns = build_worktree_diff_columns(patch); - assert!(columns.removals.contains("Branch diff vs main")); - assert!(columns.removals.contains("-old line")); - assert!(columns.removals.contains("-bye")); - assert!(columns.additions.contains("Working tree diff")); - assert!(columns.additions.contains("+new line")); - assert!(columns.additions.contains("+hello")); + let palette = test_dashboard(Vec::new(), 0).theme_palette(); + let columns = build_worktree_diff_columns(patch, palette); + let removals = text_plain_text(&columns.removals); + let additions = text_plain_text(&columns.additions); + assert!(removals.contains("Branch diff vs main")); + assert!(removals.contains("-old line")); + assert!(removals.contains("-bye")); + assert!(additions.contains("Working tree diff")); + assert!(additions.contains("+new line")); + assert!(additions.contains("+hello")); + } + + #[test] + fn split_diff_highlights_changed_words() { + let palette = test_dashboard(Vec::new(), 0).theme_palette(); + let patch = "\ +diff --git a/src/lib.rs b/src/lib.rs +@@ -1 +1 @@ +-old line ++new line"; + + let columns = build_worktree_diff_columns(patch, palette); + let removal = columns + .removals + .lines + .iter() + .find(|line| line_plain_text(line) == "-old line") + .expect("removal line"); + let addition = columns + .additions + .lines + .iter() + .find(|line| line_plain_text(line) == "+new line") + .expect("addition line"); + + assert_eq!(removal.spans[1].content.as_ref(), "old"); + assert_eq!(removal.spans[1].style, diff_removal_word_style()); + assert_eq!(removal.spans[2].content.as_ref(), " "); + assert_eq!(removal.spans[2].style, diff_removal_style(palette)); + assert_eq!(addition.spans[1].content.as_ref(), "new"); + assert_eq!(addition.spans[1].style, diff_addition_word_style()); + } + + #[test] + fn unified_diff_highlights_changed_words() { + let palette = test_dashboard(Vec::new(), 0).theme_palette(); + let patch = "\ +diff --git a/src/lib.rs b/src/lib.rs +@@ -1 +1 @@ +-old line ++new line"; + + let text = build_unified_diff_text(patch, palette); + let removal = text + .lines + .iter() + .find(|line| line_plain_text(line) == "-old line") + .expect("removal line"); + let addition = text + .lines + .iter() + .find(|line| line_plain_text(line) == "+new line") + .expect("addition line"); + + assert_eq!(removal.spans[1].content.as_ref(), "old"); + assert_eq!(removal.spans[1].style, diff_removal_word_style()); + assert_eq!(addition.spans[1].content.as_ref(), "new"); + assert_eq!(addition.spans[1].style, diff_addition_word_style()); } #[test] @@ -9859,6 +10275,21 @@ diff --git a/src/next.rs b/src/next.rs ) } + fn line_plain_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + } + + fn text_plain_text(text: &Text<'_>) -> String { + text.lines + .iter() + .map(line_plain_text) + .collect::>() + .join("\n") + } + fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); From 0513898b9dab4d088a7c55e26f2814f363695420 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 09:02:39 -0700 Subject: [PATCH 103/459] feat: add otel export for ecc sessions --- ecc2/src/main.rs | 491 ++++++++++++++++++++++++++++++++++++++ ecc2/src/session/store.rs | 30 +++ 2 files changed, 521 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 699f22dd..7c0a2d39 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -250,6 +250,14 @@ enum Commands { #[arg(long)] json: bool, }, + /// Export sessions, tool spans, and metrics in OTLP-compatible JSON + ExportOtel { + /// Session ID or alias. Omit to export all sessions. + session_id: Option, + /// Write the export to a file instead of stdout + #[arg(long)] + output: Option, + }, /// Stop a running session Stop { /// Session ID or alias @@ -808,6 +816,21 @@ async fn main() -> Result<()> { println!("{}", format_prune_worktrees_human(&outcome)); } } + Some(Commands::ExportOtel { session_id, output }) => { + sync_runtime_session_metrics(&db, &cfg)?; + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let export = build_otel_export(&db, resolved_session_id.as_deref())?; + let rendered = serde_json::to_string_pretty(&export)?; + if let Some(path) = output { + std::fs::write(&path, rendered)?; + println!("OTLP export written to {}", path.display()); + } else { + println!("{rendered}"); + } + } Some(Commands::Stop { session_id }) => { session::manager::stop_session(&db, &session_id).await?; println!("Session stopped: {session_id}"); @@ -1081,6 +1104,93 @@ struct WorktreeResolutionReport { resolution_steps: Vec, } +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpExport { + resource_spans: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpResourceSpans { + resource: OtlpResource, + scope_spans: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpResource { + attributes: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpScopeSpans { + scope: OtlpInstrumentationScope, + spans: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpInstrumentationScope { + name: String, + version: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpSpan { + trace_id: String, + span_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + parent_span_id: Option, + name: String, + kind: String, + start_time_unix_nano: String, + end_time_unix_nano: String, + attributes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + links: Vec, + status: OtlpSpanStatus, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpSpanLink { + trace_id: String, + span_id: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + attributes: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpSpanStatus { + code: String, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpKeyValue { + key: String, + value: OtlpAnyValue, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OtlpAnyValue { + #[serde(skip_serializing_if = "Option::is_none")] + string_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + int_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + double_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bool_value: Option, +} + fn build_worktree_status_report( session: &session::Session, include_patch: bool, @@ -1449,6 +1559,214 @@ fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome lines.join("\n") } +fn build_otel_export( + db: &session::store::StateStore, + session_id: Option<&str>, +) -> Result { + let sessions = if let Some(session_id) = session_id { + vec![db + .get_session(session_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?] + } else { + db.list_sessions()? + }; + + let mut spans = Vec::new(); + for session in &sessions { + spans.extend(build_session_otel_spans(db, session)?); + } + + Ok(OtlpExport { + resource_spans: vec![OtlpResourceSpans { + resource: OtlpResource { + attributes: vec![ + otlp_string_attr("service.name", "ecc2"), + otlp_string_attr("service.version", env!("CARGO_PKG_VERSION")), + otlp_string_attr("telemetry.sdk.language", "rust"), + ], + }, + scope_spans: vec![OtlpScopeSpans { + scope: OtlpInstrumentationScope { + name: "ecc2".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + spans, + }], + }], + }) +} + +fn build_session_otel_spans( + db: &session::store::StateStore, + session: &session::Session, +) -> Result> { + let trace_id = otlp_trace_id(&session.id); + let session_span_id = otlp_span_id(&format!("session:{}", session.id)); + let parent_link = db.latest_task_handoff_source(&session.id)?; + let session_end = session.updated_at.max(session.created_at); + let mut spans = vec![OtlpSpan { + trace_id: trace_id.clone(), + span_id: session_span_id.clone(), + parent_span_id: None, + name: format!("session {}", session.task), + kind: "SPAN_KIND_INTERNAL".to_string(), + start_time_unix_nano: otlp_timestamp_nanos(session.created_at), + end_time_unix_nano: otlp_timestamp_nanos(session_end), + attributes: vec![ + otlp_string_attr("ecc.session.id", &session.id), + otlp_string_attr("ecc.session.state", &session.state.to_string()), + otlp_string_attr("ecc.agent.type", &session.agent_type), + otlp_string_attr("ecc.session.task", &session.task), + otlp_string_attr( + "ecc.working_dir", + session.working_dir.to_string_lossy().as_ref(), + ), + otlp_int_attr("ecc.metrics.input_tokens", session.metrics.input_tokens), + otlp_int_attr("ecc.metrics.output_tokens", session.metrics.output_tokens), + otlp_int_attr("ecc.metrics.tokens_used", session.metrics.tokens_used), + otlp_int_attr("ecc.metrics.tool_calls", session.metrics.tool_calls), + otlp_int_attr( + "ecc.metrics.files_changed", + u64::from(session.metrics.files_changed), + ), + otlp_int_attr("ecc.metrics.duration_secs", session.metrics.duration_secs), + otlp_double_attr("ecc.metrics.cost_usd", session.metrics.cost_usd), + ], + links: parent_link + .into_iter() + .map(|parent_session_id| OtlpSpanLink { + trace_id: otlp_trace_id(&parent_session_id), + span_id: otlp_span_id(&format!("session:{parent_session_id}")), + attributes: vec![otlp_string_attr( + "ecc.parent_session.id", + &parent_session_id, + )], + }) + .collect(), + status: otlp_session_status(&session.state), + }]; + + for entry in db.list_tool_logs_for_session(&session.id)? { + let span_end = chrono::DateTime::parse_from_rfc3339(&entry.timestamp) + .unwrap_or_else(|_| session.updated_at.into()) + .with_timezone(&chrono::Utc); + let span_start = span_end - chrono::Duration::milliseconds(entry.duration_ms as i64); + + spans.push(OtlpSpan { + trace_id: trace_id.clone(), + span_id: otlp_span_id(&format!("tool:{}:{}", session.id, entry.id)), + parent_span_id: Some(session_span_id.clone()), + name: format!("tool {}", entry.tool_name), + kind: "SPAN_KIND_INTERNAL".to_string(), + start_time_unix_nano: otlp_timestamp_nanos(span_start), + end_time_unix_nano: otlp_timestamp_nanos(span_end), + attributes: vec![ + otlp_string_attr("ecc.session.id", &entry.session_id), + otlp_string_attr("tool.name", &entry.tool_name), + otlp_string_attr("tool.input_summary", &entry.input_summary), + otlp_string_attr("tool.output_summary", &entry.output_summary), + otlp_string_attr("tool.trigger_summary", &entry.trigger_summary), + otlp_string_attr("tool.input_params_json", &entry.input_params_json), + otlp_int_attr("tool.duration_ms", entry.duration_ms), + otlp_double_attr("tool.risk_score", entry.risk_score), + ], + links: Vec::new(), + status: OtlpSpanStatus { + code: "STATUS_CODE_UNSET".to_string(), + message: None, + }, + }); + } + + Ok(spans) +} + +fn otlp_timestamp_nanos(value: chrono::DateTime) -> String { + value + .timestamp_nanos_opt() + .unwrap_or_default() + .max(0) + .to_string() +} + +fn otlp_trace_id(seed: &str) -> String { + format!( + "{:016x}{:016x}", + fnv1a64(seed.as_bytes()), + fnv1a64_with_seed(seed.as_bytes(), 1099511628211) + ) +} + +fn otlp_span_id(seed: &str) -> String { + format!("{:016x}", fnv1a64(seed.as_bytes())) +} + +fn fnv1a64(bytes: &[u8]) -> u64 { + fnv1a64_with_seed(bytes, 14695981039346656037) +} + +fn fnv1a64_with_seed(bytes: &[u8], offset_basis: u64) -> u64 { + let mut hash = offset_basis; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(1099511628211); + } + hash +} + +fn otlp_string_attr(key: &str, value: &str) -> OtlpKeyValue { + OtlpKeyValue { + key: key.to_string(), + value: OtlpAnyValue { + string_value: Some(value.to_string()), + int_value: None, + double_value: None, + bool_value: None, + }, + } +} + +fn otlp_int_attr(key: &str, value: u64) -> OtlpKeyValue { + OtlpKeyValue { + key: key.to_string(), + value: OtlpAnyValue { + string_value: None, + int_value: Some(value.to_string()), + double_value: None, + bool_value: None, + }, + } +} + +fn otlp_double_attr(key: &str, value: f64) -> OtlpKeyValue { + OtlpKeyValue { + key: key.to_string(), + value: OtlpAnyValue { + string_value: None, + int_value: None, + double_value: Some(value), + bool_value: None, + }, + } +} + +fn otlp_session_status(state: &session::SessionState) -> OtlpSpanStatus { + match state { + session::SessionState::Completed => OtlpSpanStatus { + code: "STATUS_CODE_OK".to_string(), + message: None, + }, + session::SessionState::Failed => OtlpSpanStatus { + code: "STATUS_CODE_ERROR".to_string(), + message: Some("session failed".to_string()), + }, + _ => OtlpSpanStatus { + code: "STATUS_CODE_UNSET".to_string(), + message: None, + }, + } +} + fn summarize_coordinate_backlog( outcome: &session::manager::CoordinateBacklogOutcome, ) -> CoordinateBacklogPassSummary { @@ -1556,6 +1874,66 @@ fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: & mod tests { use super::*; use crate::config::Config; + use crate::session::store::StateStore; + use crate::session::{Session, SessionMetrics, SessionState}; + use chrono::{Duration, Utc}; + use std::fs; + use std::path::{Path, PathBuf}; + + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new(label: &str) -> Result { + let path = + std::env::temp_dir().join(format!("ecc2-main-{label}-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&path)?; + Ok(Self { path }) + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn build_session(id: &str, task: &str, state: SessionState) -> Session { + let now = Utc::now(); + Session { + id: id.to_string(), + task: task.to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp/ecc"), + state, + pid: None, + worktree: None, + created_at: now - Duration::seconds(5), + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics { + input_tokens: 120, + output_tokens: 30, + tokens_used: 150, + tool_calls: 2, + files_changed: 1, + duration_secs: 5, + cost_usd: 0.42, + }, + } + } + + fn attr_value<'a>(attrs: &'a [OtlpKeyValue], key: &str) -> Option<&'a OtlpAnyValue> { + attrs + .iter() + .find(|attr| attr.key == key) + .map(|attr| &attr.value) + } #[test] fn worktree_policy_defaults_to_config_setting() { @@ -1598,6 +1976,26 @@ mod tests { } } + #[test] + fn cli_parses_export_otel_command() { + let cli = Cli::try_parse_from([ + "ecc", + "export-otel", + "worker-1234", + "--output", + "/tmp/ecc-otel.json", + ]) + .expect("export-otel should parse"); + + match cli.command { + Some(Commands::ExportOtel { session_id, output }) => { + assert_eq!(session_id.as_deref(), Some("worker-1234")); + assert_eq!(output.as_deref(), Some(Path::new("/tmp/ecc-otel.json"))); + } + _ => panic!("expected export-otel subcommand"), + } + } + #[test] fn cli_parses_messages_send_command() { let cli = Cli::try_parse_from([ @@ -1886,6 +2284,99 @@ mod tests { } } + #[test] + fn build_otel_export_includes_session_and_tool_spans() -> Result<()> { + let tempdir = TestDir::new("otel-export-session")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let session = build_session("session-1", "Investigate export", SessionState::Completed); + db.insert_session(&session)?; + db.insert_tool_log( + &session.id, + "Write", + "Write src/lib.rs", + "{\"file\":\"src/lib.rs\"}", + "Updated file", + "manual test", + 120, + 0.75, + &Utc::now().to_rfc3339(), + )?; + + let export = build_otel_export(&db, Some("session-1"))?; + let spans = &export.resource_spans[0].scope_spans[0].spans; + assert_eq!(spans.len(), 2); + + let session_span = spans + .iter() + .find(|span| span.parent_span_id.is_none()) + .expect("session root span"); + let tool_span = spans + .iter() + .find(|span| span.parent_span_id.is_some()) + .expect("tool child span"); + + assert_eq!(session_span.trace_id, tool_span.trace_id); + assert_eq!( + tool_span.parent_span_id.as_deref(), + Some(session_span.span_id.as_str()) + ); + assert_eq!(session_span.status.code, "STATUS_CODE_OK"); + assert_eq!( + attr_value(&session_span.attributes, "ecc.session.id") + .and_then(|value| value.string_value.as_deref()), + Some("session-1") + ); + assert_eq!( + attr_value(&tool_span.attributes, "tool.name") + .and_then(|value| value.string_value.as_deref()), + Some("Write") + ); + assert_eq!( + attr_value(&tool_span.attributes, "tool.duration_ms") + .and_then(|value| value.int_value.as_deref()), + Some("120") + ); + + Ok(()) + } + + #[test] + fn build_otel_export_links_delegated_session_to_parent_trace() -> Result<()> { + let tempdir = TestDir::new("otel-export-parent-link")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let parent = build_session("lead-1", "Lead task", SessionState::Running); + let child = build_session("worker-1", "Delegated task", SessionState::Running); + db.insert_session(&parent)?; + db.insert_session(&child)?; + db.send_message( + &parent.id, + &child.id, + "{\"task\":\"Delegated task\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + + let export = build_otel_export(&db, Some("worker-1"))?; + let session_span = export.resource_spans[0].scope_spans[0] + .spans + .iter() + .find(|span| span.parent_span_id.is_none()) + .expect("session root span"); + + assert_eq!(session_span.links.len(), 1); + assert_eq!(session_span.links[0].trace_id, otlp_trace_id("lead-1")); + assert_eq!( + session_span.links[0].span_id, + otlp_span_id("session:lead-1") + ); + assert_eq!( + attr_value(&session_span.links[0].attributes, "ecc.parent_session.id") + .and_then(|value| value.string_value.as_deref()), + Some("lead-1") + ); + + Ok(()) + } + #[test] fn cli_parses_worktree_status_check_flag() { let cli = Cli::try_parse_from(["ecc", "worktree-status", "--check"]) diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 3b3244ca..3963c6f0 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1705,6 +1705,36 @@ impl StateStore { }) } + pub fn list_tool_logs_for_session(&self, session_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, session_id, tool_name, input_summary, input_params_json, output_summary, trigger_summary, duration_ms, risk_score, timestamp + FROM tool_log + WHERE session_id = ?1 + ORDER BY timestamp ASC, id ASC", + )?; + + let entries = stmt + .query_map(rusqlite::params![session_id], |row| { + Ok(ToolLogEntry { + id: row.get(0)?, + session_id: row.get(1)?, + tool_name: row.get(2)?, + input_summary: row.get::<_, Option>(3)?.unwrap_or_default(), + input_params_json: row + .get::<_, Option>(4)? + .unwrap_or_else(|| "{}".to_string()), + output_summary: row.get::<_, Option>(5)?.unwrap_or_default(), + trigger_summary: row.get::<_, Option>(6)?.unwrap_or_default(), + duration_ms: row.get::<_, Option>(7)?.unwrap_or_default(), + risk_score: row.get::<_, Option>(8)?.unwrap_or_default(), + timestamp: row.get(9)?, + }) + })? + .collect::, _>>()?; + + Ok(entries) + } + pub fn list_file_activity( &self, session_id: &str, From 181bc26b29f9333001563a3cfe52d6e78f1aa1cc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 18:12:08 -0700 Subject: [PATCH 104/459] docs: add ecc recovery guidance for wiped setups --- README.md | 2 ++ TROUBLESHOOTING.md | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/README.md b/README.md index 91e7303d..45fc6568 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,8 @@ Get up and running in under 2 minutes: > WARNING: **Important:** Claude Code plugins cannot distribute `rules` automatically. Install them manually: +> If your local Claude setup was wiped or reset, that does not mean you need to repurchase ECC. Start with `ecc list-installed`, then run `ecc doctor` and `ecc repair` before reinstalling anything. That usually restores ECC-managed files without rebuilding your setup. If the problem is account or marketplace access for ECC Tools, handle billing/account recovery separately. + ```bash # Clone the repo first git clone https://github.com/affaan-m/everything-claude-code.git diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 4aed5eae..1681010f 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -245,9 +245,17 @@ tmux attach -t dev - Marketplace cache not updated - Claude Code version incompatibility - Corrupted plugin files +- Local Claude setup was wiped or reset **Solutions:** ```bash +# First inspect what ECC still knows about this machine +ecc list-installed +ecc doctor +ecc repair + +# Only reinstall if doctor/repair cannot restore the missing files + # Inspect the plugin cache before changing it ls -la ~/.claude/plugins/cache/ @@ -259,6 +267,8 @@ mkdir -p ~/.claude/plugins/cache # Claude Code → Extensions → Everything Claude Code → Uninstall # Then reinstall from marketplace +# If the issue is marketplace/account access, use ECC Tools billing/account recovery separately; do not use reinstall as a proxy for account recovery + # Check Claude Code version claude --version # Requires Claude Code 2.0+ From cf8b5473c7d9fb80f859dcfb270160c0c9bd5a21 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 19:54:28 -0700 Subject: [PATCH 105/459] feat: group ecc2 sessions by project and task --- ecc2/src/main.rs | 43 +++++- ecc2/src/observability/mod.rs | 2 + ecc2/src/session/daemon.rs | 2 + ecc2/src/session/manager.rs | 246 +++++++++++++++++++++++++++++++++- ecc2/src/session/mod.rs | 30 +++++ ecc2/src/session/runtime.rs | 4 + ecc2/src/session/store.rs | 109 +++++++++++---- ecc2/src/tui/dashboard.rs | 142 +++++++++++++++++++- 8 files changed, 540 insertions(+), 38 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 7c0a2d39..a9d2040c 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -344,10 +344,29 @@ async fn main() -> Result<()> { from_session, }) => { let use_worktree = worktree.resolve(&cfg); - let session_id = - session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?; - if let Some(from_session) = from_session { - let from_id = resolve_session_id(&db, &from_session)?; + let source = if let Some(from_session) = from_session.as_ref() { + let from_id = resolve_session_id(&db, from_session)?; + Some( + db.get_session(&from_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {from_id}"))?, + ) + } else { + None + }; + let session_id = session::manager::create_session_with_grouping( + &db, + &cfg, + &task, + &agent, + use_worktree, + session::SessionGrouping { + project: source.as_ref().map(|session| session.project.clone()), + task_group: source.as_ref().map(|session| session.task_group.clone()), + }, + ) + .await?; + if let Some(source) = source { + let from_id = source.id; send_handoff_message(&db, &from_id, &session_id)?; } println!("Session started: {session_id}"); @@ -371,8 +390,18 @@ async fn main() -> Result<()> { ) }); - let session_id = - session::manager::create_session(&db, &cfg, &task, &agent, use_worktree).await?; + let session_id = session::manager::create_session_with_grouping( + &db, + &cfg, + &task, + &agent, + use_worktree, + session::SessionGrouping { + project: Some(source.project.clone()), + task_group: Some(source.task_group.clone()), + }, + ) + .await?; send_handoff_message(&db, &source.id, &session_id)?; println!( "Delegated session started: {} <- {}", @@ -1908,6 +1937,8 @@ mod tests { Session { id: id.to_string(), task: task.to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp/ecc"), state, diff --git a/ecc2/src/observability/mod.rs b/ecc2/src/observability/mod.rs index fae8ddd0..19c616b1 100644 --- a/ecc2/src/observability/mod.rs +++ b/ecc2/src/observability/mod.rs @@ -314,6 +314,8 @@ mod tests { Session { id: id.to_string(), task: "test task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Pending, diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index f8fc7c6d..47f141b8 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -480,6 +480,8 @@ mod tests { Session { id: id.to_string(), task: "Recover crashed worker".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state, diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index aeb903c4..db059367 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -9,7 +9,10 @@ use tokio::process::Command; use super::output::SessionOutputStore; use super::runtime::capture_command_output; use super::store::StateStore; -use super::{Session, SessionMetrics, SessionState}; +use super::{ + default_project_label, default_task_group_label, normalize_group_label, Session, + SessionGrouping, SessionMetrics, SessionState, +}; use crate::comms::{self, MessageType}; use crate::config::Config; use crate::observability::{log_tool_call, ToolCallEvent, ToolLogEntry, ToolLogPage, ToolLogger}; @@ -21,10 +24,29 @@ pub async fn create_session( task: &str, agent_type: &str, use_worktree: bool, +) -> Result { + create_session_with_grouping( + db, + cfg, + task, + agent_type, + use_worktree, + SessionGrouping::default(), + ) + .await +} + +pub async fn create_session_with_grouping( + db: &StateStore, + cfg: &Config, + task: &str, + agent_type: &str, + use_worktree: bool, + grouping: SessionGrouping, ) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve current working directory")?; - queue_session_in_dir(db, cfg, task, agent_type, use_worktree, &repo_root).await + queue_session_in_dir(db, cfg, task, agent_type, use_worktree, &repo_root, grouping).await } pub fn list_sessions(db: &StateStore) -> Result> { @@ -127,6 +149,27 @@ pub async fn assign_session( task: &str, agent_type: &str, use_worktree: bool, +) -> Result { + assign_session_with_grouping( + db, + cfg, + lead_id, + task, + agent_type, + use_worktree, + SessionGrouping::default(), + ) + .await +} + +pub async fn assign_session_with_grouping( + db: &StateStore, + cfg: &Config, + lead_id: &str, + task: &str, + agent_type: &str, + use_worktree: bool, + grouping: SessionGrouping, ) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve current working directory")?; @@ -139,6 +182,7 @@ pub async fn assign_session( use_worktree, &repo_root, &std::env::current_exe().context("Failed to resolve ECC executable path")?, + grouping, ) .await } @@ -175,6 +219,7 @@ pub async fn drain_inbox( use_worktree, &repo_root, &runner_program, + SessionGrouping::default(), ) .await?; @@ -380,6 +425,7 @@ pub async fn rebalance_team_backlog( use_worktree, &repo_root, &runner_program, + SessionGrouping::default(), ) .await?; @@ -538,8 +584,17 @@ async fn assign_session_in_dir_with_runner_program( use_worktree: bool, repo_root: &Path, runner_program: &Path, + grouping: SessionGrouping, ) -> Result { let lead = resolve_session(db, lead_id)?; + let inherited_grouping = SessionGrouping { + project: grouping + .project + .or_else(|| normalize_group_label(&lead.project)), + task_group: grouping + .task_group + .or_else(|| normalize_group_label(&lead.task_group)), + }; let delegates = direct_delegate_sessions(db, &lead.id, agent_type)?; let delegate_handoff_backlog = delegates .iter() @@ -577,6 +632,7 @@ async fn assign_session_in_dir_with_runner_program( use_worktree, repo_root, runner_program, + inherited_grouping.clone(), ) .await?; send_task_handoff(db, &lead, &session_id, task, "spawned new delegate")?; @@ -651,6 +707,7 @@ async fn assign_session_in_dir_with_runner_program( use_worktree, repo_root, runner_program, + inherited_grouping, ) .await?; send_task_handoff(db, &lead, &session_id, task, "spawned fallback delegate")?; @@ -1093,6 +1150,7 @@ async fn queue_session_in_dir( agent_type: &str, use_worktree: bool, repo_root: &Path, + grouping: SessionGrouping, ) -> Result { queue_session_in_dir_with_runner_program( db, @@ -1102,6 +1160,7 @@ async fn queue_session_in_dir( use_worktree, repo_root, &std::env::current_exe().context("Failed to resolve ECC executable path")?, + grouping, ) .await } @@ -1114,8 +1173,17 @@ async fn queue_session_in_dir_with_runner_program( use_worktree: bool, repo_root: &Path, runner_program: &Path, + grouping: SessionGrouping, ) -> Result { - let session = build_session_record(db, task, agent_type, use_worktree, cfg, repo_root)?; + let session = build_session_record( + db, + task, + agent_type, + use_worktree, + cfg, + repo_root, + grouping, + )?; db.insert_session(&session)?; if use_worktree && session.worktree.is_none() { @@ -1158,6 +1226,7 @@ fn build_session_record( use_worktree: bool, cfg: &Config, repo_root: &Path, + grouping: SessionGrouping, ) -> Result { let id = uuid::Uuid::new_v4().to_string()[..8].to_string(); let now = chrono::Utc::now(); @@ -1171,10 +1240,22 @@ fn build_session_record( .as_ref() .map(|worktree| worktree.path.clone()) .unwrap_or_else(|| repo_root.to_path_buf()); + let project = grouping + .project + .as_deref() + .and_then(normalize_group_label) + .unwrap_or_else(|| default_project_label(repo_root)); + let task_group = grouping + .task_group + .as_deref() + .and_then(normalize_group_label) + .unwrap_or_else(|| default_task_group_label(task)); Ok(Session { id, task: task.to_string(), + project, + task_group, agent_type: agent_type.to_string(), working_dir, state: SessionState::Pending, @@ -1196,7 +1277,15 @@ async fn create_session_in_dir( repo_root: &Path, agent_program: &Path, ) -> Result { - let session = build_session_record(db, task, agent_type, use_worktree, cfg, repo_root)?; + let session = build_session_record( + db, + task, + agent_type, + use_worktree, + cfg, + repo_root, + SessionGrouping::default(), + )?; db.insert_session(&session)?; @@ -1962,6 +2051,8 @@ mod tests { Session { id: id.to_string(), task: format!("task-{id}"), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state, @@ -1984,6 +2075,8 @@ mod tests { db.insert_session(&Session { id: "stale-1".to_string(), task: "heartbeat overdue".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2019,6 +2112,8 @@ mod tests { db.insert_session(&Session { id: "stale-2".to_string(), task: "terminate overdue".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2171,6 +2266,37 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn create_session_derives_project_and_task_group_defaults() -> Result<()> { + let tempdir = TestDir::new("manager-create-session-grouping-defaults")?; + let repo_root = tempdir.path().join("checkout-api"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let (fake_claude, _) = write_fake_claude(tempdir.path())?; + + let session_id = create_session_in_dir( + &db, + &cfg, + "stabilize auth callback", + "claude", + false, + &repo_root, + &fake_claude, + ) + .await?; + + let session = db + .get_session(&session_id)? + .context("session should exist")?; + assert_eq!(session.project, "checkout-api"); + assert_eq!(session.task_group, "stabilize auth callback"); + + stop_session_with_options(&db, &session_id, false).await?; + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn stop_session_kills_process_and_optionally_cleans_worktree() -> Result<()> { let tempdir = TestDir::new("manager-stop-session")?; @@ -2379,6 +2505,8 @@ mod tests { db.insert_session(&Session { id: "active-over-budget".to_string(), task: "pause on hard limit".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: tempdir.path().to_path_buf(), state: SessionState::Running, @@ -2440,6 +2568,8 @@ mod tests { db.insert_session(&Session { id: "completed-over-budget".to_string(), task: "already done".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: tempdir.path().to_path_buf(), state: SessionState::Completed, @@ -2485,6 +2615,8 @@ mod tests { db.insert_session(&Session { id: "deadbeef".to_string(), task: "resume previous task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: tempdir.path().join("resume-working-dir"), state: SessionState::Failed, @@ -2797,6 +2929,8 @@ mod tests { db.insert_session(&Session { id: "merge-ready".to_string(), task: "merge me".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: merged_worktree.path.clone(), state: SessionState::Completed, @@ -2813,6 +2947,8 @@ mod tests { db.insert_session(&Session { id: "active-worktree".to_string(), task: "still running".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: active_worktree.path.clone(), state: SessionState::Running, @@ -2830,6 +2966,8 @@ mod tests { db.insert_session(&Session { id: "dirty-worktree".to_string(), task: "needs commit".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: dirty_worktree.path.clone(), state: SessionState::Stopped, @@ -3056,6 +3194,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3069,6 +3209,8 @@ mod tests { db.insert_session(&Session { id: "idle-worker".to_string(), task: "old worker task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Idle, @@ -3097,6 +3239,7 @@ mod tests { true, &repo_root, &fake_runner, + SessionGrouping::default(), ) .await?; @@ -3125,6 +3268,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3138,6 +3283,8 @@ mod tests { db.insert_session(&Session { id: "idle-worker".to_string(), task: "old worker task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Idle, @@ -3165,6 +3312,7 @@ mod tests { true, &repo_root, &fake_runner, + SessionGrouping::default(), ) .await?; @@ -3203,6 +3351,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3216,6 +3366,8 @@ mod tests { db.insert_session(&Session { id: "idle-worker".to_string(), task: "old worker task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Idle, @@ -3245,6 +3397,7 @@ mod tests { true, &repo_root, &fake_runner, + SessionGrouping::default(), ) .await?; @@ -3272,6 +3425,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3285,6 +3440,8 @@ mod tests { db.insert_session(&Session { id: "busy-worker".to_string(), task: "existing work".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3312,6 +3469,7 @@ mod tests { true, &repo_root, &fake_runner, + SessionGrouping::default(), ) .await?; @@ -3331,6 +3489,57 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn assign_session_inherits_lead_grouping_for_spawned_delegate() -> Result<()> { + let tempdir = TestDir::new("manager-assign-grouping-inheritance")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "lead task".to_string(), + project: "ecc-platform".to_string(), + task_group: "checkout recovery".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + + let (fake_runner, _) = write_fake_claude(tempdir.path())?; + let outcome = assign_session_in_dir_with_runner_program( + &db, + &cfg, + "lead", + "investigate webhook retry edge cases", + "claude", + true, + &repo_root, + &fake_runner, + SessionGrouping::default(), + ) + .await?; + + assert_eq!(outcome.action, AssignmentAction::Spawned); + + let spawned = db + .get_session(&outcome.session_id)? + .context("spawned delegated session missing")?; + assert_eq!(spawned.project, "ecc-platform"); + assert_eq!(spawned.task_group, "checkout recovery"); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn assign_session_defers_when_team_is_saturated() -> Result<()> { let tempdir = TestDir::new("manager-assign-defer-saturated")?; @@ -3345,6 +3554,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3358,6 +3569,8 @@ mod tests { db.insert_session(&Session { id: "busy-worker".to_string(), task: "existing work".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3385,6 +3598,7 @@ mod tests { true, &repo_root, &fake_runner, + SessionGrouping::default(), ) .await?; @@ -3412,6 +3626,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3460,6 +3676,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3473,6 +3691,8 @@ mod tests { db.insert_session(&Session { id: "busy-worker".to_string(), task: "existing work".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3529,6 +3749,8 @@ mod tests { db.insert_session(&Session { id: lead_id.to_string(), task: format!("{lead_id} task"), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3589,6 +3811,8 @@ mod tests { db.insert_session(&Session { id: lead_id.to_string(), task: format!("{lead_id} task"), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3641,6 +3865,8 @@ mod tests { db.insert_session(&Session { id: "worker".to_string(), task: "worker task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3655,6 +3881,8 @@ mod tests { db.insert_session(&Session { id: "worker-child".to_string(), task: "delegate task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3711,6 +3939,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3724,6 +3954,8 @@ mod tests { db.insert_session(&Session { id: "worker-a".to_string(), task: "auth lane".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Idle, @@ -3737,6 +3969,8 @@ mod tests { db.insert_session(&Session { id: "worker-b".to_string(), task: "billing lane".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Idle, @@ -3799,6 +4033,8 @@ mod tests { db.insert_session(&Session { id: "lead".to_string(), task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root.clone(), state: SessionState::Running, @@ -3812,6 +4048,8 @@ mod tests { db.insert_session(&Session { id: "worker".to_string(), task: "delegate task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: repo_root, state: SessionState::Idle, diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 21a10e79..babf5d5d 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -7,12 +7,15 @@ pub mod store; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fmt; +use std::path::Path; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: String, pub task: String, + pub project: String, + pub task_group: String, pub agent_type: String, pub working_dir: PathBuf, pub state: SessionState, @@ -149,3 +152,30 @@ pub enum FileActivityAction { Delete, Touch, } + +pub fn normalize_group_label(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub fn default_project_label(working_dir: &Path) -> String { + working_dir + .file_name() + .and_then(|value| value.to_str()) + .and_then(normalize_group_label) + .unwrap_or_else(|| "workspace".to_string()) +} + +pub fn default_task_group_label(task: &str) -> String { + normalize_group_label(task).unwrap_or_else(|| "general".to_string()) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionGrouping { + pub project: Option, + pub task_group: Option, +} diff --git a/ecc2/src/session/runtime.rs b/ecc2/src/session/runtime.rs index 8310a7e1..165b32e1 100644 --- a/ecc2/src/session/runtime.rs +++ b/ecc2/src/session/runtime.rs @@ -272,6 +272,8 @@ mod tests { db.insert_session(&Session { id: session_id.clone(), task: "stream output".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "test".to_string(), working_dir: env::temp_dir(), state: SessionState::Pending, @@ -338,6 +340,8 @@ mod tests { db.insert_session(&Session { id: session_id.clone(), task: "quiet process".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "test".to_string(), working_dir: env::temp_dir(), state: SessionState::Pending, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 3963c6f0..ab477bd2 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -13,8 +13,8 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ - FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, - WorktreeInfo, + default_project_label, default_task_group_label, normalize_group_label, FileActivityAction, + FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { @@ -138,6 +138,8 @@ impl StateStore { CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, task TEXT NOT NULL, + project TEXT NOT NULL DEFAULT '', + task_group TEXT NOT NULL DEFAULT '', agent_type TEXT NOT NULL, working_dir TEXT NOT NULL DEFAULT '.', state TEXT NOT NULL DEFAULT 'pending', @@ -255,6 +257,24 @@ impl StateStore { .context("Failed to add pid column to sessions table")?; } + if !self.has_column("sessions", "project")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN project TEXT NOT NULL DEFAULT ''", + [], + ) + .context("Failed to add project column to sessions table")?; + } + + if !self.has_column("sessions", "task_group")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN task_group TEXT NOT NULL DEFAULT ''", + [], + ) + .context("Failed to add task_group column to sessions table")?; + } + if !self.has_column("sessions", "input_tokens")? { self.conn .execute( @@ -478,11 +498,13 @@ impl StateStore { pub fn insert_session(&self, session: &Session) -> Result<()> { self.conn.execute( - "INSERT INTO sessions (id, task, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, created_at, updated_at, last_heartbeat_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + "INSERT INTO sessions (id, task, project, task_group, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, created_at, updated_at, last_heartbeat_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", rusqlite::params![ session.id, session.task, + session.project, + session.task_group, session.agent_type, session.working_dir.to_string_lossy().to_string(), session.state.to_string(), @@ -1062,7 +1084,7 @@ impl StateStore { pub fn list_sessions(&self) -> Result> { let mut stmt = self.conn.prepare( - "SELECT id, task, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, + "SELECT id, task, project, task_group, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, input_tokens, output_tokens, tokens_used, tool_calls, files_changed, duration_secs, cost_usd, created_at, updated_at, last_heartbeat_at FROM sessions ORDER BY updated_at DESC", @@ -1070,27 +1092,42 @@ impl StateStore { let sessions = stmt .query_map([], |row| { - let state_str: String = row.get(4)?; + let state_str: String = row.get(6)?; let state = SessionState::from_db_value(&state_str); - let worktree_path: Option = row.get(6)?; + let working_dir = PathBuf::from(row.get::<_, String>(5)?); + let project = row + .get::<_, String>(2) + .ok() + .and_then(|value| normalize_group_label(&value)) + .unwrap_or_else(|| default_project_label(&working_dir)); + let task: String = row.get(1)?; + let task_group = row + .get::<_, String>(3) + .ok() + .and_then(|value| normalize_group_label(&value)) + .unwrap_or_else(|| default_task_group_label(&task)); + + let worktree_path: Option = row.get(8)?; let worktree = worktree_path.map(|path| super::WorktreeInfo { path: PathBuf::from(path), - branch: row.get::<_, String>(7).unwrap_or_default(), - base_branch: row.get::<_, String>(8).unwrap_or_default(), + branch: row.get::<_, String>(9).unwrap_or_default(), + base_branch: row.get::<_, String>(10).unwrap_or_default(), }); - let created_str: String = row.get(16)?; - let updated_str: String = row.get(17)?; - let heartbeat_str: String = row.get(18)?; + let created_str: String = row.get(18)?; + let updated_str: String = row.get(19)?; + let heartbeat_str: String = row.get(20)?; Ok(Session { id: row.get(0)?, - task: row.get(1)?, - agent_type: row.get(2)?, - working_dir: PathBuf::from(row.get::<_, String>(3)?), + task, + project, + task_group, + agent_type: row.get(4)?, + working_dir, state, - pid: row.get::<_, Option>(5)?, + pid: row.get::<_, Option>(7)?, worktree, created_at: chrono::DateTime::parse_from_rfc3339(&created_str) .unwrap_or_default() @@ -1104,13 +1141,13 @@ impl StateStore { }) .with_timezone(&chrono::Utc), metrics: SessionMetrics { - input_tokens: row.get(9)?, - output_tokens: row.get(10)?, - tokens_used: row.get(11)?, - tool_calls: row.get(12)?, - files_changed: row.get(13)?, - duration_secs: row.get(14)?, - cost_usd: row.get(15)?, + input_tokens: row.get(11)?, + output_tokens: row.get(12)?, + tokens_used: row.get(13)?, + tool_calls: row.get(14)?, + files_changed: row.get(15)?, + duration_secs: row.get(16)?, + cost_usd: row.get(17)?, }, }) })? @@ -2023,6 +2060,8 @@ mod tests { Session { id: id.to_string(), task: "task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state, @@ -2106,6 +2145,8 @@ mod tests { db.insert_session(&Session { id: "session-1".to_string(), task: "sync usage".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2151,6 +2192,8 @@ mod tests { db.insert_session(&Session { id: "session-1".to_string(), task: "sync tools".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2164,6 +2207,8 @@ mod tests { db.insert_session(&Session { id: "session-2".to_string(), task: "no activity".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Pending, @@ -2228,6 +2273,8 @@ mod tests { db.insert_session(&Session { id: "session-1".to_string(), task: "sync tools".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2273,6 +2320,8 @@ mod tests { db.insert_session(&Session { id: "session-1".to_string(), task: "sync tools".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2321,6 +2370,8 @@ mod tests { db.insert_session(&Session { id: "session-1".to_string(), task: "focus".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2334,6 +2385,8 @@ mod tests { db.insert_session(&Session { id: "session-2".to_string(), task: "delegate".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Idle, @@ -2347,6 +2400,8 @@ mod tests { db.insert_session(&Session { id: "session-3".to_string(), task: "done".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Completed, @@ -2392,6 +2447,8 @@ mod tests { db.insert_session(&Session { id: "running-1".to_string(), task: "live run".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2405,6 +2462,8 @@ mod tests { db.insert_session(&Session { id: "done-1".to_string(), task: "finished run".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Completed, @@ -2440,6 +2499,8 @@ mod tests { db.insert_session(&Session { id: "session-1".to_string(), task: "heartbeat".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -2470,6 +2531,8 @@ mod tests { db.insert_session(&Session { id: "session-1".to_string(), task: "buffer output".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index dda97a92..ad8e583a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -20,7 +20,7 @@ use crate::session::output::{ OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT, }; use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; -use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState}; +use crate::session::{FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState}; use crate::worktree; #[cfg(test)] @@ -115,6 +115,8 @@ pub struct Dashboard { #[derive(Debug, Default, PartialEq, Eq)] struct SessionSummary { total: usize, + projects: usize, + task_groups: usize, pending: usize, running: usize, idle: usize, @@ -373,6 +375,7 @@ impl Dashboard { last_tool_activity_signature: initial_tool_activity_signature, last_budget_alert_state: BudgetState::Normal, }; + sort_sessions_for_display(&mut dashboard.sessions); dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); dashboard.sync_handoff_backlog_counts(); dashboard.sync_global_handoff_backlog(); @@ -489,9 +492,27 @@ impl Dashboard { frame.render_widget(Paragraph::new(overview_lines), chunks[0]); + let mut previous_project: Option<&str> = None; + let mut previous_task_group: Option<&str> = None; let rows = self.sessions.iter().map(|session| { + let project_cell = if previous_project == Some(session.project.as_str()) { + None + } else { + previous_project = Some(session.project.as_str()); + previous_task_group = None; + Some(session.project.clone()) + }; + let task_group_cell = if previous_task_group == Some(session.task_group.as_str()) { + None + } else { + previous_task_group = Some(session.task_group.as_str()); + Some(session.task_group.clone()) + }; + session_row( session, + project_cell, + task_group_cell, self.approval_queue_counts .get(&session.id) .copied() @@ -504,6 +525,8 @@ impl Dashboard { }); let header = Row::new([ "ID", + "Project", + "Group", "Agent", "State", "Branch", @@ -517,6 +540,8 @@ impl Dashboard { .style(Style::default().add_modifier(Modifier::BOLD)); let widths = [ Constraint::Length(8), + Constraint::Length(12), + Constraint::Length(18), Constraint::Length(10), Constraint::Length(10), Constraint::Min(12), @@ -1650,13 +1675,22 @@ impl Dashboard { let task = self.new_session_task(); let agent = self.cfg.default_agent.clone(); + let grouping = self + .sessions + .get(self.selected_session) + .map(|session| SessionGrouping { + project: Some(session.project.clone()), + task_group: Some(session.task_group.clone()), + }) + .unwrap_or_default(); - let session_id = match manager::create_session( + let session_id = match manager::create_session_with_grouping( &self.db, &self.cfg, &task, &agent, self.cfg.auto_create_worktrees, + grouping, ) .await { @@ -2610,16 +2644,24 @@ impl Dashboard { }); let source_task = source_session.as_ref().map(|session| session.task.clone()); let source_session_id = source_session.as_ref().map(|session| session.id.clone()); + let source_grouping = source_session + .as_ref() + .map(|session| SessionGrouping { + project: Some(session.project.clone()), + task_group: Some(session.task_group.clone()), + }) + .unwrap_or_default(); let agent = self.cfg.default_agent.clone(); let mut created_ids = Vec::new(); for task in expand_spawn_tasks(&plan.task, plan.spawn_count) { - let session_id = match manager::create_session( + let session_id = match manager::create_session_with_grouping( &self.db, &self.cfg, &task, &agent, self.cfg.auto_create_worktrees, + source_grouping.clone(), ) .await { @@ -2950,7 +2992,10 @@ impl Dashboard { let (heartbeat_enforcement, budget_enforcement) = self.sync_runtime_metrics(); let selected_id = self.selected_session_id().map(ToOwned::to_owned); self.sessions = match self.db.list_sessions() { - Ok(sessions) => sessions, + Ok(mut sessions) => { + sort_sessions_for_display(&mut sessions); + sessions + } Err(error) => { tracing::warn!("Failed to refresh sessions: {error}"); Vec::new() @@ -4105,6 +4150,14 @@ impl Dashboard { fn selected_session_metrics_text(&self) -> String { if let Some(session) = self.sessions.get(self.selected_session) { let metrics = &session.metrics; + let group_peers = self + .sessions + .iter() + .filter(|candidate| { + candidate.project == session.project + && candidate.task_group == session.task_group + }) + .count(); let mut lines = vec![ format!( "Selected {} [{}]", @@ -4112,6 +4165,10 @@ impl Dashboard { session.state ), format!("Task {}", session.task), + format!( + "Project {} | Group {} | Peer sessions {}", + session.project, session.task_group, group_peers + ), ]; if let Some(parent) = self.selected_parent_session.as_ref() { @@ -5203,9 +5260,21 @@ impl SessionSummary { worktree_health_by_session: &HashMap, suppress_inbox_attention: bool, ) -> Self { + let projects = sessions + .iter() + .map(|session| session.project.as_str()) + .collect::>() + .len(); + let task_groups = sessions + .iter() + .map(|session| (session.project.as_str(), session.task_group.as_str())) + .collect::>() + .len(); sessions.iter().fold( Self { total: sessions.len(), + projects, + task_groups, unread_messages: if suppress_inbox_attention { 0 } else { @@ -5248,6 +5317,8 @@ impl SessionSummary { fn session_row( session: &Session, + project_label: Option, + task_group_label: Option, approval_requests: usize, unread_messages: usize, ) -> Row<'static> { @@ -5255,6 +5326,8 @@ fn session_row( let state_color = session_state_color(&session.state); Row::new(vec![ Cell::from(format_session_id(&session.id)), + Cell::from(project_label.unwrap_or_default()), + Cell::from(task_group_label.unwrap_or_default()), Cell::from(session.agent_type.clone()), Cell::from(state_label).style( Style::default() @@ -5293,12 +5366,24 @@ fn session_row( ]) } +fn sort_sessions_for_display(sessions: &mut [Session]) { + sessions.sort_by(|left, right| { + left.project + .cmp(&right.project) + .then_with(|| left.task_group.cmp(&right.task_group)) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)) + }); +} + fn summary_line(summary: &SessionSummary) -> Line<'static> { let mut spans = vec![ Span::styled( format!("Total {} ", summary.total), Style::default().add_modifier(Modifier::BOLD), ), + summary_span("Projects", summary.projects, Color::Cyan), + summary_span("Groups", summary.task_groups, Color::Magenta), summary_span("Running", summary.running, Color::Green), summary_span("Idle", summary.idle, Color::Yellow), summary_span("Stale", summary.stale, Color::LightRed), @@ -6284,8 +6369,9 @@ mod tests { let rendered = render_dashboard_text(dashboard, 220, 24); assert!(rendered.contains("ID")); + assert!(rendered.contains("Project")); + assert!(rendered.contains("Group")); assert!(rendered.contains("Branch")); - assert!(rendered.contains("Tool Files")); assert!(rendered.contains("Total 2")); assert!(rendered.contains("Running 1")); assert!(rendered.contains("Completed 1")); @@ -8285,6 +8371,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "sess-1".to_string(), task: "sync activity".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -8326,6 +8414,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "stale-1".to_string(), task: "stale session".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -8479,6 +8569,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "older".to_string(), task: "older".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Idle, @@ -8493,6 +8585,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "newer".to_string(), task: "newer".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -8523,6 +8617,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "session-1".to_string(), task: "inspect output".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -8566,6 +8662,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "session-1".to_string(), task: "tail output".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -9201,6 +9299,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "running-1".to_string(), task: "stop me".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), state: SessionState::Running, working_dir: PathBuf::from("/tmp"), @@ -9235,6 +9335,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "failed-1".to_string(), task: "resume me".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), state: SessionState::Failed, working_dir: PathBuf::from("/tmp/ecc2-resume"), @@ -9275,6 +9377,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "stopped-1".to_string(), task: "cleanup me".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), state: SessionState::Stopped, working_dir: worktree_path.clone(), @@ -9316,6 +9420,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "running-1".to_string(), task: "keep alive".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -9353,6 +9459,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "running-1".to_string(), task: "keep worktree".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: active_path.clone(), state: SessionState::Running, @@ -9370,6 +9478,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "stopped-1".to_string(), task: "prune me".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: stopped_path.clone(), state: SessionState::Stopped, @@ -9421,6 +9531,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "stopped-1".to_string(), task: "retain me".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: retained_path.clone(), state: SessionState::Stopped, @@ -9473,6 +9585,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: session_id.clone(), task: "merge via dashboard".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: worktree.path.clone(), state: SessionState::Completed, @@ -9555,6 +9669,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "merge-ready".to_string(), task: "merge via dashboard".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: merged_worktree.path.clone(), state: SessionState::Completed, @@ -9571,6 +9687,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "active-ready".to_string(), task: "still active".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: active_worktree.path.clone(), state: SessionState::Running, @@ -9615,6 +9733,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "done-1".to_string(), task: "delete me".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Completed, @@ -9648,6 +9768,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "lead-1".to_string(), task: "coordinate".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -9681,6 +9803,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "lead-1".to_string(), task: "coordinate".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -9714,6 +9838,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "lead-1".to_string(), task: "coordinate".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -9747,6 +9873,8 @@ diff --git a/src/lib.rs b/src/lib.rs db.insert_session(&Session { id: "lead-1".to_string(), task: "coordinate".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), working_dir: PathBuf::from("/tmp"), state: SessionState::Running, @@ -10424,6 +10552,8 @@ diff --git a/src/lib.rs b/src/lib.rs Session { id: id.to_string(), task: "Render dashboard rows".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: agent_type.to_string(), state, working_dir: branch @@ -10455,6 +10585,8 @@ diff --git a/src/lib.rs b/src/lib.rs Session { id: id.to_string(), task: "Budget tracking".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), agent_type: "claude".to_string(), state: SessionState::Running, working_dir: PathBuf::from("/tmp"), From d0dbb208059f253b405330bd5a737713cf7e62ad Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 20:04:04 -0700 Subject: [PATCH 106/459] feat: add ecc2 merge queue reporting --- ecc2/src/main.rs | 130 +++++++++++++++++ ecc2/src/session/manager.rs | 284 ++++++++++++++++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 29 ++++ ecc2/src/worktree/mod.rs | 236 ++++++++++++++++++++++++++++-- 4 files changed, 669 insertions(+), 10 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index a9d2040c..0147942f 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -244,6 +244,12 @@ enum Commands { #[arg(long)] keep_worktree: bool, }, + /// Show the merge queue for inactive worktrees and any branch-to-branch blockers + MergeQueue { + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Prune worktrees for inactive sessions and report any active sessions still holding one PruneWorktrees { /// Emit machine-readable JSON instead of the human summary @@ -837,6 +843,14 @@ async fn main() -> Result<()> { } } } + Some(Commands::MergeQueue { json }) => { + let report = session::manager::build_merge_queue(&db)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_merge_queue_human(&report)); + } + } Some(Commands::PruneWorktrees { json }) => { let outcome = session::manager::prune_inactive_worktrees(&db, &cfg).await?; if json { @@ -1588,6 +1602,59 @@ fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome lines.join("\n") } +fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> String { + let mut lines = Vec::new(); + lines.push(format!( + "Merge queue: {} ready / {} blocked", + report.ready_entries.len(), + report.blocked_entries.len() + )); + + if report.ready_entries.is_empty() { + lines.push("No merge-ready worktrees queued".to_string()); + } else { + lines.push("Ready".to_string()); + for entry in &report.ready_entries { + lines.push(format!( + "- #{} {} [{}] | {} / {} | {}", + entry.queue_position.unwrap_or(0), + entry.session_id, + entry.branch, + entry.project, + entry.task_group, + entry.task + )); + } + } + + if !report.blocked_entries.is_empty() { + lines.push(String::new()); + lines.push("Blocked".to_string()); + for entry in &report.blocked_entries { + lines.push(format!( + "- {} [{}] | {} / {} | {}", + entry.session_id, entry.branch, entry.project, entry.task_group, entry.suggested_action + )); + for blocker in entry.blocked_by.iter().take(2) { + lines.push(format!( + " blocker {} [{}] | {}", + blocker.session_id, blocker.branch, blocker.summary + )); + for conflict in blocker.conflicts.iter().take(3) { + lines.push(format!(" conflict {conflict}")); + } + if let Some(preview) = blocker.conflicting_patch_preview.as_ref() { + for line in preview.lines().take(6) { + lines.push(format!(" {}", line)); + } + } + } + } + } + + lines.join("\n") +} + fn build_otel_export( db: &session::store::StateStore, session_id: Option<&str>, @@ -2535,6 +2602,17 @@ mod tests { } } + #[test] + fn cli_parses_merge_queue_json_flag() { + let cli = Cli::try_parse_from(["ecc", "merge-queue", "--json"]) + .expect("merge-queue --json should parse"); + + match cli.command { + Some(Commands::MergeQueue { json }) => assert!(json), + _ => panic!("expected merge-queue subcommand"), + } + } + #[test] fn format_worktree_status_human_includes_readiness_and_conflicts() { let report = WorktreeStatusReport { @@ -2666,6 +2744,58 @@ mod tests { assert!(text.contains("Cleanup removed worktree and branch")); } + #[test] + fn format_merge_queue_human_reports_ready_and_blocked_entries() { + let text = format_merge_queue_human(&session::manager::MergeQueueReport { + ready_entries: vec![session::manager::MergeQueueEntry { + session_id: "alpha1234".to_string(), + task: "merge alpha".to_string(), + project: "ecc".to_string(), + task_group: "checkout".to_string(), + branch: "ecc/alpha1234".to_string(), + base_branch: "main".to_string(), + state: session::SessionState::Stopped, + worktree_health: worktree::WorktreeHealth::InProgress, + dirty: false, + queue_position: Some(1), + ready_to_merge: true, + blocked_by: Vec::new(), + suggested_action: "merge in queue order #1".to_string(), + }], + blocked_entries: vec![session::manager::MergeQueueEntry { + session_id: "beta5678".to_string(), + task: "merge beta".to_string(), + project: "ecc".to_string(), + task_group: "checkout".to_string(), + branch: "ecc/beta5678".to_string(), + base_branch: "main".to_string(), + state: session::SessionState::Stopped, + worktree_health: worktree::WorktreeHealth::InProgress, + dirty: false, + queue_position: None, + ready_to_merge: false, + blocked_by: vec![session::manager::MergeQueueBlocker { + session_id: "alpha1234".to_string(), + branch: "ecc/alpha1234".to_string(), + state: session::SessionState::Stopped, + conflicts: vec!["README.md".to_string()], + summary: "merge after alpha1234 to avoid branch conflicts".to_string(), + conflicting_patch_preview: Some("--- Branch diff vs main ---\nREADME.md".to_string()), + blocker_patch_preview: None, + }], + suggested_action: "merge after alpha1234".to_string(), + }], + }); + + assert!(text.contains("Merge queue: 1 ready / 1 blocked")); + assert!(text.contains("Ready")); + assert!(text.contains("#1 alpha1234")); + assert!(text.contains("Blocked")); + assert!(text.contains("beta5678")); + assert!(text.contains("blocker alpha1234")); + assert!(text.contains("conflict README.md")); + } + #[test] fn format_bulk_worktree_merge_human_reports_summary_and_skips() { let text = format_bulk_worktree_merge_human(&session::manager::WorktreeBulkMergeOutcome { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index db059367..a41aaaa7 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -964,6 +964,191 @@ pub async fn prune_inactive_worktrees( }) } +#[derive(Debug, Clone, Serialize)] +pub struct MergeQueueBlocker { + pub session_id: String, + pub branch: String, + pub state: SessionState, + pub conflicts: Vec, + pub summary: String, + pub conflicting_patch_preview: Option, + pub blocker_patch_preview: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MergeQueueEntry { + pub session_id: String, + pub task: String, + pub project: String, + pub task_group: String, + pub branch: String, + pub base_branch: String, + pub state: SessionState, + pub worktree_health: worktree::WorktreeHealth, + pub dirty: bool, + pub queue_position: Option, + pub ready_to_merge: bool, + pub blocked_by: Vec, + pub suggested_action: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MergeQueueReport { + pub ready_entries: Vec, + pub blocked_entries: Vec, +} + +pub fn build_merge_queue(db: &StateStore) -> Result { + let mut sessions = db + .list_sessions()? + .into_iter() + .filter(|session| session.worktree.is_some()) + .collect::>(); + sessions.sort_by(|left, right| { + merge_queue_priority(left) + .cmp(&merge_queue_priority(right)) + .then_with(|| left.project.cmp(&right.project)) + .then_with(|| left.task_group.cmp(&right.task_group)) + .then_with(|| left.updated_at.cmp(&right.updated_at)) + .then_with(|| left.id.cmp(&right.id)) + }); + + let mut entries = Vec::new(); + let mut mergeable_sessions = Vec::::new(); + let mut next_position = 1usize; + + for session in sessions { + let Some(worktree) = session.worktree.clone() else { + continue; + }; + + let worktree_health = worktree::health(&worktree)?; + let dirty = worktree::has_uncommitted_changes(&worktree)?; + let mut blocked_by = Vec::new(); + + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + ) { + blocked_by.push(MergeQueueBlocker { + session_id: session.id.clone(), + branch: worktree.branch.clone(), + state: session.state.clone(), + conflicts: Vec::new(), + summary: format!("session is still {}", session_state_label(&session.state)), + conflicting_patch_preview: None, + blocker_patch_preview: None, + }); + } else if worktree_health == worktree::WorktreeHealth::Conflicted { + let readiness = worktree::merge_readiness(&worktree)?; + blocked_by.push(MergeQueueBlocker { + session_id: session.id.clone(), + branch: worktree.branch.clone(), + state: session.state.clone(), + conflicts: readiness.conflicts, + summary: readiness.summary, + conflicting_patch_preview: worktree::diff_patch_preview(&worktree, 18)?, + blocker_patch_preview: None, + }); + } else if dirty { + blocked_by.push(MergeQueueBlocker { + session_id: session.id.clone(), + branch: worktree.branch.clone(), + state: session.state.clone(), + conflicts: Vec::new(), + summary: "worktree has uncommitted changes".to_string(), + conflicting_patch_preview: worktree::diff_patch_preview(&worktree, 18)?, + blocker_patch_preview: None, + }); + } else { + for blocker in &mergeable_sessions { + let Some(blocker_worktree) = blocker.worktree.as_ref() else { + continue; + }; + let Some(conflict) = + worktree::branch_conflict_preview(&worktree, blocker_worktree, 12)? + else { + continue; + }; + + blocked_by.push(MergeQueueBlocker { + session_id: blocker.id.clone(), + branch: blocker_worktree.branch.clone(), + state: blocker.state.clone(), + conflicts: conflict.conflicts, + summary: format!( + "merge after {} to avoid branch conflicts", + blocker.id + ), + conflicting_patch_preview: conflict.right_patch_preview, + blocker_patch_preview: conflict.left_patch_preview, + }); + } + } + + let ready_to_merge = blocked_by.is_empty(); + let queue_position = if ready_to_merge { + let position = next_position; + next_position += 1; + mergeable_sessions.push(session.clone()); + Some(position) + } else { + None + }; + + let suggested_action = if let Some(position) = queue_position { + format!("merge in queue order #{position}") + } else if blocked_by.iter().any(|blocker| blocker.session_id == session.id) { + blocked_by + .first() + .map(|blocker| blocker.summary.clone()) + .unwrap_or_else(|| "resolve merge blockers".to_string()) + } else { + format!( + "merge after {}", + blocked_by + .iter() + .map(|blocker| blocker.session_id.as_str()) + .collect::>() + .join(", ") + ) + }; + + entries.push(MergeQueueEntry { + session_id: session.id, + task: session.task, + project: session.project, + task_group: session.task_group, + branch: worktree.branch, + base_branch: worktree.base_branch, + state: session.state, + worktree_health, + dirty, + queue_position, + ready_to_merge, + blocked_by, + suggested_action, + }); + } + + let mut ready_entries = entries + .iter() + .filter(|entry| entry.ready_to_merge) + .cloned() + .collect::>(); + ready_entries.sort_by_key(|entry| entry.queue_position.unwrap_or(usize::MAX)); + + let blocked_entries = entries + .into_iter() + .filter(|entry| !entry.ready_to_merge) + .collect::>(); + + Ok(MergeQueueReport { + ready_entries, + blocked_entries, + }) +} + pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { let session = resolve_session(db, id)?; @@ -1326,6 +1511,14 @@ fn attached_worktree_count(db: &StateStore) -> Result { .count()) } +fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime) { + let active_rank = match session.state { + SessionState::Completed | SessionState::Failed | SessionState::Stopped => 0, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale => 1, + }; + (active_rank, session.updated_at) +} + async fn spawn_session_runner( task: &str, session_id: &str, @@ -3020,6 +3213,97 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn build_merge_queue_orders_ready_sessions_and_blocks_conflicts() -> Result<()> { + let tempdir = TestDir::new("manager-merge-queue")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let alpha_worktree = worktree::create_for_session_in_repo("alpha", &cfg, &repo_root)?; + fs::write(alpha_worktree.path.join("README.md"), "alpha\n")?; + run_git(&alpha_worktree.path, ["add", "README.md"])?; + run_git(&alpha_worktree.path, ["commit", "-m", "alpha change"])?; + + let beta_worktree = worktree::create_for_session_in_repo("beta", &cfg, &repo_root)?; + fs::write(beta_worktree.path.join("README.md"), "beta\n")?; + run_git(&beta_worktree.path, ["add", "README.md"])?; + run_git(&beta_worktree.path, ["commit", "-m", "beta change"])?; + + let gamma_worktree = worktree::create_for_session_in_repo("gamma", &cfg, &repo_root)?; + fs::write(gamma_worktree.path.join("src.txt"), "gamma\n")?; + run_git(&gamma_worktree.path, ["add", "src.txt"])?; + run_git(&gamma_worktree.path, ["commit", "-m", "gamma change"])?; + + db.insert_session(&Session { + id: "alpha".to_string(), + task: "alpha merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: alpha_worktree.path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(alpha_worktree), + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "beta".to_string(), + task: "beta merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: beta_worktree.path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(beta_worktree), + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "gamma".to_string(), + task: "gamma merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: gamma_worktree.path.clone(), + state: SessionState::Stopped, + pid: None, + worktree: Some(gamma_worktree), + created_at: now - Duration::minutes(1), + updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + })?; + + let queue = build_merge_queue(&db)?; + assert_eq!(queue.ready_entries.len(), 2); + assert_eq!(queue.ready_entries[0].session_id, "alpha"); + assert_eq!(queue.ready_entries[0].queue_position, Some(1)); + assert_eq!(queue.ready_entries[1].session_id, "gamma"); + assert_eq!(queue.ready_entries[1].queue_position, Some(2)); + + assert_eq!(queue.blocked_entries.len(), 1); + let blocked = &queue.blocked_entries[0]; + assert_eq!(blocked.session_id, "beta"); + assert_eq!(blocked.blocked_by.len(), 1); + assert_eq!(blocked.blocked_by[0].session_id, "alpha"); + assert!(blocked.blocked_by[0] + .conflicts + .contains(&"README.md".to_string())); + assert!(blocked.suggested_action.contains("merge after alpha")); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn delete_session_removes_inactive_session_and_worktree() -> Result<()> { let tempdir = TestDir::new("manager-delete-session")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index ad8e583a..f207f0fd 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -4371,6 +4371,35 @@ impl Dashboard { lines.push(format!("- conflict {conflict}")); } } + if let Ok(merge_queue) = manager::build_merge_queue(&self.db) { + let entry = merge_queue + .ready_entries + .iter() + .chain(merge_queue.blocked_entries.iter()) + .find(|entry| entry.session_id == session.id); + if let Some(entry) = entry { + lines.push("Merge queue".to_string()); + if let Some(position) = entry.queue_position { + lines.push(format!( + "- ready #{} | {}", + position, entry.suggested_action + )); + } else { + lines.push(format!("- blocked | {}", entry.suggested_action)); + } + for blocker in entry.blocked_by.iter().take(2) { + lines.push(format!( + " blocker {} [{}] | {}", + format_session_id(&blocker.session_id), + blocker.branch, + blocker.summary + )); + for conflict in blocker.conflicts.iter().take(3) { + lines.push(format!(" conflict {conflict}")); + } + } + } + } } lines.push(format!( diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 38f43add..6287e811 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -19,7 +19,7 @@ pub struct MergeReadiness { pub conflicts: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] pub enum WorktreeHealth { Clear, InProgress, @@ -33,6 +33,15 @@ pub struct MergeOutcome { pub already_up_to_date: bool, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BranchConflictPreview { + pub left_branch: String, + pub right_branch: String, + pub conflicts: Vec, + pub left_patch_preview: Option, + pub right_patch_preview: Option, +} + /// 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")?; @@ -255,15 +264,45 @@ pub fn diff_patch_preview(worktree: &WorktreeInfo, max_lines: usize) -> Result Result { + let mut readiness = merge_readiness_for_branches( + &base_checkout_path(worktree)?, + &worktree.base_branch, + &worktree.branch, + )?; + readiness.summary = match readiness.status { + MergeReadinessStatus::Ready => format!("Merge ready into {}", worktree.base_branch), + MergeReadinessStatus::Conflicted => { + let conflict_summary = readiness + .conflicts + .iter() + .take(3) + .cloned() + .collect::>() + .join(", "); + let overflow = readiness.conflicts.len().saturating_sub(3); + let detail = if overflow > 0 { + format!("{conflict_summary}, +{overflow} more") + } else { + conflict_summary + }; + format!( + "Merge blocked by {} conflict(s): {detail}", + readiness.conflicts.len() + ) + } + }; + Ok(readiness) +} + +pub fn merge_readiness_for_branches( + repo_root: &Path, + left_branch: &str, + right_branch: &str, +) -> Result { let output = Command::new("git") .arg("-C") - .arg(&worktree.path) - .args([ - "merge-tree", - "--write-tree", - &worktree.base_branch, - &worktree.branch, - ]) + .arg(repo_root) + .args(["merge-tree", "--write-tree", left_branch, right_branch]) .output() .context("Failed to generate merge readiness preview")?; @@ -280,7 +319,7 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result { if output.status.success() { return Ok(MergeReadiness { status: MergeReadinessStatus::Ready, - summary: format!("Merge ready into {}", worktree.base_branch), + summary: format!("Merge ready: {right_branch} into {left_branch}"), conflicts: Vec::new(), }); } @@ -301,7 +340,10 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result { return Ok(MergeReadiness { status: MergeReadinessStatus::Conflicted, - summary: format!("Merge blocked by {} conflict(s): {detail}", conflicts.len()), + summary: format!( + "Merge blocked between {left_branch} and {right_branch} by {} conflict(s): {detail}", + conflicts.len() + ), conflicts, }); } @@ -310,6 +352,30 @@ pub fn merge_readiness(worktree: &WorktreeInfo) -> Result { anyhow::bail!("git merge-tree failed: {stderr}"); } +pub fn branch_conflict_preview( + left: &WorktreeInfo, + right: &WorktreeInfo, + max_lines: usize, +) -> Result> { + if left.base_branch != right.base_branch { + return Ok(None); + } + + let repo_root = base_checkout_path(left)?; + let readiness = merge_readiness_for_branches(&repo_root, &left.branch, &right.branch)?; + if readiness.status != MergeReadinessStatus::Conflicted { + return Ok(None); + } + + Ok(Some(BranchConflictPreview { + left_branch: left.branch.clone(), + right_branch: right.branch.clone(), + conflicts: readiness.conflicts.clone(), + left_patch_preview: diff_patch_preview_for_paths(left, &readiness.conflicts, max_lines)?, + right_patch_preview: diff_patch_preview_for_paths(right, &readiness.conflicts, max_lines)?, + })) +} + pub fn health(worktree: &WorktreeInfo) -> Result { let merge_readiness = merge_readiness(worktree)?; if merge_readiness.status == MergeReadinessStatus::Conflicted { @@ -462,6 +528,79 @@ fn git_diff_patch_lines(worktree_path: &Path, extra_args: &[&str]) -> Result Result> { + if paths.is_empty() { + return Ok(Vec::new()); + } + + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .args(["--stat", "--patch", "--find-renames"]); + command.args(extra_args); + command.arg("--"); + for path in paths { + command.arg(path); + } + + let output = command + .output() + .context("Failed to generate filtered worktree patch preview")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::warn!( + "Filtered worktree patch preview warning for {}: {stderr}", + worktree_path.display() + ); + return Ok(Vec::new()); + } + + Ok(parse_nonempty_lines(&output.stdout)) +} + +pub fn diff_patch_preview_for_paths( + worktree: &WorktreeInfo, + paths: &[String], + max_lines: usize, +) -> Result> { + if paths.is_empty() { + return Ok(None); + } + + let mut remaining = max_lines.max(1); + let mut sections = Vec::new(); + let base_ref = format!("{}...HEAD", worktree.base_branch); + + let committed = git_diff_patch_lines_for_paths(&worktree.path, &[&base_ref], paths)?; + if !committed.is_empty() && remaining > 0 { + let taken = take_preview_lines(&committed, &mut remaining); + sections.push(format!( + "--- Branch diff vs {} ---\n{}", + worktree.base_branch, + taken.join("\n") + )); + } + + let working = git_diff_patch_lines_for_paths(&worktree.path, &[], paths)?; + if !working.is_empty() && remaining > 0 { + let taken = take_preview_lines(&working, &mut remaining); + sections.push(format!("--- Working tree diff ---\n{}", taken.join("\n"))); + } + + if sections.is_empty() { + Ok(None) + } else { + Ok(Some(sections.join("\n\n"))) + } +} + fn git_status_short(worktree_path: &Path) -> Result> { let output = Command::new("git") .arg("-C") @@ -901,4 +1040,81 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> { + let root = std::env::temp_dir() + .join(format!("ecc2-worktree-branch-conflict-preview-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + + let left_dir = root.join("wt-left"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/left", + left_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + fs::write(left_dir.join("README.md"), "left\n")?; + run_git(&left_dir, &["add", "README.md"])?; + run_git(&left_dir, &["commit", "-m", "left change"])?; + + let right_dir = root.join("wt-right"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/right", + right_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + fs::write(right_dir.join("README.md"), "right\n")?; + run_git(&right_dir, &["add", "README.md"])?; + run_git(&right_dir, &["commit", "-m", "right change"])?; + + let left = WorktreeInfo { + path: left_dir.clone(), + branch: "ecc/left".to_string(), + base_branch: "main".to_string(), + }; + let right = WorktreeInfo { + path: right_dir.clone(), + branch: "ecc/right".to_string(), + base_branch: "main".to_string(), + }; + + let preview = branch_conflict_preview(&left, &right, 12)? + .expect("expected branch conflict preview"); + assert_eq!(preview.conflicts, vec!["README.md".to_string()]); + assert!(preview + .left_patch_preview + .as_ref() + .is_some_and(|preview| preview.contains("README.md"))); + assert!(preview + .right_patch_preview + .as_ref() + .is_some_and(|preview| preview.contains("README.md"))); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&left_dir) + .output(); + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&right_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } } From e2b24e43a258520a01ae24b813449af18f2071cc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 20:09:41 -0700 Subject: [PATCH 107/459] feat: share dependency caches across ecc2 worktrees --- ecc2/Cargo.lock | 1 + ecc2/Cargo.toml | 1 + ecc2/src/session/manager.rs | 16 +- ecc2/src/worktree/mod.rs | 303 +++++++++++++++++++++++++++++++++++- 4 files changed, 316 insertions(+), 5 deletions(-) diff --git a/ecc2/Cargo.lock b/ecc2/Cargo.lock index 2161a8e7..7a4e60eb 100644 --- a/ecc2/Cargo.lock +++ b/ecc2/Cargo.lock @@ -501,6 +501,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "tokio", "toml", diff --git a/ecc2/Cargo.toml b/ecc2/Cargo.toml index 7283b053..170aacb4 100644 --- a/ecc2/Cargo.toml +++ b/ecc2/Cargo.toml @@ -26,6 +26,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" regex = "1" +sha2 = "0.10" # CLI clap = { version = "4", features = ["derive"] } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index a41aaaa7..907f2f84 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -539,12 +539,13 @@ pub fn query_tool_calls( ToolLogger::new(db).query(&session.id, page, page_size) } -pub async fn resume_session(db: &StateStore, _cfg: &Config, id: &str) -> Result { - resume_session_with_program(db, id, None).await +pub async fn resume_session(db: &StateStore, cfg: &Config, id: &str) -> Result { + resume_session_with_program(db, cfg, id, None).await } async fn resume_session_with_program( db: &StateStore, + _cfg: &Config, id: &str, runner_executable_override: Option<&Path>, ) -> Result { @@ -559,6 +560,14 @@ async fn resume_session_with_program( } db.update_state_and_pid(&session.id, &SessionState::Pending, None)?; + if let Some(worktree) = session.worktree.as_ref() { + if let Err(error) = worktree::sync_shared_dependency_dirs(worktree) { + tracing::warn!( + "Shared dependency cache sync warning for resumed session {}: {error}", + session.id + ); + } + } let runner_executable = match runner_executable_override { Some(program) => program.to_path_buf(), None => std::env::current_exe().context("Failed to resolve ECC executable path")?, @@ -2824,7 +2833,8 @@ mod tests { fs::create_dir_all(tempdir.path().join("resume-working-dir"))?; let (fake_claude, log_path) = write_fake_claude(tempdir.path())?; - let resumed_id = resume_session_with_program(&db, "deadbeef", Some(&fake_claude)).await?; + let resumed_id = + resume_session_with_program(&db, &cfg, "deadbeef", Some(&fake_claude)).await?; let resumed = db .get_session(&resumed_id)? .context("resumed session should exist")?; diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 6287e811..2d9a2d58 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -1,5 +1,7 @@ use anyhow::{Context, Result}; use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -82,11 +84,25 @@ pub(crate) fn create_for_session_in_repo( branch ); - Ok(WorktreeInfo { + let info = WorktreeInfo { path, branch, base_branch: base, - }) + }; + + if let Err(error) = sync_shared_dependency_dirs_in_repo(&info, repo_root) { + tracing::warn!( + "Shared dependency cache sync warning for {}: {error}", + info.path.display() + ); + } + + Ok(info) +} + +pub fn sync_shared_dependency_dirs(worktree: &WorktreeInfo) -> Result> { + let repo_root = base_checkout_path(worktree)?; + sync_shared_dependency_dirs_in_repo(worktree, &repo_root) } pub(crate) fn branch_name_for_session( @@ -565,6 +581,203 @@ fn git_diff_patch_lines_for_paths( Ok(parse_nonempty_lines(&output.stdout)) } +#[derive(Debug, Clone, PartialEq, Eq)] +struct SharedDependencyStrategy { + label: &'static str, + dir_name: &'static str, + fingerprint_files: Vec<&'static str>, +} + +fn sync_shared_dependency_dirs_in_repo( + worktree: &WorktreeInfo, + repo_root: &Path, +) -> Result> { + let mut applied = Vec::new(); + for strategy in detect_shared_dependency_strategies(repo_root) { + if sync_shared_dependency_dir(worktree, repo_root, &strategy)? { + applied.push(strategy.label.to_string()); + } + } + Ok(applied) +} + +fn detect_shared_dependency_strategies(repo_root: &Path) -> Vec { + let mut strategies = Vec::new(); + + if repo_root.join("node_modules").is_dir() { + if repo_root.join("pnpm-lock.yaml").is_file() && repo_root.join("package.json").is_file() { + strategies.push(SharedDependencyStrategy { + label: "node_modules (pnpm)", + dir_name: "node_modules", + fingerprint_files: vec!["package.json", "pnpm-lock.yaml"], + }); + } else if repo_root.join("bun.lockb").is_file() && repo_root.join("package.json").is_file() + { + strategies.push(SharedDependencyStrategy { + label: "node_modules (bun)", + dir_name: "node_modules", + fingerprint_files: vec!["package.json", "bun.lockb"], + }); + } else if repo_root.join("yarn.lock").is_file() && repo_root.join("package.json").is_file() + { + strategies.push(SharedDependencyStrategy { + label: "node_modules (yarn)", + dir_name: "node_modules", + fingerprint_files: vec!["package.json", "yarn.lock"], + }); + } else if repo_root.join("package-lock.json").is_file() + && repo_root.join("package.json").is_file() + { + strategies.push(SharedDependencyStrategy { + label: "node_modules (npm)", + dir_name: "node_modules", + fingerprint_files: vec!["package.json", "package-lock.json"], + }); + } + } + + if repo_root.join("target").is_dir() && repo_root.join("Cargo.toml").is_file() { + let mut fingerprint_files = vec!["Cargo.toml"]; + if repo_root.join("Cargo.lock").is_file() { + fingerprint_files.push("Cargo.lock"); + } + strategies.push(SharedDependencyStrategy { + label: "target (cargo)", + dir_name: "target", + fingerprint_files, + }); + } + + if repo_root.join(".venv").is_dir() { + let python_files = [ + "uv.lock", + "poetry.lock", + "Pipfile.lock", + "requirements.txt", + "pyproject.toml", + "setup.py", + "setup.cfg", + ]; + let fingerprint_files = python_files + .into_iter() + .filter(|file| repo_root.join(file).is_file()) + .collect::>(); + if !fingerprint_files.is_empty() { + strategies.push(SharedDependencyStrategy { + label: ".venv (python)", + dir_name: ".venv", + fingerprint_files, + }); + } + } + + strategies +} + +fn sync_shared_dependency_dir( + worktree: &WorktreeInfo, + repo_root: &Path, + strategy: &SharedDependencyStrategy, +) -> Result { + let root_dir = repo_root.join(strategy.dir_name); + if !root_dir.exists() { + return Ok(false); + } + + let worktree_dir = worktree.path.join(strategy.dir_name); + let worktree_is_symlink = fs::symlink_metadata(&worktree_dir) + .map(|metadata| metadata.file_type().is_symlink()) + .unwrap_or(false); + let root_fingerprint = dependency_fingerprint(repo_root, &strategy.fingerprint_files)?; + let worktree_fingerprint = + dependency_fingerprint(&worktree.path, &strategy.fingerprint_files).ok(); + + if worktree_fingerprint.as_deref() != Some(root_fingerprint.as_str()) { + if worktree_is_symlink { + remove_symlink(&worktree_dir)?; + fs::create_dir_all(&worktree_dir).with_context(|| { + format!( + "Failed to create independent {} directory in {}", + strategy.dir_name, + worktree.path.display() + ) + })?; + } + return Ok(false); + } + + if worktree_dir.exists() { + if is_symlink_to(&worktree_dir, &root_dir)? { + return Ok(true); + } + return Ok(false); + } + + create_dir_symlink(&root_dir, &worktree_dir).with_context(|| { + format!( + "Failed to link shared dependency cache {} into {}", + strategy.dir_name, + worktree.path.display() + ) + })?; + Ok(true) +} + +fn dependency_fingerprint(root: &Path, files: &[&str]) -> Result { + let mut hasher = Sha256::new(); + for rel in files { + let path = root.join(rel); + let content = fs::read(&path) + .with_context(|| format!("Failed to read dependency fingerprint input {}", path.display()))?; + hasher.update(rel.as_bytes()); + hasher.update([0]); + hasher.update(&content); + hasher.update([0xff]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn is_symlink_to(path: &Path, target: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(error).with_context(|| { + format!("Failed to inspect dependency cache link {}", path.display()) + }) + } + }; + if !metadata.file_type().is_symlink() { + return Ok(false); + } + + let linked = fs::read_link(path) + .with_context(|| format!("Failed to read dependency cache link {}", path.display()))?; + Ok(linked == target) +} + +fn remove_symlink(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => { + fs::remove_dir(path) + .with_context(|| format!("Failed to remove dependency cache link {}", path.display())) + } + Err(error) => Err(error) + .with_context(|| format!("Failed to remove dependency cache link {}", path.display())), + } +} + +#[cfg(unix)] +fn create_dir_symlink(src: &Path, dst: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(src, dst) +} + +#[cfg(windows)] +fn create_dir_symlink(src: &Path, dst: &Path) -> std::io::Result<()> { + std::os::windows::fs::symlink_dir(src, dst) +} + pub fn diff_patch_preview_for_paths( worktree: &WorktreeInfo, paths: &[String], @@ -1117,4 +1330,90 @@ mod tests { let _ = fs::remove_dir_all(root); Ok(()) } + + #[test] + fn create_for_session_links_shared_node_modules_cache() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?; + fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?; + fs::create_dir_all(repo.join("node_modules"))?; + fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?; + run_git(&repo, &["add", "package.json", "package-lock.json"])?; + run_git(&repo, &["commit", "-m", "add node deps"])?; + + let mut cfg = Config::default(); + cfg.worktree_root = root.join("worktrees"); + let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; + + let node_modules = worktree.path.join("node_modules"); + assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + assert_eq!(fs::read_link(&node_modules)?, repo.join("node_modules")); + + remove(&worktree)?; + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn sync_shared_dependency_dirs_falls_back_when_lockfiles_diverge() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-worktree-node-fallback-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?; + fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?; + fs::create_dir_all(repo.join("node_modules"))?; + fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?; + run_git(&repo, &["add", "package.json", "package-lock.json"])?; + run_git(&repo, &["commit", "-m", "add node deps"])?; + + let mut cfg = Config::default(); + cfg.worktree_root = root.join("worktrees"); + let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; + + let node_modules = worktree.path.join("node_modules"); + assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + + fs::write( + worktree.path.join("package-lock.json"), + "{\n \"lockfileVersion\": 4\n}\n", + )?; + let applied = sync_shared_dependency_dirs(&worktree)?; + assert!(applied.is_empty()); + assert!(node_modules.is_dir()); + assert!(!fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + assert!(repo.join("node_modules/.cache-marker").exists()); + + remove(&worktree)?; + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn create_for_session_links_shared_cargo_target_cache() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-worktree-cargo-cache-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + fs::write( + repo.join("Cargo.toml"), + "[package]\nname = \"repo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + )?; + fs::write(repo.join("Cargo.lock"), "# lock\n")?; + fs::create_dir_all(repo.join("target/debug"))?; + fs::write(repo.join("target/debug/.cache-marker"), "shared\n")?; + run_git(&repo, &["add", "Cargo.toml", "Cargo.lock"])?; + run_git(&repo, &["commit", "-m", "add cargo deps"])?; + + let mut cfg = Config::default(); + cfg.worktree_root = root.join("worktrees"); + let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; + + let target = worktree.path.join("target"); + assert!(fs::symlink_metadata(&target)?.file_type().is_symlink()); + assert_eq!(fs::read_link(&target)?, repo.join("target")); + + remove(&worktree)?; + let _ = fs::remove_dir_all(root); + Ok(()) + } } From 75c2503abd2cab105025d5e63165266aa487a2bc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 20:22:51 -0700 Subject: [PATCH 108/459] feat: add ecc2 git staging ui controls --- ecc2/src/tui/app.rs | 5 + ecc2/src/tui/dashboard.rs | 415 +++++++++++++++++++++++++++++++++++++- ecc2/src/worktree/mod.rs | 233 +++++++++++++++++++++ 3 files changed, 648 insertions(+), 5 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index d7b4e6ea..646ac399 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -91,7 +91,12 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(), (_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), + (_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(), (_, KeyCode::Char('V')) => dashboard.toggle_diff_view_mode(), + (_, KeyCode::Char('S')) => dashboard.stage_selected_git_status(), + (_, KeyCode::Char('U')) => dashboard.unstage_selected_git_status(), + (_, KeyCode::Char('R')) => dashboard.reset_selected_git_status(), + (_, KeyCode::Char('C')) => dashboard.begin_commit_prompt(), (_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(), (_, KeyCode::Char('}')) => dashboard.next_diff_hunk(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f207f0fd..008a48e4 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -82,6 +82,8 @@ pub struct Dashboard { diff_view_mode: DiffViewMode, selected_conflict_protocol: Option, selected_merge_readiness: Option, + selected_git_status_entries: Vec, + selected_git_status: usize, output_mode: OutputMode, output_filter: OutputFilter, output_time_filter: OutputTimeFilter, @@ -101,6 +103,7 @@ pub struct Dashboard { collapsed_panes: HashSet, search_input: Option, spawn_input: Option, + commit_input: Option, search_query: Option, search_scope: SearchScope, search_agent_filter: SearchAgentFilter, @@ -144,6 +147,7 @@ enum OutputMode { Timeline, WorktreeDiff, ConflictProtocol, + GitStatus, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -346,6 +350,8 @@ impl Dashboard { diff_view_mode: DiffViewMode::Split, selected_conflict_protocol: None, selected_merge_readiness: None, + selected_git_status_entries: Vec::new(), + selected_git_status: 0, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, @@ -365,6 +371,7 @@ impl Dashboard { collapsed_panes: HashSet::new(), search_input: None, spawn_input: None, + commit_input: None, search_query: None, search_scope: SearchScope::SelectedSession, search_agent_filter: SearchAgentFilter::AllAgents, @@ -641,6 +648,14 @@ impl Dashboard { }); (" Conflict Protocol ".to_string(), Text::from(content)) } + OutputMode::GitStatus => { + let content = if self.selected_git_status_entries.is_empty() { + Text::from(self.empty_git_status_message()) + } else { + Text::from(self.visible_git_status_lines()) + }; + (self.output_title(), content) + } } } else { ( @@ -712,6 +727,28 @@ impl Dashboard { ); } + if self.output_mode == OutputMode::GitStatus { + let staged = self + .selected_git_status_entries + .iter() + .filter(|entry| entry.staged) + .count(); + let unstaged = self + .selected_git_status_entries + .iter() + .filter(|entry| entry.unstaged || entry.untracked) + .count(); + let total = self.selected_git_status_entries.len(); + let current = if total == 0 { + 0 + } else { + self.selected_git_status.min(total.saturating_sub(1)) + 1 + }; + return format!( + " Git status staged:{staged} unstaged:{unstaged} {current}/{total} " + ); + } + let filter = format!( "{}{}", self.output_filter.title_suffix(), @@ -757,6 +794,10 @@ impl Dashboard { } } + fn empty_git_status_message(&self) -> &'static str { + "No staged or unstaged changes for this worktree." + } + fn empty_timeline_message(&self) -> &'static str { match ( self.timeline_scope, @@ -980,7 +1021,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff git status [z] stage [S] unstage [U] reset [R] commit [C] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.pane_focus_shortcuts_label(), self.pane_move_shortcuts_label(), self.layout_label(), @@ -989,6 +1030,8 @@ impl Dashboard { let search_prefix = if let Some(input) = self.spawn_input.as_ref() { format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |") + } else if let Some(input) = self.commit_input.as_ref() { + format!(" commit>{input}_ | [Enter] commit [Esc] cancel |") } else if let Some(input) = self.search_input.as_ref() { format!( " /{input}_ | {} | {} | [Enter] apply [Esc] cancel |", @@ -1015,6 +1058,7 @@ impl Dashboard { }; let text = if self.spawn_input.is_some() + || self.commit_input.is_some() || self.search_input.is_some() || self.search_query.is_some() || self.pane_command_mode @@ -1075,8 +1119,11 @@ impl Dashboard { " y Toggle selected-session timeline view".to_string(), " E Cycle timeline event filter".to_string(), " v Toggle selected worktree diff in output pane".to_string(), + " z Toggle selected worktree git status in output pane".to_string(), " V Toggle diff view mode between split and unified".to_string(), " {/} Jump to previous/next diff hunk in the active diff view".to_string(), + " S/U/R Stage, unstage, or reset the selected git-status entry".to_string(), + " C Commit staged changes for the selected worktree".to_string(), " c Show conflict-resolution protocol for selected conflicted worktree" .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), @@ -1539,6 +1586,14 @@ impl Dashboard { self.refresh_logs(); } Pane::Output => { + if self.output_mode == OutputMode::GitStatus { + self.output_follow = false; + if self.selected_git_status + 1 < self.selected_git_status_entries.len() { + self.selected_git_status += 1; + self.sync_output_scroll(self.last_output_height.max(1)); + } + return; + } let max_scroll = self.max_output_scroll(); if self.output_follow { return; @@ -1578,6 +1633,12 @@ impl Dashboard { self.refresh_logs(); } Pane::Output => { + if self.output_mode == OutputMode::GitStatus { + self.output_follow = false; + self.selected_git_status = self.selected_git_status.saturating_sub(1); + self.sync_output_scroll(self.last_output_height.max(1)); + return; + } if self.output_follow { self.output_follow = false; self.output_scroll_offset = self.max_output_scroll(); @@ -1789,9 +1850,136 @@ impl Dashboard { self.reset_output_view(); self.set_operator_note("showing session output".to_string()); } + OutputMode::GitStatus => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } } } + pub fn toggle_git_status_mode(&mut self) { + match self.output_mode { + OutputMode::GitStatus => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + _ => { + let has_worktree = self + .sessions + .get(self.selected_session) + .and_then(|session| session.worktree.as_ref()) + .is_some(); + if !has_worktree { + self.set_operator_note("selected session has no worktree".to_string()); + return; + } + + self.sync_selected_git_status(); + self.output_mode = OutputMode::GitStatus; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note("showing selected worktree git status".to_string()); + } + } + } + + pub fn stage_selected_git_status(&mut self) { + if self.output_mode != OutputMode::GitStatus { + self.set_operator_note( + "git staging controls are only available in git status view".to_string(), + ); + return; + } + + let Some((entry, worktree)) = self.selected_git_status_context() else { + self.set_operator_note("no git status entry selected".to_string()); + return; + }; + + if let Err(error) = worktree::stage_path(&worktree, &entry.path) { + tracing::warn!("Failed to stage {}: {error}", entry.path); + self.set_operator_note(format!("stage failed for {}: {error}", entry.display_path)); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("staged {}", entry.display_path)); + } + + pub fn unstage_selected_git_status(&mut self) { + if self.output_mode != OutputMode::GitStatus { + self.set_operator_note( + "git staging controls are only available in git status view".to_string(), + ); + return; + } + + let Some((entry, worktree)) = self.selected_git_status_context() else { + self.set_operator_note("no git status entry selected".to_string()); + return; + }; + + if let Err(error) = worktree::unstage_path(&worktree, &entry.path) { + tracing::warn!("Failed to unstage {}: {error}", entry.path); + self.set_operator_note(format!("unstage failed for {}: {error}", entry.display_path)); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("unstaged {}", entry.display_path)); + } + + pub fn reset_selected_git_status(&mut self) { + if self.output_mode != OutputMode::GitStatus { + self.set_operator_note( + "git staging controls are only available in git status view".to_string(), + ); + return; + } + + let Some((entry, worktree)) = self.selected_git_status_context() else { + self.set_operator_note("no git status entry selected".to_string()); + return; + }; + + if let Err(error) = worktree::reset_path(&worktree, &entry) { + tracing::warn!("Failed to reset {}: {error}", entry.path); + self.set_operator_note(format!("reset failed for {}: {error}", entry.display_path)); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("reset {}", entry.display_path)); + } + + pub fn begin_commit_prompt(&mut self) { + if self.output_mode != OutputMode::GitStatus { + self.set_operator_note("commit prompt is only available in git status view".to_string()); + return; + } + + if self + .sessions + .get(self.selected_session) + .and_then(|session| session.worktree.as_ref()) + .is_none() + { + self.set_operator_note("selected session has no worktree".to_string()); + return; + } + + if !self.selected_git_status_entries.iter().any(|entry| entry.staged) { + self.set_operator_note("no staged changes to commit".to_string()); + return; + } + + self.commit_input = Some(String::new()); + self.set_operator_note("commit mode | type a message and press Enter".to_string()); + } + pub fn toggle_diff_view_mode(&mut self) { if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { self.set_operator_note("no active worktree diff view to toggle".to_string()); @@ -2445,7 +2633,7 @@ impl Dashboard { } pub fn is_input_mode(&self) -> bool { - self.spawn_input.is_some() || self.search_input.is_some() + self.spawn_input.is_some() || self.search_input.is_some() || self.commit_input.is_some() } pub fn has_active_search(&self) -> bool { @@ -2553,6 +2741,8 @@ impl Dashboard { input.push(ch); } else if let Some(input) = self.search_input.as_mut() { input.push(ch); + } else if let Some(input) = self.commit_input.as_mut() { + input.push(ch); } } @@ -2561,6 +2751,8 @@ impl Dashboard { input.pop(); } else if let Some(input) = self.search_input.as_mut() { input.pop(); + } else if let Some(input) = self.commit_input.as_mut() { + input.pop(); } } @@ -2569,17 +2761,56 @@ impl Dashboard { self.set_operator_note("spawn input cancelled".to_string()); } else if self.search_input.take().is_some() { self.set_operator_note("search input cancelled".to_string()); + } else if self.commit_input.take().is_some() { + self.set_operator_note("commit input cancelled".to_string()); } } pub async fn submit_input(&mut self) { if self.spawn_input.is_some() { self.submit_spawn_prompt().await; + } else if self.commit_input.is_some() { + self.submit_commit_prompt(); } else { self.submit_search(); } } + fn submit_commit_prompt(&mut self) { + let Some(input) = self.commit_input.take() else { + return; + }; + + let message = input.trim().to_string(); + let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { + self.set_operator_note("no session selected".to_string()); + return; + }; + let Some(worktree) = self + .sessions + .get(self.selected_session) + .and_then(|session| session.worktree.clone()) + else { + self.set_operator_note("selected session has no worktree".to_string()); + return; + }; + + match worktree::commit_staged(&worktree, &message) { + Ok(hash) => { + self.refresh_after_git_status_action(None); + self.set_operator_note(format!( + "committed {} as {}", + format_session_id(&session_id), + hash + )); + } + Err(error) => { + self.commit_input = Some(input); + self.set_operator_note(format!("commit failed: {error}")); + } + } + } + fn submit_search(&mut self) { let Some(input) = self.search_input.take() else { return; @@ -3017,6 +3248,7 @@ impl Dashboard { self.ensure_selected_pane_visible(); self.sync_selected_output(); self.sync_selected_diff(); + self.sync_selected_git_status(); self.sync_selected_messages(); self.sync_selected_lineage(); self.refresh_logs(); @@ -3313,6 +3545,53 @@ impl Dashboard { { self.output_mode = OutputMode::SessionOutput; } + self.sync_selected_git_status(); + } + + fn sync_selected_git_status(&mut self) { + let session = self.sessions.get(self.selected_session); + let worktree = session.and_then(|session| session.worktree.as_ref()); + self.selected_git_status_entries = worktree + .and_then(|worktree| worktree::git_status_entries(worktree).ok()) + .unwrap_or_default(); + if self.selected_git_status >= self.selected_git_status_entries.len() { + self.selected_git_status = self + .selected_git_status_entries + .len() + .saturating_sub(1); + } + if self.output_mode == OutputMode::GitStatus && worktree.is_none() { + self.output_mode = OutputMode::SessionOutput; + } + } + + fn selected_git_status_context( + &self, + ) -> Option<(worktree::GitStatusEntry, crate::session::WorktreeInfo)> { + let session = self.sessions.get(self.selected_session)?; + let worktree = session.worktree.clone()?; + let entry = self + .selected_git_status_entries + .get(self.selected_git_status) + .cloned()?; + Some((entry, worktree)) + } + + fn refresh_after_git_status_action(&mut self, preferred_path: Option<&str>) { + self.refresh(); + self.output_mode = OutputMode::GitStatus; + self.selected_pane = Pane::Output; + self.output_follow = false; + if let Some(path) = preferred_path { + if let Some(index) = self + .selected_git_status_entries + .iter() + .position(|entry| entry.path == path) + { + self.selected_git_status = index; + } + } + self.sync_output_scroll(self.last_output_height.max(1)); } fn current_diff_hunk_offsets(&self) -> &[usize] { @@ -3625,6 +3904,42 @@ impl Dashboard { .unwrap_or_default() } + fn visible_git_status_lines(&self) -> Vec> { + self.selected_git_status_entries + .iter() + .enumerate() + .map(|(index, entry)| { + let marker = if index == self.selected_git_status { ">>" } else { "-" }; + let mut flags = Vec::new(); + if entry.conflicted { + flags.push("conflict"); + } + if entry.staged { + flags.push("staged"); + } + if entry.unstaged { + flags.push("unstaged"); + } + if entry.untracked { + flags.push("untracked"); + } + let flag_text = if flags.is_empty() { + "clean".to_string() + } else { + flags.join(",") + }; + Line::from(format!( + "{} [{}{}] [{}] {}", + marker, + entry.index_status, + entry.worktree_status, + flag_text, + entry.display_path + )) + }) + .collect() + } + fn visible_timeline_lines(&self) -> Vec> { let show_session_label = self.timeline_scope == SearchScope::AllSessions; self.timeline_events() @@ -3928,6 +4243,14 @@ impl Dashboard { fn sync_output_scroll(&mut self, viewport_height: usize) { self.last_output_height = viewport_height.max(1); + if self.output_mode == OutputMode::GitStatus { + let max_scroll = self.max_output_scroll(); + let centered = self + .selected_git_status + .saturating_sub(self.last_output_height.max(1).saturating_sub(1) / 2); + self.output_scroll_offset = centered.min(max_scroll); + return; + } let max_scroll = self.max_output_scroll(); if self.output_follow { @@ -3938,9 +4261,14 @@ impl Dashboard { } fn max_output_scroll(&self) -> usize { - self.visible_output_lines() - .len() - .saturating_sub(self.last_output_height.max(1)) + let total_lines = if self.output_mode == OutputMode::GitStatus { + self.selected_git_status_entries.len() + } else if self.output_mode == OutputMode::Timeline { + self.visible_timeline_lines().len() + } else { + self.visible_output_lines().len() + }; + total_lines.saturating_sub(self.last_output_height.max(1)) } fn sync_metrics_scroll(&mut self, viewport_height: usize) { @@ -6709,6 +7037,80 @@ mod tests { assert!(rendered.contains("+new line")); } + #[test] + fn toggle_git_status_mode_renders_selected_worktree_status() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-git-status-{}", Uuid::new_v4())); + init_git_repo(&root)?; + fs::write(root.join("README.md"), "hello from git status\n")?; + + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.working_dir = root.clone(); + session.worktree = Some(WorktreeInfo { + path: root.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }); + let mut dashboard = test_dashboard(vec![session], 0); + + dashboard.toggle_git_status_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::GitStatus); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected worktree git status") + ); + assert_eq!(dashboard.output_title(), " Git status staged:0 unstaged:1 1/1 "); + let rendered = dashboard.rendered_output_text(180, 20); + assert!(rendered.contains("Git status")); + assert!(rendered.contains("README.md")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn begin_commit_prompt_opens_commit_input_for_staged_entries() { + let mut dashboard = test_dashboard( + vec![sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + )], + 0, + ); + dashboard.output_mode = OutputMode::GitStatus; + dashboard.selected_git_status_entries = vec![worktree::GitStatusEntry { + path: "README.md".to_string(), + display_path: "README.md".to_string(), + index_status: 'M', + worktree_status: ' ', + staged: true, + unstaged: false, + untracked: false, + conflicted: false, + }]; + + dashboard.begin_commit_prompt(); + + assert_eq!(dashboard.commit_input.as_deref(), Some("")); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("commit mode | type a message and press Enter") + ); + let rendered = render_dashboard_text(dashboard, 180, 20); + assert!(rendered.contains("commit>_")); + } + #[test] fn toggle_diff_view_mode_switches_to_unified_rendering() { let mut dashboard = test_dashboard( @@ -10489,6 +10891,8 @@ diff --git a/src/lib.rs b/src/lib.rs diff_view_mode: DiffViewMode::Split, selected_conflict_protocol: None, selected_merge_readiness: None, + selected_git_status_entries: Vec::new(), + selected_git_status: 0, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, @@ -10507,6 +10911,7 @@ diff --git a/src/lib.rs b/src/lib.rs collapsed_panes: HashSet::new(), search_input: None, spawn_input: None, + commit_input: None, search_query: None, search_scope: SearchScope::SelectedSession, search_agent_filter: SearchAgentFilter::AllAgents, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 2d9a2d58..92bbb068 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -44,6 +44,18 @@ pub struct BranchConflictPreview { pub right_patch_preview: Option, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct GitStatusEntry { + pub path: String, + pub display_path: String, + pub index_status: char, + pub worktree_status: char, + pub staged: bool, + pub unstaged: bool, + pub untracked: bool, + pub conflicted: bool, +} + /// 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")?; @@ -222,6 +234,124 @@ pub fn diff_summary(worktree: &WorktreeInfo) -> Result> { } } +pub fn git_status_entries(worktree: &WorktreeInfo) -> Result> { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["status", "--porcelain=v1", "--untracked-files=all"]) + .output() + .context("Failed to load git status entries")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git status failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(parse_git_status_entry) + .collect()) +} + +pub fn stage_path(worktree: &WorktreeInfo, path: &str) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["add", "--"]) + .arg(path) + .output() + .with_context(|| format!("Failed to stage {}", path))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git add failed for {path}: {stderr}"); + } +} + +pub fn unstage_path(worktree: &WorktreeInfo, path: &str) -> Result<()> { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["reset", "HEAD", "--"]) + .arg(path) + .output() + .with_context(|| format!("Failed to unstage {}", path))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git reset failed for {path}: {stderr}"); + } +} + +pub fn reset_path(worktree: &WorktreeInfo, entry: &GitStatusEntry) -> Result<()> { + if entry.untracked { + let target = worktree.path.join(&entry.path); + if !target.exists() { + return Ok(()); + } + let metadata = fs::symlink_metadata(&target) + .with_context(|| format!("Failed to inspect untracked path {}", target.display()))?; + if metadata.is_dir() { + fs::remove_dir_all(&target) + .with_context(|| format!("Failed to remove {}", target.display()))?; + } else { + fs::remove_file(&target) + .with_context(|| format!("Failed to remove {}", target.display()))?; + } + return Ok(()); + } + + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["restore", "--source=HEAD", "--staged", "--worktree", "--"]) + .arg(&entry.path) + .output() + .with_context(|| format!("Failed to reset {}", entry.path))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git restore failed for {}: {stderr}", entry.path); + } +} + +pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result { + let message = message.trim(); + if message.is_empty() { + anyhow::bail!("commit message cannot be empty"); + } + if !has_staged_changes(worktree)? { + anyhow::bail!("no staged changes to commit"); + } + + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["commit", "-m", message]) + .output() + .context("Failed to create commit")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git commit failed: {stderr}"); + } + + let rev_parse = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["rev-parse", "--short", "HEAD"]) + .output() + .context("Failed to resolve commit hash")?; + if !rev_parse.status.success() { + let stderr = String::from_utf8_lossy(&rev_parse.stderr); + anyhow::bail!("git rev-parse failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&rev_parse.stdout).trim().to_string()) +} + pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result> { let mut preview = Vec::new(); let base_ref = format!("{}...HEAD", worktree.base_branch); @@ -409,6 +539,10 @@ pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result { Ok(!git_status_short(&worktree.path)?.is_empty()) } +pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result { + Ok(git_status_entries(worktree)?.iter().any(|entry| entry.staged)) +} + pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { let readiness = merge_readiness(worktree)?; if readiness.status == MergeReadinessStatus::Conflicted { @@ -854,6 +988,43 @@ fn validate_branch_name(repo_root: &Path, branch: &str) -> Result<()> { } } +fn parse_git_status_entry(line: &str) -> Option { + if line.len() < 4 { + return None; + } + let bytes = line.as_bytes(); + let index_status = bytes[0] as char; + let worktree_status = bytes[1] as char; + let raw_path = line.get(3..)?.trim(); + if raw_path.is_empty() { + return None; + } + let display_path = raw_path.to_string(); + let normalized_path = raw_path + .split(" -> ") + .last() + .unwrap_or(raw_path) + .trim() + .to_string(); + let conflicted = matches!( + (index_status, worktree_status), + ('U', _) + | (_, 'U') + | ('A', 'A') + | ('D', 'D') + ); + Some(GitStatusEntry { + path: normalized_path, + display_path, + index_status, + worktree_status, + staged: index_status != ' ' && index_status != '?', + unstaged: worktree_status != ' ' && worktree_status != '?', + untracked: index_status == '?' && worktree_status == '?', + conflicted, + }) +} + fn parse_nonempty_lines(stdout: &[u8]) -> Vec { String::from_utf8_lossy(stdout) .lines() @@ -1331,6 +1502,68 @@ mod tests { Ok(()) } + #[test] + fn git_status_helpers_stage_unstage_reset_and_commit() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-git-status-helpers-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }; + + fs::write(repo.join("README.md"), "hello updated\n")?; + fs::write(repo.join("notes.txt"), "draft\n")?; + + let mut entries = git_status_entries(&worktree)?; + let readme = entries + .iter() + .find(|entry| entry.path == "README.md") + .expect("tracked README entry"); + assert!(readme.unstaged); + let notes = entries + .iter() + .find(|entry| entry.path == "notes.txt") + .expect("untracked notes entry"); + assert!(notes.untracked); + + stage_path(&worktree, "notes.txt")?; + entries = git_status_entries(&worktree)?; + let notes = entries + .iter() + .find(|entry| entry.path == "notes.txt") + .expect("staged notes entry"); + assert!(notes.staged); + assert!(!notes.untracked); + + unstage_path(&worktree, "notes.txt")?; + entries = git_status_entries(&worktree)?; + let notes = entries + .iter() + .find(|entry| entry.path == "notes.txt") + .expect("restored notes entry"); + assert!(notes.untracked); + + let notes_entry = notes.clone(); + reset_path(&worktree, ¬es_entry)?; + assert!(!repo.join("notes.txt").exists()); + + stage_path(&worktree, "README.md")?; + let hash = commit_staged(&worktree, "update readme")?; + assert!(!hash.is_empty()); + assert!(git_status_entries(&worktree)?.is_empty()); + + let output = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["log", "-1", "--pretty=%s"]) + .output()?; + assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "update readme"); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn create_for_session_links_shared_node_modules_cache() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4())); From 491ee818894af6278f16b388311f266f7674fda2 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 20:29:27 -0700 Subject: [PATCH 109/459] feat: add ecc2 draft PR prompt --- ecc2/src/tui/app.rs | 1 + ecc2/src/tui/dashboard.rs | 173 +++++++++++++++++++++++++++++++++++++- ecc2/src/worktree/mod.rs | 147 ++++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+), 2 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 646ac399..a2ce9ab7 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -97,6 +97,7 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('U')) => dashboard.unstage_selected_git_status(), (_, KeyCode::Char('R')) => dashboard.reset_selected_git_status(), (_, KeyCode::Char('C')) => dashboard.begin_commit_prompt(), + (_, KeyCode::Char('P')) => dashboard.begin_pr_prompt(), (_, KeyCode::Char('{')) => dashboard.prev_diff_hunk(), (_, KeyCode::Char('}')) => dashboard.next_diff_hunk(), (_, KeyCode::Char('c')) => dashboard.toggle_conflict_protocol_mode(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 008a48e4..f59607b9 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -104,6 +104,7 @@ pub struct Dashboard { search_input: Option, spawn_input: Option, commit_input: Option, + pr_input: Option, search_query: Option, search_scope: SearchScope, search_agent_filter: SearchAgentFilter, @@ -372,6 +373,7 @@ impl Dashboard { search_input: None, spawn_input: None, commit_input: None, + pr_input: None, search_query: None, search_scope: SearchScope::SelectedSession, search_agent_filter: SearchAgentFilter::AllAgents, @@ -1021,7 +1023,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff git status [z] stage [S] unstage [U] reset [R] commit [C] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff git status [z] stage [S] unstage [U] reset [R] commit [C] create PR [P] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.pane_focus_shortcuts_label(), self.pane_move_shortcuts_label(), self.layout_label(), @@ -1032,6 +1034,8 @@ impl Dashboard { format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |") } else if let Some(input) = self.commit_input.as_ref() { format!(" commit>{input}_ | [Enter] commit [Esc] cancel |") + } else if let Some(input) = self.pr_input.as_ref() { + format!(" pr>{input}_ | [Enter] create draft PR [Esc] cancel |") } else if let Some(input) = self.search_input.as_ref() { format!( " /{input}_ | {} | {} | [Enter] apply [Esc] cancel |", @@ -1059,6 +1063,7 @@ impl Dashboard { let text = if self.spawn_input.is_some() || self.commit_input.is_some() + || self.pr_input.is_some() || self.search_input.is_some() || self.search_query.is_some() || self.pane_command_mode @@ -1124,6 +1129,7 @@ impl Dashboard { " {/} Jump to previous/next diff hunk in the active diff view".to_string(), " S/U/R Stage, unstage, or reset the selected git-status entry".to_string(), " C Commit staged changes for the selected worktree".to_string(), + " P Create a draft PR from the selected worktree branch".to_string(), " c Show conflict-resolution protocol for selected conflicted worktree" .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), @@ -1980,6 +1986,30 @@ impl Dashboard { self.set_operator_note("commit mode | type a message and press Enter".to_string()); } + pub fn begin_pr_prompt(&mut self) { + let Some(session) = self.sessions.get(self.selected_session) else { + self.set_operator_note("no session selected".to_string()); + return; + }; + let Some(worktree) = session.worktree.as_ref() else { + self.set_operator_note("selected session has no worktree".to_string()); + return; + }; + if worktree::has_uncommitted_changes(worktree).unwrap_or(false) { + self.set_operator_note( + "commit or reset worktree changes before creating a PR".to_string(), + ); + return; + } + + let seed = worktree::latest_commit_subject(worktree) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| session.task.clone()); + self.pr_input = Some(seed); + self.set_operator_note("pr mode | edit the title and press Enter".to_string()); + } + pub fn toggle_diff_view_mode(&mut self) { if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { self.set_operator_note("no active worktree diff view to toggle".to_string()); @@ -2633,7 +2663,10 @@ impl Dashboard { } pub fn is_input_mode(&self) -> bool { - self.spawn_input.is_some() || self.search_input.is_some() || self.commit_input.is_some() + self.spawn_input.is_some() + || self.search_input.is_some() + || self.commit_input.is_some() + || self.pr_input.is_some() } pub fn has_active_search(&self) -> bool { @@ -2743,6 +2776,8 @@ impl Dashboard { input.push(ch); } else if let Some(input) = self.commit_input.as_mut() { input.push(ch); + } else if let Some(input) = self.pr_input.as_mut() { + input.push(ch); } } @@ -2753,6 +2788,8 @@ impl Dashboard { input.pop(); } else if let Some(input) = self.commit_input.as_mut() { input.pop(); + } else if let Some(input) = self.pr_input.as_mut() { + input.pop(); } } @@ -2763,6 +2800,8 @@ impl Dashboard { self.set_operator_note("search input cancelled".to_string()); } else if self.commit_input.take().is_some() { self.set_operator_note("commit input cancelled".to_string()); + } else if self.pr_input.take().is_some() { + self.set_operator_note("pr input cancelled".to_string()); } } @@ -2771,11 +2810,57 @@ impl Dashboard { self.submit_spawn_prompt().await; } else if self.commit_input.is_some() { self.submit_commit_prompt(); + } else if self.pr_input.is_some() { + self.submit_pr_prompt(); } else { self.submit_search(); } } + fn submit_pr_prompt(&mut self) { + let Some(input) = self.pr_input.take() else { + return; + }; + + let title = input.trim().to_string(); + if title.is_empty() { + self.pr_input = Some(input); + self.set_operator_note("pr title cannot be empty".to_string()); + return; + } + + let Some(session) = self.sessions.get(self.selected_session).cloned() else { + self.set_operator_note("no session selected".to_string()); + return; + }; + let Some(worktree) = session.worktree.clone() else { + self.set_operator_note("selected session has no worktree".to_string()); + return; + }; + if let Ok(true) = worktree::has_uncommitted_changes(&worktree) { + self.pr_input = Some(input); + self.set_operator_note( + "commit or reset worktree changes before creating a PR".to_string(), + ); + return; + } + + let body = self.build_pull_request_body(&session); + match worktree::create_draft_pr(&worktree, &title, &body) { + Ok(url) => { + self.set_operator_note(format!( + "created draft PR for {}: {}", + format_session_id(&session.id), + url + )); + } + Err(error) => { + self.pr_input = Some(input); + self.set_operator_note(format!("draft PR failed: {error}")); + } + } + } + fn submit_commit_prompt(&mut self) { let Some(input) = self.commit_input.take() else { return; @@ -2841,6 +2926,54 @@ impl Dashboard { } } + fn build_pull_request_body(&self, session: &Session) -> String { + let mut lines = vec![ + "## Summary".to_string(), + format!("- Task: {}", session.task), + format!("- Agent: {}", session.agent_type), + format!("- Project: {}", session.project), + format!("- Task group: {}", session.task_group), + ]; + if let Some(worktree) = session.worktree.as_ref() { + lines.push(format!( + "- Branch: {} -> {}", + worktree.branch, worktree.base_branch + )); + } + if let Some(summary) = self.selected_diff_summary.as_ref() { + lines.push(format!("- Diff: {summary}")); + } + let changed_files = self + .selected_diff_preview + .iter() + .take(5) + .cloned() + .collect::>(); + if !changed_files.is_empty() { + lines.push(String::new()); + lines.push("## Changed Files".to_string()); + for file in changed_files { + lines.push(format!("- {file}")); + } + } + lines.push(String::new()); + lines.push("## Session Metrics".to_string()); + lines.push(format!( + "- Tokens: {} total (in {} / out {})", + session.metrics.tokens_used, session.metrics.input_tokens, session.metrics.output_tokens + )); + lines.push(format!("- Tool calls: {}", session.metrics.tool_calls)); + lines.push(format!("- Files changed: {}", session.metrics.files_changed)); + lines.push(format!( + "- Duration: {}", + format_duration(session.metrics.duration_secs) + )); + lines.push(String::new()); + lines.push("## Testing".to_string()); + lines.push("- Verified in ECC 2.0 dashboard workflow".to_string()); + lines.join("\n") + } + async fn submit_spawn_prompt(&mut self) { let Some(input) = self.spawn_input.take() else { return; @@ -7111,6 +7244,41 @@ mod tests { assert!(rendered.contains("commit>_")); } + #[test] + fn begin_pr_prompt_seeds_latest_commit_subject() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-pr-prompt-{}", Uuid::new_v4())); + init_git_repo(&root)?; + fs::write(root.join("README.md"), "seed pr title\n")?; + run_git(&root, &["commit", "-am", "seed pr title"])?; + + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.working_dir = root.clone(); + session.worktree = Some(WorktreeInfo { + path: root.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }); + let mut dashboard = test_dashboard(vec![session], 0); + + dashboard.begin_pr_prompt(); + + assert_eq!(dashboard.pr_input.as_deref(), Some("seed pr title")); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("pr mode | edit the title and press Enter") + ); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn toggle_diff_view_mode_switches_to_unified_rendering() { let mut dashboard = test_dashboard( @@ -10912,6 +11080,7 @@ diff --git a/src/lib.rs b/src/lib.rs search_input: None, spawn_input: None, commit_input: None, + pr_input: None, search_query: None, search_scope: SearchScope::SelectedSession, search_agent_filter: SearchAgentFilter::AllAgents, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 92bbb068..6703b01c 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -352,6 +352,70 @@ pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result { Ok(String::from_utf8_lossy(&rev_parse.stdout).trim().to_string()) } +pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["log", "-1", "--pretty=%s"]) + .output() + .context("Failed to read latest commit subject")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git log failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +pub fn create_draft_pr(worktree: &WorktreeInfo, title: &str, body: &str) -> Result { + create_draft_pr_with_gh(worktree, title, body, Path::new("gh")) +} + +fn create_draft_pr_with_gh( + worktree: &WorktreeInfo, + title: &str, + body: &str, + gh_bin: &Path, +) -> Result { + let title = title.trim(); + if title.is_empty() { + anyhow::bail!("PR title cannot be empty"); + } + + let push = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["push", "-u", "origin", &worktree.branch]) + .output() + .context("Failed to push worktree branch before PR creation")?; + if !push.status.success() { + let stderr = String::from_utf8_lossy(&push.stderr); + anyhow::bail!("git push failed: {stderr}"); + } + + let output = Command::new(gh_bin) + .arg("pr") + .arg("create") + .arg("--draft") + .arg("--base") + .arg(&worktree.base_branch) + .arg("--head") + .arg(&worktree.branch) + .arg("--title") + .arg(title) + .arg("--body") + .arg(body) + .current_dir(&worktree.path) + .output() + .context("Failed to create draft PR with gh")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("gh pr create failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result> { let mut preview = Vec::new(); let base_ref = format!("{}...HEAD", worktree.base_branch); @@ -1564,6 +1628,89 @@ mod tests { Ok(()) } + #[test] + fn latest_commit_subject_reads_head_subject() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-pr-subject-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + fs::write(repo.join("README.md"), "subject test\n")?; + run_git(&repo, &["commit", "-am", "subject test"])?; + + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }; + + assert_eq!(latest_commit_subject(&worktree)?, "subject test"); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn create_draft_pr_pushes_branch_and_invokes_gh() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-pr-create-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let remote = root.join("remote.git"); + run_git(&root, &["init", "--bare", remote.to_str().expect("utf8 path")])?; + run_git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf8 path")])?; + run_git(&repo, &["push", "-u", "origin", "main"])?; + run_git(&repo, &["checkout", "-b", "feat/pr-test"])?; + fs::write(repo.join("README.md"), "pr test\n")?; + run_git(&repo, &["commit", "-am", "pr test"])?; + + let bin_dir = root.join("bin"); + fs::create_dir_all(&bin_dir)?; + let gh_path = bin_dir.join("gh"); + let args_path = root.join("gh-args.txt"); + fs::write( + &gh_path, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s\\n' 'https://github.com/example/repo/pull/123'\n", + args_path.display() + ), + )?; + let mut perms = fs::metadata(&gh_path)?.permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + perms.set_mode(0o755); + fs::set_permissions(&gh_path, perms)?; + } + #[cfg(not(unix))] + fs::set_permissions(&gh_path, perms)?; + + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "feat/pr-test".to_string(), + base_branch: "main".to_string(), + }; + + let url = create_draft_pr_with_gh(&worktree, "My PR", "Body line", &gh_path)?; + assert_eq!(url, "https://github.com/example/repo/pull/123"); + + let remote_branch = Command::new("git") + .arg("--git-dir") + .arg(&remote) + .args(["branch", "--list", "feat/pr-test"]) + .output()?; + assert!(remote_branch.status.success()); + assert_eq!( + String::from_utf8_lossy(&remote_branch.stdout).trim(), + "feat/pr-test" + ); + + let gh_args = fs::read_to_string(&args_path)?; + assert!(gh_args.contains("pr\ncreate\n--draft")); + assert!(gh_args.contains("--base\nmain")); + assert!(gh_args.contains("--head\nfeat/pr-test")); + assert!(gh_args.contains("--title\nMy PR")); + assert!(gh_args.contains("--body\nBody line")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn create_for_session_links_shared_node_modules_cache() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4())); From a4d0a4fc14b5e4868d572ce0960e3fec1c97e3da Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 20:43:33 -0700 Subject: [PATCH 110/459] feat: add ecc2 desktop notifications --- ecc2/src/config/mod.rs | 42 ++++++ ecc2/src/main.rs | 1 + ecc2/src/notifications.rs | 289 ++++++++++++++++++++++++++++++++++++ ecc2/src/session/manager.rs | 9 +- ecc2/src/session/store.rs | 29 ++++ ecc2/src/tui/dashboard.rs | 229 ++++++++++++++++++++++++++-- 6 files changed, 581 insertions(+), 18 deletions(-) create mode 100644 ecc2/src/notifications.rs diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 165d8363..8259207e 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -3,6 +3,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use crate::notifications::DesktopNotificationConfig; + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PaneLayout { @@ -45,6 +47,7 @@ pub struct Config { pub auto_dispatch_limit_per_session: usize, pub auto_create_worktrees: bool, pub auto_merge_ready_worktrees: bool, + pub desktop_notifications: DesktopNotificationConfig, pub cost_budget_usd: f64, pub token_budget: u64, pub budget_alert_thresholds: BudgetAlertThresholds, @@ -107,6 +110,7 @@ impl Default for Config { auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, auto_merge_ready_worktrees: false, + desktop_notifications: DesktopNotificationConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS, @@ -431,6 +435,7 @@ theme = "Dark" config.auto_merge_ready_worktrees, defaults.auto_merge_ready_worktrees ); + assert_eq!(config.desktop_notifications, defaults.desktop_notifications); assert_eq!( config.auto_terminate_stale_sessions, defaults.auto_terminate_stale_sessions @@ -582,6 +587,35 @@ critical = 0.85 ); } + #[test] + fn desktop_notifications_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[desktop_notifications] +enabled = true +session_completed = false +session_failed = true +budget_alerts = true +approval_requests = false + +[desktop_notifications.quiet_hours] +enabled = true +start_hour = 21 +end_hour = 7 +"#, + ) + .unwrap(); + + assert!(config.desktop_notifications.enabled); + assert!(!config.desktop_notifications.session_completed); + assert!(config.desktop_notifications.session_failed); + assert!(config.desktop_notifications.budget_alerts); + assert!(!config.desktop_notifications.approval_requests); + assert!(config.desktop_notifications.quiet_hours.enabled); + assert_eq!(config.desktop_notifications.quiet_hours.start_hour, 21); + assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7); + } + #[test] fn invalid_budget_alert_thresholds_fall_back_to_defaults() { let config: Config = toml::from_str( @@ -608,6 +642,10 @@ critical = 1.10 config.auto_dispatch_limit_per_session = 9; config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; + config.desktop_notifications.session_completed = false; + config.desktop_notifications.quiet_hours.enabled = true; + config.desktop_notifications.quiet_hours.start_hour = 21; + config.desktop_notifications.quiet_hours.end_hour = 7; config.worktree_branch_prefix = "bots/ecc".to_string(); config.budget_alert_thresholds = BudgetAlertThresholds { advisory: 0.45, @@ -627,6 +665,10 @@ critical = 1.10 assert_eq!(loaded.auto_dispatch_limit_per_session, 9); assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); + assert!(!loaded.desktop_notifications.session_completed); + assert!(loaded.desktop_notifications.quiet_hours.enabled); + assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21); + assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7); assert_eq!(loaded.worktree_branch_prefix, "bots/ecc"); assert_eq!( loaded.budget_alert_thresholds, diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 0147942f..0f043382 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1,5 +1,6 @@ mod comms; mod config; +mod notifications; mod observability; mod session; mod tui; diff --git a/ecc2/src/notifications.rs b/ecc2/src/notifications.rs new file mode 100644 index 00000000..c4c70711 --- /dev/null +++ b/ecc2/src/notifications.rs @@ -0,0 +1,289 @@ +use anyhow::Result; +use chrono::{DateTime, Local, Timelike}; +use serde::{Deserialize, Serialize}; + +#[cfg(not(test))] +use anyhow::Context; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationEvent { + SessionCompleted, + SessionFailed, + BudgetAlert, + ApprovalRequest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct QuietHoursConfig { + pub enabled: bool, + pub start_hour: u8, + pub end_hour: u8, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct DesktopNotificationConfig { + pub enabled: bool, + pub session_completed: bool, + pub session_failed: bool, + pub budget_alerts: bool, + pub approval_requests: bool, + pub quiet_hours: QuietHoursConfig, +} + +#[derive(Debug, Clone)] +pub struct DesktopNotifier { + config: DesktopNotificationConfig, +} + +impl Default for QuietHoursConfig { + fn default() -> Self { + Self { + enabled: false, + start_hour: 22, + end_hour: 8, + } + } +} + +impl QuietHoursConfig { + pub fn sanitized(self) -> Self { + let valid = self.start_hour <= 23 && self.end_hour <= 23; + if valid { + self + } else { + Self::default() + } + } + + pub fn is_active(&self, now: DateTime) -> bool { + if !self.enabled { + return false; + } + + let quiet = self.clone().sanitized(); + if quiet.start_hour == quiet.end_hour { + return false; + } + + let hour = now.hour() as u8; + if quiet.start_hour < quiet.end_hour { + hour >= quiet.start_hour && hour < quiet.end_hour + } else { + hour >= quiet.start_hour || hour < quiet.end_hour + } + } +} + +impl Default for DesktopNotificationConfig { + fn default() -> Self { + Self { + enabled: true, + session_completed: true, + session_failed: true, + budget_alerts: true, + approval_requests: true, + quiet_hours: QuietHoursConfig::default(), + } + } +} + +impl DesktopNotificationConfig { + pub fn sanitized(self) -> Self { + Self { + quiet_hours: self.quiet_hours.sanitized(), + ..self + } + } + + pub fn allows(&self, event: NotificationEvent, now: DateTime) -> bool { + let config = self.clone().sanitized(); + if !config.enabled || config.quiet_hours.is_active(now) { + return false; + } + + match event { + NotificationEvent::SessionCompleted => config.session_completed, + NotificationEvent::SessionFailed => config.session_failed, + NotificationEvent::BudgetAlert => config.budget_alerts, + NotificationEvent::ApprovalRequest => config.approval_requests, + } + } +} + +impl DesktopNotifier { + pub fn new(config: DesktopNotificationConfig) -> Self { + Self { + config: config.sanitized(), + } + } + + pub fn notify(&self, event: NotificationEvent, title: &str, body: &str) -> bool { + match self.try_notify(event, title, body, Local::now()) { + Ok(sent) => sent, + Err(error) => { + tracing::warn!("Failed to send desktop notification: {error}"); + false + } + } + } + + fn try_notify( + &self, + event: NotificationEvent, + title: &str, + body: &str, + now: DateTime, + ) -> Result { + if !self.config.allows(event, now) { + return Ok(false); + } + + let Some((program, args)) = notification_command(std::env::consts::OS, title, body) else { + return Ok(false); + }; + + run_notification_command(&program, &args)?; + Ok(true) + } +} + +fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec)> { + match platform { + "macos" => Some(( + "osascript".to_string(), + vec![ + "-e".to_string(), + format!( + "display notification \"{}\" with title \"{}\"", + sanitize_osascript(body), + sanitize_osascript(title) + ), + ], + )), + "linux" => Some(( + "notify-send".to_string(), + vec![ + "--app-name".to_string(), + "ECC 2.0".to_string(), + title.trim().to_string(), + body.trim().to_string(), + ], + )), + _ => None, + } +} + +#[cfg(not(test))] +fn run_notification_command(program: &str, args: &[String]) -> Result<()> { + let status = std::process::Command::new(program) + .args(args) + .status() + .with_context(|| format!("launch {program}"))?; + + if status.success() { + Ok(()) + } else { + anyhow::bail!("{program} exited with {status}"); + } +} + +#[cfg(test)] +fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> { + Ok(()) +} + +fn sanitize_osascript(value: &str) -> String { + value + .replace('\\', "") + .replace('"', "\u{201C}") + .replace('\n', " ") +} + +#[cfg(test)] +mod tests { + use super::{ + notification_command, DesktopNotificationConfig, DesktopNotifier, NotificationEvent, + QuietHoursConfig, + }; + use chrono::{Local, TimeZone}; + + #[test] + fn quiet_hours_support_cross_midnight_ranges() { + let quiet_hours = QuietHoursConfig { + enabled: true, + start_hour: 22, + end_hour: 8, + }; + + assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap())); + assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 7, 0, 0).unwrap())); + assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 14, 0, 0).unwrap())); + } + + #[test] + fn quiet_hours_support_same_day_ranges() { + let quiet_hours = QuietHoursConfig { + enabled: true, + start_hour: 9, + end_hour: 17, + }; + + assert!(quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 10, 0, 0).unwrap())); + assert!(!quiet_hours.is_active(Local.with_ymd_and_hms(2026, 4, 9, 18, 0, 0).unwrap())); + } + + #[test] + fn notification_preferences_respect_event_flags() { + let mut config = DesktopNotificationConfig::default(); + config.session_completed = false; + let now = Local.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap(); + + assert!(!config.allows(NotificationEvent::SessionCompleted, now)); + assert!(config.allows(NotificationEvent::BudgetAlert, now)); + } + + #[test] + fn notifier_skips_delivery_during_quiet_hours() { + let mut config = DesktopNotificationConfig::default(); + config.quiet_hours = QuietHoursConfig { + enabled: true, + start_hour: 22, + end_hour: 8, + }; + let notifier = DesktopNotifier::new(config); + + assert!(!notifier + .try_notify( + NotificationEvent::ApprovalRequest, + "ECC 2.0: Approval needed", + "worker-123 needs review", + Local.with_ymd_and_hms(2026, 4, 9, 23, 0, 0).unwrap(), + ) + .unwrap()); + } + + #[test] + fn macos_notifications_use_osascript() { + let (program, args) = + notification_command("macos", "ECC 2.0: Completed", "Task finished").unwrap(); + + assert_eq!(program, "osascript"); + assert_eq!(args[0], "-e"); + assert!(args[1].contains("display notification")); + assert!(args[1].contains("ECC 2.0: Completed")); + } + + #[test] + fn linux_notifications_use_notify_send() { + let (program, args) = + notification_command("linux", "ECC 2.0: Approval needed", "worker-123").unwrap(); + + assert_eq!(program, "notify-send"); + assert_eq!(args[0], "--app-name"); + assert_eq!(args[1], "ECC 2.0"); + assert_eq!(args[2], "ECC 2.0: Approval needed"); + assert_eq!(args[3], "worker-123"); + } +} diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 907f2f84..c80929d2 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -219,7 +219,7 @@ pub async fn drain_inbox( use_worktree, &repo_root, &runner_program, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -2237,6 +2237,7 @@ mod tests { auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, auto_merge_ready_worktrees: false, + desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS, @@ -3691,7 +3692,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3763,7 +3764,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3819,7 +3820,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index ab477bd2..4f68ad2f 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1299,6 +1299,35 @@ impl StateStore { messages.collect::, _>>().map_err(Into::into) } + pub fn latest_unread_approval_message(&self) -> Result> { + self.conn + .query_row( + "SELECT id, from_session, to_session, content, msg_type, read, timestamp + FROM messages + WHERE read = 0 AND msg_type IN ('query', 'conflict') + ORDER BY id DESC + LIMIT 1", + [], + |row| { + let timestamp: String = row.get(6)?; + + Ok(SessionMessage { + id: row.get(0)?, + from_session: row.get(1)?, + to_session: row.get(2)?, + content: row.get(3)?, + msg_type: row.get(4)?, + read: row.get::<_, i64>(5)? != 0, + timestamp: chrono::DateTime::parse_from_rfc3339(×tamp) + .unwrap_or_default() + .with_timezone(&chrono::Utc), + }) + }, + ) + .optional() + .map_err(Into::into) + } + pub fn unread_task_handoffs_for_session( &self, session_id: &str, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f59607b9..60d86ff6 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -14,6 +14,7 @@ use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; +use crate::notifications::{DesktopNotifier, NotificationEvent}; use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{ @@ -56,6 +57,7 @@ pub struct Dashboard { cfg: Config, output_store: SessionOutputStore, output_rx: broadcast::Receiver, + notifier: DesktopNotifier, sessions: Vec, session_output_cache: HashMap>, unread_message_counts: HashMap, @@ -114,6 +116,8 @@ pub struct Dashboard { last_cost_metrics_signature: Option<(u64, u128)>, last_tool_activity_signature: Option<(u64, u128)>, last_budget_alert_state: BudgetState, + last_session_states: HashMap, + last_seen_approval_message_id: Option, } #[derive(Debug, Default, PartialEq, Eq)] @@ -314,7 +318,17 @@ impl Dashboard { let _ = db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path()); } let sessions = db.list_sessions().unwrap_or_default(); + let initial_session_states = sessions + .iter() + .map(|session| (session.id.clone(), session.state.clone())) + .collect(); + let initial_approval_message_id = db + .latest_unread_approval_message() + .ok() + .flatten() + .map(|message| message.id); let output_rx = output_store.subscribe(); + let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); let mut session_table_state = TableState::default(); if !sessions.is_empty() { session_table_state.select(Some(0)); @@ -325,6 +339,7 @@ impl Dashboard { cfg, output_store, output_rx, + notifier, sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), @@ -383,6 +398,8 @@ impl Dashboard { last_cost_metrics_signature: initial_cost_metrics_signature, last_tool_activity_signature: initial_tool_activity_signature, last_budget_alert_state: BudgetState::Normal, + last_session_states: initial_session_states, + last_seen_approval_message_id: initial_approval_message_id, }; sort_sessions_for_display(&mut dashboard.sessions); dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); @@ -746,9 +763,7 @@ impl Dashboard { } else { self.selected_git_status.min(total.saturating_sub(1)) + 1 }; - return format!( - " Git status staged:{staged} unstaged:{unstaged} {current}/{total} " - ); + return format!(" Git status staged:{staged} unstaged:{unstaged} {current}/{total} "); } let filter = format!( @@ -1930,7 +1945,10 @@ impl Dashboard { if let Err(error) = worktree::unstage_path(&worktree, &entry.path) { tracing::warn!("Failed to unstage {}: {error}", entry.path); - self.set_operator_note(format!("unstage failed for {}: {error}", entry.display_path)); + self.set_operator_note(format!( + "unstage failed for {}: {error}", + entry.display_path + )); return; } @@ -1963,7 +1981,9 @@ impl Dashboard { pub fn begin_commit_prompt(&mut self) { if self.output_mode != OutputMode::GitStatus { - self.set_operator_note("commit prompt is only available in git status view".to_string()); + self.set_operator_note( + "commit prompt is only available in git status view".to_string(), + ); return; } @@ -1977,7 +1997,11 @@ impl Dashboard { return; } - if !self.selected_git_status_entries.iter().any(|entry| entry.staged) { + if !self + .selected_git_status_entries + .iter() + .any(|entry| entry.staged) + { self.set_operator_note("no staged changes to commit".to_string()); return; } @@ -2960,10 +2984,15 @@ impl Dashboard { lines.push("## Session Metrics".to_string()); lines.push(format!( "- Tokens: {} total (in {} / out {})", - session.metrics.tokens_used, session.metrics.input_tokens, session.metrics.output_tokens + session.metrics.tokens_used, + session.metrics.input_tokens, + session.metrics.output_tokens )); lines.push(format!("- Tool calls: {}", session.metrics.tool_calls)); - lines.push(format!("- Files changed: {}", session.metrics.files_changed)); + lines.push(format!( + "- Files changed: {}", + session.metrics.files_changed + )); lines.push(format!( "- Duration: {}", format_duration(session.metrics.duration_secs) @@ -3372,6 +3401,8 @@ impl Dashboard { HashMap::new() } }; + self.sync_session_state_notifications(); + self.sync_approval_notifications(); self.sync_handoff_backlog_counts(); self.sync_worktree_health_by_session(); self.sync_global_handoff_backlog(); @@ -3440,6 +3471,91 @@ impl Dashboard { self.set_operator_note(format!( "{summary_suffix} | tokens {token_budget} | cost {cost_budget}" )); + self.notify_desktop( + NotificationEvent::BudgetAlert, + "ECC 2.0: Budget alert", + &format!("{summary_suffix} | tokens {token_budget} | cost {cost_budget}"), + ); + } + + fn sync_session_state_notifications(&mut self) { + let mut next_states = HashMap::new(); + + for session in &self.sessions { + let previous_state = self.last_session_states.get(&session.id); + if let Some(previous_state) = previous_state { + if previous_state != &session.state { + match session.state { + SessionState::Completed => { + self.notify_desktop( + NotificationEvent::SessionCompleted, + "ECC 2.0: Session completed", + &format!( + "{} | {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 96) + ), + ); + } + SessionState::Failed => { + self.notify_desktop( + NotificationEvent::SessionFailed, + "ECC 2.0: Session failed", + &format!( + "{} | {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 96) + ), + ); + } + _ => {} + } + } + } + + next_states.insert(session.id.clone(), session.state.clone()); + } + + self.last_session_states = next_states; + } + + fn sync_approval_notifications(&mut self) { + let latest_message = match self.db.latest_unread_approval_message() { + Ok(message) => message, + Err(error) => { + tracing::warn!("Failed to refresh latest approval request: {error}"); + return; + } + }; + + let Some(message) = latest_message else { + return; + }; + + if self + .last_seen_approval_message_id + .is_some_and(|last_seen| message.id <= last_seen) + { + return; + } + + self.last_seen_approval_message_id = Some(message.id); + let preview = + truncate_for_dashboard(&comms::preview(&message.msg_type, &message.content), 96); + self.notify_desktop( + NotificationEvent::ApprovalRequest, + "ECC 2.0: Approval needed", + &format!( + "{} from {} | {}", + format_session_id(&message.to_session), + format_session_id(&message.from_session), + preview + ), + ); + } + + fn notify_desktop(&self, event: NotificationEvent, title: &str, body: &str) { + let _ = self.notifier.notify(event, title, body); } fn sync_selection(&mut self) { @@ -3688,10 +3804,7 @@ impl Dashboard { .and_then(|worktree| worktree::git_status_entries(worktree).ok()) .unwrap_or_default(); if self.selected_git_status >= self.selected_git_status_entries.len() { - self.selected_git_status = self - .selected_git_status_entries - .len() - .saturating_sub(1); + self.selected_git_status = self.selected_git_status_entries.len().saturating_sub(1); } if self.output_mode == OutputMode::GitStatus && worktree.is_none() { self.output_mode = OutputMode::SessionOutput; @@ -4042,7 +4155,11 @@ impl Dashboard { .iter() .enumerate() .map(|(index, entry)| { - let marker = if index == self.selected_git_status { ">>" } else { "-" }; + let marker = if index == self.selected_git_status { + ">>" + } else { + "-" + }; let mut flags = Vec::new(); if entry.conflicted { flags.push("conflict"); @@ -6932,6 +7049,42 @@ mod tests { assert!(dashboard.approval_queue_preview.is_empty()); } + #[test] + fn refresh_tracks_latest_unread_approval_before_selected_messages_mark_read() { + let sessions = vec![sample_session( + "worker-123456", + "reviewer", + SessionState::Idle, + Some("ecc/worker"), + 64, + 5, + )]; + let mut dashboard = test_dashboard(sessions, 0); + for session in &dashboard.sessions { + dashboard.db.insert_session(session).unwrap(); + } + dashboard + .db + .send_message( + "lead-12345678", + "worker-123456", + "{\"question\":\"Need operator input\"}", + "query", + ) + .unwrap(); + let message_id = dashboard + .db + .latest_unread_approval_message() + .unwrap() + .expect("approval message should exist") + .id; + + dashboard.refresh(); + + assert_eq!(dashboard.last_seen_approval_message_id, Some(message_id)); + assert!(dashboard.approval_queue_preview.is_empty()); + } + #[test] fn focus_next_approval_target_selects_oldest_unread_target() { let sessions = vec![ @@ -7199,7 +7352,10 @@ mod tests { dashboard.operator_note.as_deref(), Some("showing selected worktree git status") ); - assert_eq!(dashboard.output_title(), " Git status staged:0 unstaged:1 1/1 "); + assert_eq!( + dashboard.output_title(), + " Git status staged:0 unstaged:1 1/1 " + ); let rendered = dashboard.rendered_output_text(180, 20); assert!(rendered.contains("Git status")); assert!(rendered.contains("README.md")); @@ -8959,6 +9115,42 @@ diff --git a/src/lib.rs b/src/lib.rs ); } + #[test] + fn refresh_updates_session_state_snapshot_after_completion() { + let db = StateStore::open(Path::new(":memory:")).unwrap(); + let now = Utc::now(); + let session = Session { + id: "done-1".to_string(), + task: "complete session".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + }; + db.insert_session(&session).unwrap(); + + let mut dashboard = Dashboard::new(db, Config::default()); + dashboard + .db + .update_state("done-1", &SessionState::Completed) + .unwrap(); + + dashboard.refresh(); + + assert_eq!(dashboard.sessions[0].state, SessionState::Completed); + assert_eq!( + dashboard.last_session_states.get("done-1"), + Some(&SessionState::Completed) + ); + } + #[test] fn refresh_syncs_tool_activity_metrics_from_hook_file() { let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4())); @@ -11020,6 +11212,11 @@ diff --git a/src/lib.rs b/src/lib.rs fn test_dashboard(sessions: Vec, selected_session: usize) -> Dashboard { let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); + let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); + let last_session_states = sessions + .iter() + .map(|session| (session.id.clone(), session.state.clone())) + .collect(); let output_store = SessionOutputStore::default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -11033,6 +11230,7 @@ diff --git a/src/lib.rs b/src/lib.rs cfg, output_store, output_rx, + notifier, sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), @@ -11090,6 +11288,8 @@ diff --git a/src/lib.rs b/src/lib.rs last_cost_metrics_signature: None, last_tool_activity_signature: None, last_budget_alert_state: BudgetState::Normal, + last_session_states, + last_seen_approval_message_id: None, } } @@ -11109,6 +11309,7 @@ diff --git a/src/lib.rs b/src/lib.rs auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, auto_merge_ready_worktrees: false, + desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS, From b45a6ca81027ee31dc991d64a574fb66382d0f4b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 20:59:24 -0700 Subject: [PATCH 111/459] feat: add ecc2 completion summary notifications --- ecc2/src/config/mod.rs | 28 +- ecc2/src/main.rs | 10 +- ecc2/src/notifications.rs | 43 +++ ecc2/src/session/manager.rs | 54 +-- ecc2/src/tui/app.rs | 12 + ecc2/src/tui/dashboard.rs | 669 ++++++++++++++++++++++++++++++++++-- ecc2/src/worktree/mod.rs | 82 +++-- 7 files changed, 828 insertions(+), 70 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 8259207e..2b0fbe6a 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use crate::notifications::DesktopNotificationConfig; +use crate::notifications::{CompletionSummaryConfig, DesktopNotificationConfig}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -48,6 +48,7 @@ pub struct Config { pub auto_create_worktrees: bool, pub auto_merge_ready_worktrees: bool, pub desktop_notifications: DesktopNotificationConfig, + pub completion_summary_notifications: CompletionSummaryConfig, pub cost_budget_usd: f64, pub token_budget: u64, pub budget_alert_thresholds: BudgetAlertThresholds, @@ -111,6 +112,7 @@ impl Default for Config { auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: DesktopNotificationConfig::default(), + completion_summary_notifications: CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS, @@ -616,6 +618,24 @@ end_hour = 7 assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7); } + #[test] + fn completion_summary_notifications_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[completion_summary_notifications] +enabled = true +delivery = "desktop_and_tui_popup" +"#, + ) + .unwrap(); + + assert!(config.completion_summary_notifications.enabled); + assert_eq!( + config.completion_summary_notifications.delivery, + crate::notifications::CompletionSummaryDelivery::DesktopAndTuiPopup + ); + } + #[test] fn invalid_budget_alert_thresholds_fall_back_to_defaults() { let config: Config = toml::from_str( @@ -643,6 +663,8 @@ critical = 1.10 config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; config.desktop_notifications.session_completed = false; + config.completion_summary_notifications.delivery = + crate::notifications::CompletionSummaryDelivery::TuiPopup; config.desktop_notifications.quiet_hours.enabled = true; config.desktop_notifications.quiet_hours.start_hour = 21; config.desktop_notifications.quiet_hours.end_hour = 7; @@ -666,6 +688,10 @@ critical = 1.10 assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); assert!(!loaded.desktop_notifications.session_completed); + assert_eq!( + loaded.completion_summary_notifications.delivery, + crate::notifications::CompletionSummaryDelivery::TuiPopup + ); assert!(loaded.desktop_notifications.quiet_hours.enabled); assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21); assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7); diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 0f043382..8095cfca 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1634,7 +1634,11 @@ fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> Stri for entry in &report.blocked_entries { lines.push(format!( "- {} [{}] | {} / {} | {}", - entry.session_id, entry.branch, entry.project, entry.task_group, entry.suggested_action + entry.session_id, + entry.branch, + entry.project, + entry.task_group, + entry.suggested_action )); for blocker in entry.blocked_by.iter().take(2) { lines.push(format!( @@ -2781,7 +2785,9 @@ mod tests { state: session::SessionState::Stopped, conflicts: vec!["README.md".to_string()], summary: "merge after alpha1234 to avoid branch conflicts".to_string(), - conflicting_patch_preview: Some("--- Branch diff vs main ---\nREADME.md".to_string()), + conflicting_patch_preview: Some( + "--- Branch diff vs main ---\nREADME.md".to_string(), + ), blocker_patch_preview: None, }], suggested_action: "merge after alpha1234".to_string(), diff --git a/ecc2/src/notifications.rs b/ecc2/src/notifications.rs index c4c70711..e4238627 100644 --- a/ecc2/src/notifications.rs +++ b/ecc2/src/notifications.rs @@ -32,6 +32,22 @@ pub struct DesktopNotificationConfig { pub quiet_hours: QuietHoursConfig, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompletionSummaryDelivery { + #[default] + Desktop, + TuiPopup, + DesktopAndTuiPopup, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CompletionSummaryConfig { + pub enabled: bool, + pub delivery: CompletionSummaryDelivery, +} + #[derive(Debug, Clone)] pub struct DesktopNotifier { config: DesktopNotificationConfig, @@ -112,6 +128,33 @@ impl DesktopNotificationConfig { } } +impl Default for CompletionSummaryConfig { + fn default() -> Self { + Self { + enabled: true, + delivery: CompletionSummaryDelivery::Desktop, + } + } +} + +impl CompletionSummaryConfig { + pub fn desktop_enabled(&self) -> bool { + self.enabled + && matches!( + self.delivery, + CompletionSummaryDelivery::Desktop | CompletionSummaryDelivery::DesktopAndTuiPopup + ) + } + + pub fn popup_enabled(&self) -> bool { + self.enabled + && matches!( + self.delivery, + CompletionSummaryDelivery::TuiPopup | CompletionSummaryDelivery::DesktopAndTuiPopup + ) + } +} + impl DesktopNotifier { pub fn new(config: DesktopNotificationConfig) -> Self { Self { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index c80929d2..edbaa539 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -46,7 +46,16 @@ pub async fn create_session_with_grouping( ) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve current working directory")?; - queue_session_in_dir(db, cfg, task, agent_type, use_worktree, &repo_root, grouping).await + queue_session_in_dir( + db, + cfg, + task, + agent_type, + use_worktree, + &repo_root, + grouping, + ) + .await } pub fn list_sessions(db: &StateStore) -> Result> { @@ -219,7 +228,7 @@ pub async fn drain_inbox( use_worktree, &repo_root, &runner_program, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -1037,7 +1046,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result { if matches!( session.state, - SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale ) { blocked_by.push(MergeQueueBlocker { session_id: session.id.clone(), @@ -1085,10 +1097,7 @@ pub fn build_merge_queue(db: &StateStore) -> Result { branch: blocker_worktree.branch.clone(), state: blocker.state.clone(), conflicts: conflict.conflicts, - summary: format!( - "merge after {} to avoid branch conflicts", - blocker.id - ), + summary: format!("merge after {} to avoid branch conflicts", blocker.id), conflicting_patch_preview: conflict.right_patch_preview, blocker_patch_preview: conflict.left_patch_preview, }); @@ -1107,7 +1116,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result { let suggested_action = if let Some(position) = queue_position { format!("merge in queue order #{position}") - } else if blocked_by.iter().any(|blocker| blocker.session_id == session.id) { + } else if blocked_by + .iter() + .any(|blocker| blocker.session_id == session.id) + { blocked_by .first() .map(|blocker| blocker.summary.clone()) @@ -1369,15 +1381,8 @@ async fn queue_session_in_dir_with_runner_program( runner_program: &Path, grouping: SessionGrouping, ) -> Result { - let session = build_session_record( - db, - task, - agent_type, - use_worktree, - cfg, - repo_root, - grouping, - )?; + let session = + build_session_record(db, task, agent_type, use_worktree, cfg, repo_root, grouping)?; db.insert_session(&session)?; if use_worktree && session.worktree.is_none() { @@ -1523,7 +1528,10 @@ fn attached_worktree_count(db: &StateStore) -> Result { fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime) { let active_rank = match session.state { SessionState::Completed | SessionState::Failed | SessionState::Stopped => 0, - SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale => 1, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale => 1, }; (active_rank, session.updated_at) } @@ -2238,6 +2246,8 @@ mod tests { auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), + completion_summary_notifications: + crate::notifications::CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS, @@ -3534,7 +3544,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3607,7 +3617,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3820,7 +3830,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; @@ -3893,7 +3903,7 @@ mod tests { true, &repo_root, &fake_runner, - SessionGrouping::default(), + SessionGrouping::default(), ) .await?; diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index a2ce9ab7..78da92b0 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -27,6 +27,18 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { if event::poll(Duration::from_millis(250))? { if let Event::Key(key) = event::read()? { + if dashboard.has_active_completion_popup() { + match (key.modifiers, key.code) { + (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, + (_, KeyCode::Esc) | (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => { + dashboard.dismiss_completion_popup(); + } + _ => {} + } + + continue; + } + if dashboard.is_input_mode() { match (key.modifiers, key.code) { (KeyModifiers::CONTROL, KeyCode::Char('c')) => break, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 60d86ff6..f2f5e8d2 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -3,11 +3,12 @@ use crossterm::event::KeyEvent; use ratatui::{ prelude::*, widgets::{ - Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap, + Block, Borders, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, + Wrap, }, }; use regex::Regex; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::time::UNIX_EPOCH; use tokio::sync::broadcast; @@ -52,6 +53,28 @@ struct ThemePalette { help_border: Color, } +#[derive(Debug, Clone)] +struct SessionCompletionSummary { + session_id: String, + task: String, + state: SessionState, + files_changed: u32, + tokens_used: u64, + duration_secs: u64, + cost_usd: f64, + tests_run: usize, + tests_passed: usize, + recent_files: Vec, + key_decisions: Vec, + warnings: Vec, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct TestRunSummary { + total: usize, + passed: usize, +} + pub struct Dashboard { db: StateStore, cfg: Config, @@ -112,6 +135,8 @@ pub struct Dashboard { search_agent_filter: SearchAgentFilter, search_matches: Vec, selected_search_match: usize, + active_completion_popup: Option, + queued_completion_popups: VecDeque, session_table_state: TableState, last_cost_metrics_signature: Option<(u64, u128)>, last_tool_activity_signature: Option<(u64, u128)>, @@ -296,6 +321,108 @@ struct TeamSummary { stopped: usize, } +impl SessionCompletionSummary { + fn title(&self) -> String { + match self.state { + SessionState::Completed => "ECC 2.0: Session completed".to_string(), + SessionState::Failed => "ECC 2.0: Session failed".to_string(), + _ => "ECC 2.0: Session summary".to_string(), + } + } + + fn subtitle(&self) -> String { + format!( + "{} | {}", + format_session_id(&self.session_id), + truncate_for_dashboard(&self.task, 88) + ) + } + + fn notification_body(&self) -> String { + let tests_line = if self.tests_run > 0 { + format!( + "Tests {} run / {} passed", + self.tests_run, self.tests_passed + ) + } else { + "Tests not detected".to_string() + }; + + let warnings_line = if self.warnings.is_empty() { + "Warnings none".to_string() + } else { + format!( + "Warnings {}", + truncate_for_dashboard(&self.warnings.join("; "), 88) + ) + }; + + [ + self.subtitle(), + format!( + "Files {} | Tokens {} | Duration {}", + self.files_changed, + format_token_count(self.tokens_used), + format_duration(self.duration_secs) + ), + tests_line, + warnings_line, + ] + .join("\n") + } + + fn popup_text(&self) -> String { + let mut lines = vec![ + self.subtitle(), + String::new(), + format!( + "Files {} | Tokens {} | Cost {} | Duration {}", + self.files_changed, + format_token_count(self.tokens_used), + format_currency(self.cost_usd), + format_duration(self.duration_secs) + ), + ]; + + if self.tests_run > 0 { + lines.push(format!( + "Tests {} run / {} passed", + self.tests_run, self.tests_passed + )); + } else { + lines.push("Tests not detected".to_string()); + } + + if !self.recent_files.is_empty() { + lines.push(String::new()); + lines.push("Recent files".to_string()); + for item in &self.recent_files { + lines.push(format!("- {item}")); + } + } + + if !self.key_decisions.is_empty() { + lines.push(String::new()); + lines.push("Key decisions".to_string()); + for item in &self.key_decisions { + lines.push(format!("- {item}")); + } + } + + if !self.warnings.is_empty() { + lines.push(String::new()); + lines.push("Warnings".to_string()); + for item in &self.warnings { + lines.push(format!("- {item}")); + } + } + + lines.push(String::new()); + lines.push("[Enter]/[Space]/[Esc] dismiss".to_string()); + lines.join("\n") + } +} + impl Dashboard { pub fn new(db: StateStore, cfg: Config) -> Self { Self::with_output_store(db, cfg, SessionOutputStore::default()) @@ -394,6 +521,8 @@ impl Dashboard { search_agent_filter: SearchAgentFilter::AllAgents, search_matches: Vec::new(), selected_search_match: 0, + active_completion_popup: None, + queued_completion_popups: VecDeque::new(), session_table_state, last_cost_metrics_signature: initial_cost_metrics_signature, last_tool_activity_signature: initial_tool_activity_signature, @@ -403,6 +532,7 @@ impl Dashboard { }; sort_sessions_for_display(&mut dashboard.sessions); dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); + dashboard.sync_approval_queue(); dashboard.sync_handoff_backlog_counts(); dashboard.sync_global_handoff_backlog(); dashboard.sync_selected_output(); @@ -444,6 +574,10 @@ impl Dashboard { } self.render_status_bar(frame, chunks[2]); + + if let Some(summary) = self.active_completion_popup.as_ref() { + self.render_completion_popup(frame, summary); + } } fn render_header(&self, frame: &mut Frame, area: Rect) { @@ -1045,7 +1179,9 @@ impl Dashboard { self.theme_label() ); - let search_prefix = if let Some(input) = self.spawn_input.as_ref() { + let search_prefix = if self.active_completion_popup.is_some() { + " completion summary | [Enter]/[Space]/[Esc] dismiss |".to_string() + } else if let Some(input) = self.spawn_input.as_ref() { format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |") } else if let Some(input) = self.commit_input.as_ref() { format!(" commit>{input}_ | [Enter] commit [Esc] cancel |") @@ -1076,7 +1212,8 @@ impl Dashboard { String::new() }; - let text = if self.spawn_input.is_some() + let text = if self.active_completion_popup.is_some() + || self.spawn_input.is_some() || self.commit_input.is_some() || self.pr_input.is_some() || self.search_input.is_some() @@ -1121,6 +1258,31 @@ impl Dashboard { ); } + fn render_completion_popup(&self, frame: &mut Frame, summary: &SessionCompletionSummary) { + let popup_area = centered_rect(72, 65, frame.area()); + if popup_area.is_empty() { + return; + } + + frame.render_widget(Clear, popup_area); + let block = Block::default() + .borders(Borders::ALL) + .title(format!(" {} ", summary.title())) + .border_style(self.pane_border_style(Pane::Output)); + let inner = block.inner(popup_area); + frame.render_widget(block, popup_area); + if inner.is_empty() { + return; + } + + frame.render_widget( + Paragraph::new(summary.popup_text()) + .wrap(Wrap { trim: true }) + .scroll((0, 0)), + inner, + ); + } + fn render_help(&self, frame: &mut Frame, area: Rect) { let help = vec![ "Keyboard Shortcuts:".to_string(), @@ -2697,6 +2859,16 @@ impl Dashboard { self.search_query.is_some() } + pub fn has_active_completion_popup(&self) -> bool { + self.active_completion_popup.is_some() + } + + pub fn dismiss_completion_popup(&mut self) { + if self.active_completion_popup.take().is_some() { + self.active_completion_popup = self.queued_completion_popups.pop_front(); + } + } + pub fn begin_spawn_prompt(&mut self) { if self.search_input.is_some() { self.set_operator_note( @@ -3401,10 +3573,11 @@ impl Dashboard { HashMap::new() } }; - self.sync_session_state_notifications(); - self.sync_approval_notifications(); + self.sync_approval_queue(); self.sync_handoff_backlog_counts(); self.sync_worktree_health_by_session(); + self.sync_session_state_notifications(); + self.sync_approval_notifications(); self.sync_global_handoff_backlog(); self.sync_daemon_activity(); self.sync_output_cache(); @@ -3480,6 +3653,8 @@ impl Dashboard { fn sync_session_state_notifications(&mut self) { let mut next_states = HashMap::new(); + let mut completion_summaries = Vec::new(); + let mut failed_notifications = Vec::new(); for session in &self.sessions { let previous_state = self.last_session_states.get(&session.id); @@ -3487,26 +3662,29 @@ impl Dashboard { if previous_state != &session.state { match session.state { SessionState::Completed => { - self.notify_desktop( - NotificationEvent::SessionCompleted, - "ECC 2.0: Session completed", - &format!( - "{} | {}", - format_session_id(&session.id), - truncate_for_dashboard(&session.task, 96) - ), - ); + if self.cfg.completion_summary_notifications.enabled { + completion_summaries.push(self.build_completion_summary(session)); + } else if self.cfg.desktop_notifications.session_completed { + self.notify_desktop( + NotificationEvent::SessionCompleted, + "ECC 2.0: Session completed", + &format!( + "{} | {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 96) + ), + ); + } } SessionState::Failed => { - self.notify_desktop( - NotificationEvent::SessionFailed, - "ECC 2.0: Session failed", - &format!( + failed_notifications.push(( + "ECC 2.0: Session failed".to_string(), + format!( "{} | {}", format_session_id(&session.id), truncate_for_dashboard(&session.task, 96) ), - ); + )); } _ => {} } @@ -3516,6 +3694,16 @@ impl Dashboard { next_states.insert(session.id.clone(), session.state.clone()); } + for summary in completion_summaries { + self.deliver_completion_summary(summary); + } + + if self.cfg.desktop_notifications.session_failed { + for (title, body) in failed_notifications { + self.notify_desktop(NotificationEvent::SessionFailed, &title, &body); + } + } + self.last_session_states = next_states; } @@ -3554,6 +3742,90 @@ impl Dashboard { ); } + fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) { + if self.cfg.completion_summary_notifications.desktop_enabled() + && self.cfg.desktop_notifications.session_completed + { + self.notify_desktop( + NotificationEvent::SessionCompleted, + &summary.title(), + &summary.notification_body(), + ); + } + + if self.cfg.completion_summary_notifications.popup_enabled() { + if self.active_completion_popup.is_none() { + self.active_completion_popup = Some(summary); + } else { + self.queued_completion_popups.push_back(summary); + } + } + } + + fn build_completion_summary(&self, session: &Session) -> SessionCompletionSummary { + let file_activity = match self.db.list_file_activity(&session.id, 5) { + Ok(entries) => entries, + Err(error) => { + tracing::warn!( + "Failed to load file activity for completion summary {}: {error}", + session.id + ); + Vec::new() + } + }; + let tool_logs = match self.db.list_tool_logs_for_session(&session.id) { + Ok(entries) => entries, + Err(error) => { + tracing::warn!( + "Failed to load tool logs for completion summary {}: {error}", + session.id + ); + Vec::new() + } + }; + let overlaps = match self.db.list_file_overlaps(&session.id, 3) { + Ok(entries) => entries, + Err(error) => { + tracing::warn!( + "Failed to load file overlaps for completion summary {}: {error}", + session.id + ); + Vec::new() + } + }; + + let tests = summarize_test_runs(&tool_logs, session.state == SessionState::Completed); + let recent_files = recent_completion_files(&file_activity, session.metrics.files_changed); + let key_decisions = + summarize_completion_decisions(&tool_logs, &file_activity, &session.task); + let warnings = summarize_completion_warnings( + session, + &tool_logs, + &tests, + self.worktree_health_by_session.get(&session.id), + self.approval_queue_counts + .get(&session.id) + .copied() + .unwrap_or(0), + overlaps.len(), + ); + + SessionCompletionSummary { + session_id: session.id.clone(), + task: session.task.clone(), + state: session.state.clone(), + files_changed: session.metrics.files_changed, + tokens_used: session.metrics.tokens_used, + duration_secs: session.metrics.duration_secs, + cost_usd: session.metrics.cost_usd, + tests_run: tests.total, + tests_passed: tests.passed, + recent_files, + key_decisions, + warnings, + } + } + fn notify_desktop(&self, event: NotificationEvent, title: &str, body: &str) { let _ = self.notifier.notify(event, title, body); } @@ -6743,6 +7015,254 @@ fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec { lines } +fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - height_percent) / 2), + Constraint::Percentage(height_percent), + Constraint::Percentage((100 - height_percent) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - width_percent) / 2), + Constraint::Percentage(width_percent), + Constraint::Percentage((100 - width_percent) / 2), + ]) + .split(vertical[1])[1] +} + +fn summarize_test_runs( + tool_logs: &[ToolLogEntry], + assume_success_on_completion: bool, +) -> TestRunSummary { + let mut summary = TestRunSummary::default(); + + for entry in tool_logs { + if !tool_log_looks_like_test(entry) { + continue; + } + + summary.total += 1; + let failed = tool_log_looks_failed(entry); + let passed = tool_log_looks_passed(entry); + if !failed && (passed || assume_success_on_completion) { + summary.passed += 1; + } + } + + summary +} + +fn tool_log_looks_like_test(entry: &ToolLogEntry) -> bool { + let haystack = format!( + "{} {} {} {}", + entry.tool_name, + entry.input_summary, + extract_tool_command(entry), + entry.output_summary + ) + .to_ascii_lowercase(); + const TEST_MARKERS: &[&str] = &[ + "cargo test", + "npm test", + "pnpm test", + "pnpm exec vitest", + "pnpm exec playwright", + "yarn test", + "bun test", + "vitest", + "jest", + "pytest", + "go test", + "playwright test", + "cypress", + "rspec", + "phpunit", + "e2e", + ]; + + TEST_MARKERS.iter().any(|marker| haystack.contains(marker)) +} + +fn tool_log_looks_failed(entry: &ToolLogEntry) -> bool { + let haystack = format!( + "{} {} {} {}", + entry.tool_name, + entry.input_summary, + extract_tool_command(entry), + entry.output_summary + ) + .to_ascii_lowercase(); + const FAILURE_MARKERS: &[&str] = &[ + " fail", + "failed", + " error", + "panic", + "timed out", + "non-zero", + "exit code 1", + "exited with", + ]; + + FAILURE_MARKERS + .iter() + .any(|marker| haystack.contains(marker)) +} + +fn tool_log_looks_passed(entry: &ToolLogEntry) -> bool { + let haystack = format!( + "{} {} {} {}", + entry.tool_name, + entry.input_summary, + extract_tool_command(entry), + entry.output_summary + ) + .to_ascii_lowercase(); + const SUCCESS_MARKERS: &[&str] = &[" pass", "passed", " ok", "success", "green", "completed"]; + + SUCCESS_MARKERS + .iter() + .any(|marker| haystack.contains(marker)) +} + +fn extract_tool_command(entry: &ToolLogEntry) -> String { + let Ok(value) = serde_json::from_str::(&entry.input_params_json) else { + return String::new(); + }; + + value + .get("command") + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .unwrap_or_default() +} + +fn recent_completion_files(file_activity: &[FileActivityEntry], files_changed: u32) -> Vec { + if !file_activity.is_empty() { + return file_activity + .iter() + .take(3) + .map(file_activity_summary) + .collect(); + } + + if files_changed > 0 { + return vec![format!("files touched {}", files_changed)]; + } + + Vec::new() +} + +fn summarize_completion_decisions( + tool_logs: &[ToolLogEntry], + file_activity: &[FileActivityEntry], + session_task: &str, +) -> Vec { + let mut seen = HashSet::new(); + let mut decisions = Vec::new(); + + for entry in tool_logs.iter().rev() { + let mut candidates = Vec::new(); + if !entry.trigger_summary.trim().is_empty() + && entry.trigger_summary.trim() != session_task.trim() + { + candidates.push(format!( + "why {}", + truncate_for_dashboard(&entry.trigger_summary, 72) + )); + } + + let action = if entry.tool_name.eq_ignore_ascii_case("Bash") { + truncate_for_dashboard(&extract_tool_command(entry), 72) + } else if !entry.output_summary.trim().is_empty() && entry.output_summary.trim() != "ok" { + truncate_for_dashboard(&entry.output_summary, 72) + } else { + truncate_for_dashboard(&entry.input_summary, 72) + }; + + if !action.trim().is_empty() { + candidates.push(action); + } + + for candidate in candidates { + let normalized = candidate.to_ascii_lowercase(); + if seen.insert(normalized) { + decisions.push(candidate); + } + if decisions.len() >= 3 { + return decisions; + } + } + } + + for entry in file_activity.iter().take(3) { + let candidate = file_activity_summary(entry); + let normalized = candidate.to_ascii_lowercase(); + if seen.insert(normalized) { + decisions.push(candidate); + } + if decisions.len() >= 3 { + break; + } + } + + decisions +} + +fn summarize_completion_warnings( + session: &Session, + tool_logs: &[ToolLogEntry], + tests: &TestRunSummary, + worktree_health: Option<&worktree::WorktreeHealth>, + approval_backlog: usize, + overlap_count: usize, +) -> Vec { + let mut warnings = Vec::new(); + let high_risk_tool_calls = tool_logs + .iter() + .filter(|entry| entry.risk_score >= Config::RISK_THRESHOLDS.review) + .count(); + + if session.metrics.files_changed > 0 && tests.total == 0 { + warnings.push("no test runs detected".to_string()); + } + if tests.total > tests.passed { + warnings.push(format!( + "{} detected test run(s) were not confirmed passed", + tests.total - tests.passed + )); + } + if high_risk_tool_calls > 0 { + warnings.push(format!( + "{high_risk_tool_calls} high-risk tool call(s) recorded" + )); + } + if approval_backlog > 0 { + warnings.push(format!( + "{approval_backlog} approval/conflict request(s) remained unread" + )); + } + if overlap_count > 0 { + warnings.push(format!( + "{overlap_count} potential file overlap(s) remained" + )); + } + match worktree_health { + Some(worktree::WorktreeHealth::Conflicted) => { + warnings.push("worktree still has unresolved conflicts".to_string()); + } + Some(worktree::WorktreeHealth::InProgress) => { + warnings.push("worktree still has unmerged changes".to_string()); + } + Some(worktree::WorktreeHealth::Clear) | None => {} + } + + warnings +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -9151,6 +9671,111 @@ diff --git a/src/lib.rs b/src/lib.rs ); } + #[test] + fn refresh_builds_completion_summary_popup_from_metrics_activity_and_logs() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-completion-popup-{}", Uuid::new_v4())); + fs::create_dir_all(root.join(".claude").join("metrics"))?; + + let mut cfg = build_config(&root.join(".claude")); + cfg.completion_summary_notifications.delivery = + crate::notifications::CompletionSummaryDelivery::TuiPopup; + cfg.desktop_notifications.session_completed = false; + + let db = StateStore::open(&cfg.db_path)?; + let mut session = sample_session( + "done-12345678", + "claude", + SessionState::Running, + Some("ecc/done"), + 384, + 95, + ); + session.task = "Finish session summary notifications".to_string(); + db.insert_session(&session)?; + + let metrics_path = cfg.tool_activity_metrics_path(); + fs::create_dir_all(metrics_path.parent().unwrap())?; + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"done-12345678\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"done-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ session summary notifications\",\"patch_preview\":\"+ session summary notifications\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n", + "{\"id\":\"evt-3\",\"session_id\":\"done-12345678\",\"tool_name\":\"Bash\",\"input_summary\":\"rm -rf build\",\"input_params_json\":\"{\\\"command\\\":\\\"rm -rf build\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:02:00Z\"}\n" + ), + )?; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard + .db + .update_state("done-12345678", &SessionState::Completed)?; + + dashboard.refresh(); + + let popup = dashboard + .active_completion_popup + .as_ref() + .expect("completion summary popup"); + let popup_text = popup.popup_text(); + assert!(popup_text.contains("done-123")); + assert!(popup_text.contains("Tests 1 run / 1 passed")); + assert!(popup_text.contains("Recent files")); + assert!(popup_text.contains("create README.md")); + assert!(popup_text.contains("Warnings")); + assert!(popup_text.contains("high-risk tool call")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn dismiss_completion_popup_promotes_the_next_summary() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.active_completion_popup = Some(SessionCompletionSummary { + session_id: "sess-a".to_string(), + task: "First".to_string(), + state: SessionState::Completed, + files_changed: 1, + tokens_used: 10, + duration_secs: 5, + cost_usd: 0.01, + tests_run: 1, + tests_passed: 1, + recent_files: vec!["create README.md".to_string()], + key_decisions: vec!["cargo test -q".to_string()], + warnings: Vec::new(), + }); + dashboard + .queued_completion_popups + .push_back(SessionCompletionSummary { + session_id: "sess-b".to_string(), + task: "Second".to_string(), + state: SessionState::Completed, + files_changed: 2, + tokens_used: 20, + duration_secs: 8, + cost_usd: 0.02, + tests_run: 0, + tests_passed: 0, + recent_files: vec!["modify src/lib.rs".to_string()], + key_decisions: vec!["updated lib".to_string()], + warnings: vec!["no test runs detected".to_string()], + }); + + dashboard.dismiss_completion_popup(); + + assert_eq!( + dashboard + .active_completion_popup + .as_ref() + .map(|summary| summary.session_id.as_str()), + Some("sess-b") + ); + assert!(dashboard.queued_completion_popups.is_empty()); + + dashboard.dismiss_completion_popup(); + assert!(dashboard.active_completion_popup.is_none()); + } + #[test] fn refresh_syncs_tool_activity_metrics_from_hook_file() { let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4())); @@ -11284,6 +11909,8 @@ diff --git a/src/lib.rs b/src/lib.rs search_agent_filter: SearchAgentFilter::AllAgents, search_matches: Vec::new(), selected_search_match: 0, + active_completion_popup: None, + queued_completion_popups: VecDeque::new(), session_table_state, last_cost_metrics_signature: None, last_tool_activity_signature: None, @@ -11310,6 +11937,8 @@ diff --git a/src/lib.rs b/src/lib.rs auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), + completion_summary_notifications: + crate::notifications::CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 6703b01c..caab2466 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -349,7 +349,9 @@ pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result { anyhow::bail!("git rev-parse failed: {stderr}"); } - Ok(String::from_utf8_lossy(&rev_parse.stdout).trim().to_string()) + Ok(String::from_utf8_lossy(&rev_parse.stdout) + .trim() + .to_string()) } pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result { @@ -604,7 +606,9 @@ pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result { } pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result { - Ok(git_status_entries(worktree)?.iter().any(|entry| entry.staged)) + Ok(git_status_entries(worktree)? + .iter() + .any(|entry| entry.staged)) } pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { @@ -925,8 +929,12 @@ fn dependency_fingerprint(root: &Path, files: &[&str]) -> Result { let mut hasher = Sha256::new(); for rel in files { let path = root.join(rel); - let content = fs::read(&path) - .with_context(|| format!("Failed to read dependency fingerprint input {}", path.display()))?; + let content = fs::read(&path).with_context(|| { + format!( + "Failed to read dependency fingerprint input {}", + path.display() + ) + })?; hasher.update(rel.as_bytes()); hasher.update([0]); hasher.update(&content); @@ -957,10 +965,8 @@ fn is_symlink_to(path: &Path, target: &Path) -> Result { fn remove_symlink(path: &Path) -> Result<()> { match fs::remove_file(path) { Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => { - fs::remove_dir(path) - .with_context(|| format!("Failed to remove dependency cache link {}", path.display())) - } + Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => fs::remove_dir(path) + .with_context(|| format!("Failed to remove dependency cache link {}", path.display())), Err(error) => Err(error) .with_context(|| format!("Failed to remove dependency cache link {}", path.display())), } @@ -1072,10 +1078,7 @@ fn parse_git_status_entry(line: &str) -> Option { .to_string(); let conflicted = matches!( (index_status, worktree_status), - ('U', _) - | (_, 'U') - | ('A', 'A') - | ('D', 'D') + ('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D') ); Some(GitStatusEntry { path: normalized_path, @@ -1491,8 +1494,10 @@ mod tests { #[test] fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> { - let root = std::env::temp_dir() - .join(format!("ecc2-worktree-branch-conflict-preview-{}", Uuid::new_v4())); + let root = std::env::temp_dir().join(format!( + "ecc2-worktree-branch-conflict-preview-{}", + Uuid::new_v4() + )); let repo = init_repo(&root)?; let left_dir = root.join("wt-left"); @@ -1538,8 +1543,8 @@ mod tests { base_branch: "main".to_string(), }; - let preview = branch_conflict_preview(&left, &right, 12)? - .expect("expected branch conflict preview"); + let preview = + branch_conflict_preview(&left, &right, 12)?.expect("expected branch conflict preview"); assert_eq!(preview.conflicts, vec!["README.md".to_string()]); assert!(preview .left_patch_preview @@ -1622,7 +1627,10 @@ mod tests { .arg(&repo) .args(["log", "-1", "--pretty=%s"]) .output()?; - assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "update readme"); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "update readme" + ); let _ = fs::remove_dir_all(root); Ok(()) @@ -1652,8 +1660,19 @@ mod tests { let root = std::env::temp_dir().join(format!("ecc2-pr-create-{}", Uuid::new_v4())); let repo = init_repo(&root)?; let remote = root.join("remote.git"); - run_git(&root, &["init", "--bare", remote.to_str().expect("utf8 path")])?; - run_git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf8 path")])?; + run_git( + &root, + &["init", "--bare", remote.to_str().expect("utf8 path")], + )?; + run_git( + &repo, + &[ + "remote", + "add", + "origin", + remote.to_str().expect("utf8 path"), + ], + )?; run_git(&repo, &["push", "-u", "origin", "main"])?; run_git(&repo, &["checkout", "-b", "feat/pr-test"])?; fs::write(repo.join("README.md"), "pr test\n")?; @@ -1713,10 +1732,14 @@ mod tests { #[test] fn create_for_session_links_shared_node_modules_cache() -> Result<()> { - let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4())); + let root = + std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4())); let repo = init_repo(&root)?; fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?; - fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?; + fs::write( + repo.join("package-lock.json"), + "{\n \"lockfileVersion\": 3\n}\n", + )?; fs::create_dir_all(repo.join("node_modules"))?; fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?; run_git(&repo, &["add", "package.json", "package-lock.json"])?; @@ -1727,7 +1750,9 @@ mod tests { let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; let node_modules = worktree.path.join("node_modules"); - assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + assert!(fs::symlink_metadata(&node_modules)? + .file_type() + .is_symlink()); assert_eq!(fs::read_link(&node_modules)?, repo.join("node_modules")); remove(&worktree)?; @@ -1741,7 +1766,10 @@ mod tests { std::env::temp_dir().join(format!("ecc2-worktree-node-fallback-{}", Uuid::new_v4())); let repo = init_repo(&root)?; fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?; - fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?; + fs::write( + repo.join("package-lock.json"), + "{\n \"lockfileVersion\": 3\n}\n", + )?; fs::create_dir_all(repo.join("node_modules"))?; fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?; run_git(&repo, &["add", "package.json", "package-lock.json"])?; @@ -1752,7 +1780,9 @@ mod tests { let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?; let node_modules = worktree.path.join("node_modules"); - assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + assert!(fs::symlink_metadata(&node_modules)? + .file_type() + .is_symlink()); fs::write( worktree.path.join("package-lock.json"), @@ -1761,7 +1791,9 @@ mod tests { let applied = sync_shared_dependency_dirs(&worktree)?; assert!(applied.is_empty()); assert!(node_modules.is_dir()); - assert!(!fs::symlink_metadata(&node_modules)?.file_type().is_symlink()); + assert!(!fs::symlink_metadata(&node_modules)? + .file_type() + .is_symlink()); assert!(repo.join("node_modules/.cache-marker").exists()); remove(&worktree)?; From 5fb2e6221647dd981b95ac16a871294bf36f48fc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 21:14:09 -0700 Subject: [PATCH 112/459] feat: add ecc2 webhook notifications --- ecc2/Cargo.lock | 154 ++++++++++++++++++ ecc2/Cargo.toml | 1 + ecc2/src/config/mod.rs | 54 ++++++- ecc2/src/notifications.rs | 307 +++++++++++++++++++++++++++++++++++- ecc2/src/session/manager.rs | 1 + ecc2/src/tui/dashboard.rs | 188 +++++++++++++++++++++- ecc2/src/worktree/mod.rs | 116 ++++++++++++++ 7 files changed, 816 insertions(+), 5 deletions(-) diff --git a/ecc2/Cargo.lock b/ecc2/Cargo.lock index 7a4e60eb..40cd4724 100644 --- a/ecc2/Cargo.lock +++ b/ecc2/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -300,6 +306,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -507,6 +522,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "ureq", "uuid", ] @@ -592,6 +608,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1141,6 +1167,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1612,6 +1648,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -1661,6 +1711,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1794,6 +1879,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.2" @@ -1855,6 +1946,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2208,6 +2305,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + [[package]] name = "url" version = "2.5.8" @@ -2374,6 +2495,24 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -2527,6 +2666,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2776,6 +2924,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/ecc2/Cargo.toml b/ecc2/Cargo.toml index 170aacb4..85399a3b 100644 --- a/ecc2/Cargo.toml +++ b/ecc2/Cargo.toml @@ -27,6 +27,7 @@ serde_json = "1" toml = "0.8" regex = "1" sha2 = "0.10" +ureq = { version = "2", features = ["json"] } # CLI clap = { version = "4", features = ["derive"] } diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 2b0fbe6a..f83d7a32 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -3,7 +3,9 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -use crate::notifications::{CompletionSummaryConfig, DesktopNotificationConfig}; +use crate::notifications::{ + CompletionSummaryConfig, DesktopNotificationConfig, WebhookNotificationConfig, +}; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -48,6 +50,7 @@ pub struct Config { pub auto_create_worktrees: bool, pub auto_merge_ready_worktrees: bool, pub desktop_notifications: DesktopNotificationConfig, + pub webhook_notifications: WebhookNotificationConfig, pub completion_summary_notifications: CompletionSummaryConfig, pub cost_budget_usd: f64, pub token_budget: u64, @@ -112,6 +115,7 @@ impl Default for Config { auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: DesktopNotificationConfig::default(), + webhook_notifications: WebhookNotificationConfig::default(), completion_summary_notifications: CompletionSummaryConfig::default(), cost_budget_usd: 10.0, token_budget: 500_000, @@ -438,6 +442,7 @@ theme = "Dark" defaults.auto_merge_ready_worktrees ); assert_eq!(config.desktop_notifications, defaults.desktop_notifications); + assert_eq!(config.webhook_notifications, defaults.webhook_notifications); assert_eq!( config.auto_terminate_stale_sessions, defaults.auto_terminate_stale_sessions @@ -636,6 +641,42 @@ delivery = "desktop_and_tui_popup" ); } + #[test] + fn webhook_notifications_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[webhook_notifications] +enabled = true +session_started = true +session_completed = true +session_failed = true +budget_alerts = true +approval_requests = false + +[[webhook_notifications.targets]] +provider = "slack" +url = "https://hooks.slack.test/services/abc" + +[[webhook_notifications.targets]] +provider = "discord" +url = "https://discord.test/api/webhooks/123" +"#, + ) + .unwrap(); + + assert!(config.webhook_notifications.enabled); + assert!(config.webhook_notifications.session_started); + assert_eq!(config.webhook_notifications.targets.len(), 2); + assert_eq!( + config.webhook_notifications.targets[0].provider, + crate::notifications::WebhookProvider::Slack + ); + assert_eq!( + config.webhook_notifications.targets[1].provider, + crate::notifications::WebhookProvider::Discord + ); + } + #[test] fn invalid_budget_alert_thresholds_fall_back_to_defaults() { let config: Config = toml::from_str( @@ -663,6 +704,11 @@ critical = 1.10 config.auto_create_worktrees = false; config.auto_merge_ready_worktrees = true; config.desktop_notifications.session_completed = false; + config.webhook_notifications.enabled = true; + config.webhook_notifications.targets = vec![crate::notifications::WebhookTarget { + provider: crate::notifications::WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }]; config.completion_summary_notifications.delivery = crate::notifications::CompletionSummaryDelivery::TuiPopup; config.desktop_notifications.quiet_hours.enabled = true; @@ -688,6 +734,12 @@ critical = 1.10 assert!(!loaded.auto_create_worktrees); assert!(loaded.auto_merge_ready_worktrees); assert!(!loaded.desktop_notifications.session_completed); + assert!(loaded.webhook_notifications.enabled); + assert_eq!(loaded.webhook_notifications.targets.len(), 1); + assert_eq!( + loaded.webhook_notifications.targets[0].provider, + crate::notifications::WebhookProvider::Slack + ); assert_eq!( loaded.completion_summary_notifications.delivery, crate::notifications::CompletionSummaryDelivery::TuiPopup diff --git a/ecc2/src/notifications.rs b/ecc2/src/notifications.rs index e4238627..f7d51883 100644 --- a/ecc2/src/notifications.rs +++ b/ecc2/src/notifications.rs @@ -1,12 +1,14 @@ use anyhow::Result; use chrono::{DateTime, Local, Timelike}; use serde::{Deserialize, Serialize}; +use serde_json::json; #[cfg(not(test))] use anyhow::Context; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NotificationEvent { + SessionStarted, SessionCompleted, SessionFailed, BudgetAlert, @@ -25,6 +27,7 @@ pub struct QuietHoursConfig { #[serde(default)] pub struct DesktopNotificationConfig { pub enabled: bool, + pub session_started: bool, pub session_completed: bool, pub session_failed: bool, pub budget_alerts: bool, @@ -48,11 +51,43 @@ pub struct CompletionSummaryConfig { pub delivery: CompletionSummaryDelivery, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WebhookProvider { + #[default] + Slack, + Discord, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WebhookTarget { + pub provider: WebhookProvider, + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WebhookNotificationConfig { + pub enabled: bool, + pub session_started: bool, + pub session_completed: bool, + pub session_failed: bool, + pub budget_alerts: bool, + pub approval_requests: bool, + pub targets: Vec, +} + #[derive(Debug, Clone)] pub struct DesktopNotifier { config: DesktopNotificationConfig, } +#[derive(Debug, Clone)] +pub struct WebhookNotifier { + config: WebhookNotificationConfig, +} + impl Default for QuietHoursConfig { fn default() -> Self { Self { @@ -96,6 +131,7 @@ impl Default for DesktopNotificationConfig { fn default() -> Self { Self { enabled: true, + session_started: false, session_completed: true, session_failed: true, budget_alerts: true, @@ -120,6 +156,7 @@ impl DesktopNotificationConfig { } match event { + NotificationEvent::SessionStarted => config.session_started, NotificationEvent::SessionCompleted => config.session_completed, NotificationEvent::SessionFailed => config.session_failed, NotificationEvent::BudgetAlert => config.budget_alerts, @@ -155,6 +192,68 @@ impl CompletionSummaryConfig { } } +impl Default for WebhookTarget { + fn default() -> Self { + Self { + provider: WebhookProvider::Slack, + url: String::new(), + } + } +} + +impl WebhookTarget { + fn sanitized(self) -> Option { + let url = self.url.trim().to_string(); + if url.starts_with("https://") || url.starts_with("http://") { + Some(Self { url, ..self }) + } else { + None + } + } +} + +impl Default for WebhookNotificationConfig { + fn default() -> Self { + Self { + enabled: false, + session_started: true, + session_completed: true, + session_failed: true, + budget_alerts: true, + approval_requests: false, + targets: Vec::new(), + } + } +} + +impl WebhookNotificationConfig { + pub fn sanitized(self) -> Self { + Self { + targets: self + .targets + .into_iter() + .filter_map(WebhookTarget::sanitized) + .collect(), + ..self + } + } + + pub fn allows(&self, event: NotificationEvent) -> bool { + let config = self.clone().sanitized(); + if !config.enabled || config.targets.is_empty() { + return false; + } + + match event { + NotificationEvent::SessionStarted => config.session_started, + NotificationEvent::SessionCompleted => config.session_completed, + NotificationEvent::SessionFailed => config.session_failed, + NotificationEvent::BudgetAlert => config.budget_alerts, + NotificationEvent::ApprovalRequest => config.approval_requests, + } + } +} + impl DesktopNotifier { pub fn new(config: DesktopNotificationConfig) -> Self { Self { @@ -192,6 +291,57 @@ impl DesktopNotifier { } } +impl WebhookNotifier { + pub fn new(config: WebhookNotificationConfig) -> Self { + Self { + config: config.sanitized(), + } + } + + pub fn notify(&self, event: NotificationEvent, message: &str) -> bool { + match self.try_notify(event, message) { + Ok(sent) => sent, + Err(error) => { + tracing::warn!("Failed to send webhook notification: {error}"); + false + } + } + } + + fn try_notify(&self, event: NotificationEvent, message: &str) -> Result { + self.try_notify_with(event, message, send_webhook_request) + } + + fn try_notify_with( + &self, + event: NotificationEvent, + message: &str, + mut sender: F, + ) -> Result + where + F: FnMut(&WebhookTarget, serde_json::Value) -> Result<()>, + { + if !self.config.allows(event) { + return Ok(false); + } + + let mut delivered = false; + for target in &self.config.targets { + let payload = webhook_payload(target, message); + match sender(target, payload) { + Ok(()) => delivered = true, + Err(error) => tracing::warn!( + "Failed to deliver {:?} webhook notification to {}: {error}", + target.provider, + target.url + ), + } + } + + Ok(delivered) + } +} + fn notification_command(platform: &str, title: &str, body: &str) -> Option<(String, Vec)> { match platform { "macos" => Some(( @@ -218,6 +368,20 @@ fn notification_command(platform: &str, title: &str, body: &str) -> Option<(Stri } } +fn webhook_payload(target: &WebhookTarget, message: &str) -> serde_json::Value { + match target.provider { + WebhookProvider::Slack => json!({ + "text": message, + }), + WebhookProvider::Discord => json!({ + "content": message, + "allowed_mentions": { + "parse": [] + } + }), + } +} + #[cfg(not(test))] fn run_notification_command(program: &str, args: &[String]) -> Result<()> { let status = std::process::Command::new(program) @@ -237,6 +401,29 @@ fn run_notification_command(_program: &str, _args: &[String]) -> Result<()> { Ok(()) } +#[cfg(not(test))] +fn send_webhook_request(target: &WebhookTarget, payload: serde_json::Value) -> Result<()> { + let agent = ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(5)) + .timeout_read(std::time::Duration::from_secs(5)) + .build(); + let response = agent + .post(&target.url) + .send_json(payload) + .with_context(|| format!("POST {}", target.url))?; + + if response.status() >= 200 && response.status() < 300 { + Ok(()) + } else { + anyhow::bail!("{} returned {}", target.url, response.status()); + } +} + +#[cfg(test)] +fn send_webhook_request(_target: &WebhookTarget, _payload: serde_json::Value) -> Result<()> { + Ok(()) +} + fn sanitize_osascript(value: &str) -> String { value .replace('\\', "") @@ -247,10 +434,12 @@ fn sanitize_osascript(value: &str) -> String { #[cfg(test)] mod tests { use super::{ - notification_command, DesktopNotificationConfig, DesktopNotifier, NotificationEvent, - QuietHoursConfig, + notification_command, webhook_payload, CompletionSummaryDelivery, + DesktopNotificationConfig, DesktopNotifier, NotificationEvent, QuietHoursConfig, + WebhookNotificationConfig, WebhookNotifier, WebhookProvider, WebhookTarget, }; use chrono::{Local, TimeZone}; + use serde_json::json; #[test] fn quiet_hours_support_cross_midnight_ranges() { @@ -285,6 +474,7 @@ mod tests { assert!(!config.allows(NotificationEvent::SessionCompleted, now)); assert!(config.allows(NotificationEvent::BudgetAlert, now)); + assert!(!config.allows(NotificationEvent::SessionStarted, now)); } #[test] @@ -329,4 +519,117 @@ mod tests { assert_eq!(args[2], "ECC 2.0: Approval needed"); assert_eq!(args[3], "worker-123"); } + + #[test] + fn webhook_notifications_require_enabled_targets_and_event() { + let mut config = WebhookNotificationConfig::default(); + assert!(!config.allows(NotificationEvent::SessionCompleted)); + + config.enabled = true; + config.targets = vec![WebhookTarget { + provider: WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }]; + + assert!(config.allows(NotificationEvent::SessionCompleted)); + assert!(config.allows(NotificationEvent::SessionStarted)); + assert!(!config.allows(NotificationEvent::ApprovalRequest)); + } + + #[test] + fn webhook_sanitization_filters_invalid_urls() { + let config = WebhookNotificationConfig { + enabled: true, + targets: vec![ + WebhookTarget { + provider: WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }, + WebhookTarget { + provider: WebhookProvider::Discord, + url: "ftp://discord.invalid".to_string(), + }, + ], + ..WebhookNotificationConfig::default() + } + .sanitized(); + + assert_eq!(config.targets.len(), 1); + assert_eq!(config.targets[0].provider, WebhookProvider::Slack); + } + + #[test] + fn slack_webhook_payload_uses_text() { + let payload = webhook_payload( + &WebhookTarget { + provider: WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }, + "*ECC 2.0* hello", + ); + + assert_eq!(payload, json!({ "text": "*ECC 2.0* hello" })); + } + + #[test] + fn discord_webhook_payload_disables_mentions() { + let payload = webhook_payload( + &WebhookTarget { + provider: WebhookProvider::Discord, + url: "https://discord.test/api/webhooks/123".to_string(), + }, + "```text\nsummary\n```", + ); + + assert_eq!( + payload, + json!({ + "content": "```text\nsummary\n```", + "allowed_mentions": { "parse": [] } + }) + ); + } + + #[test] + fn webhook_notifier_sends_to_each_target() { + let notifier = WebhookNotifier::new(WebhookNotificationConfig { + enabled: true, + targets: vec![ + WebhookTarget { + provider: WebhookProvider::Slack, + url: "https://hooks.slack.test/services/abc".to_string(), + }, + WebhookTarget { + provider: WebhookProvider::Discord, + url: "https://discord.test/api/webhooks/123".to_string(), + }, + ], + ..WebhookNotificationConfig::default() + }); + let mut sent = Vec::new(); + + let delivered = notifier + .try_notify_with( + NotificationEvent::SessionCompleted, + "payload text", + |target, payload| { + sent.push((target.provider, payload)); + Ok(()) + }, + ) + .unwrap(); + + assert!(delivered); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0].0, WebhookProvider::Slack); + assert_eq!(sent[1].0, WebhookProvider::Discord); + } + + #[test] + fn completion_summary_delivery_defaults_to_desktop() { + assert_eq!( + CompletionSummaryDelivery::default(), + CompletionSummaryDelivery::Desktop + ); + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index edbaa539..64a55e9b 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -2246,6 +2246,7 @@ mod tests { auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), + webhook_notifications: crate::notifications::WebhookNotificationConfig::default(), completion_summary_notifications: crate::notifications::CompletionSummaryConfig::default(), cost_budget_usd: 10.0, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index f2f5e8d2..ae1aa796 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -15,7 +15,7 @@ use tokio::sync::broadcast; use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter}; use crate::comms; use crate::config::{Config, PaneLayout, PaneNavigationAction, Theme}; -use crate::notifications::{DesktopNotifier, NotificationEvent}; +use crate::notifications::{DesktopNotifier, NotificationEvent, WebhookNotifier}; use crate::observability::ToolLogEntry; use crate::session::manager; use crate::session::output::{ @@ -81,6 +81,7 @@ pub struct Dashboard { output_store: SessionOutputStore, output_rx: broadcast::Receiver, notifier: DesktopNotifier, + webhook_notifier: WebhookNotifier, sessions: Vec, session_output_cache: HashMap>, unread_message_counts: HashMap, @@ -456,6 +457,7 @@ impl Dashboard { .map(|message| message.id); let output_rx = output_store.subscribe(); let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); + let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone()); let mut session_table_state = TableState::default(); if !sessions.is_empty() { session_table_state.select(Some(0)); @@ -467,6 +469,7 @@ impl Dashboard { output_store, output_rx, notifier, + webhook_notifier, sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), @@ -3649,21 +3652,40 @@ impl Dashboard { "ECC 2.0: Budget alert", &format!("{summary_suffix} | tokens {token_budget} | cost {cost_budget}"), ); + self.notify_webhook( + NotificationEvent::BudgetAlert, + &budget_alert_webhook_body( + &summary_suffix, + &token_budget, + &cost_budget, + self.active_session_count(), + ), + ); } fn sync_session_state_notifications(&mut self) { let mut next_states = HashMap::new(); let mut completion_summaries = Vec::new(); let mut failed_notifications = Vec::new(); + let mut started_webhooks = Vec::new(); + let mut completion_webhooks = Vec::new(); + let mut failed_webhooks = Vec::new(); for session in &self.sessions { let previous_state = self.last_session_states.get(&session.id); if let Some(previous_state) = previous_state { if previous_state != &session.state { match session.state { + SessionState::Running => { + started_webhooks.push(session_started_webhook_body( + session, + session_compare_url(session).as_deref(), + )); + } SessionState::Completed => { + let summary = self.build_completion_summary(session); if self.cfg.completion_summary_notifications.enabled { - completion_summaries.push(self.build_completion_summary(session)); + completion_summaries.push(summary.clone()); } else if self.cfg.desktop_notifications.session_completed { self.notify_desktop( NotificationEvent::SessionCompleted, @@ -3675,8 +3697,14 @@ impl Dashboard { ), ); } + completion_webhooks.push(completion_summary_webhook_body( + &summary, + session, + session_compare_url(session).as_deref(), + )); } SessionState::Failed => { + let summary = self.build_completion_summary(session); failed_notifications.push(( "ECC 2.0: Session failed".to_string(), format!( @@ -3685,10 +3713,20 @@ impl Dashboard { truncate_for_dashboard(&session.task, 96) ), )); + failed_webhooks.push(completion_summary_webhook_body( + &summary, + session, + session_compare_url(session).as_deref(), + )); } _ => {} } } + } else if session.state == SessionState::Running { + started_webhooks.push(session_started_webhook_body( + session, + session_compare_url(session).as_deref(), + )); } next_states.insert(session.id.clone(), session.state.clone()); @@ -3698,12 +3736,24 @@ impl Dashboard { self.deliver_completion_summary(summary); } + for body in started_webhooks { + self.notify_webhook(NotificationEvent::SessionStarted, &body); + } + if self.cfg.desktop_notifications.session_failed { for (title, body) in failed_notifications { self.notify_desktop(NotificationEvent::SessionFailed, &title, &body); } } + for body in completion_webhooks { + self.notify_webhook(NotificationEvent::SessionCompleted, &body); + } + + for body in failed_webhooks { + self.notify_webhook(NotificationEvent::SessionFailed, &body); + } + self.last_session_states = next_states; } @@ -3740,6 +3790,10 @@ impl Dashboard { preview ), ); + self.notify_webhook( + NotificationEvent::ApprovalRequest, + &approval_request_webhook_body(&message, &preview), + ); } fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) { @@ -3830,6 +3884,10 @@ impl Dashboard { let _ = self.notifier.notify(event, title, body); } + fn notify_webhook(&self, event: NotificationEvent, body: &str) { + let _ = self.webhook_notifier.notify(event, body); + } + fn sync_selection(&mut self) { if self.sessions.is_empty() { self.selected_session = 0; @@ -7263,6 +7321,129 @@ fn summarize_completion_warnings( warnings } +fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String { + let mut lines = vec![ + "*ECC 2.0: Session started*".to_string(), + format!( + "`{}` {}", + format_session_id(&session.id), + truncate_for_dashboard(&session.task, 96) + ), + format!( + "Project `{}` | Group `{}` | Agent `{}`", + session.project, session.task_group, session.agent_type + ), + ]; + + if let Some(worktree) = session.worktree.as_ref() { + lines.push(format!( + "```text\nbranch: {}\nbase: {}\nworktree: {}\n```", + worktree.branch, + worktree.base_branch, + worktree.path.display() + )); + } + + if let Some(compare_url) = compare_url { + lines.push(format!("PR / compare: {compare_url}")); + } + + lines.join("\n") +} + +fn completion_summary_webhook_body( + summary: &SessionCompletionSummary, + session: &Session, + compare_url: Option<&str>, +) -> String { + let mut lines = vec![ + format!("*{}*", summary.title()), + format!( + "`{}` {}", + format_session_id(&summary.session_id), + truncate_for_dashboard(&summary.task, 96) + ), + format!( + "Project `{}` | Group `{}` | State `{}`", + session.project, session.task_group, session.state + ), + format!( + "Duration `{}` | Files `{}` | Tokens `{}` | Cost `{}`", + format_duration(summary.duration_secs), + summary.files_changed, + format_token_count(summary.tokens_used), + format_currency(summary.cost_usd) + ), + if summary.tests_run > 0 { + format!( + "Tests `{}` run / `{}` passed", + summary.tests_run, summary.tests_passed + ) + } else { + "Tests `not detected`".to_string() + }, + ]; + + if !summary.recent_files.is_empty() { + lines.push(markdown_code_block("Recent files", &summary.recent_files)); + } + + if !summary.key_decisions.is_empty() { + lines.push(markdown_code_block("Key decisions", &summary.key_decisions)); + } + + if !summary.warnings.is_empty() { + lines.push(markdown_code_block("Warnings", &summary.warnings)); + } + + if let Some(compare_url) = compare_url { + lines.push(format!("PR / compare: {compare_url}")); + } + + lines.join("\n") +} + +fn budget_alert_webhook_body( + summary_suffix: &str, + token_budget: &str, + cost_budget: &str, + active_sessions: usize, +) -> String { + [ + "*ECC 2.0: Budget alert*".to_string(), + summary_suffix.to_string(), + format!("Tokens `{token_budget}`"), + format!("Cost `{cost_budget}`"), + format!("Active sessions `{active_sessions}`"), + ] + .join("\n") +} + +fn approval_request_webhook_body(message: &SessionMessage, preview: &str) -> String { + [ + "*ECC 2.0: Approval needed*".to_string(), + format!( + "To `{}` from `{}`", + format_session_id(&message.to_session), + format_session_id(&message.from_session) + ), + format!("Type `{}`", message.msg_type), + markdown_code_block("Request", &[preview.to_string()]), + ] + .join("\n") +} + +fn markdown_code_block(label: &str, lines: &[String]) -> String { + format!("{label}\n```text\n{}\n```", lines.join("\n")) +} + +fn session_compare_url(session: &Session) -> Option { + session + .worktree + .as_ref() + .and_then(|worktree| worktree::github_compare_url(worktree).ok().flatten()) +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -11838,6 +12019,7 @@ diff --git a/src/lib.rs b/src/lib.rs let selected_session = selected_session.min(sessions.len().saturating_sub(1)); let cfg = Config::default(); let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone()); + let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone()); let last_session_states = sessions .iter() .map(|session| (session.id.clone(), session.state.clone())) @@ -11856,6 +12038,7 @@ diff --git a/src/lib.rs b/src/lib.rs output_store, output_rx, notifier, + webhook_notifier, sessions, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), @@ -11937,6 +12120,7 @@ diff --git a/src/lib.rs b/src/lib.rs auto_create_worktrees: true, auto_merge_ready_worktrees: false, desktop_notifications: crate::notifications::DesktopNotificationConfig::default(), + webhook_notifications: crate::notifications::WebhookNotificationConfig::default(), completion_summary_notifications: crate::notifications::CompletionSummaryConfig::default(), cost_budget_usd: 10.0, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index caab2466..3173662f 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -373,6 +373,20 @@ pub fn create_draft_pr(worktree: &WorktreeInfo, title: &str, body: &str) -> Resu create_draft_pr_with_gh(worktree, title, body, Path::new("gh")) } +pub fn github_compare_url(worktree: &WorktreeInfo) -> Result> { + let repo_root = base_checkout_path(worktree)?; + let origin = git_remote_origin_url(&repo_root)?; + let Some(repo_url) = github_repo_web_url(&origin) else { + return Ok(None); + }; + + Ok(Some(format!( + "{repo_url}/compare/{}...{}?expand=1", + percent_encode_git_ref(&worktree.base_branch), + percent_encode_git_ref(&worktree.branch) + ))) +} + fn create_draft_pr_with_gh( worktree: &WorktreeInfo, title: &str, @@ -418,6 +432,67 @@ fn create_draft_pr_with_gh( Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +fn git_remote_origin_url(repo_root: &Path) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["remote", "get-url", "origin"]) + .output() + .context("Failed to resolve git origin remote")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git remote get-url origin failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn github_repo_web_url(origin: &str) -> Option { + let trimmed = origin.trim().trim_end_matches(".git"); + if trimmed.is_empty() { + return None; + } + + if let Some(rest) = trimmed.strip_prefix("git@") { + let (host, path) = rest.split_once(':')?; + return Some(format!("https://{host}/{}", path.trim_start_matches('/'))); + } + + if let Some(rest) = trimmed.strip_prefix("ssh://") { + return parse_httpish_remote(rest); + } + + if let Some(rest) = trimmed.strip_prefix("https://") { + return parse_httpish_remote(rest); + } + + if let Some(rest) = trimmed.strip_prefix("http://") { + return parse_httpish_remote(rest); + } + + None +} + +fn parse_httpish_remote(rest: &str) -> Option { + let without_user = rest.strip_prefix("git@").unwrap_or(rest); + let (host, path) = without_user.split_once('/')?; + Some(format!("https://{host}/{}", path.trim_start_matches('/'))) +} + +fn percent_encode_git_ref(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + let ch = byte as char; + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~') { + encoded.push(ch); + } else { + encoded.push('%'); + encoded.push_str(&format!("{byte:02X}")); + } + } + encoded +} + pub fn diff_file_preview(worktree: &WorktreeInfo, limit: usize) -> Result> { let mut preview = Vec::new(); let base_ref = format!("{}...HEAD", worktree.base_branch); @@ -1730,6 +1805,47 @@ mod tests { Ok(()) } + #[test] + fn github_compare_url_uses_origin_remote_and_encodes_refs() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-compare-url-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + run_git( + &repo, + &["remote", "add", "origin", "git@github.com:example/ecc.git"], + )?; + + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "ecc/worker-123".to_string(), + base_branch: "main".to_string(), + }; + + let url = github_compare_url(&worktree)?.expect("compare url"); + assert_eq!( + url, + "https://github.com/example/ecc/compare/main...ecc%2Fworker-123?expand=1" + ); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn github_repo_web_url_supports_multiple_remote_formats() { + assert_eq!( + github_repo_web_url("git@github.com:example/ecc.git").as_deref(), + Some("https://github.com/example/ecc") + ); + assert_eq!( + github_repo_web_url("https://github.example.com/org/repo.git").as_deref(), + Some("https://github.example.com/org/repo") + ); + assert_eq!( + github_repo_web_url("ssh://git@github.example.com/org/repo.git").as_deref(), + Some("https://github.example.com/org/repo") + ); + } + #[test] fn create_for_session_links_shared_node_modules_cache() -> Result<()> { let root = From 599a9d1e7bd48bc23a12e5386f04f130abe4cebd Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 21:28:33 -0700 Subject: [PATCH 113/459] feat: auto-rebase blocked merge queue worktrees --- ecc2/src/main.rs | 77 +++++++- ecc2/src/session/daemon.rs | 9 + ecc2/src/session/manager.rs | 344 ++++++++++++++++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 11 ++ ecc2/src/worktree/mod.rs | 206 +++++++++++++++++++++ 5 files changed, 641 insertions(+), 6 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 8095cfca..ce00a2e6 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -250,6 +250,9 @@ enum Commands { /// Emit machine-readable JSON instead of the human summary #[arg(long)] json: bool, + /// Process the queue, auto-rebasing clean blocked worktrees and merging what becomes ready + #[arg(long)] + apply: bool, }, /// Prune worktrees for inactive sessions and report any active sessions still holding one PruneWorktrees { @@ -844,12 +847,21 @@ async fn main() -> Result<()> { } } } - Some(Commands::MergeQueue { json }) => { - let report = session::manager::build_merge_queue(&db)?; - if json { - println!("{}", serde_json::to_string_pretty(&report)?); + Some(Commands::MergeQueue { json, apply }) => { + if apply { + let outcome = session::manager::process_merge_queue(&db).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcome)?); + } else { + println!("{}", format_bulk_worktree_merge_human(&outcome)); + } } else { - println!("{}", format_merge_queue_human(&report)); + let report = session::manager::build_merge_queue(&db)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_merge_queue_human(&report)); + } } } Some(Commands::PruneWorktrees { json }) => { @@ -1506,6 +1518,26 @@ fn format_bulk_worktree_merge_human( )); } + if !outcome.rebased.is_empty() { + lines.push(format!( + "Rebased {} blocked worktree(s) onto their base branch", + outcome.rebased.len() + )); + for rebased in &outcome.rebased { + lines.push(format!( + "- rebased {} onto {} for {}{}", + rebased.branch, + rebased.base_branch, + short_session(&rebased.session_id), + if rebased.already_up_to_date { + " (already up to date)" + } else { + "" + } + )); + } + } + if !outcome.active_with_worktree_ids.is_empty() { lines.push(format!( "Skipped {} active worktree session(s)", @@ -1524,6 +1556,12 @@ fn format_bulk_worktree_merge_human( outcome.dirty_worktree_ids.len() )); } + if !outcome.blocked_by_queue_session_ids.is_empty() { + lines.push(format!( + "Blocked {} worktree(s) on remaining queue conflicts", + outcome.blocked_by_queue_session_ids.len() + )); + } if !outcome.failures.is_empty() { lines.push(format!( "Encountered {} merge failure(s)", @@ -2613,7 +2651,24 @@ mod tests { .expect("merge-queue --json should parse"); match cli.command { - Some(Commands::MergeQueue { json }) => assert!(json), + Some(Commands::MergeQueue { json, apply }) => { + assert!(json); + assert!(!apply); + } + _ => panic!("expected merge-queue subcommand"), + } + } + + #[test] + fn cli_parses_merge_queue_apply_flag() { + let cli = Cli::try_parse_from(["ecc", "merge-queue", "--apply", "--json"]) + .expect("merge-queue --apply --json should parse"); + + match cli.command { + Some(Commands::MergeQueue { json, apply }) => { + assert!(json); + assert!(apply); + } _ => panic!("expected merge-queue subcommand"), } } @@ -2813,9 +2868,16 @@ mod tests { already_up_to_date: false, cleaned_worktree: true, }], + rebased: vec![session::manager::WorktreeRebaseOutcome { + session_id: "rebased12345678".to_string(), + branch: "ecc/rebased12345678".to_string(), + base_branch: "main".to_string(), + already_up_to_date: false, + }], active_with_worktree_ids: vec!["running12345678".to_string()], conflicted_session_ids: vec!["conflict123456".to_string()], dirty_worktree_ids: vec!["dirty123456789".to_string()], + blocked_by_queue_session_ids: vec!["queue123456789".to_string()], failures: vec![session::manager::WorktreeMergeFailure { session_id: "fail1234567890".to_string(), reason: "base branch not checked out".to_string(), @@ -2824,9 +2886,12 @@ mod tests { assert!(text.contains("Merged 1 ready worktree(s)")); assert!(text.contains("- merged ecc/deadbeefcafefeed -> main for deadbeef")); + assert!(text.contains("Rebased 1 blocked worktree(s) onto their base branch")); + assert!(text.contains("- rebased ecc/rebased12345678 onto main for rebased1")); assert!(text.contains("Skipped 1 active worktree session(s)")); assert!(text.contains("Skipped 1 conflicted worktree(s)")); assert!(text.contains("Skipped 1 dirty worktree(s)")); + assert!(text.contains("Blocked 1 worktree(s) on remaining queue conflicts")); assert!(text.contains("Encountered 1 merge failure(s)")); assert!(text.contains("- failed fail1234: base branch not checked out")); } diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 47f141b8..2f5096fb 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -1202,9 +1202,11 @@ mod tests { invoked_flag.store(true, std::sync::atomic::Ordering::SeqCst); Ok(manager::WorktreeBulkMergeOutcome { merged: Vec::new(), + rebased: Vec::new(), active_with_worktree_ids: Vec::new(), conflicted_session_ids: Vec::new(), dirty_worktree_ids: Vec::new(), + blocked_by_queue_session_ids: Vec::new(), failures: Vec::new(), }) } @@ -1239,9 +1241,16 @@ mod tests { cleaned_worktree: true, }, ], + rebased: vec![manager::WorktreeRebaseOutcome { + session_id: "worker-r".to_string(), + branch: "ecc/worker-r".to_string(), + base_branch: "main".to_string(), + already_up_to_date: false, + }], 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()], + blocked_by_queue_session_ids: vec!["worker-f".to_string()], failures: Vec::new(), }) }) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 64a55e9b..ef96d26b 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -803,6 +803,14 @@ pub struct WorktreeMergeOutcome { pub cleaned_worktree: bool, } +#[derive(Debug, Clone, Serialize)] +pub struct WorktreeRebaseOutcome { + pub session_id: String, + pub branch: String, + pub base_branch: String, + pub already_up_to_date: bool, +} + pub async fn merge_session_worktree( db: &StateStore, id: &str, @@ -841,6 +849,34 @@ pub async fn merge_session_worktree( }) } +pub async fn rebase_session_worktree(db: &StateStore, id: &str) -> Result { + let session = resolve_session(db, id)?; + + if matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + ) { + anyhow::bail!( + "Cannot rebase active session {} while it is {}", + session.id, + session.state + ); + } + + let worktree = session + .worktree + .clone() + .ok_or_else(|| anyhow::anyhow!("Session {} has no attached worktree", session.id))?; + let outcome = crate::worktree::rebase_onto_base(&worktree)?; + + Ok(WorktreeRebaseOutcome { + session_id: session.id, + branch: outcome.branch, + base_branch: outcome.base_branch, + already_up_to_date: outcome.already_up_to_date, + }) +} + #[derive(Debug, Clone, Serialize)] pub struct WorktreeMergeFailure { pub session_id: String, @@ -850,15 +886,110 @@ pub struct WorktreeMergeFailure { #[derive(Debug, Clone, Serialize)] pub struct WorktreeBulkMergeOutcome { pub merged: Vec, + pub rebased: Vec, pub active_with_worktree_ids: Vec, pub conflicted_session_ids: Vec, pub dirty_worktree_ids: Vec, + pub blocked_by_queue_session_ids: Vec, pub failures: Vec, } pub async fn merge_ready_worktrees( db: &StateStore, cleanup_worktree: bool, +) -> Result { + if cleanup_worktree { + return process_merge_queue(db).await; + } + + merge_ready_worktrees_one_pass(db, cleanup_worktree).await +} + +pub async fn process_merge_queue(db: &StateStore) -> Result { + let mut merged = Vec::new(); + let mut rebased = Vec::new(); + let mut failures = Vec::new(); + let mut attempted_rebase_heads = BTreeMap::::new(); + + loop { + let report = build_merge_queue(db)?; + let mut merged_any = false; + + for entry in &report.ready_entries { + match merge_session_worktree(db, &entry.session_id, true).await { + Ok(outcome) => { + merged.push(outcome); + merged_any = true; + } + Err(error) => failures.push(WorktreeMergeFailure { + session_id: entry.session_id.clone(), + reason: error.to_string(), + }), + } + } + + if merged_any { + continue; + } + + let mut rebased_any = false; + for entry in &report.blocked_entries { + if !can_auto_rebase_merge_queue_entry(entry) { + continue; + } + + let session = resolve_session(db, &entry.session_id)?; + let Some(worktree) = session.worktree.clone() else { + continue; + }; + let base_head = crate::worktree::branch_head_oid(&worktree, &worktree.base_branch)?; + if attempted_rebase_heads + .get(&entry.session_id) + .is_some_and(|last_head| last_head == &base_head) + { + continue; + } + attempted_rebase_heads.insert(entry.session_id.clone(), base_head); + + match rebase_session_worktree(db, &entry.session_id).await { + Ok(outcome) => { + rebased.push(outcome); + rebased_any = true; + break; + } + Err(error) => failures.push(WorktreeMergeFailure { + session_id: entry.session_id.clone(), + reason: error.to_string(), + }), + } + } + + if rebased_any { + continue; + } + + let ( + active_with_worktree_ids, + conflicted_session_ids, + dirty_worktree_ids, + blocked_by_queue_session_ids, + ) = classify_merge_queue_report(&report); + + return Ok(WorktreeBulkMergeOutcome { + merged, + rebased, + active_with_worktree_ids, + conflicted_session_ids, + dirty_worktree_ids, + blocked_by_queue_session_ids, + failures, + }); + } +} + +async fn merge_ready_worktrees_one_pass( + db: &StateStore, + cleanup_worktree: bool, ) -> Result { let sessions = db.list_sessions()?; let mut merged = Vec::new(); @@ -926,9 +1057,11 @@ pub async fn merge_ready_worktrees( Ok(WorktreeBulkMergeOutcome { merged, + rebased: Vec::new(), active_with_worktree_ids, conflicted_session_ids, dirty_worktree_ids, + blocked_by_queue_session_ids: Vec::new(), failures, }) } @@ -1170,6 +1303,49 @@ pub fn build_merge_queue(db: &StateStore) -> Result { }) } +fn can_auto_rebase_merge_queue_entry(entry: &MergeQueueEntry) -> bool { + !entry.ready_to_merge + && !entry.dirty + && entry.worktree_health == worktree::WorktreeHealth::Conflicted + && !entry.blocked_by.is_empty() + && entry + .blocked_by + .iter() + .all(|blocker| blocker.session_id == entry.session_id) +} + +fn classify_merge_queue_report( + report: &MergeQueueReport, +) -> (Vec, Vec, Vec, Vec) { + let mut active = Vec::new(); + let mut conflicted = Vec::new(); + let mut dirty = Vec::new(); + let mut queue_blocked = Vec::new(); + + for entry in &report.blocked_entries { + if entry.blocked_by.iter().any(|blocker| { + blocker.session_id == entry.session_id + && matches!( + blocker.state, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale + ) + }) { + active.push(entry.session_id.clone()); + } else if entry.dirty { + dirty.push(entry.session_id.clone()); + } else if entry.worktree_health == worktree::WorktreeHealth::Conflicted { + conflicted.push(entry.session_id.clone()); + } else { + queue_blocked.push(entry.session_id.clone()); + } + } + + (active, conflicted, dirty, queue_blocked) +} + pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { let session = resolve_session(db, id)?; @@ -3235,6 +3411,174 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn process_merge_queue_rebases_blocked_session_and_merges_it() -> Result<()> { + let tempdir = TestDir::new("manager-process-merge-queue-success")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let alpha_worktree = worktree::create_for_session_in_repo("alpha", &cfg, &repo_root)?; + fs::write(alpha_worktree.path.join("README.md"), "hello\nalpha\n")?; + run_git(&alpha_worktree.path, ["commit", "-am", "alpha change"])?; + + let beta_worktree = worktree::create_for_session_in_repo("beta", &cfg, &repo_root)?; + fs::write(beta_worktree.path.join("README.md"), "hello\nalpha\n")?; + run_git(&beta_worktree.path, ["commit", "-am", "beta shared change"])?; + fs::write(beta_worktree.path.join("README.md"), "hello\nalpha\nbeta\n")?; + run_git(&beta_worktree.path, ["commit", "-am", "beta follow-up"])?; + + db.insert_session(&Session { + id: "alpha".to_string(), + task: "alpha merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: alpha_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(alpha_worktree.clone()), + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "beta".to_string(), + task: "beta merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: beta_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(beta_worktree.clone()), + created_at: now - Duration::minutes(1), + updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + })?; + + let queue_before = build_merge_queue(&db)?; + assert_eq!(queue_before.ready_entries.len(), 1); + assert_eq!(queue_before.ready_entries[0].session_id, "alpha"); + assert_eq!(queue_before.blocked_entries.len(), 1); + assert_eq!(queue_before.blocked_entries[0].session_id, "beta"); + + let outcome = process_merge_queue(&db).await?; + + assert_eq!( + outcome + .merged + .iter() + .map(|entry| entry.session_id.as_str()) + .collect::>(), + vec!["alpha", "beta"] + ); + assert_eq!(outcome.rebased.len(), 1); + assert_eq!(outcome.rebased[0].session_id, "beta"); + assert!(outcome.active_with_worktree_ids.is_empty()); + assert!(outcome.conflicted_session_ids.is_empty()); + assert!(outcome.dirty_worktree_ids.is_empty()); + assert!(outcome.blocked_by_queue_session_ids.is_empty()); + assert!(outcome.failures.is_empty()); + assert_eq!( + fs::read_to_string(repo_root.join("README.md"))?, + "hello\nalpha\nbeta\n" + ); + assert!(db + .get_session("alpha")? + .context("alpha should still exist")? + .worktree + .is_none()); + assert!(db + .get_session("beta")? + .context("beta should still exist")? + .worktree + .is_none()); + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn process_merge_queue_records_failed_rebase_and_leaves_blocked_session() -> Result<()> { + let tempdir = TestDir::new("manager-process-merge-queue-fail")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + let alpha_worktree = worktree::create_for_session_in_repo("alpha", &cfg, &repo_root)?; + fs::write(alpha_worktree.path.join("README.md"), "hello\nalpha\n")?; + run_git(&alpha_worktree.path, ["commit", "-am", "alpha change"])?; + + let beta_worktree = worktree::create_for_session_in_repo("beta", &cfg, &repo_root)?; + fs::write(beta_worktree.path.join("README.md"), "hello\nbeta\n")?; + run_git(&beta_worktree.path, ["commit", "-am", "beta change"])?; + + db.insert_session(&Session { + id: "alpha".to_string(), + task: "alpha merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: alpha_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(alpha_worktree.clone()), + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "beta".to_string(), + task: "beta merge".to_string(), + project: "ecc".to_string(), + task_group: "merge".to_string(), + agent_type: "claude".to_string(), + working_dir: beta_worktree.path.clone(), + state: SessionState::Completed, + pid: None, + worktree: Some(beta_worktree.clone()), + created_at: now - Duration::minutes(1), + updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + })?; + + let outcome = process_merge_queue(&db).await?; + + assert_eq!( + outcome + .merged + .iter() + .map(|entry| entry.session_id.as_str()) + .collect::>(), + vec!["alpha"] + ); + assert!(outcome.rebased.is_empty()); + assert_eq!(outcome.conflicted_session_ids, vec!["beta".to_string()]); + assert!(outcome.active_with_worktree_ids.is_empty()); + assert!(outcome.dirty_worktree_ids.is_empty()); + assert!(outcome.blocked_by_queue_session_ids.is_empty()); + assert_eq!(outcome.failures.len(), 1); + assert_eq!(outcome.failures[0].session_id, "beta"); + assert!(outcome.failures[0].reason.contains("git rebase failed")); + assert!(db + .get_session("beta")? + .context("beta should still exist")? + .worktree + .is_some()); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn build_merge_queue_orders_ready_sessions_and_blocks_conflicts() -> Result<()> { let tempdir = TestDir::new("manager-merge-queue")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index ae1aa796..16b6fc2c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -2734,9 +2734,11 @@ impl Dashboard { Ok(outcome) => { self.refresh(); if outcome.merged.is_empty() + && outcome.rebased.is_empty() && outcome.active_with_worktree_ids.is_empty() && outcome.conflicted_session_ids.is_empty() && outcome.dirty_worktree_ids.is_empty() + && outcome.blocked_by_queue_session_ids.is_empty() && outcome.failures.is_empty() { self.set_operator_note("no ready worktrees to merge".to_string()); @@ -2744,6 +2746,9 @@ impl Dashboard { } let mut parts = vec![format!("merged {} ready worktree(s)", outcome.merged.len())]; + if !outcome.rebased.is_empty() { + parts.push(format!("rebased {}", outcome.rebased.len())); + } if !outcome.active_with_worktree_ids.is_empty() { parts.push(format!( "skipped {} active", @@ -2762,6 +2767,12 @@ impl Dashboard { outcome.dirty_worktree_ids.len() )); } + if !outcome.blocked_by_queue_session_ids.is_empty() { + parts.push(format!( + "blocked {} in queue", + outcome.blocked_by_queue_session_ids.len() + )); + } if !outcome.failures.is_empty() { parts.push(format!("{} failed", outcome.failures.len())); } diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 3173662f..6165a559 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -35,6 +35,13 @@ pub struct MergeOutcome { pub already_up_to_date: bool, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct RebaseOutcome { + pub branch: String, + pub base_branch: String, + pub already_up_to_date: bool, +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct BranchConflictPreview { pub left_branch: String, @@ -741,6 +748,65 @@ pub fn merge_into_base(worktree: &WorktreeInfo) -> Result { }) } +pub fn rebase_onto_base(worktree: &WorktreeInfo) -> Result { + if has_uncommitted_changes(worktree)? { + anyhow::bail!( + "Worktree {} has uncommitted changes; commit or discard them before rebasing", + worktree.branch + ); + } + + let repo_root = base_checkout_path(worktree)?; + let before_head = branch_head_oid_in_repo(&repo_root, &worktree.branch)?; + let output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["rebase", &worktree.base_branch]) + .output() + .context("Failed to rebase worktree branch onto base")?; + + if !output.status.success() { + let abort_output = Command::new("git") + .arg("-C") + .arg(&worktree.path) + .args(["rebase", "--abort"]) + .output() + .context("Failed to abort unsuccessful rebase")?; + let abort_warning = if abort_output.status.success() { + String::new() + } else { + format!( + " (rebase abort warning: {})", + String::from_utf8_lossy(&abort_output.stderr).trim() + ) + }; + let stderr = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + anyhow::bail!("git rebase failed: {}{}", stderr.trim(), abort_warning); + } + + let after_head = branch_head_oid_in_repo(&repo_root, &worktree.branch)?; + let rebase_output = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + Ok(RebaseOutcome { + branch: worktree.branch.clone(), + base_branch: worktree.base_branch.clone(), + already_up_to_date: before_head == after_head || rebase_output.contains("up to date"), + }) +} + +pub fn branch_head_oid(worktree: &WorktreeInfo, branch: &str) -> Result { + let repo_root = base_checkout_path(worktree)?; + branch_head_oid_in_repo(&repo_root, branch) +} + fn git_diff_shortstat(worktree_path: &Path, extra_args: &[&str]) -> Result> { let mut command = Command::new("git"); command @@ -1113,6 +1179,22 @@ fn git_status_short(worktree_path: &Path) -> Result> { Ok(parse_nonempty_lines(&output.stdout)) } +fn branch_head_oid_in_repo(repo_root: &Path, branch: &str) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["rev-parse", branch]) + .output() + .context("Failed to resolve branch head")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git rev-parse failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + fn validate_branch_name(repo_root: &Path, branch: &str) -> Result<()> { let output = Command::new("git") .arg("-C") @@ -1567,6 +1649,130 @@ mod tests { Ok(()) } + #[test] + fn rebase_onto_base_replays_simple_branch_after_base_advances() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-worktree-rebase-success-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + + let alpha_dir = root.join("wt-alpha"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/alpha", + alpha_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + fs::write(alpha_dir.join("README.md"), "hello\nalpha\n")?; + run_git(&alpha_dir, &["commit", "-am", "alpha change"])?; + + let beta_dir = root.join("wt-beta"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/beta", + beta_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + fs::write(beta_dir.join("README.md"), "hello\nalpha\n")?; + run_git(&beta_dir, &["commit", "-am", "beta shared change"])?; + fs::write(beta_dir.join("README.md"), "hello\nalpha\nbeta\n")?; + run_git(&beta_dir, &["commit", "-am", "beta follow-up"])?; + + run_git(&repo, &["merge", "--no-edit", "ecc/alpha"])?; + + let beta = WorktreeInfo { + path: beta_dir.clone(), + branch: "ecc/beta".to_string(), + base_branch: "main".to_string(), + }; + let readiness_before = merge_readiness(&beta)?; + assert_eq!(readiness_before.status, MergeReadinessStatus::Conflicted); + + let outcome = rebase_onto_base(&beta)?; + assert_eq!(outcome.branch, "ecc/beta"); + assert_eq!(outcome.base_branch, "main"); + assert!(!outcome.already_up_to_date); + + let readiness_after = merge_readiness(&beta)?; + assert_eq!(readiness_after.status, MergeReadinessStatus::Ready); + assert_eq!( + fs::read_to_string(beta_dir.join("README.md"))?, + "hello\nalpha\nbeta\n" + ); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&alpha_dir) + .output(); + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&beta_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn rebase_onto_base_aborts_failed_rebase() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-worktree-rebase-fail-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + + let worktree_dir = root.join("wt-conflict"); + run_git( + &repo, + &[ + "worktree", + "add", + "-b", + "ecc/conflict", + worktree_dir.to_str().expect("utf8 path"), + "HEAD", + ], + )?; + + fs::write(worktree_dir.join("README.md"), "hello\nbranch\n")?; + run_git(&worktree_dir, &["commit", "-am", "branch change"])?; + fs::write(repo.join("README.md"), "hello\nmain\n")?; + run_git(&repo, &["commit", "-am", "main change"])?; + + let info = WorktreeInfo { + path: worktree_dir.clone(), + branch: "ecc/conflict".to_string(), + base_branch: "main".to_string(), + }; + + let error = rebase_onto_base(&info).expect_err("rebase should fail"); + assert!(error.to_string().contains("git rebase failed")); + assert!(git_status_short(&worktree_dir)?.is_empty()); + assert_eq!( + merge_readiness(&info)?.status, + MergeReadinessStatus::Conflicted + ); + + let _ = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["worktree", "remove", "--force"]) + .arg(&worktree_dir) + .output(); + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> { let root = std::env::temp_dir().join(format!( From 8936d09951977f00f3fb09b3ca5aad7f52ab1cda Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 21:41:07 -0700 Subject: [PATCH 114/459] feat: add ecc2 hunk-level git patch actions --- ecc2/src/tui/dashboard.rs | 403 ++++++++++++++++++++++++++++++++++--- ecc2/src/worktree/mod.rs | 404 +++++++++++++++++++++++++++++++++++++- 2 files changed, 783 insertions(+), 24 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 16b6fc2c..05d4a311 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -110,6 +110,10 @@ pub struct Dashboard { selected_merge_readiness: Option, selected_git_status_entries: Vec, selected_git_status: usize, + selected_git_patch: Option, + selected_git_patch_hunk_offsets_unified: Vec, + selected_git_patch_hunk_offsets_split: Vec, + selected_git_patch_hunk: usize, output_mode: OutputMode, output_filter: OutputFilter, output_time_filter: OutputTimeFilter, @@ -179,6 +183,7 @@ enum OutputMode { WorktreeDiff, ConflictProtocol, GitStatus, + GitPatch, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -498,6 +503,10 @@ impl Dashboard { selected_merge_readiness: None, selected_git_status_entries: Vec::new(), selected_git_status: 0, + selected_git_patch: None, + selected_git_patch_hunk_offsets_unified: Vec::new(), + selected_git_patch_hunk_offsets_split: Vec::new(), + selected_git_patch_hunk: 0, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, @@ -743,8 +752,11 @@ impl Dashboard { self.sync_output_scroll(area.height.saturating_sub(2) as usize); if self.sessions.get(self.selected_session).is_some() - && self.output_mode == OutputMode::WorktreeDiff - && self.selected_diff_patch.is_some() + && matches!( + self.output_mode, + OutputMode::WorktreeDiff | OutputMode::GitPatch + ) + && self.active_patch_text().is_some() && self.diff_view_mode == DiffViewMode::Split { self.render_split_diff_output(frame, area); @@ -798,6 +810,16 @@ impl Dashboard { }; (self.output_title(), content) } + OutputMode::GitPatch => { + let content = if let Some(patch) = self.selected_git_patch.as_ref() { + build_unified_diff_text(&patch.patch, self.theme_palette()) + } else { + Text::from( + "No selected-file patch available for the current git-status entry.", + ) + }; + (self.output_title(), content) + } OutputMode::ConflictProtocol => { let content = self.selected_conflict_protocol.clone().unwrap_or_else(|| { "No conflicted worktree available for the selected session.".to_string() @@ -843,7 +865,7 @@ impl Dashboard { return; } - let Some(patch) = self.selected_diff_patch.as_ref() else { + let Some(patch) = self.active_patch_text() else { return; }; let columns = build_worktree_diff_columns(patch, self.theme_palette()); @@ -883,6 +905,20 @@ impl Dashboard { ); } + if self.output_mode == OutputMode::GitPatch { + let path = self + .selected_git_patch + .as_ref() + .map(|patch| patch.display_path.as_str()) + .unwrap_or("selected file"); + return format!( + " Git patch {}{}{} ", + path, + self.diff_view_mode.title_suffix(), + self.diff_hunk_title_suffix() + ); + } + if self.output_mode == OutputMode::GitStatus { let staged = self .selected_git_status_entries @@ -1175,7 +1211,7 @@ impl Dashboard { fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let base_text = format!( - " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] [v]iew diff git status [z] stage [S] unstage [U] reset [R] commit [C] create PR [P] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", + " [n]ew session natural spawn [N] [a]ssign re[b]alance global re[B]alance dra[i]n inbox approval jump [I] [g]lobal dispatch coordinate [G]lobal collapse pane [h] restore panes [H] timeline [y] timeline filter [E] file patch [v] git status [z] stage [S] unstage [U] reset [R] commit [C] create PR [P] diff mode [V] hunks [{{/}}] conflict proto[c]ol cont[e]nt filter time [f]ilter scope [A] agent filter [o] [m]erge merge ready [M] auto-worktree [t] auto-merge [w] toggle [p]olicy [,/.] dispatch limit [s]top [u]resume [x]cleanup prune inactive [X] [d]elete [r]efresh [{}] focus pane [Tab] cycle pane [{}] move pane [j/k] scroll delegate [ or ] [Enter] open [+/-] resize [l]ayout {} [T]heme {} [?] help [q]uit ", self.pane_focus_shortcuts_label(), self.pane_move_shortcuts_label(), self.layout_label(), @@ -1303,11 +1339,12 @@ impl Dashboard { " H Restore all collapsed panes".to_string(), " y Toggle selected-session timeline view".to_string(), " E Cycle timeline event filter".to_string(), - " v Toggle selected worktree diff in output pane".to_string(), + " v Toggle selected worktree diff or selected-file patch in output pane" + .to_string(), " z Toggle selected worktree git status in output pane".to_string(), " V Toggle diff view mode between split and unified".to_string(), " {/} Jump to previous/next diff hunk in the active diff view".to_string(), - " S/U/R Stage, unstage, or reset the selected git-status entry".to_string(), + " S/U/R Stage, unstage, or reset the selected file or active diff hunk".to_string(), " C Commit staged changes for the selected worktree".to_string(), " P Create a draft PR from the selected worktree branch".to_string(), " c Show conflict-resolution protocol for selected conflicted worktree" @@ -2037,16 +2074,31 @@ impl Dashboard { self.set_operator_note("showing session output".to_string()); } OutputMode::GitStatus => { - self.output_mode = OutputMode::SessionOutput; - self.reset_output_view(); - self.set_operator_note("showing session output".to_string()); + self.sync_selected_git_patch(); + if self.selected_git_patch.is_some() { + self.output_mode = OutputMode::GitPatch; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = self.current_diff_hunk_offset(); + self.set_operator_note("showing selected file patch".to_string()); + } else { + self.set_operator_note( + "no patch hunks available for the selected git-status entry".to_string(), + ); + } + } + OutputMode::GitPatch => { + self.output_mode = OutputMode::GitStatus; + self.output_follow = false; + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note("showing selected worktree git status".to_string()); } } } pub fn toggle_git_status_mode(&mut self) { match self.output_mode { - OutputMode::GitStatus => { + OutputMode::GitStatus | OutputMode::GitPatch => { self.output_mode = OutputMode::SessionOutput; self.reset_output_view(); self.set_operator_note("showing session output".to_string()); @@ -2073,6 +2125,11 @@ impl Dashboard { } pub fn stage_selected_git_status(&mut self) { + if self.output_mode == OutputMode::GitPatch { + self.stage_selected_git_hunk(); + return; + } + if self.output_mode != OutputMode::GitStatus { self.set_operator_note( "git staging controls are only available in git status view".to_string(), @@ -2096,6 +2153,11 @@ impl Dashboard { } pub fn unstage_selected_git_status(&mut self) { + if self.output_mode == OutputMode::GitPatch { + self.unstage_selected_git_hunk(); + return; + } + if self.output_mode != OutputMode::GitStatus { self.set_operator_note( "git staging controls are only available in git status view".to_string(), @@ -2122,6 +2184,11 @@ impl Dashboard { } pub fn reset_selected_git_status(&mut self) { + if self.output_mode == OutputMode::GitPatch { + self.reset_selected_git_hunk(); + return; + } + if self.output_mode != OutputMode::GitStatus { self.set_operator_note( "git staging controls are only available in git status view".to_string(), @@ -2145,7 +2212,10 @@ impl Dashboard { } pub fn begin_commit_prompt(&mut self) { - if self.output_mode != OutputMode::GitStatus { + if !matches!( + self.output_mode, + OutputMode::GitStatus | OutputMode::GitPatch + ) { self.set_operator_note( "commit prompt is only available in git status view".to_string(), ); @@ -2199,8 +2269,69 @@ impl Dashboard { self.set_operator_note("pr mode | edit the title and press Enter".to_string()); } + fn stage_selected_git_hunk(&mut self) { + let Some((entry, worktree, _, hunk)) = self.selected_git_patch_context() else { + self.set_operator_note("no git hunk selected".to_string()); + return; + }; + + if let Err(error) = worktree::stage_hunk(&worktree, &hunk) { + tracing::warn!("Failed to stage hunk for {}: {error}", entry.path); + self.set_operator_note(format!( + "stage hunk failed for {}: {error}", + entry.display_path + )); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("staged hunk in {}", entry.display_path)); + } + + fn unstage_selected_git_hunk(&mut self) { + let Some((entry, worktree, _, hunk)) = self.selected_git_patch_context() else { + self.set_operator_note("no git hunk selected".to_string()); + return; + }; + + if let Err(error) = worktree::unstage_hunk(&worktree, &hunk) { + tracing::warn!("Failed to unstage hunk for {}: {error}", entry.path); + self.set_operator_note(format!( + "unstage hunk failed for {}: {error}", + entry.display_path + )); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("unstaged hunk in {}", entry.display_path)); + } + + fn reset_selected_git_hunk(&mut self) { + let Some((entry, worktree, _, hunk)) = self.selected_git_patch_context() else { + self.set_operator_note("no git hunk selected".to_string()); + return; + }; + + if let Err(error) = worktree::reset_hunk(&worktree, &entry, &hunk) { + tracing::warn!("Failed to reset hunk for {}: {error}", entry.path); + self.set_operator_note(format!( + "reset hunk failed for {}: {error}", + entry.display_path + )); + return; + } + + self.refresh_after_git_status_action(Some(&entry.path)); + self.set_operator_note(format!("reset hunk in {}", entry.display_path)); + } + pub fn toggle_diff_view_mode(&mut self) { - if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { + if !matches!( + self.output_mode, + OutputMode::WorktreeDiff | OutputMode::GitPatch + ) || self.active_patch_text().is_none() + { self.set_operator_note("no active worktree diff view to toggle".to_string()); return; } @@ -2223,7 +2354,11 @@ impl Dashboard { } fn move_diff_hunk(&mut self, delta: isize) { - if self.output_mode != OutputMode::WorktreeDiff || self.selected_diff_patch.is_none() { + if !matches!( + self.output_mode, + OutputMode::WorktreeDiff | OutputMode::GitPatch + ) || self.active_patch_text().is_none() + { self.set_operator_note("no active worktree diff to navigate".to_string()); return; } @@ -2236,12 +2371,14 @@ impl Dashboard { } let len = offsets.len(); - let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize; + let next = + (self.current_diff_hunk_index() as isize + delta).rem_euclid(len as isize) as usize; (len, offsets[next]) }; - let next = (self.selected_diff_hunk as isize + delta).rem_euclid(len as isize) as usize; - self.selected_diff_hunk = next; + let next = + (self.current_diff_hunk_index() as isize + delta).rem_euclid(len as isize) as usize; + self.set_current_diff_hunk_index(next); self.output_follow = false; self.output_scroll_offset = next_offset; self.set_operator_note(format!("diff hunk {}/{}", next + 1, len)); @@ -4136,6 +4273,7 @@ impl Dashboard { self.output_mode = OutputMode::SessionOutput; } self.sync_selected_git_status(); + self.sync_selected_git_patch(); } fn sync_selected_git_status(&mut self) { @@ -4147,11 +4285,50 @@ impl Dashboard { if self.selected_git_status >= self.selected_git_status_entries.len() { self.selected_git_status = self.selected_git_status_entries.len().saturating_sub(1); } - if self.output_mode == OutputMode::GitStatus && worktree.is_none() { + if matches!( + self.output_mode, + OutputMode::GitStatus | OutputMode::GitPatch + ) && worktree.is_none() + { self.output_mode = OutputMode::SessionOutput; } } + fn sync_selected_git_patch(&mut self) { + let Some((entry, worktree)) = self.selected_git_status_context() else { + self.selected_git_patch = None; + self.selected_git_patch_hunk_offsets_unified.clear(); + self.selected_git_patch_hunk_offsets_split.clear(); + self.selected_git_patch_hunk = 0; + if self.output_mode == OutputMode::GitPatch { + self.output_mode = OutputMode::GitStatus; + } + return; + }; + + self.selected_git_patch = worktree::git_status_patch_view(&worktree, &entry) + .ok() + .flatten(); + self.selected_git_patch_hunk_offsets_unified = self + .selected_git_patch + .as_ref() + .map(|patch| build_unified_diff_hunk_offsets(&patch.patch)) + .unwrap_or_default(); + self.selected_git_patch_hunk_offsets_split = self + .selected_git_patch + .as_ref() + .map(|patch| { + build_worktree_diff_columns(&patch.patch, self.theme_palette()).hunk_offsets + }) + .unwrap_or_default(); + if self.selected_git_patch_hunk >= self.current_diff_hunk_offsets().len() { + self.selected_git_patch_hunk = 0; + } + if self.output_mode == OutputMode::GitPatch && self.selected_git_patch.is_none() { + self.output_mode = OutputMode::GitStatus; + } + } + fn selected_git_status_context( &self, ) -> Option<(worktree::GitStatusEntry, crate::session::WorktreeInfo)> { @@ -4164,9 +4341,24 @@ impl Dashboard { Some((entry, worktree)) } + fn selected_git_patch_context( + &self, + ) -> Option<( + worktree::GitStatusEntry, + crate::session::WorktreeInfo, + worktree::GitStatusPatchView, + worktree::GitPatchHunk, + )> { + let (entry, worktree) = self.selected_git_status_context()?; + let patch = self.selected_git_patch.clone()?; + let hunk = patch.hunks.get(self.selected_git_patch_hunk).cloned()?; + Some((entry, worktree, patch, hunk)) + } + fn refresh_after_git_status_action(&mut self, preferred_path: Option<&str>) { + let keep_patch_view = self.output_mode == OutputMode::GitPatch; + let preferred_hunk = self.selected_git_patch_hunk; self.refresh(); - self.output_mode = OutputMode::GitStatus; self.selected_pane = Pane::Output; self.output_follow = false; if let Some(path) = preferred_path { @@ -4178,19 +4370,56 @@ impl Dashboard { self.selected_git_status = index; } } + self.sync_selected_git_patch(); + if keep_patch_view && self.selected_git_patch.is_some() { + self.output_mode = OutputMode::GitPatch; + let max_index = self.current_diff_hunk_offsets().len().saturating_sub(1); + self.selected_git_patch_hunk = preferred_hunk.min(max_index); + self.output_scroll_offset = self.current_diff_hunk_offset(); + } else { + self.output_mode = OutputMode::GitStatus; + } self.sync_output_scroll(self.last_output_height.max(1)); } + fn active_patch_text(&self) -> Option<&String> { + match self.output_mode { + OutputMode::GitPatch => self.selected_git_patch.as_ref().map(|patch| &patch.patch), + OutputMode::WorktreeDiff => self.selected_diff_patch.as_ref(), + _ => None, + } + } + fn current_diff_hunk_offsets(&self) -> &[usize] { - match self.diff_view_mode { - DiffViewMode::Split => &self.selected_diff_hunk_offsets_split, - DiffViewMode::Unified => &self.selected_diff_hunk_offsets_unified, + match self.output_mode { + OutputMode::GitPatch => match self.diff_view_mode { + DiffViewMode::Split => &self.selected_git_patch_hunk_offsets_split, + DiffViewMode::Unified => &self.selected_git_patch_hunk_offsets_unified, + }, + _ => match self.diff_view_mode { + DiffViewMode::Split => &self.selected_diff_hunk_offsets_split, + DiffViewMode::Unified => &self.selected_diff_hunk_offsets_unified, + }, + } + } + + fn current_diff_hunk_index(&self) -> usize { + match self.output_mode { + OutputMode::GitPatch => self.selected_git_patch_hunk, + _ => self.selected_diff_hunk, + } + } + + fn set_current_diff_hunk_index(&mut self, index: usize) { + match self.output_mode { + OutputMode::GitPatch => self.selected_git_patch_hunk = index, + _ => self.selected_diff_hunk = index, } } fn current_diff_hunk_offset(&self) -> usize { self.current_diff_hunk_offsets() - .get(self.selected_diff_hunk) + .get(self.current_diff_hunk_index()) .copied() .unwrap_or(0) } @@ -4200,7 +4429,7 @@ impl Dashboard { if total == 0 { String::new() } else { - format!(" {}/{}", self.selected_diff_hunk + 1, total) + format!(" {}/{}", self.current_diff_hunk_index() + 1, total) } } @@ -4854,6 +5083,13 @@ impl Dashboard { fn max_output_scroll(&self) -> usize { let total_lines = if self.output_mode == OutputMode::GitStatus { self.selected_git_status_entries.len() + } else if matches!( + self.output_mode, + OutputMode::WorktreeDiff | OutputMode::GitPatch + ) { + self.active_patch_text() + .map(|patch| patch.lines().count()) + .unwrap_or(0) } else if self.output_mode == OutputMode::Timeline { self.visible_timeline_lines().len() } else { @@ -8076,6 +8312,111 @@ mod tests { Ok(()) } + #[test] + fn toggle_output_mode_from_git_status_opens_selected_file_patch() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-git-patch-view-{}", Uuid::new_v4())); + init_git_repo(&root)?; + fs::write( + root.join("README.md"), + "line 1\nline 2\nline 3\nline 4\nline 5\nline 6 updated\n", + )?; + + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.working_dir = root.clone(); + session.worktree = Some(WorktreeInfo { + path: root.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }); + let mut dashboard = test_dashboard(vec![session], 0); + let stored = dashboard.sessions[0].clone(); + dashboard.db.insert_session(&stored)?; + + dashboard.toggle_git_status_mode(); + dashboard.toggle_output_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::GitPatch); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected file patch") + ); + assert!(dashboard.output_title().contains("Git patch README.md")); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Git patch README.md")); + assert!(rendered.contains("+line 6 updated")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn git_patch_mode_stages_only_selected_hunk() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-git-patch-stage-{}", Uuid::new_v4())); + init_git_repo(&root)?; + let original = (1..=12) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + fs::write(root.join("notes.txt"), format!("{original}\n"))?; + run_git(&root, &["add", "notes.txt"])?; + run_git(&root, &["commit", "-qm", "add notes"])?; + + let updated = (1..=12) + .map(|index| match index { + 2 => "line 2 changed".to_string(), + 11 => "line 11 changed".to_string(), + _ => format!("line {index}"), + }) + .collect::>() + .join("\n"); + fs::write(root.join("notes.txt"), format!("{updated}\n"))?; + + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.working_dir = root.clone(); + session.worktree = Some(WorktreeInfo { + path: root.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }); + let mut dashboard = test_dashboard(vec![session], 0); + let stored = dashboard.sessions[0].clone(); + dashboard.db.insert_session(&stored)?; + + dashboard.toggle_git_status_mode(); + dashboard.toggle_output_mode(); + dashboard.stage_selected_git_status(); + + assert_eq!(dashboard.output_mode, OutputMode::GitPatch); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("staged hunk in notes.txt") + ); + let cached = git_stdout(&root, &["diff", "--cached", "--", "notes.txt"])?; + assert!(cached.contains("line 2 changed")); + assert!(!cached.contains("line 11 changed")); + let working = git_stdout(&root, &["diff", "--", "notes.txt"])?; + assert!(!working.contains("line 2 changed")); + assert!(working.contains("line 11 changed")); + assert!(dashboard.output_title().contains("Git patch notes.txt")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn begin_commit_prompt_opens_commit_input_for_staged_entries() { let mut dashboard = test_dashboard( @@ -12078,6 +12419,10 @@ diff --git a/src/lib.rs b/src/lib.rs selected_merge_readiness: None, selected_git_status_entries: Vec::new(), selected_git_status: 0, + selected_git_patch: None, + selected_git_patch_hunk_offsets_unified: Vec::new(), + selected_git_patch_hunk_offsets_split: Vec::new(), + selected_git_patch_hunk: 0, output_mode: OutputMode::SessionOutput, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, @@ -12169,6 +12514,18 @@ diff --git a/src/lib.rs b/src/lib.rs Ok(()) } + fn git_stdout(path: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output()?; + if !output.status.success() { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + fn sample_session( id: &str, agent_type: &str, diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 6165a559..7fe7ff6c 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -2,8 +2,9 @@ use anyhow::{Context, Result}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::fs; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; use crate::config::Config; use crate::session::WorktreeInfo; @@ -63,6 +64,27 @@ pub struct GitStatusEntry { pub conflicted: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitPatchSectionKind { + Staged, + Unstaged, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitPatchHunk { + pub section: GitPatchSectionKind, + pub header: String, + pub patch: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitStatusPatchView { + pub path: String, + pub display_path: String, + pub patch: String, + pub hunks: Vec, +} + /// 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")?; @@ -325,6 +347,104 @@ pub fn reset_path(worktree: &WorktreeInfo, entry: &GitStatusEntry) -> Result<()> } } +pub fn git_status_patch_view( + worktree: &WorktreeInfo, + entry: &GitStatusEntry, +) -> Result> { + if entry.untracked { + return Ok(None); + } + + let staged_patch = + git_diff_patch_text_for_paths(&worktree.path, &["--cached"], &[entry.path.clone()])?; + let unstaged_patch = git_diff_patch_text_for_paths(&worktree.path, &[], &[entry.path.clone()])?; + + let mut sections = Vec::new(); + let mut hunks = Vec::new(); + + if !staged_patch.trim().is_empty() { + sections.push(format!("--- Staged diff ---\n{}", staged_patch.trim_end())); + hunks.extend(extract_patch_hunks( + GitPatchSectionKind::Staged, + &staged_patch, + )); + } + if !unstaged_patch.trim().is_empty() { + sections.push(format!( + "--- Working tree diff ---\n{}", + unstaged_patch.trim_end() + )); + hunks.extend(extract_patch_hunks( + GitPatchSectionKind::Unstaged, + &unstaged_patch, + )); + } + + if sections.is_empty() { + Ok(None) + } else { + Ok(Some(GitStatusPatchView { + path: entry.path.clone(), + display_path: entry.display_path.clone(), + patch: sections.join("\n\n"), + hunks, + })) + } +} + +pub fn stage_hunk(worktree: &WorktreeInfo, hunk: &GitPatchHunk) -> Result<()> { + if hunk.section != GitPatchSectionKind::Unstaged { + anyhow::bail!("selected hunk is already staged"); + } + git_apply_patch( + &worktree.path, + &["--cached"], + &hunk.patch, + "stage selected hunk", + ) +} + +pub fn unstage_hunk(worktree: &WorktreeInfo, hunk: &GitPatchHunk) -> Result<()> { + if hunk.section != GitPatchSectionKind::Staged { + anyhow::bail!("selected hunk is not staged"); + } + git_apply_patch( + &worktree.path, + &["-R", "--cached"], + &hunk.patch, + "unstage selected hunk", + ) +} + +pub fn reset_hunk( + worktree: &WorktreeInfo, + entry: &GitStatusEntry, + hunk: &GitPatchHunk, +) -> Result<()> { + if entry.untracked { + anyhow::bail!("cannot reset hunks for untracked files"); + } + + match hunk.section { + GitPatchSectionKind::Unstaged => { + git_apply_patch(&worktree.path, &["-R"], &hunk.patch, "reset selected hunk") + } + GitPatchSectionKind::Staged => { + if entry.unstaged { + anyhow::bail!( + "cannot reset a staged hunk while the file also has unstaged changes; unstage it first" + ); + } + git_apply_patch( + &worktree.path, + &["-R", "--index"], + &hunk.patch, + "reset selected staged hunk", + ) + } + } +} + pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result { let message = message.trim(); if message.is_empty() { @@ -887,6 +1007,39 @@ fn git_diff_patch_lines(worktree_path: &Path, extra_args: &[&str]) -> Result Result { + if paths.is_empty() { + return Ok(String::new()); + } + + let mut command = Command::new("git"); + command + .arg("-C") + .arg(worktree_path) + .arg("diff") + .args(["--patch", "--find-renames"]); + command.args(extra_args); + command.arg("--"); + for path in paths { + command.arg(path); + } + + let output = command + .output() + .context("Failed to generate filtered git patch")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git diff failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + fn git_diff_patch_lines_for_paths( worktree_path: &Path, extra_args: &[&str], @@ -924,6 +1077,86 @@ fn git_diff_patch_lines_for_paths( Ok(parse_nonempty_lines(&output.stdout)) } +fn extract_patch_hunks(section: GitPatchSectionKind, patch_text: &str) -> Vec { + let lines: Vec<&str> = patch_text.lines().collect(); + let Some(diff_start) = lines + .iter() + .position(|line| line.starts_with("diff --git ")) + else { + return Vec::new(); + }; + let Some(first_hunk_start) = lines + .iter() + .enumerate() + .skip(diff_start) + .find_map(|(index, line)| line.starts_with("@@").then_some(index)) + else { + return Vec::new(); + }; + + let header_lines = lines[diff_start..first_hunk_start].to_vec(); + let hunk_starts = lines + .iter() + .enumerate() + .skip(first_hunk_start) + .filter_map(|(index, line)| line.starts_with("@@").then_some(index)) + .collect::>(); + + hunk_starts + .iter() + .enumerate() + .map(|(position, start)| { + let end = hunk_starts + .get(position + 1) + .copied() + .unwrap_or(lines.len()); + let mut patch_lines = header_lines + .iter() + .map(|line| (*line).to_string()) + .collect::>(); + patch_lines.extend(lines[*start..end].iter().map(|line| (*line).to_string())); + GitPatchHunk { + section, + header: lines[*start].to_string(), + patch: format!("{}\n", patch_lines.join("\n")), + } + }) + .collect() +} + +fn git_apply_patch(worktree_path: &Path, args: &[&str], patch: &str, action: &str) -> Result<()> { + let mut child = Command::new("git") + .arg("-C") + .arg(worktree_path) + .arg("apply") + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("Failed to {action}"))?; + + { + let stdin = child + .stdin + .as_mut() + .context("Failed to open git apply stdin")?; + stdin + .write_all(patch.as_bytes()) + .with_context(|| format!("Failed to write patch for {action}"))?; + } + + let output = child + .wait_with_output() + .with_context(|| format!("Failed to wait for git apply while trying to {action}"))?; + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("git apply failed while trying to {action}: {stderr}"); + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SharedDependencyStrategy { label: &'static str, @@ -1364,6 +1597,18 @@ mod tests { Ok(()) } + fn git_stdout(repo: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output()?; + if !output.status.success() { + anyhow::bail!("{}", String::from_utf8_lossy(&output.stderr)); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + fn init_repo(root: &Path) -> Result { let repo = root.join("repo"); fs::create_dir_all(&repo)?; @@ -1917,6 +2162,163 @@ mod tests { Ok(()) } + #[test] + fn git_status_patch_view_supports_hunk_stage_and_unstage() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-hunk-stage-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }; + + let original = (1..=12) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + fs::write(repo.join("notes.txt"), format!("{original}\n"))?; + run_git(&repo, &["add", "notes.txt"])?; + run_git(&repo, &["commit", "-m", "add notes"])?; + + let updated = (1..=12) + .map(|index| match index { + 2 => "line 2 changed".to_string(), + 11 => "line 11 changed".to_string(), + _ => format!("line {index}"), + }) + .collect::>() + .join("\n"); + fs::write(repo.join("notes.txt"), format!("{updated}\n"))?; + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry"); + let patch = + git_status_patch_view(&worktree, &entry)?.expect("selected-file patch view for notes"); + assert_eq!(patch.hunks.len(), 2); + assert!(patch + .hunks + .iter() + .all(|hunk| hunk.section == GitPatchSectionKind::Unstaged)); + + stage_hunk(&worktree, &patch.hunks[0])?; + + let cached = git_stdout(&repo, &["diff", "--cached", "--", "notes.txt"])?; + assert!(cached.contains("line 2 changed")); + assert!(!cached.contains("line 11 changed")); + + let working = git_stdout(&repo, &["diff", "--", "notes.txt"])?; + assert!(!working.contains("line 2 changed")); + assert!(working.contains("line 11 changed")); + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry after stage"); + let patch = git_status_patch_view(&worktree, &entry)?.expect("patch after hunk stage"); + let staged_hunk = patch + .hunks + .iter() + .find(|hunk| hunk.section == GitPatchSectionKind::Staged) + .cloned() + .expect("staged hunk"); + + unstage_hunk(&worktree, &staged_hunk)?; + + let cached = git_stdout(&repo, &["diff", "--cached", "--", "notes.txt"])?; + assert!(cached.trim().is_empty()); + + let working = git_stdout(&repo, &["diff", "--", "notes.txt"])?; + assert!(working.contains("line 2 changed")); + assert!(working.contains("line 11 changed")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + + #[test] + fn reset_hunk_discards_unstaged_then_staged_hunks() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-hunk-reset-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "main".to_string(), + base_branch: "main".to_string(), + }; + + let original = (1..=12) + .map(|index| format!("line {index}")) + .collect::>() + .join("\n"); + fs::write(repo.join("notes.txt"), format!("{original}\n"))?; + run_git(&repo, &["add", "notes.txt"])?; + run_git(&repo, &["commit", "-m", "add notes"])?; + + let updated = (1..=12) + .map(|index| match index { + 2 => "line 2 changed".to_string(), + 11 => "line 11 changed".to_string(), + _ => format!("line {index}"), + }) + .collect::>() + .join("\n"); + fs::write(repo.join("notes.txt"), format!("{updated}\n"))?; + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry"); + let patch = + git_status_patch_view(&worktree, &entry)?.expect("selected-file patch view for notes"); + stage_hunk(&worktree, &patch.hunks[0])?; + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry after stage"); + let patch = git_status_patch_view(&worktree, &entry)?.expect("patch after stage"); + let unstaged_hunk = patch + .hunks + .iter() + .find(|hunk| hunk.section == GitPatchSectionKind::Unstaged) + .cloned() + .expect("unstaged hunk"); + reset_hunk(&worktree, &entry, &unstaged_hunk)?; + + let working = git_stdout(&repo, &["diff", "--", "notes.txt"])?; + assert!(working.trim().is_empty()); + + let entry = git_status_entries(&worktree)? + .into_iter() + .find(|entry| entry.path == "notes.txt") + .expect("notes status entry after unstaged reset"); + assert!(!entry.unstaged); + + let patch = git_status_patch_view(&worktree, &entry)?.expect("staged-only patch"); + let staged_hunk = patch + .hunks + .iter() + .find(|hunk| hunk.section == GitPatchSectionKind::Staged) + .cloned() + .expect("staged hunk"); + reset_hunk(&worktree, &entry, &staged_hunk)?; + + assert!(git_stdout(&repo, &["diff", "--cached", "--", "notes.txt"])? + .trim() + .is_empty()); + assert!(git_stdout(&repo, &["diff", "--", "notes.txt"])? + .trim() + .is_empty()); + assert_eq!( + fs::read_to_string(repo.join("notes.txt"))?, + format!("{original}\n") + ); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn latest_commit_subject_reads_head_subject() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-pr-subject-{}", Uuid::new_v4())); From 913c00c74d1816f8df78f537b0df55879e31467d Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 21:46:26 -0700 Subject: [PATCH 115/459] feat: extend ecc2 draft pr prompt metadata --- ecc2/src/tui/dashboard.rs | 212 ++++++++++++++++++++++++++++++++++++-- ecc2/src/worktree/mod.rs | 127 ++++++++++++++++++++++- 2 files changed, 326 insertions(+), 13 deletions(-) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 05d4a311..4f54e4b6 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -243,6 +243,14 @@ struct SearchMatch { line_index: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct PrPromptSpec { + title: String, + base_branch: Option, + labels: Vec, + reviewers: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TimelineEventType { Lifecycle, @@ -1225,7 +1233,9 @@ impl Dashboard { } else if let Some(input) = self.commit_input.as_ref() { format!(" commit>{input}_ | [Enter] commit [Esc] cancel |") } else if let Some(input) = self.pr_input.as_ref() { - format!(" pr>{input}_ | [Enter] create draft PR [Esc] cancel |") + format!( + " pr>{input}_ | [Enter] create draft PR | title | base=branch | labels=a,b | reviewers=a,b | [Esc] cancel |" + ) } else if let Some(input) = self.search_input.as_ref() { format!( " /{input}_ | {} | {} | [Enter] apply [Esc] cancel |", @@ -1346,7 +1356,7 @@ impl Dashboard { " {/} Jump to previous/next diff hunk in the active diff view".to_string(), " S/U/R Stage, unstage, or reset the selected file or active diff hunk".to_string(), " C Commit staged changes for the selected worktree".to_string(), - " P Create a draft PR from the selected worktree branch".to_string(), + " P Create a draft PR; supports title | base=branch | labels=a,b | reviewers=a,b".to_string(), " c Show conflict-resolution protocol for selected conflicted worktree" .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), @@ -2266,7 +2276,9 @@ impl Dashboard { .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| session.task.clone()); self.pr_input = Some(seed); - self.set_operator_note("pr mode | edit the title and press Enter".to_string()); + self.set_operator_note( + "pr mode | title | base=branch | labels=a,b | reviewers=a,b".to_string(), + ); } fn stage_selected_git_hunk(&mut self) { @@ -3169,8 +3181,16 @@ impl Dashboard { return; }; - let title = input.trim().to_string(); - if title.is_empty() { + let request = match parse_pr_prompt(&input) { + Ok(request) => request, + Err(error) => { + self.pr_input = Some(input); + self.set_operator_note(format!("invalid PR input: {error}")); + return; + } + }; + + if request.title.is_empty() { self.pr_input = Some(input); self.set_operator_note("pr title cannot be empty".to_string()); return; @@ -3193,11 +3213,20 @@ impl Dashboard { } let body = self.build_pull_request_body(&session); - match worktree::create_draft_pr(&worktree, &title, &body) { + let options = worktree::DraftPrOptions { + base_branch: request.base_branch.clone(), + labels: request.labels.clone(), + reviewers: request.reviewers.clone(), + }; + match worktree::create_draft_pr_with_options(&worktree, &request.title, &body, &options) { Ok(url) => { self.set_operator_note(format!( - "created draft PR for {}: {}", + "created draft PR for {} against {}: {}", format_session_id(&session.id), + options + .base_branch + .as_deref() + .unwrap_or(&worktree.base_branch), url )); } @@ -7786,6 +7815,59 @@ fn assignment_action_label(action: manager::AssignmentAction) -> &'static str { } } +fn parse_pr_prompt(input: &str) -> std::result::Result { + let mut segments = input.split('|').map(str::trim); + let title = segments.next().unwrap_or_default().trim().to_string(); + if title.is_empty() { + return Err("missing PR title".to_string()); + } + + let mut request = PrPromptSpec { + title, + base_branch: None, + labels: Vec::new(), + reviewers: Vec::new(), + }; + + for segment in segments { + if segment.is_empty() { + continue; + } + let (key, value) = segment + .split_once('=') + .ok_or_else(|| format!("expected key=value segment, got `{segment}`"))?; + let key = key.trim().to_ascii_lowercase(); + let value = value.trim(); + match key.as_str() { + "base" => { + if value.is_empty() { + return Err("base branch cannot be empty".to_string()); + } + request.base_branch = Some(value.to_string()); + } + "labels" | "label" => { + request.labels = value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + } + "reviewers" | "reviewer" => { + request.reviewers = value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + } + _ => return Err(format!("unsupported PR field `{key}`")), + } + } + + Ok(request) +} + fn delegate_worktree_health_label(health: worktree::WorktreeHealth) -> &'static str { match health { worktree::WorktreeHealth::Clear => "clear", @@ -8481,13 +8563,127 @@ mod tests { assert_eq!(dashboard.pr_input.as_deref(), Some("seed pr title")); assert_eq!( dashboard.operator_note.as_deref(), - Some("pr mode | edit the title and press Enter") + Some("pr mode | title | base=branch | labels=a,b | reviewers=a,b") ); let _ = fs::remove_dir_all(root); Ok(()) } + #[test] + fn parse_pr_prompt_supports_base_labels_and_reviewers() { + let parsed = parse_pr_prompt( + "Improve retry flow | base=release/2.0 | labels=billing, ux | reviewers=alice, bob", + ) + .expect("parse prompt"); + + assert_eq!(parsed.title, "Improve retry flow"); + assert_eq!(parsed.base_branch.as_deref(), Some("release/2.0")); + assert_eq!(parsed.labels, vec!["billing", "ux"]); + assert_eq!(parsed.reviewers, vec!["alice", "bob"]); + } + + #[test] + fn submit_pr_prompt_passes_custom_metadata_to_gh() -> Result<()> { + let temp_root = + std::env::temp_dir().join(format!("ecc2-dashboard-pr-submit-{}", Uuid::new_v4())); + let root = temp_root.join("repo"); + init_git_repo(&root)?; + let remote = temp_root.join("remote.git"); + run_git( + &root, + &["init", "--bare", remote.to_str().expect("utf8 path")], + )?; + run_git( + &root, + &[ + "remote", + "add", + "origin", + remote.to_str().expect("utf8 path"), + ], + )?; + run_git(&root, &["push", "-u", "origin", "main"])?; + run_git(&root, &["checkout", "-b", "feat/dashboard-pr"])?; + fs::write(root.join("README.md"), "dashboard pr\n")?; + run_git(&root, &["commit", "-am", "dashboard pr"])?; + + let bin_dir = temp_root.join("bin"); + fs::create_dir_all(&bin_dir)?; + let gh_path = bin_dir.join("gh"); + let args_path = temp_root.join("gh-dashboard-args.txt"); + fs::write( + &gh_path, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s\\n' 'https://github.com/example/repo/pull/789'\n", + args_path.display() + ), + )?; + let mut perms = fs::metadata(&gh_path)?.permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + perms.set_mode(0o755); + fs::set_permissions(&gh_path, perms)?; + } + #[cfg(not(unix))] + fs::set_permissions(&gh_path, perms)?; + + let original_path = std::env::var_os("PATH"); + std::env::set_var( + "PATH", + format!( + "{}:{}", + bin_dir.display(), + original_path + .as_deref() + .map(std::ffi::OsStr::to_string_lossy) + .unwrap_or_default() + ), + ); + + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + session.working_dir = root.clone(); + session.worktree = Some(WorktreeInfo { + path: root.clone(), + branch: "feat/dashboard-pr".to_string(), + base_branch: "main".to_string(), + }); + let mut dashboard = test_dashboard(vec![session], 0); + dashboard.pr_input = Some( + "Improve retry flow | base=release/2.0 | labels=billing,ux | reviewers=alice,bob" + .to_string(), + ); + + dashboard.submit_pr_prompt(); + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("created draft PR for focus-12 against release/2.0: https://github.com/example/repo/pull/789") + ); + let gh_args = fs::read_to_string(&args_path)?; + assert!(gh_args.contains("--base\nrelease/2.0")); + assert!(gh_args.contains("--label\nbilling")); + assert!(gh_args.contains("--label\nux")); + assert!(gh_args.contains("--reviewer\nalice")); + assert!(gh_args.contains("--reviewer\nbob")); + + if let Some(path) = original_path { + std::env::set_var("PATH", path); + } else { + std::env::remove_var("PATH"); + } + let _ = fs::remove_dir_all(temp_root); + Ok(()) + } + #[test] fn toggle_diff_view_mode_switches_to_unified_rendering() { let mut dashboard = test_dashboard( diff --git a/ecc2/src/worktree/mod.rs b/ecc2/src/worktree/mod.rs index 7fe7ff6c..3d3de924 100644 --- a/ecc2/src/worktree/mod.rs +++ b/ecc2/src/worktree/mod.rs @@ -64,6 +64,13 @@ pub struct GitStatusEntry { pub conflicted: bool, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct DraftPrOptions { + pub base_branch: Option, + pub labels: Vec, + pub reviewers: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GitPatchSectionKind { Staged, @@ -497,7 +504,16 @@ pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result { } pub fn create_draft_pr(worktree: &WorktreeInfo, title: &str, body: &str) -> Result { - create_draft_pr_with_gh(worktree, title, body, Path::new("gh")) + create_draft_pr_with_options(worktree, title, body, &DraftPrOptions::default()) +} + +pub fn create_draft_pr_with_options( + worktree: &WorktreeInfo, + title: &str, + body: &str, + options: &DraftPrOptions, +) -> Result { + create_draft_pr_with_gh(worktree, title, body, options, Path::new("gh")) } pub fn github_compare_url(worktree: &WorktreeInfo) -> Result> { @@ -518,6 +534,7 @@ fn create_draft_pr_with_gh( worktree: &WorktreeInfo, title: &str, body: &str, + options: &DraftPrOptions, gh_bin: &Path, ) -> Result { let title = title.trim(); @@ -525,6 +542,13 @@ fn create_draft_pr_with_gh( anyhow::bail!("PR title cannot be empty"); } + let base_branch = options + .base_branch + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&worktree.base_branch); + let push = Command::new("git") .arg("-C") .arg(&worktree.path) @@ -536,18 +560,36 @@ fn create_draft_pr_with_gh( anyhow::bail!("git push failed: {stderr}"); } - let output = Command::new(gh_bin) + let mut command = Command::new(gh_bin); + command .arg("pr") .arg("create") .arg("--draft") .arg("--base") - .arg(&worktree.base_branch) + .arg(base_branch) .arg("--head") .arg(&worktree.branch) .arg("--title") .arg(title) .arg("--body") - .arg(body) + .arg(body); + for label in options + .labels + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + command.arg("--label").arg(label); + } + for reviewer in options + .reviewers + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + { + command.arg("--reviewer").arg(reviewer); + } + let output = command .current_dir(&worktree.path) .output() .context("Failed to create draft PR with gh")?; @@ -2388,7 +2430,13 @@ mod tests { base_branch: "main".to_string(), }; - let url = create_draft_pr_with_gh(&worktree, "My PR", "Body line", &gh_path)?; + let url = create_draft_pr_with_gh( + &worktree, + "My PR", + "Body line", + &DraftPrOptions::default(), + &gh_path, + )?; assert_eq!(url, "https://github.com/example/repo/pull/123"); let remote_branch = Command::new("git") @@ -2413,6 +2461,75 @@ mod tests { Ok(()) } + #[test] + fn create_draft_pr_forwards_custom_base_labels_and_reviewers() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-pr-create-options-{}", Uuid::new_v4())); + let repo = init_repo(&root)?; + let remote = root.join("remote.git"); + run_git( + &root, + &["init", "--bare", remote.to_str().expect("utf8 path")], + )?; + run_git( + &repo, + &[ + "remote", + "add", + "origin", + remote.to_str().expect("utf8 path"), + ], + )?; + run_git(&repo, &["push", "-u", "origin", "main"])?; + run_git(&repo, &["checkout", "-b", "feat/pr-options"])?; + fs::write(repo.join("README.md"), "pr options\n")?; + run_git(&repo, &["commit", "-am", "pr options"])?; + + let bin_dir = root.join("bin"); + fs::create_dir_all(&bin_dir)?; + let gh_path = bin_dir.join("gh"); + let args_path = root.join("gh-args-options.txt"); + fs::write( + &gh_path, + format!( + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"{}\"\nprintf '%s\\n' 'https://github.com/example/repo/pull/456'\n", + args_path.display() + ), + )?; + let mut perms = fs::metadata(&gh_path)?.permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + perms.set_mode(0o755); + fs::set_permissions(&gh_path, perms)?; + } + #[cfg(not(unix))] + fs::set_permissions(&gh_path, perms)?; + + let worktree = WorktreeInfo { + path: repo.clone(), + branch: "feat/pr-options".to_string(), + base_branch: "main".to_string(), + }; + let options = DraftPrOptions { + base_branch: Some("release/2.0".to_string()), + labels: vec!["billing".to_string(), "ui".to_string()], + reviewers: vec!["alice".to_string(), "bob".to_string()], + }; + + let url = create_draft_pr_with_gh(&worktree, "My PR", "Body line", &options, &gh_path)?; + assert_eq!(url, "https://github.com/example/repo/pull/456"); + + let gh_args = fs::read_to_string(&args_path)?; + assert!(gh_args.contains("--base\nrelease/2.0")); + assert!(gh_args.contains("--label\nbilling")); + assert!(gh_args.contains("--label\nui")); + assert!(gh_args.contains("--reviewer\nalice")); + assert!(gh_args.contains("--reviewer\nbob")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn github_compare_url_uses_origin_remote_and_encodes_refs() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-compare-url-{}", Uuid::new_v4())); From b48a52f9a0a01d539ff095bd8220e3bb102512b5 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 21:57:28 -0700 Subject: [PATCH 116/459] feat: add ecc2 decision log audit trail --- ecc2/src/main.rs | 208 +++++++++++++++++++++++++++++ ecc2/src/session/mod.rs | 10 ++ ecc2/src/session/store.rs | 270 +++++++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 135 ++++++++++++++++++- 4 files changed, 616 insertions(+), 7 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index ce00a2e6..756dadcb 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -260,6 +260,37 @@ enum Commands { #[arg(long)] json: bool, }, + /// Log a significant agent decision for auditability + LogDecision { + /// Session ID or alias. Omit to log against the latest session. + session_id: Option, + /// The chosen decision or direction + #[arg(long)] + decision: String, + /// Why the agent made this choice + #[arg(long)] + reasoning: String, + /// Alternative considered and rejected; repeat for multiple entries + #[arg(long = "alternative")] + alternatives: Vec, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Show recent decision-log entries + Decisions { + /// Session ID or alias. Omit to read the latest session. + session_id: Option, + /// Show decision log entries across all sessions + #[arg(long)] + all: bool, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + /// Maximum decision-log entries to return + #[arg(long, default_value_t = 20)] + limit: usize, + }, /// Export sessions, tool spans, and metrics in OTLP-compatible JSON ExportOtel { /// Session ID or alias. Omit to export all sessions. @@ -872,6 +903,45 @@ async fn main() -> Result<()> { println!("{}", format_prune_worktrees_human(&outcome)); } } + Some(Commands::LogDecision { + session_id, + decision, + reasoning, + alternatives, + json, + }) => { + let resolved_id = resolve_session_id(&db, session_id.as_deref().unwrap_or("latest"))?; + let entry = db.insert_decision(&resolved_id, &decision, &alternatives, &reasoning)?; + if json { + println!("{}", serde_json::to_string_pretty(&entry)?); + } else { + println!("{}", format_logged_decision_human(&entry)); + } + } + Some(Commands::Decisions { + session_id, + all, + json, + limit, + }) => { + if all && session_id.is_some() { + return Err(anyhow::anyhow!( + "decisions does not accept a session ID when --all is set" + )); + } + let entries = if all { + db.list_decisions(limit)? + } else { + let resolved_id = + resolve_session_id(&db, session_id.as_deref().unwrap_or("latest"))?; + db.list_decisions_for_session(&resolved_id, limit)? + }; + if json { + println!("{}", serde_json::to_string_pretty(&entries)?); + } else { + println!("{}", format_decisions_human(&entries, all)); + } + } Some(Commands::ExportOtel { session_id, output }) => { sync_runtime_session_metrics(&db, &cfg)?; let resolved_session_id = session_id @@ -1641,6 +1711,63 @@ fn format_prune_worktrees_human(outcome: &session::manager::WorktreePruneOutcome lines.join("\n") } +fn format_logged_decision_human(entry: &session::DecisionLogEntry) -> String { + let mut lines = vec![ + format!("Logged decision for {}", short_session(&entry.session_id)), + format!("Decision: {}", entry.decision), + format!("Why: {}", entry.reasoning), + ]; + + if entry.alternatives.is_empty() { + lines.push("Alternatives: none recorded".to_string()); + } else { + lines.push("Alternatives:".to_string()); + for alternative in &entry.alternatives { + lines.push(format!("- {alternative}")); + } + } + + lines.push(format!( + "Recorded at: {}", + entry.timestamp.format("%Y-%m-%d %H:%M:%S UTC") + )); + lines.join("\n") +} + +fn format_decisions_human(entries: &[session::DecisionLogEntry], include_session: bool) -> String { + if entries.is_empty() { + return if include_session { + "No decision-log entries across all sessions yet.".to_string() + } else { + "No decision-log entries for this session yet.".to_string() + }; + } + + let mut lines = vec![format!("Decision log: {} entries", entries.len())]; + for entry in entries { + let prefix = if include_session { + format!("{} | ", short_session(&entry.session_id)) + } else { + String::new() + }; + lines.push(format!( + "- [{}] {prefix}{}", + entry.timestamp.format("%H:%M:%S"), + entry.decision + )); + lines.push(format!(" why {}", entry.reasoning)); + if entry.alternatives.is_empty() { + lines.push(" alternatives none recorded".to_string()); + } else { + for alternative in &entry.alternatives { + lines.push(format!(" alternative {alternative}")); + } + } + } + + lines.join("\n") +} + fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> String { let mut lines = Vec::new(); lines.push(format!( @@ -3259,6 +3386,87 @@ mod tests { } } + #[test] + fn cli_parses_log_decision_command() { + let cli = Cli::try_parse_from([ + "ecc", + "log-decision", + "latest", + "--decision", + "Use sqlite", + "--reasoning", + "It is already embedded", + "--alternative", + "json files", + "--alternative", + "memory only", + "--json", + ]) + .expect("log-decision should parse"); + + match cli.command { + Some(Commands::LogDecision { + session_id, + decision, + reasoning, + alternatives, + json, + }) => { + assert_eq!(session_id.as_deref(), Some("latest")); + assert_eq!(decision, "Use sqlite"); + assert_eq!(reasoning, "It is already embedded"); + assert_eq!(alternatives, vec!["json files", "memory only"]); + assert!(json); + } + _ => panic!("expected log-decision subcommand"), + } + } + + #[test] + fn cli_parses_decisions_command() { + let cli = Cli::try_parse_from(["ecc", "decisions", "--all", "--limit", "5", "--json"]) + .expect("decisions should parse"); + + match cli.command { + Some(Commands::Decisions { + session_id, + all, + json, + limit, + }) => { + assert!(session_id.is_none()); + assert!(all); + assert!(json); + assert_eq!(limit, 5); + } + _ => panic!("expected decisions subcommand"), + } + } + + #[test] + fn format_decisions_human_renders_details() { + let text = format_decisions_human( + &[session::DecisionLogEntry { + id: 1, + session_id: "sess-12345678".to_string(), + decision: "Use sqlite for the shared context graph".to_string(), + alternatives: vec!["json files".to_string(), "memory only".to_string()], + reasoning: "SQLite keeps the audit trail queryable.".to_string(), + timestamp: chrono::DateTime::parse_from_rfc3339("2026-04-09T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }], + true, + ); + + assert!(text.contains("Decision log: 1 entries")); + assert!(text.contains("sess-123")); + assert!(text.contains("Use sqlite for the shared context graph")); + assert!(text.contains("why SQLite keeps the audit trail queryable.")); + assert!(text.contains("alternative json files")); + assert!(text.contains("alternative memory only")); + } + #[test] fn cli_parses_coordination_status_json_flag() { let cli = Cli::try_parse_from(["ecc", "coordination-status", "--json"]) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index babf5d5d..b66f6ee0 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -142,6 +142,16 @@ pub struct FileActivityEntry { pub timestamp: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DecisionLogEntry { + pub id: i64, + pub session_id: String, + pub decision: String, + pub alternatives: Vec, + pub reasoning: String, + pub timestamp: DateTime, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FileActivityAction { diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 4f68ad2f..af5ff0e2 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -13,8 +13,9 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ - default_project_label, default_task_group_label, normalize_group_label, FileActivityAction, - FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, WorktreeInfo, + default_project_label, default_task_group_label, normalize_group_label, DecisionLogEntry, + FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, + WorktreeInfo, }; pub struct StateStore { @@ -193,6 +194,15 @@ impl StateStore { timestamp TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS decision_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + decision TEXT NOT NULL, + alternatives_json TEXT NOT NULL DEFAULT '[]', + reasoning TEXT NOT NULL, + timestamp TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_worktree_queue ( session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, repo_root TEXT NOT NULL, @@ -225,12 +235,11 @@ impl StateStore { CREATE INDEX IF NOT EXISTS idx_sessions_state ON sessions(state); CREATE INDEX IF NOT EXISTS idx_tool_log_session ON tool_log(session_id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_log_hook_event - ON tool_log(hook_event_id) - WHERE hook_event_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session, read); CREATE INDEX IF NOT EXISTS idx_session_output_session ON session_output(session_id, id); + CREATE INDEX IF NOT EXISTS idx_decision_log_session + ON decision_log(session_id, timestamp, id); CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at ON pending_worktree_queue(requested_at, session_id); @@ -1423,6 +1432,84 @@ impl StateStore { .map_err(Into::into) } + pub fn insert_decision( + &self, + session_id: &str, + decision: &str, + alternatives: &[String], + reasoning: &str, + ) -> Result { + let timestamp = chrono::Utc::now(); + let alternatives_json = serde_json::to_string(alternatives) + .context("Failed to serialize decision alternatives")?; + + self.conn.execute( + "INSERT INTO decision_log (session_id, decision, alternatives_json, reasoning, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + session_id, + decision, + alternatives_json, + reasoning, + timestamp.to_rfc3339(), + ], + )?; + + Ok(DecisionLogEntry { + id: self.conn.last_insert_rowid(), + session_id: session_id.to_string(), + decision: decision.to_string(), + alternatives: alternatives.to_vec(), + reasoning: reasoning.to_string(), + timestamp, + }) + } + + pub fn list_decisions_for_session( + &self, + session_id: &str, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, session_id, decision, alternatives_json, reasoning, timestamp + FROM ( + SELECT id, session_id, decision, alternatives_json, reasoning, timestamp + FROM decision_log + WHERE session_id = ?1 + ORDER BY timestamp DESC, id DESC + LIMIT ?2 + ) + ORDER BY timestamp ASC, id ASC", + )?; + + let entries = stmt + .query_map(rusqlite::params![session_id, limit as i64], |row| { + map_decision_log_entry(row) + })? + .collect::, _>>()?; + + Ok(entries) + } + + pub fn list_decisions(&self, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, session_id, decision, alternatives_json, reasoning, timestamp + FROM ( + SELECT id, session_id, decision, alternatives_json, reasoning, timestamp + FROM decision_log + ORDER BY timestamp DESC, id DESC + LIMIT ?1 + ) + ORDER BY timestamp ASC, id ASC", + )?; + + let entries = stmt + .query_map(rusqlite::params![limit as i64], map_decision_log_entry)? + .collect::, _>>()?; + + Ok(entries) + } + pub fn daemon_activity(&self) -> Result { self.conn .query_row( @@ -2037,6 +2124,34 @@ fn session_state_supports_overlap(state: &SessionState) -> bool { ) } +fn map_decision_log_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let alternatives_json = row + .get::<_, Option>(3)? + .unwrap_or_else(|| "[]".to_string()); + let alternatives = serde_json::from_str(&alternatives_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(error)) + })?; + let timestamp = row.get::<_, String>(5)?; + let timestamp = chrono::DateTime::parse_from_rfc3339(×tamp) + .map(|value| value.with_timezone(&chrono::Utc)) + .map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + + Ok(DecisionLogEntry { + id: row.get(0)?, + session_id: row.get(1)?, + decision: row.get(2)?, + alternatives, + reasoning: row.get(4)?, + timestamp, + }) +} + fn file_overlap_is_relevant(current: &FileActivityEntry, other: &FileActivityEntry) -> bool { current.path == other.path && !(matches!(current.action, FileActivityAction::Read) @@ -2467,6 +2582,151 @@ mod tests { Ok(()) } + #[test] + fn open_migrates_legacy_tool_log_before_creating_hook_event_index() -> Result<()> { + let tempdir = TestDir::new("store-legacy-hook-event")?; + let db_path = tempdir.path().join("state.db"); + let conn = Connection::open(&db_path)?; + conn.execute_batch( + " + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + task TEXT NOT NULL, + agent_type TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE tool_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + input_summary TEXT, + output_summary TEXT, + duration_ms INTEGER, + risk_score REAL DEFAULT 0.0, + timestamp TEXT NOT NULL + ); + ", + )?; + drop(conn); + + let db = StateStore::open(&db_path)?; + assert!(db.has_column("tool_log", "hook_event_id")?); + + let conn = Connection::open(&db_path)?; + let index_count: i64 = conn.query_row( + "SELECT COUNT(*) + FROM sqlite_master + WHERE type = 'index' AND name = 'idx_tool_log_hook_event'", + [], + |row| row.get(0), + )?; + assert_eq!(index_count, 1); + + Ok(()) + } + + #[test] + fn insert_and_list_decisions_for_session() -> Result<()> { + let tempdir = TestDir::new("store-decisions")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "architect".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + db.insert_decision( + "session-1", + "Use sqlite for the shared context graph", + &["json files".to_string(), "memory only".to_string()], + "SQLite keeps the audit trail queryable from both CLI and TUI.", + )?; + db.insert_decision( + "session-1", + "Keep decision logging append-only", + &["mutable edits".to_string()], + "Append-only history preserves operator trust and timeline integrity.", + )?; + + let entries = db.list_decisions_for_session("session-1", 10)?; + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].session_id, "session-1"); + assert_eq!( + entries[0].decision, + "Use sqlite for the shared context graph" + ); + assert_eq!( + entries[0].alternatives, + vec!["json files".to_string(), "memory only".to_string()] + ); + assert_eq!(entries[1].decision, "Keep decision logging append-only"); + assert_eq!( + entries[1].reasoning, + "Append-only history preserves operator trust and timeline integrity." + ); + + Ok(()) + } + + #[test] + fn list_recent_decisions_across_sessions_returns_latest_subset_in_order() -> Result<()> { + let tempdir = TestDir::new("store-decisions-all")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + for session_id in ["session-a", "session-b", "session-c"] { + db.insert_session(&Session { + id: session_id.to_string(), + task: "decision log".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + } + + db.insert_decision("session-a", "Oldest", &[], "first")?; + std::thread::sleep(std::time::Duration::from_millis(2)); + db.insert_decision("session-b", "Middle", &[], "second")?; + std::thread::sleep(std::time::Duration::from_millis(2)); + db.insert_decision("session-c", "Newest", &[], "third")?; + + let entries = db.list_decisions(2)?; + assert_eq!( + entries + .iter() + .map(|entry| entry.decision.as_str()) + .collect::>(), + vec!["Middle", "Newest"] + ); + assert_eq!(entries[0].session_id, "session-b"); + assert_eq!(entries[1].session_id, "session-c"); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 4f54e4b6..d67869d9 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -22,7 +22,9 @@ use crate::session::output::{ OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT, }; use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; -use crate::session::{FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState}; +use crate::session::{ + DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState, +}; use crate::worktree; #[cfg(test)] @@ -215,6 +217,7 @@ enum TimelineEventFilter { Messages, ToolCalls, FileChanges, + Decisions, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -257,6 +260,7 @@ enum TimelineEventType { Message, ToolCall, FileChange, + Decision, } #[derive(Debug, Clone)] @@ -1025,6 +1029,11 @@ impl Dashboard { TimelineEventFilter::FileChanges, OutputTimeFilter::AllTime, ) => "No file-change events across all sessions yet.", + ( + SearchScope::AllSessions, + TimelineEventFilter::Decisions, + OutputTimeFilter::AllTime, + ) => "No decision-log events across all sessions yet.", (SearchScope::AllSessions, TimelineEventFilter::All, _) => { "No timeline events across all sessions in the selected time range." } @@ -1040,6 +1049,9 @@ impl Dashboard { (SearchScope::AllSessions, TimelineEventFilter::FileChanges, _) => { "No file-change events across all sessions in the selected time range." } + (SearchScope::AllSessions, TimelineEventFilter::Decisions, _) => { + "No decision-log events across all sessions in the selected time range." + } (SearchScope::SelectedSession, TimelineEventFilter::All, OutputTimeFilter::AllTime) => { "No timeline events for this session yet." } @@ -1063,6 +1075,11 @@ impl Dashboard { TimelineEventFilter::FileChanges, OutputTimeFilter::AllTime, ) => "No file-change events for this session yet.", + ( + SearchScope::SelectedSession, + TimelineEventFilter::Decisions, + OutputTimeFilter::AllTime, + ) => "No decision-log events for this session yet.", (SearchScope::SelectedSession, TimelineEventFilter::All, _) => { "No timeline events in the selected time range." } @@ -1078,6 +1095,9 @@ impl Dashboard { (SearchScope::SelectedSession, TimelineEventFilter::FileChanges, _) => { "No file-change events in the selected time range." } + (SearchScope::SelectedSession, TimelineEventFilter::Decisions, _) => { + "No decision-log events in the selected time range." + } } } @@ -4926,6 +4946,18 @@ impl Dashboard { } })); + let decisions = self + .db + .list_decisions_for_session(&session.id, 32) + .unwrap_or_default(); + events.extend(decisions.into_iter().map(|entry| TimelineEvent { + occurred_at: entry.timestamp, + session_id: session.id.clone(), + event_type: TimelineEventType::Decision, + summary: decision_log_summary(&entry), + detail_lines: decision_log_detail_lines(&entry), + })); + let tool_logs = self .db .query_tool_logs(&session.id, 1, 128) @@ -5613,6 +5645,23 @@ impl Dashboard { } } } + let recent_decisions = self + .db + .list_decisions_for_session(&session.id, 5) + .unwrap_or_default(); + if !recent_decisions.is_empty() { + lines.push("Recent decisions".to_string()); + for entry in recent_decisions { + lines.push(format!( + "- {} {}", + self.short_timestamp(&entry.timestamp.to_rfc3339()), + decision_log_summary(&entry) + )); + for detail in decision_log_detail_lines(&entry).into_iter().take(3) { + lines.push(format!(" {}", detail)); + } + } + } let file_overlaps = self .db .list_file_overlaps(&session.id, 3) @@ -6361,7 +6410,8 @@ impl TimelineEventFilter { Self::Lifecycle => Self::Messages, Self::Messages => Self::ToolCalls, Self::ToolCalls => Self::FileChanges, - Self::FileChanges => Self::All, + Self::FileChanges => Self::Decisions, + Self::Decisions => Self::All, } } @@ -6372,6 +6422,7 @@ impl TimelineEventFilter { Self::Messages => event_type == TimelineEventType::Message, Self::ToolCalls => event_type == TimelineEventType::ToolCall, Self::FileChanges => event_type == TimelineEventType::FileChange, + Self::Decisions => event_type == TimelineEventType::Decision, } } @@ -6382,6 +6433,7 @@ impl TimelineEventFilter { Self::Messages => "messages", Self::ToolCalls => "tool calls", Self::FileChanges => "file changes", + Self::Decisions => "decisions", } } @@ -6392,6 +6444,7 @@ impl TimelineEventFilter { Self::Messages => " messages", Self::ToolCalls => " tool calls", Self::FileChanges => " file changes", + Self::Decisions => " decisions", } } } @@ -6403,6 +6456,7 @@ impl TimelineEventType { Self::Message => "message", Self::ToolCall => "tool", Self::FileChange => "file-change", + Self::Decision => "decision", } } } @@ -7332,6 +7386,28 @@ fn file_overlap_summary(entry: &FileActivityOverlap, timestamp: &str) -> String ) } +fn decision_log_summary(entry: &DecisionLogEntry) -> String { + format!("decided {}", truncate_for_dashboard(&entry.decision, 72)) +} + +fn decision_log_detail_lines(entry: &DecisionLogEntry) -> Vec { + let mut lines = vec![format!( + "why {}", + truncate_for_dashboard(&entry.reasoning, 72) + )]; + if entry.alternatives.is_empty() { + lines.push("alternatives none recorded".to_string()); + } else { + for alternative in entry.alternatives.iter().take(3) { + lines.push(format!( + "alternative {}", + truncate_for_dashboard(alternative, 72) + )); + } + } + lines +} + fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec { let mut lines = Vec::new(); if !entry.trigger_summary.trim().is_empty() { @@ -8994,6 +9070,61 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ Ok(()) } + #[test] + fn timeline_and_metrics_render_decision_log_entries() -> Result<()> { + let now = Utc::now(); + let mut session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 256, + 7, + ); + session.created_at = now - chrono::Duration::hours(1); + session.updated_at = now - chrono::Duration::minutes(2); + + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session)?; + dashboard.db.insert_decision( + &session.id, + "Use sqlite for the shared context graph", + &["json files".to_string(), "memory only".to_string()], + "SQLite keeps the audit trail queryable from CLI and TUI.", + )?; + + dashboard.toggle_timeline_mode(); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("decision")); + assert!(rendered.contains("decided Use sqlite for the shared context graph")); + assert!(rendered.contains("why SQLite keeps the audit trail queryable")); + assert!(rendered.contains("alternative json files")); + assert!(rendered.contains("alternative memory only")); + + let metrics_text = dashboard.selected_session_metrics_text(); + assert!(metrics_text.contains("Recent decisions")); + assert!(metrics_text.contains("decided Use sqlite for the shared context graph")); + assert!(metrics_text.contains("alternative json files")); + + dashboard.cycle_timeline_event_filter(); + dashboard.cycle_timeline_event_filter(); + dashboard.cycle_timeline_event_filter(); + dashboard.cycle_timeline_event_filter(); + dashboard.cycle_timeline_event_filter(); + + assert_eq!( + dashboard.timeline_event_filter, + TimelineEventFilter::Decisions + ); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("timeline filter set to decisions") + ); + assert_eq!(dashboard.output_title(), " Timeline decisions "); + + Ok(()) + } + #[test] fn timeline_time_filter_hides_old_events() { let now = Utc::now(); From ea0fb3c0fcdbe2ceec5fea2d32cbab06469f6aa3 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 22:01:57 -0700 Subject: [PATCH 117/459] feat: add layered ecc2 toml config loading --- ecc2/src/config/mod.rs | 203 +++++++++++++++++++++++++++++++++-------- 1 file changed, 163 insertions(+), 40 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index f83d7a32..8d8bbe62 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -76,11 +76,6 @@ pub struct PaneNavigationConfig { pub move_right: String, } -#[derive(Debug, Default, Deserialize)] -struct ProjectWorktreeConfigOverride { - max_parallel_worktrees: Option, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaneNavigationAction { FocusSlot(usize), @@ -144,10 +139,7 @@ impl Config { }; pub fn config_path() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".claude") - .join("ecc2.toml") + Self::config_root().join("ecc2").join("config.toml") } pub fn cost_metrics_path(&self) -> PathBuf { @@ -171,48 +163,105 @@ impl Config { } pub fn load() -> Result { - let config_path = Self::config_path(); - let project_path = std::env::current_dir() + let global_paths = Self::global_config_paths(); + let project_paths = std::env::current_dir() .ok() - .and_then(|cwd| Self::project_config_path_from(&cwd)); - Self::load_from_paths(&config_path, project_path.as_deref()) + .map(|cwd| Self::project_config_paths_from(&cwd)) + .unwrap_or_default(); + Self::load_from_paths(&global_paths, &project_paths) } fn load_from_paths( - config_path: &std::path::Path, - project_override_path: Option<&std::path::Path>, + global_paths: &[PathBuf], + project_override_paths: &[PathBuf], ) -> Result { - let mut config = if config_path.exists() { - let content = std::fs::read_to_string(config_path)?; - toml::from_str(&content)? - } else { - Config::default() - }; + let mut merged = toml::Value::try_from(Self::default()) + .context("serialize default ECC 2.0 config for layered merge")?; - if let Some(project_path) = project_override_path.filter(|path| path.exists()) { - let content = std::fs::read_to_string(project_path)?; - let overrides: ProjectWorktreeConfigOverride = toml::from_str(&content)?; - if let Some(limit) = overrides.max_parallel_worktrees { - config.max_parallel_worktrees = limit; + for path in global_paths.iter().chain(project_override_paths.iter()) { + if path.exists() { + Self::merge_config_file(&mut merged, path)?; } } - Ok(config) + merged + .try_into() + .context("deserialize merged ECC 2.0 config") } - fn project_config_path_from(start: &std::path::Path) -> Option { - let global = Self::config_path(); + fn config_root() -> PathBuf { + dirs::config_dir().unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + }) + } + + fn legacy_global_config_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".claude") + .join("ecc2.toml") + } + + fn global_config_paths() -> Vec { + let legacy = Self::legacy_global_config_path(); + let primary = Self::config_path(); + + if legacy == primary { + vec![primary] + } else { + vec![legacy, primary] + } + } + + fn project_config_paths_from(start: &std::path::Path) -> Vec { + let global_paths = Self::global_config_paths(); let mut current = Some(start); while let Some(path) = current { - let candidate = path.join(".claude").join("ecc2.toml"); - if candidate.exists() && candidate != global { - return Some(candidate); + let legacy = path.join(".claude").join("ecc2.toml"); + let primary = path.join("ecc2.toml"); + let mut matches = Vec::new(); + + if legacy.exists() && !global_paths.iter().any(|global| global == &legacy) { + matches.push(legacy); + } + if primary.exists() && !global_paths.iter().any(|global| global == &primary) { + matches.push(primary); + } + + if !matches.is_empty() { + return matches; } current = path.parent(); } - None + Vec::new() + } + + fn merge_config_file(base: &mut toml::Value, path: &std::path::Path) -> Result<()> { + let content = std::fs::read_to_string(path) + .with_context(|| format!("read ECC 2.0 config from {}", path.display()))?; + let overlay: toml::Value = toml::from_str(&content) + .with_context(|| format!("parse ECC 2.0 config from {}", path.display()))?; + Self::merge_toml_values(base, overlay); + Ok(()) + } + + fn merge_toml_values(base: &mut toml::Value, overlay: toml::Value) { + match (base, overlay) { + (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => { + for (key, overlay_value) in overlay_table { + if let Some(base_value) = base_table.get_mut(&key) { + Self::merge_toml_values(base_value, overlay_value); + } else { + base_table.insert(key, overlay_value); + } + } + } + (base_value, overlay_value) => *base_value = overlay_value, + } } pub fn save(&self) -> Result<()> { @@ -477,20 +526,94 @@ theme = "Dark" } #[test] - fn project_worktree_limit_override_replaces_global_limit() { + fn layered_config_merges_global_and_project_overrides() { let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4())); - let global_path = tempdir.join("global.toml"); - let project_path = tempdir.join("project.toml"); + let legacy_global_path = tempdir.join("legacy-global.toml"); + let global_path = tempdir.join("config.toml"); + let project_path = tempdir.join("ecc2.toml"); std::fs::create_dir_all(&tempdir).unwrap(); - std::fs::write(&global_path, "max_parallel_worktrees = 6\n").unwrap(); - std::fs::write(&project_path, "max_parallel_worktrees = 2\n").unwrap(); + std::fs::write( + &legacy_global_path, + r#" +max_parallel_worktrees = 6 +auto_create_worktrees = false - let config = Config::load_from_paths(&global_path, Some(&project_path)).unwrap(); +[desktop_notifications] +enabled = true +session_completed = false +"#, + ) + .unwrap(); + std::fs::write( + &global_path, + r#" +auto_merge_ready_worktrees = true + +[pane_navigation] +focus_sessions = "q" +move_right = "d" +"#, + ) + .unwrap(); + std::fs::write( + &project_path, + r#" +max_parallel_worktrees = 2 +auto_dispatch_limit_per_session = 9 + +[desktop_notifications] +approval_requests = false + +[pane_navigation] +focus_metrics = "e" +"#, + ) + .unwrap(); + + let config = + Config::load_from_paths(&[legacy_global_path, global_path], &[project_path]).unwrap(); assert_eq!(config.max_parallel_worktrees, 2); + assert!(!config.auto_create_worktrees); + assert!(config.auto_merge_ready_worktrees); + assert_eq!(config.auto_dispatch_limit_per_session, 9); + assert!(config.desktop_notifications.enabled); + assert!(!config.desktop_notifications.session_completed); + assert!(!config.desktop_notifications.approval_requests); + assert_eq!(config.pane_navigation.focus_sessions, "q"); + assert_eq!(config.pane_navigation.focus_metrics, "e"); + assert_eq!(config.pane_navigation.move_right, "d"); let _ = std::fs::remove_dir_all(tempdir); } + #[test] + fn project_config_discovery_prefers_nearest_directory_and_new_path() { + let tempdir = std::env::temp_dir().join(format!("ecc2-config-{}", Uuid::new_v4())); + let project_root = tempdir.join("project"); + let nested_dir = project_root.join("src").join("module"); + std::fs::create_dir_all(project_root.join(".claude")).unwrap(); + std::fs::create_dir_all(&nested_dir).unwrap(); + std::fs::write(project_root.join(".claude").join("ecc2.toml"), "").unwrap(); + std::fs::write(project_root.join("ecc2.toml"), "").unwrap(); + + let paths = Config::project_config_paths_from(&nested_dir); + assert_eq!( + paths, + vec![ + project_root.join(".claude").join("ecc2.toml"), + project_root.join("ecc2.toml") + ] + ); + + let _ = std::fs::remove_dir_all(tempdir); + } + + #[test] + fn primary_config_path_uses_xdg_style_location() { + let path = Config::config_path(); + assert!(path.ends_with("ecc2/config.toml")); + } + #[test] fn pane_navigation_deserializes_from_toml() { let config: Config = toml::from_str( From e48468a9e79a70c0e80ae85948280af1405f2084 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 22:20:35 -0700 Subject: [PATCH 118/459] feat: add ecc2 conflict resolution protocol --- ecc2/src/config/mod.rs | 69 ++++++- ecc2/src/session/manager.rs | 365 +++++++++++++++++++++++++++++++++++- ecc2/src/session/store.rs | 302 +++++++++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 240 ++++++++++++++++++++++-- 4 files changed, 961 insertions(+), 15 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 8d8bbe62..ffe9cdbc 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -32,6 +32,22 @@ pub struct BudgetAlertThresholds { pub critical: f64, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConflictResolutionStrategy { + Escalate, + LastWriteWins, + Merge, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ConflictResolutionConfig { + pub enabled: bool, + pub strategy: ConflictResolutionStrategy, + pub notify_lead: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { @@ -55,6 +71,7 @@ pub struct Config { pub cost_budget_usd: f64, pub token_budget: u64, pub budget_alert_thresholds: BudgetAlertThresholds, + pub conflict_resolution: ConflictResolutionConfig, pub theme: Theme, pub pane_layout: PaneLayout, pub pane_navigation: PaneNavigationConfig, @@ -115,6 +132,7 @@ impl Default for Config { cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS, + conflict_resolution: ConflictResolutionConfig::default(), theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, pane_navigation: PaneNavigationConfig::default(), @@ -403,6 +421,22 @@ impl Default for BudgetAlertThresholds { } } +impl Default for ConflictResolutionStrategy { + fn default() -> Self { + Self::Escalate + } +} + +impl Default for ConflictResolutionConfig { + fn default() -> Self { + Self { + enabled: true, + strategy: ConflictResolutionStrategy::Escalate, + notify_lead: true, + } + } +} + impl BudgetAlertThresholds { pub fn sanitized(self) -> Self { let values = [self.advisory, self.warning, self.critical]; @@ -422,7 +456,10 @@ impl BudgetAlertThresholds { #[cfg(test)] mod tests { - use super::{BudgetAlertThresholds, Config, PaneLayout}; + use super::{ + BudgetAlertThresholds, Config, ConflictResolutionConfig, ConflictResolutionStrategy, + PaneLayout, + }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use uuid::Uuid; @@ -466,6 +503,7 @@ theme = "Dark" config.budget_alert_thresholds, defaults.budget_alert_thresholds ); + assert_eq!(config.conflict_resolution, defaults.conflict_resolution); assert_eq!(config.pane_layout, defaults.pane_layout); assert_eq!(config.pane_navigation, defaults.pane_navigation); assert_eq!( @@ -746,6 +784,28 @@ end_hour = 7 assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7); } + #[test] + fn conflict_resolution_deserializes_from_toml() { + let config: Config = toml::from_str( + r#" +[conflict_resolution] +enabled = true +strategy = "last_write_wins" +notify_lead = false +"#, + ) + .unwrap(); + + assert_eq!( + config.conflict_resolution, + ConflictResolutionConfig { + enabled: true, + strategy: ConflictResolutionStrategy::LastWriteWins, + notify_lead: false, + } + ); + } + #[test] fn completion_summary_notifications_deserialize_from_toml() { let config: Config = toml::from_str( @@ -843,6 +903,8 @@ critical = 1.10 warning: 0.70, critical: 0.88, }; + config.conflict_resolution.strategy = ConflictResolutionStrategy::Merge; + config.conflict_resolution.notify_lead = false; config.pane_navigation.focus_metrics = "e".to_string(); config.pane_navigation.move_right = "d".to_string(); config.linear_pane_size_percent = 42; @@ -879,6 +941,11 @@ critical = 1.10 critical: 0.88, } ); + assert_eq!( + loaded.conflict_resolution.strategy, + ConflictResolutionStrategy::Merge + ); + assert!(!loaded.conflict_resolution.notify_lead); assert_eq!(loaded.pane_navigation.focus_metrics, "e"); assert_eq!(loaded.pane_navigation.move_right, "d"); assert_eq!(loaded.linear_pane_size_percent, 42); diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index ef96d26b..8311a4f2 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; use serde::Serialize; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -510,6 +510,224 @@ pub fn enforce_budget_hard_limits( Ok(outcome) } +#[derive(Debug, Clone, Default, Serialize, PartialEq)] +pub struct ConflictEnforcementOutcome { + pub strategy: crate::config::ConflictResolutionStrategy, + pub created_incidents: usize, + pub resolved_incidents: usize, + pub paused_sessions: Vec, +} + +pub fn enforce_conflict_resolution( + db: &StateStore, + cfg: &Config, +) -> Result { + let mut outcome = ConflictEnforcementOutcome { + strategy: cfg.conflict_resolution.strategy, + created_incidents: 0, + resolved_incidents: 0, + paused_sessions: Vec::new(), + }; + + if !cfg.conflict_resolution.enabled { + return Ok(outcome); + } + + let sessions = db.list_sessions()?; + let sessions_by_id: HashMap<_, _> = sessions + .iter() + .cloned() + .map(|session| (session.id.clone(), session)) + .collect(); + + let active_sessions: Vec<_> = sessions + .into_iter() + .filter(|session| { + matches!( + session.state, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale + ) + }) + .collect(); + + let mut latest_activity_by_path: BTreeMap> = + BTreeMap::new(); + for session in &active_sessions { + let mut seen_paths = HashSet::new(); + for entry in db.list_file_activity(&session.id, 64)? { + if seen_paths.insert(entry.path.clone()) { + latest_activity_by_path + .entry(entry.path.clone()) + .or_default() + .push(entry); + } + } + } + + let mut paused_once = HashSet::new(); + + for (path, mut entries) in latest_activity_by_path { + entries.retain(|entry| !matches!(entry.action, super::FileActivityAction::Read)); + if entries.len() < 2 { + continue; + } + + entries.sort_by_key(|entry| (entry.timestamp, entry.session_id.clone())); + let latest = entries.last().cloned().expect("entries is not empty"); + for other in entries[..entries.len() - 1].iter() { + let conflict_key = conflict_incident_key(&path, &latest.session_id, &other.session_id); + if db.has_open_conflict_incident(&conflict_key)? { + continue; + } + + let (active_session_id, paused_session_id, summary) = + choose_conflict_resolution(&path, &latest, other, cfg.conflict_resolution.strategy); + let (first_session_id, second_session_id, first_action, second_action) = + if latest.session_id <= other.session_id { + ( + latest.session_id.clone(), + other.session_id.clone(), + latest.action.clone(), + other.action.clone(), + ) + } else { + ( + other.session_id.clone(), + latest.session_id.clone(), + other.action.clone(), + latest.action.clone(), + ) + }; + + db.upsert_conflict_incident( + &conflict_key, + &path, + &first_session_id, + &second_session_id, + &active_session_id, + &paused_session_id, + &first_action, + &second_action, + conflict_strategy_label(cfg.conflict_resolution.strategy), + &summary, + )?; + + if paused_once.insert(paused_session_id.clone()) { + if let Some(session) = sessions_by_id.get(&paused_session_id) { + if matches!( + session.state, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale + ) { + stop_session_recorded(db, session, false)?; + outcome.paused_sessions.push(paused_session_id.clone()); + } + } + } + + comms::send( + db, + &active_session_id, + &paused_session_id, + &MessageType::Conflict { + file: path.clone(), + description: summary.clone(), + }, + )?; + + db.insert_decision( + &paused_session_id, + &format!("Pause work due to conflict on {path}"), + &[ + format!("Keep {active_session_id} active"), + "Continue concurrently".to_string(), + ], + &summary, + )?; + + if cfg.conflict_resolution.notify_lead { + if let Some(lead_session_id) = db.latest_task_handoff_source(&paused_session_id)? { + if lead_session_id != paused_session_id && lead_session_id != active_session_id + { + comms::send( + db, + &paused_session_id, + &lead_session_id, + &MessageType::Conflict { + file: path.clone(), + description: format!( + "{} | delegate {} paused", + summary, paused_session_id + ), + }, + )?; + } + } + } + + outcome.created_incidents += 1; + } + } + + Ok(outcome) +} + +fn conflict_incident_key(path: &str, session_a: &str, session_b: &str) -> String { + let (first, second) = if session_a <= session_b { + (session_a, session_b) + } else { + (session_b, session_a) + }; + format!("{path}::{first}::{second}") +} + +fn conflict_strategy_label(strategy: crate::config::ConflictResolutionStrategy) -> &'static str { + match strategy { + crate::config::ConflictResolutionStrategy::Escalate => "escalate", + crate::config::ConflictResolutionStrategy::LastWriteWins => "last_write_wins", + crate::config::ConflictResolutionStrategy::Merge => "merge", + } +} + +fn choose_conflict_resolution( + path: &str, + latest: &super::FileActivityEntry, + other: &super::FileActivityEntry, + strategy: crate::config::ConflictResolutionStrategy, +) -> (String, String, String) { + match strategy { + crate::config::ConflictResolutionStrategy::Escalate => ( + other.session_id.clone(), + latest.session_id.clone(), + format!( + "Escalated overlap on {path}; paused later session {} while {} stays active", + latest.session_id, other.session_id + ), + ), + crate::config::ConflictResolutionStrategy::LastWriteWins => ( + latest.session_id.clone(), + other.session_id.clone(), + format!( + "Applied last-write-wins on {path}; kept later session {} active and paused {}", + latest.session_id, other.session_id + ), + ), + crate::config::ConflictResolutionStrategy::Merge => ( + other.session_id.clone(), + latest.session_id.clone(), + format!( + "Queued manual merge on {path}; paused later session {} until merge review against {}", + latest.session_id, other.session_id + ), + ), + } +} + pub fn record_tool_call( db: &StateStore, session_id: &str, @@ -2428,6 +2646,7 @@ mod tests { cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS, + conflict_resolution: crate::config::ConflictResolutionConfig::default(), theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, pane_navigation: Default::default(), @@ -4821,4 +5040,148 @@ mod tests { Ok(()) } + + #[test] + fn enforce_conflict_resolution_pauses_later_session_and_notifies_lead() -> Result<()> { + let tempdir = TestDir::new("manager-conflict-escalate")?; + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&build_session("lead", SessionState::Running, now))?; + db.insert_session(&build_session( + "session-a", + SessionState::Running, + now - Duration::minutes(2), + ))?; + db.insert_session(&build_session( + "session-b", + SessionState::Running, + now - Duration::minutes(1), + ))?; + + crate::comms::send( + &db, + "lead", + "session-b", + &crate::comms::MessageType::TaskHandoff { + task: "Review src/lib.rs".to_string(), + context: "Lead delegated follow-up".to_string(), + }, + )?; + + let metrics_dir = tempdir.path().join("metrics"); + std::fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + std::fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-a\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"updated logic\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:02:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-b\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"newer change\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:03:00Z\"}\n" + ), + )?; + db.sync_tool_activity_metrics(&metrics_path)?; + + let outcome = enforce_conflict_resolution(&db, &cfg)?; + assert_eq!(outcome.created_incidents, 1); + assert_eq!(outcome.resolved_incidents, 0); + assert_eq!(outcome.paused_sessions, vec!["session-b".to_string()]); + + let session_a = db + .get_session("session-a")? + .expect("session-a should still exist"); + let session_b = db + .get_session("session-b")? + .expect("session-b should still exist"); + assert_eq!(session_a.state, SessionState::Running); + assert_eq!(session_b.state, SessionState::Stopped); + + assert!(db.has_open_conflict_incident("src/lib.rs::session-a::session-b")?); + + let decisions = db.list_decisions_for_session("session-b", 10)?; + assert!(decisions + .iter() + .any(|entry| entry.decision == "Pause work due to conflict on src/lib.rs")); + + let approval_counts = db.unread_approval_counts()?; + assert_eq!(approval_counts.get("session-b"), Some(&1usize)); + assert_eq!(approval_counts.get("lead"), Some(&1usize)); + + let unread_queue = db.unread_approval_queue(10)?; + assert!(unread_queue.iter().any(|msg| { + msg.to_session == "session-b" + && msg.msg_type == "conflict" + && msg.content.contains("src/lib.rs") + })); + assert!(unread_queue.iter().any(|msg| { + msg.to_session == "lead" + && msg.msg_type == "conflict" + && msg.content.contains("delegate session-b paused") + })); + + let second_pass = enforce_conflict_resolution(&db, &cfg)?; + assert_eq!(second_pass.created_incidents, 0); + assert_eq!(second_pass.paused_sessions, Vec::::new()); + assert_eq!( + db.list_open_conflict_incidents_for_session("session-b", 10)? + .len(), + 1 + ); + + Ok(()) + } + + #[test] + fn enforce_conflict_resolution_supports_last_write_wins() -> Result<()> { + let tempdir = TestDir::new("manager-conflict-last-write-wins")?; + let mut cfg = build_config(tempdir.path()); + cfg.conflict_resolution.strategy = crate::config::ConflictResolutionStrategy::LastWriteWins; + cfg.conflict_resolution.notify_lead = false; + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&build_session( + "session-a", + SessionState::Running, + now - Duration::minutes(2), + ))?; + db.insert_session(&build_session( + "session-b", + SessionState::Running, + now - Duration::minutes(1), + ))?; + + let metrics_dir = tempdir.path().join("metrics"); + std::fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + std::fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-a\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"older change\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:02:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-b\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"later change\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:03:00Z\"}\n" + ), + )?; + db.sync_tool_activity_metrics(&metrics_path)?; + + let outcome = enforce_conflict_resolution(&db, &cfg)?; + assert_eq!(outcome.created_incidents, 1); + assert_eq!(outcome.paused_sessions, vec!["session-a".to_string()]); + + let session_a = db + .get_session("session-a")? + .expect("session-a should still exist"); + let session_b = db + .get_session("session-b")? + .expect("session-b should still exist"); + assert_eq!(session_a.state, SessionState::Stopped); + assert_eq!(session_b.state, SessionState::Running); + + let incidents = db.list_open_conflict_incidents_for_session("session-a", 10)?; + assert_eq!(incidents.len(), 1); + assert_eq!(incidents[0].active_session_id, "session-b"); + assert_eq!(incidents[0].paused_session_id, "session-a"); + assert_eq!(incidents[0].strategy, "last_write_wins"); + + Ok(()) + } } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index af5ff0e2..b7029b57 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -39,6 +39,24 @@ pub struct FileActivityOverlap { pub timestamp: chrono::DateTime, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConflictIncident { + pub id: i64, + pub conflict_key: String, + pub path: String, + pub first_session_id: String, + pub second_session_id: String, + pub active_session_id: String, + pub paused_session_id: String, + pub first_action: FileActivityAction, + pub second_action: FileActivityAction, + pub strategy: String, + pub summary: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub resolved_at: Option>, +} + #[derive(Debug, Clone, Default, Serialize)] pub struct DaemonActivity { pub last_dispatch_at: Option>, @@ -209,6 +227,23 @@ impl StateStore { requested_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS conflict_incidents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conflict_key TEXT NOT NULL UNIQUE, + path TEXT NOT NULL, + first_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + second_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + active_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + paused_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + first_action TEXT NOT NULL, + second_action TEXT NOT NULL, + strategy TEXT NOT NULL, + summary TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + resolved_at TEXT + ); + CREATE TABLE IF NOT EXISTS daemon_activity ( id INTEGER PRIMARY KEY CHECK(id = 1), last_dispatch_at TEXT, @@ -240,6 +275,8 @@ impl StateStore { ON session_output(session_id, id); CREATE INDEX IF NOT EXISTS idx_decision_log_session ON decision_log(session_id, timestamp, id); + CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions + ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at); CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at ON pending_worktree_queue(requested_at, session_id); @@ -2038,6 +2075,157 @@ impl StateStore { overlaps.truncate(limit); Ok(overlaps) } + + pub fn has_open_conflict_incident(&self, conflict_key: &str) -> Result { + let exists = self + .conn + .query_row( + "SELECT 1 + FROM conflict_incidents + WHERE conflict_key = ?1 AND resolved_at IS NULL + LIMIT 1", + rusqlite::params![conflict_key], + |_| Ok(()), + ) + .optional()? + .is_some(); + Ok(exists) + } + + #[allow(clippy::too_many_arguments)] + pub fn upsert_conflict_incident( + &self, + conflict_key: &str, + path: &str, + first_session_id: &str, + second_session_id: &str, + active_session_id: &str, + paused_session_id: &str, + first_action: &FileActivityAction, + second_action: &FileActivityAction, + strategy: &str, + summary: &str, + ) -> Result { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT INTO conflict_incidents ( + conflict_key, path, first_session_id, second_session_id, + active_session_id, paused_session_id, first_action, second_action, + strategy, summary, created_at, updated_at, resolved_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11, NULL) + ON CONFLICT(conflict_key) DO UPDATE SET + path = excluded.path, + first_session_id = excluded.first_session_id, + second_session_id = excluded.second_session_id, + active_session_id = excluded.active_session_id, + paused_session_id = excluded.paused_session_id, + first_action = excluded.first_action, + second_action = excluded.second_action, + strategy = excluded.strategy, + summary = excluded.summary, + updated_at = excluded.updated_at, + resolved_at = NULL", + rusqlite::params![ + conflict_key, + path, + first_session_id, + second_session_id, + active_session_id, + paused_session_id, + file_activity_action_value(first_action), + file_activity_action_value(second_action), + strategy, + summary, + now, + ], + )?; + + self.conn + .query_row( + "SELECT id, conflict_key, path, first_session_id, second_session_id, + active_session_id, paused_session_id, first_action, second_action, + strategy, summary, created_at, updated_at, resolved_at + FROM conflict_incidents + WHERE conflict_key = ?1", + rusqlite::params![conflict_key], + map_conflict_incident, + ) + .map_err(Into::into) + } + + pub fn resolve_conflict_incidents_not_in( + &self, + active_keys: &HashSet, + ) -> Result { + let open = self.list_open_conflict_incidents(512)?; + let now = chrono::Utc::now().to_rfc3339(); + let mut resolved = 0; + + for incident in open { + if active_keys.contains(&incident.conflict_key) { + continue; + } + + resolved += self.conn.execute( + "UPDATE conflict_incidents + SET resolved_at = ?2, updated_at = ?2 + WHERE conflict_key = ?1 AND resolved_at IS NULL", + rusqlite::params![incident.conflict_key, now], + )?; + } + + Ok(resolved) + } + + pub fn list_open_conflict_incidents_for_session( + &self, + session_id: &str, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, conflict_key, path, first_session_id, second_session_id, + active_session_id, paused_session_id, first_action, second_action, + strategy, summary, created_at, updated_at, resolved_at + FROM conflict_incidents + WHERE resolved_at IS NULL + AND ( + first_session_id = ?1 + OR second_session_id = ?1 + OR active_session_id = ?1 + OR paused_session_id = ?1 + ) + ORDER BY updated_at DESC, id DESC + LIMIT ?2", + )?; + + let incidents = stmt + .query_map( + rusqlite::params![session_id, limit as i64], + map_conflict_incident, + )? + .collect::, _>>() + .map_err(anyhow::Error::from)?; + Ok(incidents) + } + + fn list_open_conflict_incidents(&self, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, conflict_key, path, first_session_id, second_session_id, + active_session_id, paused_session_id, first_action, second_action, + strategy, summary, created_at, updated_at, resolved_at + FROM conflict_incidents + WHERE resolved_at IS NULL + ORDER BY updated_at DESC, id DESC + LIMIT ?1", + )?; + + let incidents = stmt + .query_map(rusqlite::params![limit as i64], map_conflict_incident)? + .collect::, _>>() + .map_err(anyhow::Error::from)?; + Ok(incidents) + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -2073,6 +2261,70 @@ fn parse_persisted_file_events(value: &str) -> Option> { Some(events) } +fn file_activity_action_value(action: &FileActivityAction) -> &'static str { + match action { + FileActivityAction::Read => "read", + FileActivityAction::Create => "create", + FileActivityAction::Modify => "modify", + FileActivityAction::Move => "move", + FileActivityAction::Delete => "delete", + FileActivityAction::Touch => "touch", + } +} + +fn map_conflict_incident(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let created_at = parse_timestamp_column(row.get::<_, String>(11)?, 11)?; + let updated_at = parse_timestamp_column(row.get::<_, String>(12)?, 12)?; + let resolved_at = row + .get::<_, Option>(13)? + .map(|value| parse_timestamp_column(value, 13)) + .transpose()?; + + Ok(ConflictIncident { + id: row.get(0)?, + conflict_key: row.get(1)?, + path: row.get(2)?, + first_session_id: row.get(3)?, + second_session_id: row.get(4)?, + active_session_id: row.get(5)?, + paused_session_id: row.get(6)?, + first_action: parse_file_activity_action(&row.get::<_, String>(7)?).ok_or_else(|| { + rusqlite::Error::InvalidColumnType( + 7, + "first_action".into(), + rusqlite::types::Type::Text, + ) + })?, + second_action: parse_file_activity_action(&row.get::<_, String>(8)?).ok_or_else(|| { + rusqlite::Error::InvalidColumnType( + 8, + "second_action".into(), + rusqlite::types::Type::Text, + ) + })?, + strategy: row.get(9)?, + summary: row.get(10)?, + created_at, + updated_at, + resolved_at, + }) +} + +fn parse_timestamp_column( + value: String, + index: usize, +) -> rusqlite::Result> { + chrono::DateTime::parse_from_rfc3339(&value) + .map(|value| value.with_timezone(&chrono::Utc)) + .map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + index, + rusqlite::types::Type::Text, + Box::new(error), + ) + }) +} + fn parse_file_activity_action(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "read" => Some(FileActivityAction::Read), @@ -2582,6 +2834,56 @@ mod tests { Ok(()) } + #[test] + fn conflict_incidents_upsert_and_resolve() -> Result<()> { + let tempdir = TestDir::new("store-conflict-incidents")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + for id in ["session-a", "session-b"] { + db.insert_session(&Session { + id: id.to_string(), + task: id.to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + } + + let incident = db.upsert_conflict_incident( + "src/lib.rs::session-a::session-b", + "src/lib.rs", + "session-a", + "session-b", + "session-a", + "session-b", + &FileActivityAction::Modify, + &FileActivityAction::Modify, + "escalate", + "Paused session-b after overlapping modify on src/lib.rs", + )?; + assert_eq!(incident.paused_session_id, "session-b"); + assert!(db.has_open_conflict_incident("src/lib.rs::session-a::session-b")?); + + let listed = db.list_open_conflict_incidents_for_session("session-b", 10)?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].path, "src/lib.rs"); + + let resolved = db.resolve_conflict_incidents_not_in(&HashSet::new())?; + assert_eq!(resolved, 1); + assert!(!db.has_open_conflict_incident("src/lib.rs::session-a::session-b")?); + + Ok(()) + } + #[test] fn open_migrates_legacy_tool_log_before_creating_hook_event_index() -> Result<()> { let tempdir = TestDir::new("store-legacy-hook-event")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index d67869d9..68603fb9 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -3707,6 +3707,7 @@ impl Dashboard { ) -> ( Option, Option, + Option, ) { if let Err(error) = self.db.refresh_session_durations() { tracing::warn!("Failed to refresh session durations: {error}"); @@ -3750,11 +3751,24 @@ impl Dashboard { } }; - (heartbeat_enforcement, budget_enforcement) + let conflict_enforcement = match manager::enforce_conflict_resolution(&self.db, &self.cfg) { + Ok(outcome) => Some(outcome), + Err(error) => { + tracing::warn!("Failed to enforce conflict resolution: {error}"); + None + } + }; + + ( + heartbeat_enforcement, + budget_enforcement, + conflict_enforcement, + ) } fn sync_from_store(&mut self) { - let (heartbeat_enforcement, budget_enforcement) = self.sync_runtime_metrics(); + let (heartbeat_enforcement, budget_enforcement, conflict_enforcement) = + self.sync_runtime_metrics(); let selected_id = self.selected_session_id().map(ToOwned::to_owned); self.sessions = match self.db.list_sessions() { Ok(mut sessions) => { @@ -3796,6 +3810,10 @@ impl Dashboard { { self.set_operator_note(budget_auto_pause_note(&outcome)); } + if let Some(outcome) = conflict_enforcement.filter(|outcome| outcome.created_incidents > 0) + { + self.set_operator_note(conflict_enforcement_note(&outcome)); + } if let Some(outcome) = heartbeat_enforcement.filter(|outcome| { !outcome.stale_sessions.is_empty() || !outcome.auto_terminated_sessions.is_empty() }) { @@ -4307,12 +4325,20 @@ impl Dashboard { } self.selected_merge_readiness = worktree.and_then(|worktree| worktree::merge_readiness(worktree).ok()); - self.selected_conflict_protocol = session - .zip(worktree) - .zip(self.selected_merge_readiness.as_ref()) - .and_then(|((session, worktree), merge_readiness)| { - build_conflict_protocol(&session.id, worktree, merge_readiness) - }); + self.selected_conflict_protocol = session.and_then(|selected_session| { + worktree + .zip(self.selected_merge_readiness.as_ref()) + .and_then(|(worktree, merge_readiness)| { + build_conflict_protocol(&selected_session.id, worktree, merge_readiness) + }) + .or_else(|| { + let incidents = self + .db + .list_open_conflict_incidents_for_session(&selected_session.id, 5) + .unwrap_or_default(); + build_session_conflict_protocol(&selected_session.id, &incidents) + }) + }); if self.output_mode == OutputMode::WorktreeDiff && self.selected_diff_patch.is_none() { self.output_mode = OutputMode::SessionOutput; } @@ -5678,6 +5704,22 @@ impl Dashboard { )); } } + let conflict_incidents = self + .db + .list_open_conflict_incidents_for_session(&session.id, 3) + .unwrap_or_default(); + if !conflict_incidents.is_empty() { + lines.push("Active conflicts".to_string()); + for incident in conflict_incidents { + lines.push(format!( + "- {}", + conflict_incident_summary( + &incident, + &self.short_timestamp(&incident.updated_at.to_rfc3339()) + ) + )); + } + } lines.push(format!( "Cost ${:.4} | Duration {}s", metrics.cost_usd, metrics.duration_secs @@ -7386,6 +7428,20 @@ fn file_overlap_summary(entry: &FileActivityOverlap, timestamp: &str) -> String ) } +fn conflict_incident_summary( + incident: &crate::session::store::ConflictIncident, + timestamp: &str, +) -> String { + format!( + "{} {} | active {} | paused {} | {}", + timestamp, + truncate_for_dashboard(&incident.path, 48), + format_session_id(&incident.active_session_id), + format_session_id(&incident.paused_session_id), + incident.strategy.replace('_', "-") + ) +} + fn decision_log_summary(entry: &DecisionLogEntry) -> String { format!("decided {}", truncate_for_dashboard(&entry.decision, 72)) } @@ -7835,6 +7891,21 @@ fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String ) } +fn conflict_enforcement_note(outcome: &manager::ConflictEnforcementOutcome) -> String { + let strategy = match outcome.strategy { + crate::config::ConflictResolutionStrategy::Escalate => "escalation", + crate::config::ConflictResolutionStrategy::LastWriteWins => "last-write-wins", + crate::config::ConflictResolutionStrategy::Merge => "merge review", + }; + + format!( + "file conflict detected | opened {} incident(s), auto-paused {} session(s) via {}", + outcome.created_incidents, + outcome.paused_sessions.len(), + strategy + ) +} + fn format_session_id(id: &str) -> String { id.chars().take(8).collect() } @@ -7882,6 +7953,44 @@ fn build_conflict_protocol( Some(lines.join("\n")) } +fn build_session_conflict_protocol( + session_id: &str, + incidents: &[crate::session::store::ConflictIncident], +) -> Option { + if incidents.is_empty() { + return None; + } + + let mut lines = vec![ + format!("Conflict protocol for {}", format_session_id(session_id)), + "Session overlap incidents".to_string(), + ]; + + for incident in incidents { + lines.push(format!( + "- {}", + conflict_incident_summary( + incident, + &incident.updated_at.format("%H:%M:%S").to_string() + ) + )); + lines.push(format!(" {}", incident.summary)); + } + + lines.push("Resolution steps".to_string()); + lines.push("1. Inspect the affected session output and recent file activity".to_string()); + lines.push( + "2. Decide whether to keep the active session, reassign, or merge changes manually" + .to_string(), + ); + lines.push(format!( + "3. Resume the paused session only after reviewing the overlap: ecc resume {}", + session_id + )); + + Some(lines.join("\n")) +} + fn assignment_action_label(action: manager::AssignmentAction) -> &'static str { match action { manager::AssignmentAction::Spawned => "spawned", @@ -9019,7 +9128,7 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ } #[test] - fn metrics_text_surfaces_file_activity_overlaps() -> Result<()> { + fn metrics_text_surfaces_file_activity_conflicts() -> Result<()> { let root = std::env::temp_dir().join(format!("ecc2-file-overlaps-{}", Uuid::new_v4())); fs::create_dir_all(&root)?; let now = Utc::now(); @@ -9061,10 +9170,17 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ dashboard.sync_from_store(); let metrics_text = dashboard.selected_session_metrics_text(); - assert!(metrics_text.contains("Potential overlaps")); - assert!(metrics_text.contains("modify src/lib.rs")); - assert!(metrics_text.contains("idle delegate")); - assert!(metrics_text.contains("as modify")); + assert!(metrics_text.contains("Active conflicts")); + assert!(metrics_text.contains("src/lib.rs")); + assert!(metrics_text.contains("escalate")); + assert_eq!( + dashboard + .db + .get_session("delegate-87654321")? + .expect("delegate should exist") + .state, + SessionState::Stopped + ); let _ = fs::remove_dir_all(root); Ok(()) @@ -10715,6 +10831,103 @@ diff --git a/src/lib.rs b/src/lib.rs ); } + #[test] + fn refresh_enforces_conflicts_and_surfaces_active_incidents() -> Result<()> { + let tempdir = + std::env::temp_dir().join(format!("dashboard-conflict-refresh-{}", Uuid::new_v4())); + fs::create_dir_all(&tempdir)?; + let mut cfg = build_config(&tempdir); + cfg.session_timeout_secs = 3600; + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-a".to_string(), + task: "keep active".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: Some(1111), + worktree: None, + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "session-b".to_string(), + task: "later overlap".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: Some(2222), + worktree: None, + created_at: now - Duration::minutes(1), + updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + })?; + + fs::create_dir_all( + cfg.tool_activity_metrics_path() + .parent() + .expect("metrics dir"), + )?; + fs::write( + cfg.tool_activity_metrics_path(), + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-a\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"older change\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:02:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-b\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"later change\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:03:00Z\"}\n" + ), + )?; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard.refresh(); + dashboard.sync_selection_by_id(Some("session-b")); + dashboard.sync_selected_diff(); + + assert_eq!( + dashboard.operator_note.as_deref(), + Some("file conflict detected | opened 1 incident(s), auto-paused 1 session(s) via escalation") + ); + assert_eq!( + dashboard + .db + .get_session("session-b")? + .expect("session-b should exist") + .state, + SessionState::Stopped + ); + + let metrics_text = dashboard.selected_session_metrics_text(); + assert!(metrics_text.contains("Active conflicts")); + assert!(metrics_text.contains("src/lib.rs")); + assert!(metrics_text.contains("escalate")); + + let conflict_protocol = dashboard + .selected_conflict_protocol + .clone() + .expect("conflict protocol should be present"); + assert!(conflict_protocol.contains("Session overlap incidents")); + assert!(conflict_protocol.contains("ecc resume session-b")); + + dashboard.refresh(); + assert_eq!( + dashboard + .db + .list_open_conflict_incidents_for_session("session-b", 10)? + .len(), + 1 + ); + + let _ = fs::remove_dir_all(tempdir); + Ok(()) + } + #[test] fn new_session_task_uses_selected_session_context() { let dashboard = test_dashboard( @@ -12809,6 +13022,7 @@ diff --git a/src/lib.rs b/src/lib.rs cost_budget_usd: 10.0, token_budget: 500_000, budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS, + conflict_resolution: crate::config::ConflictResolutionConfig::default(), theme: Theme::Dark, pane_layout: PaneLayout::Horizontal, pane_navigation: Default::default(), From 1e4d6a4161061ca93d83409832af0192ec1c16a8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 22:43:16 -0700 Subject: [PATCH 119/459] feat: add ecc2 agent profiles --- ecc2/src/config/mod.rs | 173 +++++++++++++++ ecc2/src/main.rs | 70 ++++-- ecc2/src/session/manager.rs | 431 ++++++++++++++++++++++++++++++++++-- ecc2/src/session/mod.rs | 2 + ecc2/src/session/store.rs | 166 +++++++++++++- ecc2/src/tui/dashboard.rs | 73 +++++- 6 files changed, 873 insertions(+), 42 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index ffe9cdbc..938903a1 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::path::PathBuf; use crate::notifications::{ @@ -48,6 +49,35 @@ pub struct ConflictResolutionConfig { pub notify_lead: bool, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AgentProfileConfig { + pub inherits: Option, + pub agent: Option, + pub model: Option, + pub allowed_tools: Vec, + pub disallowed_tools: Vec, + pub permission_mode: Option, + pub add_dirs: Vec, + pub max_budget_usd: Option, + pub token_budget: Option, + pub append_system_prompt: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ResolvedAgentProfile { + pub profile_name: String, + pub agent: Option, + pub model: Option, + pub allowed_tools: Vec, + pub disallowed_tools: Vec, + pub permission_mode: Option, + pub add_dirs: Vec, + pub max_budget_usd: Option, + pub token_budget: Option, + pub append_system_prompt: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { @@ -61,6 +91,8 @@ pub struct Config { pub heartbeat_interval_secs: u64, pub auto_terminate_stale_sessions: bool, pub default_agent: String, + pub default_agent_profile: Option, + pub agent_profiles: BTreeMap, pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_limit_per_session: usize, pub auto_create_worktrees: bool, @@ -122,6 +154,8 @@ impl Default for Config { heartbeat_interval_secs: 30, auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), + default_agent_profile: None, + agent_profiles: BTreeMap::new(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -180,6 +214,41 @@ impl Config { self.budget_alert_thresholds.sanitized() } + pub fn resolve_agent_profile(&self, name: &str) -> Result { + let mut chain = Vec::new(); + self.resolve_agent_profile_inner(name, &mut chain) + } + + fn resolve_agent_profile_inner( + &self, + name: &str, + chain: &mut Vec, + ) -> Result { + if chain.iter().any(|existing| existing == name) { + chain.push(name.to_string()); + anyhow::bail!( + "agent profile inheritance cycle: {}", + chain.join(" -> ") + ); + } + + let profile = self + .agent_profiles + .get(name) + .ok_or_else(|| anyhow::anyhow!("Unknown agent profile: {name}"))?; + + chain.push(name.to_string()); + let mut resolved = if let Some(parent) = profile.inherits.as_deref() { + self.resolve_agent_profile_inner(parent, chain)? + } else { + ResolvedAgentProfile::default() + }; + chain.pop(); + + resolved.apply(name, profile); + Ok(resolved) + } + pub fn load() -> Result { let global_paths = Self::global_config_paths(); let project_paths = std::env::current_dir() @@ -437,6 +506,50 @@ impl Default for ConflictResolutionConfig { } } +impl ResolvedAgentProfile { + fn apply(&mut self, profile_name: &str, config: &AgentProfileConfig) { + self.profile_name = profile_name.to_string(); + if let Some(agent) = config.agent.as_ref() { + self.agent = Some(agent.clone()); + } + if let Some(model) = config.model.as_ref() { + self.model = Some(model.clone()); + } + merge_unique(&mut self.allowed_tools, &config.allowed_tools); + merge_unique(&mut self.disallowed_tools, &config.disallowed_tools); + if let Some(permission_mode) = config.permission_mode.as_ref() { + self.permission_mode = Some(permission_mode.clone()); + } + merge_unique(&mut self.add_dirs, &config.add_dirs); + if let Some(max_budget_usd) = config.max_budget_usd { + self.max_budget_usd = Some(max_budget_usd); + } + if let Some(token_budget) = config.token_budget { + self.token_budget = Some(token_budget); + } + self.append_system_prompt = match ( + self.append_system_prompt.take(), + config.append_system_prompt.as_ref(), + ) { + (Some(parent), Some(child)) => Some(format!("{parent}\n\n{child}")), + (Some(parent), None) => Some(parent), + (None, Some(child)) => Some(child.clone()), + (None, None) => None, + }; + } +} + +fn merge_unique(base: &mut Vec, additions: &[T]) +where + T: Clone + PartialEq, +{ + for value in additions { + if !base.contains(value) { + base.push(value.clone()); + } + } +} + impl BudgetAlertThresholds { pub fn sanitized(self) -> Self { let values = [self.advisory, self.warning, self.critical]; @@ -461,6 +574,7 @@ mod tests { PaneLayout, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::path::PathBuf; use uuid::Uuid; #[test] @@ -806,6 +920,65 @@ notify_lead = false ); } + #[test] + fn agent_profiles_resolve_inheritance_and_defaults() { + let config: Config = toml::from_str( + r#" +default_agent_profile = "reviewer" + +[agent_profiles.base] +model = "sonnet" +allowed_tools = ["Read"] +permission_mode = "plan" +add_dirs = ["docs"] +append_system_prompt = "Be careful." + +[agent_profiles.reviewer] +inherits = "base" +allowed_tools = ["Edit"] +disallowed_tools = ["Bash"] +token_budget = 1200 +append_system_prompt = "Review thoroughly." +"#, + ) + .unwrap(); + + let profile = config.resolve_agent_profile("reviewer").unwrap(); + assert_eq!(config.default_agent_profile.as_deref(), Some("reviewer")); + assert_eq!(profile.profile_name, "reviewer"); + assert_eq!(profile.model.as_deref(), Some("sonnet")); + assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]); + assert_eq!(profile.disallowed_tools, vec!["Bash"]); + assert_eq!(profile.permission_mode.as_deref(), Some("plan")); + assert_eq!(profile.add_dirs, vec![PathBuf::from("docs")]); + assert_eq!(profile.token_budget, Some(1200)); + assert_eq!( + profile.append_system_prompt.as_deref(), + Some("Be careful.\n\nReview thoroughly.") + ); + } + + #[test] + fn agent_profile_resolution_rejects_inheritance_cycles() { + let config: Config = toml::from_str( + r#" +[agent_profiles.a] +inherits = "b" + +[agent_profiles.b] +inherits = "a" +"#, + ) + .unwrap(); + + let error = config + .resolve_agent_profile("a") + .expect_err("profile inheritance cycles must fail"); + assert!(error + .to_string() + .contains("agent profile inheritance cycle")); + } + #[test] fn completion_summary_notifications_deserialize_from_toml() { let config: Config = toml::from_str( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 756dadcb..ee1fcb50 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -53,6 +53,9 @@ enum Commands { /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, + /// Agent profile defined in ecc2.toml + #[arg(long)] + profile: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Source session to delegate from @@ -69,6 +72,9 @@ enum Commands { /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, + /// Agent profile defined in ecc2.toml + #[arg(long)] + profile: Option, #[command(flatten)] worktree: WorktreePolicyArgs, }, @@ -82,6 +88,9 @@ enum Commands { /// Agent type (claude, codex, custom) #[arg(short, long, default_value = "claude")] agent: String, + /// Agent profile defined in ecc2.toml + #[arg(long)] + profile: Option, #[command(flatten)] worktree: WorktreePolicyArgs, }, @@ -381,6 +390,7 @@ async fn main() -> Result<()> { Some(Commands::Start { task, agent, + profile, worktree, from_session, }) => { @@ -394,18 +404,34 @@ async fn main() -> Result<()> { } else { None }; - let session_id = session::manager::create_session_with_grouping( - &db, - &cfg, - &task, - &agent, - use_worktree, - session::SessionGrouping { - project: source.as_ref().map(|session| session.project.clone()), - task_group: source.as_ref().map(|session| session.task_group.clone()), - }, - ) - .await?; + let grouping = session::SessionGrouping { + project: source.as_ref().map(|session| session.project.clone()), + task_group: source.as_ref().map(|session| session.task_group.clone()), + }; + let session_id = if let Some(source) = source.as_ref() { + session::manager::create_session_from_source_with_profile_and_grouping( + &db, + &cfg, + &task, + &agent, + use_worktree, + profile.as_deref(), + &source.id, + grouping, + ) + .await? + } else { + session::manager::create_session_with_profile_and_grouping( + &db, + &cfg, + &task, + &agent, + use_worktree, + profile.as_deref(), + grouping, + ) + .await? + }; if let Some(source) = source { let from_id = source.id; send_handoff_message(&db, &from_id, &session_id)?; @@ -416,6 +442,7 @@ async fn main() -> Result<()> { from_session, task, agent, + profile, worktree, }) => { let use_worktree = worktree.resolve(&cfg); @@ -431,12 +458,14 @@ async fn main() -> Result<()> { ) }); - let session_id = session::manager::create_session_with_grouping( + let session_id = session::manager::create_session_from_source_with_profile_and_grouping( &db, &cfg, &task, &agent, use_worktree, + profile.as_deref(), + &source.id, session::SessionGrouping { project: Some(source.project.clone()), task_group: Some(source.task_group.clone()), @@ -454,13 +483,22 @@ async fn main() -> Result<()> { from_session, task, agent, + profile, worktree, }) => { let use_worktree = worktree.resolve(&cfg); let lead_id = resolve_session_id(&db, &from_session)?; - let outcome = - session::manager::assign_session(&db, &cfg, &lead_id, &task, &agent, use_worktree) - .await?; + let outcome = session::manager::assign_session_with_profile_and_grouping( + &db, + &cfg, + &lead_id, + &task, + &agent, + use_worktree, + profile.as_deref(), + session::SessionGrouping::default(), + ) + .await?; if session::manager::assignment_action_routes_work(outcome.action) { println!( "Assignment routed: {} -> {} ({})", diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 8311a4f2..28669e9d 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -11,7 +11,7 @@ use super::runtime::capture_command_output; use super::store::StateStore; use super::{ default_project_label, default_task_group_label, normalize_group_label, Session, - SessionGrouping, SessionMetrics, SessionState, + SessionAgentProfile, SessionGrouping, SessionMetrics, SessionState, }; use crate::comms::{self, MessageType}; use crate::config::Config; @@ -25,12 +25,13 @@ pub async fn create_session( agent_type: &str, use_worktree: bool, ) -> Result { - create_session_with_grouping( + create_session_with_profile_and_grouping( db, cfg, task, agent_type, use_worktree, + None, SessionGrouping::default(), ) .await @@ -43,6 +44,27 @@ pub async fn create_session_with_grouping( agent_type: &str, use_worktree: bool, grouping: SessionGrouping, +) -> Result { + create_session_with_profile_and_grouping( + db, + cfg, + task, + agent_type, + use_worktree, + None, + grouping, + ) + .await +} + +pub async fn create_session_with_profile_and_grouping( + db: &StateStore, + cfg: &Config, + task: &str, + agent_type: &str, + use_worktree: bool, + profile_name: Option<&str>, + grouping: SessionGrouping, ) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve current working directory")?; @@ -53,6 +75,34 @@ pub async fn create_session_with_grouping( agent_type, use_worktree, &repo_root, + profile_name, + None, + grouping, + ) + .await +} + +pub async fn create_session_from_source_with_profile_and_grouping( + db: &StateStore, + cfg: &Config, + task: &str, + agent_type: &str, + use_worktree: bool, + profile_name: Option<&str>, + source_session_id: &str, + grouping: SessionGrouping, +) -> Result { + let repo_root = + std::env::current_dir().context("Failed to resolve current working directory")?; + queue_session_in_dir( + db, + cfg, + task, + agent_type, + use_worktree, + &repo_root, + profile_name, + Some(source_session_id), grouping, ) .await @@ -66,6 +116,7 @@ pub fn get_status(db: &StateStore, id: &str) -> Result { let session = resolve_session(db, id)?; let session_id = session.id.clone(); Ok(SessionStatus { + profile: db.get_session_profile(&session_id)?, session, parent_session: db.latest_task_handoff_source(&session_id)?, delegated_children: db.delegated_children(&session_id, 5)?, @@ -159,13 +210,14 @@ pub async fn assign_session( agent_type: &str, use_worktree: bool, ) -> Result { - assign_session_with_grouping( + assign_session_with_profile_and_grouping( db, cfg, lead_id, task, agent_type, use_worktree, + None, SessionGrouping::default(), ) .await @@ -179,6 +231,29 @@ pub async fn assign_session_with_grouping( agent_type: &str, use_worktree: bool, grouping: SessionGrouping, +) -> Result { + assign_session_with_profile_and_grouping( + db, + cfg, + lead_id, + task, + agent_type, + use_worktree, + None, + grouping, + ) + .await +} + +pub async fn assign_session_with_profile_and_grouping( + db: &StateStore, + cfg: &Config, + lead_id: &str, + task: &str, + agent_type: &str, + use_worktree: bool, + profile_name: Option<&str>, + grouping: SessionGrouping, ) -> Result { let repo_root = std::env::current_dir().context("Failed to resolve current working directory")?; @@ -191,6 +266,7 @@ pub async fn assign_session_with_grouping( use_worktree, &repo_root, &std::env::current_exe().context("Failed to resolve ECC executable path")?, + profile_name, grouping, ) .await @@ -228,6 +304,7 @@ pub async fn drain_inbox( use_worktree, &repo_root, &runner_program, + None, SessionGrouping::default(), ) .await?; @@ -434,6 +511,7 @@ pub async fn rebalance_team_backlog( use_worktree, &repo_root, &runner_program, + None, SessionGrouping::default(), ) .await?; @@ -464,12 +542,15 @@ pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> { pub struct BudgetEnforcementOutcome { pub token_budget_exceeded: bool, pub cost_budget_exceeded: bool, + pub profile_token_budget_exceeded: bool, pub paused_sessions: Vec, } impl BudgetEnforcementOutcome { pub fn hard_limit_exceeded(&self) -> bool { - self.token_budget_exceeded || self.cost_budget_exceeded + self.token_budget_exceeded + || self.cost_budget_exceeded + || self.profile_token_budget_exceeded } } @@ -490,18 +571,51 @@ pub fn enforce_budget_hard_limits( let mut outcome = BudgetEnforcementOutcome { token_budget_exceeded: cfg.token_budget > 0 && total_tokens >= cfg.token_budget, cost_budget_exceeded: cfg.cost_budget_usd > 0.0 && total_cost >= cfg.cost_budget_usd, + profile_token_budget_exceeded: false, paused_sessions: Vec::new(), }; + let mut sessions_to_pause = HashSet::new(); + + if outcome.token_budget_exceeded || outcome.cost_budget_exceeded { + for session in sessions.iter().filter(|session| { + matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) + }) { + sessions_to_pause.insert(session.id.clone()); + } + } + + for session in sessions.iter().filter(|session| { + matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) + }) { + let Some(profile) = db.get_session_profile(&session.id)? else { + continue; + }; + let Some(token_budget) = profile.token_budget else { + continue; + }; + if token_budget > 0 && session.metrics.tokens_used >= token_budget { + outcome.profile_token_budget_exceeded = true; + sessions_to_pause.insert(session.id.clone()); + } + } + if !outcome.hard_limit_exceeded() { return Ok(outcome); } for session in sessions.into_iter().filter(|session| { - matches!( - session.state, - SessionState::Pending | SessionState::Running | SessionState::Idle - ) + sessions_to_pause.contains(&session.id) + && matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle + ) }) { stop_session_recorded(db, &session, false)?; outcome.paused_sessions.push(session.id); @@ -820,6 +934,7 @@ async fn assign_session_in_dir_with_runner_program( use_worktree: bool, repo_root: &Path, runner_program: &Path, + profile_name: Option<&str>, grouping: SessionGrouping, ) -> Result { let lead = resolve_session(db, lead_id)?; @@ -868,6 +983,8 @@ async fn assign_session_in_dir_with_runner_program( use_worktree, repo_root, runner_program, + profile_name, + Some(&lead.id), inherited_grouping.clone(), ) .await?; @@ -943,6 +1060,8 @@ async fn assign_session_in_dir_with_runner_program( use_worktree, repo_root, runner_program, + profile_name, + Some(&lead.id), inherited_grouping, ) .await?; @@ -1623,7 +1742,8 @@ pub async fn run_session( } let agent_program = agent_program(agent_type)?; - let command = build_agent_command(&agent_program, task, session_id, working_dir); + let profile = db.get_session_profile(session_id)?; + let command = build_agent_command(&agent_program, task, session_id, working_dir, profile.as_ref()); capture_command_output( cfg.db_path.clone(), session_id.to_string(), @@ -1750,6 +1870,8 @@ async fn queue_session_in_dir( agent_type: &str, use_worktree: bool, repo_root: &Path, + profile_name: Option<&str>, + inherited_profile_session_id: Option<&str>, grouping: SessionGrouping, ) -> Result { queue_session_in_dir_with_runner_program( @@ -1760,6 +1882,8 @@ async fn queue_session_in_dir( use_worktree, repo_root, &std::env::current_exe().context("Failed to resolve ECC executable path")?, + profile_name, + inherited_profile_session_id, grouping, ) .await @@ -1773,11 +1897,29 @@ async fn queue_session_in_dir_with_runner_program( use_worktree: bool, repo_root: &Path, runner_program: &Path, + profile_name: Option<&str>, + inherited_profile_session_id: Option<&str>, grouping: SessionGrouping, ) -> Result { - let session = - build_session_record(db, task, agent_type, use_worktree, cfg, repo_root, grouping)?; + let profile = + resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?; + let effective_agent_type = profile + .as_ref() + .and_then(|profile| profile.agent.as_deref()) + .unwrap_or(agent_type); + let session = build_session_record( + db, + task, + effective_agent_type, + use_worktree, + cfg, + repo_root, + grouping, + )?; db.insert_session(&session)?; + if let Some(profile) = profile.as_ref() { + db.upsert_session_profile(&session.id, profile)?; + } if use_worktree && session.worktree.is_none() { db.enqueue_pending_worktree(&session.id, repo_root)?; @@ -1793,7 +1935,7 @@ async fn queue_session_in_dir_with_runner_program( match spawn_session_runner_for_program( task, &session.id, - agent_type, + &session.agent_type, working_dir, runner_program, ) @@ -1911,6 +2053,27 @@ async fn create_session_in_dir( } } +fn resolve_launch_profile( + db: &StateStore, + cfg: &Config, + explicit_profile_name: Option<&str>, + inherited_profile_session_id: Option<&str>, +) -> Result> { + let inherited_profile_name = match inherited_profile_session_id { + Some(session_id) => db.get_session_profile(session_id)?.map(|profile| profile.profile_name), + None => None, + }; + let profile_name = explicit_profile_name + .map(ToOwned::to_owned) + .or(inherited_profile_name) + .or_else(|| cfg.default_agent_profile.clone()); + + profile_name + .as_deref() + .map(|name| cfg.resolve_agent_profile(name)) + .transpose() +} + fn attached_worktree_count(db: &StateStore) -> Result { Ok(db .list_sessions()? @@ -2075,16 +2238,44 @@ fn build_agent_command( task: &str, session_id: &str, working_dir: &Path, + profile: Option<&SessionAgentProfile>, ) -> Command { let mut command = Command::new(agent_program); + command.env("ECC_SESSION_ID", session_id); command - .env("ECC_SESSION_ID", session_id) .arg("--print") .arg("--name") - .arg(format!("ecc-{session_id}")) - .arg(task) - .current_dir(working_dir) - .stdin(Stdio::null()); + .arg(format!("ecc-{session_id}")); + if let Some(profile) = profile { + if let Some(model) = profile.model.as_ref() { + command.arg("--model").arg(model); + } + if !profile.allowed_tools.is_empty() { + command + .arg("--allowed-tools") + .arg(profile.allowed_tools.join(",")); + } + if !profile.disallowed_tools.is_empty() { + command + .arg("--disallowed-tools") + .arg(profile.disallowed_tools.join(",")); + } + if let Some(permission_mode) = profile.permission_mode.as_ref() { + command.arg("--permission-mode").arg(permission_mode); + } + for dir in &profile.add_dirs { + command.arg("--add-dir").arg(dir); + } + if let Some(max_budget_usd) = profile.max_budget_usd { + command + .arg("--max-budget-usd") + .arg(max_budget_usd.to_string()); + } + if let Some(prompt) = profile.append_system_prompt.as_ref() { + command.arg("--append-system-prompt").arg(prompt); + } + } + command.arg(task).current_dir(working_dir).stdin(Stdio::null()); command } @@ -2094,7 +2285,7 @@ async fn spawn_claude_code( session_id: &str, working_dir: &Path, ) -> Result { - let mut command = build_agent_command(agent_program, task, session_id, working_dir); + let mut command = build_agent_command(agent_program, task, session_id, working_dir, None); let child = command .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -2194,6 +2385,7 @@ async fn kill_process(pid: u32) -> Result<()> { } pub struct SessionStatus { + profile: Option, session: Session, parent_session: Option, delegated_children: Vec, @@ -2363,6 +2555,21 @@ impl fmt::Display for SessionStatus { writeln!(f, "Task: {}", s.task)?; writeln!(f, "Agent: {}", s.agent_type)?; writeln!(f, "State: {}", s.state)?; + if let Some(profile) = self.profile.as_ref() { + writeln!(f, "Profile: {}", profile.profile_name)?; + if let Some(model) = profile.model.as_ref() { + writeln!(f, "Model: {}", model)?; + } + if let Some(permission_mode) = profile.permission_mode.as_ref() { + writeln!(f, "Perms: {}", permission_mode)?; + } + if let Some(token_budget) = profile.token_budget { + writeln!(f, "Profile tokens: {}", token_budget)?; + } + if let Some(max_budget_usd) = profile.max_budget_usd { + writeln!(f, "Profile cost: ${max_budget_usd:.4}")?; + } + } if let Some(parent) = self.parent_session.as_ref() { writeln!(f, "Parent: {}", parent)?; } @@ -2590,7 +2797,7 @@ fn session_state_label(state: &SessionState) -> &'static str { mod tests { use super::*; use crate::config::{Config, PaneLayout, Theme}; - use crate::session::{Session, SessionMetrics, SessionState}; + use crate::session::{Session, SessionAgentProfile, SessionMetrics, SessionState}; use anyhow::{Context, Result}; use chrono::{Duration, Utc}; use std::fs; @@ -2635,6 +2842,8 @@ mod tests { heartbeat_interval_secs: 5, auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), + default_agent_profile: None, + agent_profiles: Default::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -2674,6 +2883,61 @@ mod tests { } } + #[test] + fn build_agent_command_applies_profile_runner_flags() { + let profile = SessionAgentProfile { + profile_name: "reviewer".to_string(), + agent: None, + model: Some("sonnet".to_string()), + allowed_tools: vec!["Read".to_string(), "Edit".to_string()], + disallowed_tools: vec!["Bash".to_string()], + permission_mode: Some("plan".to_string()), + add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")], + max_budget_usd: Some(1.25), + token_budget: Some(750), + append_system_prompt: Some("Review thoroughly.".to_string()), + }; + + let command = build_agent_command( + Path::new("claude"), + "review this change", + "sess-1234", + Path::new("/tmp/repo"), + Some(&profile), + ); + let args = command + .as_std() + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!( + args, + vec![ + "--print", + "--name", + "ecc-sess-1234", + "--model", + "sonnet", + "--allowed-tools", + "Read,Edit", + "--disallowed-tools", + "Bash", + "--permission-mode", + "plan", + "--add-dir", + "docs", + "--add-dir", + "specs", + "--max-budget-usd", + "1.25", + "--append-system-prompt", + "Review thoroughly.", + "review this change", + ] + ); + } + #[test] fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> { let tempdir = TestDir::new("manager-heartbeat-stale")?; @@ -3099,6 +3363,62 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()> { + let tempdir = TestDir::new("manager-default-agent-profile")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.default_agent_profile = Some("reviewer".to_string()); + cfg.agent_profiles.insert( + "reviewer".to_string(), + crate::config::AgentProfileConfig { + model: Some("sonnet".to_string()), + allowed_tools: vec!["Read".to_string(), "Edit".to_string()], + disallowed_tools: vec!["Bash".to_string()], + permission_mode: Some("plan".to_string()), + add_dirs: vec![PathBuf::from("docs")], + token_budget: Some(800), + append_system_prompt: Some("Review thoroughly.".to_string()), + ..Default::default() + }, + ); + let db = StateStore::open(&cfg.db_path)?; + let (fake_runner, _) = write_fake_claude(tempdir.path())?; + + let session_id = queue_session_in_dir_with_runner_program( + &db, + &cfg, + "review work", + "claude", + false, + &repo_root, + &fake_runner, + None, + None, + SessionGrouping::default(), + ) + .await?; + + let profile = db + .get_session_profile(&session_id)? + .context("session profile should be persisted")?; + assert_eq!(profile.profile_name, "reviewer"); + assert_eq!(profile.model.as_deref(), Some("sonnet")); + assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]); + assert_eq!(profile.disallowed_tools, vec!["Bash"]); + assert_eq!(profile.permission_mode.as_deref(), Some("plan")); + assert_eq!(profile.add_dirs, vec![PathBuf::from("docs")]); + assert_eq!(profile.token_budget, Some(800)); + assert_eq!( + profile.append_system_prompt.as_deref(), + Some("Review thoroughly.") + ); + + Ok(()) + } + #[test] fn enforce_budget_hard_limits_stops_active_sessions_without_cleaning_worktrees() -> Result<()> { let tempdir = TestDir::new("manager-budget-pause")?; @@ -3214,6 +3534,73 @@ mod tests { Ok(()) } + #[test] + fn enforce_budget_hard_limits_pauses_sessions_over_profile_token_budget() -> Result<()> { + let tempdir = TestDir::new("manager-profile-token-budget")?; + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "profile-over-budget".to_string(), + task: "review work".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: tempdir.path().to_path_buf(), + state: SessionState::Running, + pid: Some(999_998), + worktree: None, + created_at: now - Duration::minutes(1), + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.upsert_session_profile( + "profile-over-budget", + &SessionAgentProfile { + profile_name: "reviewer".to_string(), + agent: None, + model: Some("sonnet".to_string()), + allowed_tools: vec!["Read".to_string()], + disallowed_tools: Vec::new(), + permission_mode: Some("plan".to_string()), + add_dirs: Vec::new(), + max_budget_usd: None, + token_budget: Some(75), + append_system_prompt: None, + }, + )?; + db.update_metrics( + "profile-over-budget", + &SessionMetrics { + input_tokens: 60, + output_tokens: 30, + tokens_used: 90, + tool_calls: 0, + files_changed: 0, + duration_secs: 60, + cost_usd: 0.0, + }, + )?; + + let outcome = enforce_budget_hard_limits(&db, &cfg)?; + assert!(!outcome.token_budget_exceeded); + assert!(!outcome.cost_budget_exceeded); + assert!(outcome.profile_token_budget_exceeded); + assert_eq!( + outcome.paused_sessions, + vec!["profile-over-budget".to_string()] + ); + + let session = db + .get_session("profile-over-budget")? + .context("session should still exist")?; + assert_eq!(session.state, SessionState::Stopped); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn resume_session_requeues_failed_session() -> Result<()> { let tempdir = TestDir::new("manager-resume-session")?; @@ -4108,6 +4495,7 @@ mod tests { true, &repo_root, &fake_runner, + None, SessionGrouping::default(), ) .await?; @@ -4181,6 +4569,7 @@ mod tests { true, &repo_root, &fake_runner, + None, SessionGrouping::default(), ) .await?; @@ -4266,6 +4655,7 @@ mod tests { true, &repo_root, &fake_runner, + None, SessionGrouping::default(), ) .await?; @@ -4338,6 +4728,7 @@ mod tests { true, &repo_root, &fake_runner, + None, SessionGrouping::default(), ) .await?; @@ -4394,6 +4785,7 @@ mod tests { true, &repo_root, &fake_runner, + None, SessionGrouping::default(), ) .await?; @@ -4467,6 +4859,7 @@ mod tests { true, &repo_root, &fake_runner, + None, SessionGrouping::default(), ) .await?; diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index b66f6ee0..301f3384 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -10,6 +10,8 @@ use std::fmt; use std::path::Path; use std::path::PathBuf; +pub type SessionAgentProfile = crate::config::ResolvedAgentProfile; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: String, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index b7029b57..d0b82686 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -14,8 +14,8 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ default_project_label, default_task_group_label, normalize_group_label, DecisionLogEntry, - FileActivityAction, FileActivityEntry, Session, SessionMessage, SessionMetrics, SessionState, - WorktreeInfo, + FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, SessionMessage, + SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { @@ -194,6 +194,19 @@ impl StateStore { file_events_json TEXT NOT NULL DEFAULT '[]' ); + CREATE TABLE IF NOT EXISTS session_profiles ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + profile_name TEXT NOT NULL, + model TEXT, + allowed_tools_json TEXT NOT NULL DEFAULT '[]', + disallowed_tools_json TEXT NOT NULL DEFAULT '[]', + permission_mode TEXT, + add_dirs_json TEXT NOT NULL DEFAULT '[]', + max_budget_usd REAL, + token_budget INTEGER, + append_system_prompt TEXT + ); + CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, from_session TEXT NOT NULL, @@ -569,6 +582,98 @@ impl StateStore { Ok(()) } + pub fn upsert_session_profile( + &self, + session_id: &str, + profile: &SessionAgentProfile, + ) -> Result<()> { + let allowed_tools_json = serde_json::to_string(&profile.allowed_tools) + .context("serialize allowed agent profile tools")?; + let disallowed_tools_json = serde_json::to_string(&profile.disallowed_tools) + .context("serialize disallowed agent profile tools")?; + let add_dirs_json = serde_json::to_string(&profile.add_dirs) + .context("serialize agent profile add_dirs")?; + + self.conn.execute( + "INSERT INTO session_profiles ( + session_id, + profile_name, + model, + allowed_tools_json, + disallowed_tools_json, + permission_mode, + add_dirs_json, + max_budget_usd, + token_budget, + append_system_prompt + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(session_id) DO UPDATE SET + profile_name = excluded.profile_name, + model = excluded.model, + allowed_tools_json = excluded.allowed_tools_json, + disallowed_tools_json = excluded.disallowed_tools_json, + permission_mode = excluded.permission_mode, + add_dirs_json = excluded.add_dirs_json, + max_budget_usd = excluded.max_budget_usd, + token_budget = excluded.token_budget, + append_system_prompt = excluded.append_system_prompt", + rusqlite::params![ + session_id, + profile.profile_name, + profile.model, + allowed_tools_json, + disallowed_tools_json, + profile.permission_mode, + add_dirs_json, + profile.max_budget_usd, + profile.token_budget, + profile.append_system_prompt, + ], + )?; + Ok(()) + } + + pub fn get_session_profile(&self, session_id: &str) -> Result> { + self.conn + .query_row( + "SELECT + profile_name, + model, + allowed_tools_json, + disallowed_tools_json, + permission_mode, + add_dirs_json, + max_budget_usd, + token_budget, + append_system_prompt + FROM session_profiles + WHERE session_id = ?1", + [session_id], + |row| { + let allowed_tools_json: String = row.get(2)?; + let disallowed_tools_json: String = row.get(3)?; + let add_dirs_json: String = row.get(5)?; + Ok(SessionAgentProfile { + profile_name: row.get(0)?, + model: row.get(1)?, + allowed_tools: serde_json::from_str(&allowed_tools_json) + .unwrap_or_default(), + disallowed_tools: serde_json::from_str(&disallowed_tools_json) + .unwrap_or_default(), + permission_mode: row.get(4)?, + add_dirs: serde_json::from_str(&add_dirs_json).unwrap_or_default(), + max_budget_usd: row.get(6)?, + token_budget: row.get(7)?, + append_system_prompt: row.get(8)?, + agent: None, + }) + }, + ) + .optional() + .map_err(Into::into) + } + pub fn update_state_and_pid( &self, session_id: &str, @@ -2532,6 +2637,63 @@ mod tests { Ok(()) } + #[test] + fn session_profile_round_trips_with_launch_settings() -> Result<()> { + let tempdir = TestDir::new("store-session-profile")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "review work".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Pending, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + db.upsert_session_profile( + "session-1", + &crate::session::SessionAgentProfile { + agent: None, + profile_name: "reviewer".to_string(), + model: Some("sonnet".to_string()), + allowed_tools: vec!["Read".to_string(), "Edit".to_string()], + disallowed_tools: vec!["Bash".to_string()], + permission_mode: Some("plan".to_string()), + add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")], + max_budget_usd: Some(1.5), + token_budget: Some(1200), + append_system_prompt: Some("Review thoroughly.".to_string()), + }, + )?; + + let profile = db + .get_session_profile("session-1")? + .expect("profile should be stored"); + assert_eq!(profile.profile_name, "reviewer"); + assert_eq!(profile.model.as_deref(), Some("sonnet")); + assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]); + assert_eq!(profile.disallowed_tools, vec!["Bash"]); + assert_eq!(profile.permission_mode.as_deref(), Some("plan")); + assert_eq!(profile.add_dirs, vec![PathBuf::from("docs"), PathBuf::from("specs")]); + assert_eq!(profile.max_budget_usd, Some(1.5)); + assert_eq!(profile.token_budget, Some(1200)); + assert_eq!( + profile.append_system_prompt.as_deref(), + Some("Review thoroughly.") + ); + + Ok(()) + } + #[test] fn sync_cost_tracker_metrics_aggregates_usage_into_sessions() -> Result<()> { let tempdir = TestDir::new("store-cost-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 68603fb9..a471d637 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -5392,6 +5392,11 @@ impl Dashboard { fn selected_session_metrics_text(&self) -> String { if let Some(session) = self.sessions.get(self.selected_session) { let metrics = &session.metrics; + let selected_profile = self + .db + .get_session_profile(&session.id) + .ok() + .flatten(); let group_peers = self .sessions .iter() @@ -5413,6 +5418,57 @@ impl Dashboard { ), ]; + if let Some(profile) = selected_profile.as_ref() { + let model = profile.model.as_deref().unwrap_or("default"); + let permission_mode = profile.permission_mode.as_deref().unwrap_or("default"); + lines.push(format!( + "Profile {} | Model {} | Permissions {}", + profile.profile_name, model, permission_mode + )); + let mut profile_details = Vec::new(); + if let Some(token_budget) = profile.token_budget { + profile_details.push(format!( + "Profile tokens {}", + format_token_count(token_budget) + )); + } + if let Some(max_budget_usd) = profile.max_budget_usd { + profile_details.push(format!( + "Profile cost {}", + format_currency(max_budget_usd) + )); + } + if !profile.allowed_tools.is_empty() { + profile_details.push(format!( + "Allow {}", + truncate_for_dashboard(&profile.allowed_tools.join(", "), 36) + )); + } + if !profile.disallowed_tools.is_empty() { + profile_details.push(format!( + "Deny {}", + truncate_for_dashboard(&profile.disallowed_tools.join(", "), 36) + )); + } + if !profile.add_dirs.is_empty() { + profile_details.push(format!( + "Dirs {}", + truncate_for_dashboard( + &profile + .add_dirs + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "), + 36 + ) + )); + } + if !profile_details.is_empty() { + lines.push(profile_details.join(" | ")); + } + } + if let Some(parent) = self.selected_parent_session.as_ref() { lines.push(format!("Delegated from {}", format_session_id(parent))); } @@ -7878,11 +7934,16 @@ fn heartbeat_enforcement_note(outcome: &manager::HeartbeatEnforcementOutcome) -> } fn budget_auto_pause_note(outcome: &manager::BudgetEnforcementOutcome) -> String { - let cause = match (outcome.token_budget_exceeded, outcome.cost_budget_exceeded) { - (true, true) => "token and cost budgets exceeded", - (true, false) => "token budget exceeded", - (false, true) => "cost budget exceeded", - (false, false) => "budget exceeded", + let cause = match ( + outcome.token_budget_exceeded, + outcome.cost_budget_exceeded, + outcome.profile_token_budget_exceeded, + ) { + (true, true, _) => "token and cost budgets exceeded", + (true, false, _) => "token budget exceeded", + (false, true, _) => "cost budget exceeded", + (false, false, true) => "profile token budget exceeded", + (false, false, false) => "budget exceeded", }; format!( @@ -13011,6 +13072,8 @@ diff --git a/src/lib.rs b/src/lib.rs heartbeat_interval_secs: 5, auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), + default_agent_profile: None, + agent_profiles: Default::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, From 194bf605c216547319af6aae1e069e2f79acf3fe Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 03:38:11 -0700 Subject: [PATCH 120/459] feat: add ecc2 orchestration templates --- ecc2/src/config/mod.rs | 260 ++++++++++++++++++- ecc2/src/main.rs | 161 +++++++++++- ecc2/src/session/manager.rs | 240 ++++++++++++++++- ecc2/src/session/store.rs | 9 +- ecc2/src/tui/dashboard.rs | 502 +++++++++++++++++++++++++++++------- 5 files changed, 1053 insertions(+), 119 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 938903a1..4e00bc9b 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::PathBuf; @@ -78,6 +79,50 @@ pub struct ResolvedAgentProfile { pub append_system_prompt: Option, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct OrchestrationTemplateConfig { + pub description: Option, + pub project: Option, + pub task_group: Option, + pub agent: Option, + pub profile: Option, + pub worktree: Option, + pub steps: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct OrchestrationTemplateStepConfig { + pub name: Option, + pub task: String, + pub agent: Option, + pub profile: Option, + pub worktree: Option, + pub project: Option, + pub task_group: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedOrchestrationTemplate { + pub template_name: String, + pub description: Option, + pub project: Option, + pub task_group: Option, + pub steps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedOrchestrationTemplateStep { + pub name: String, + pub task: String, + pub agent: Option, + pub profile: Option, + pub worktree: bool, + pub project: Option, + pub task_group: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { @@ -93,6 +138,7 @@ pub struct Config { pub default_agent: String, pub default_agent_profile: Option, pub agent_profiles: BTreeMap, + pub orchestration_templates: BTreeMap, pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_limit_per_session: usize, pub auto_create_worktrees: bool, @@ -156,6 +202,7 @@ impl Default for Config { default_agent: "claude".to_string(), default_agent_profile: None, agent_profiles: BTreeMap::new(), + orchestration_templates: BTreeMap::new(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -219,6 +266,80 @@ impl Config { self.resolve_agent_profile_inner(name, &mut chain) } + pub fn resolve_orchestration_template( + &self, + name: &str, + vars: &BTreeMap, + ) -> Result { + let template = self + .orchestration_templates + .get(name) + .ok_or_else(|| anyhow::anyhow!("Unknown orchestration template: {name}"))?; + + if template.steps.is_empty() { + anyhow::bail!("orchestration template {name} has no steps"); + } + + let description = interpolate_optional_string(template.description.as_deref(), vars)?; + let project = interpolate_optional_string(template.project.as_deref(), vars)?; + let task_group = interpolate_optional_string(template.task_group.as_deref(), vars)?; + let default_agent = interpolate_optional_string(template.agent.as_deref(), vars)?; + let default_profile = interpolate_optional_string(template.profile.as_deref(), vars)?; + if let Some(profile_name) = default_profile.as_deref() { + self.resolve_agent_profile(profile_name)?; + } + + let mut steps = Vec::with_capacity(template.steps.len()); + for (index, step) in template.steps.iter().enumerate() { + let task = interpolate_required_string(&step.task, vars).with_context(|| { + format!( + "resolve task for orchestration template {name} step {}", + index + 1 + ) + })?; + let step_name = interpolate_optional_string(step.name.as_deref(), vars)? + .unwrap_or_else(|| format!("step {}", index + 1)); + let agent = interpolate_optional_string( + step.agent.as_deref().or(default_agent.as_deref()), + vars, + )?; + let profile = interpolate_optional_string( + step.profile.as_deref().or(default_profile.as_deref()), + vars, + )?; + if let Some(profile_name) = profile.as_deref() { + self.resolve_agent_profile(profile_name)?; + } + + steps.push(ResolvedOrchestrationTemplateStep { + name: step_name, + task, + agent, + profile, + worktree: step + .worktree + .or(template.worktree) + .unwrap_or(self.auto_create_worktrees), + project: interpolate_optional_string( + step.project.as_deref().or(project.as_deref()), + vars, + )?, + task_group: interpolate_optional_string( + step.task_group.as_deref().or(task_group.as_deref()), + vars, + )?, + }); + } + + Ok(ResolvedOrchestrationTemplate { + template_name: name.to_string(), + description, + project, + task_group, + steps, + }) + } + fn resolve_agent_profile_inner( &self, name: &str, @@ -226,10 +347,7 @@ impl Config { ) -> Result { if chain.iter().any(|existing| existing == name) { chain.push(name.to_string()); - anyhow::bail!( - "agent profile inheritance cycle: {}", - chain.join(" -> ") - ); + anyhow::bail!("agent profile inheritance cycle: {}", chain.join(" -> ")); } let profile = self @@ -550,6 +668,55 @@ where } } +fn interpolate_optional_string( + value: Option<&str>, + vars: &BTreeMap, +) -> Result> { + value + .map(|value| interpolate_required_string(value, vars)) + .transpose() + .map(|value| { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + }) +} + +fn interpolate_required_string(value: &str, vars: &BTreeMap) -> Result { + let placeholder = Regex::new(r"\{\{\s*([A-Za-z0-9_-]+)\s*\}\}") + .expect("orchestration template placeholder regex"); + let mut missing = Vec::new(); + let rendered = placeholder.replace_all(value, |captures: ®ex::Captures<'_>| { + let key = captures + .get(1) + .map(|capture| capture.as_str()) + .unwrap_or_default(); + match vars.get(key) { + Some(value) => value.to_string(), + None => { + missing.push(key.to_string()); + String::new() + } + } + }); + + if !missing.is_empty() { + missing.sort(); + missing.dedup(); + anyhow::bail!( + "missing orchestration template variable(s): {}", + missing.join(", ") + ); + } + + Ok(rendered.into_owned()) +} + impl BudgetAlertThresholds { pub fn sanitized(self) -> Self { let values = [self.advisory, self.warning, self.critical]; @@ -574,6 +741,7 @@ mod tests { PaneLayout, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::collections::BTreeMap; use std::path::PathBuf; use uuid::Uuid; @@ -979,6 +1147,90 @@ inherits = "a" .contains("agent profile inheritance cycle")); } + #[test] + fn orchestration_templates_resolve_steps_and_interpolate_variables() { + let config: Config = toml::from_str( + r#" +default_agent = "claude" +default_agent_profile = "reviewer" + +[agent_profiles.reviewer] +model = "sonnet" + +[orchestration_templates.feature_development] +description = "Ship {{task}}" +project = "{{project}}" +task_group = "{{task_group}}" +profile = "reviewer" +worktree = true + +[[orchestration_templates.feature_development.steps]] +name = "planner" +task = "Plan {{task}}" +agent = "claude" + +[[orchestration_templates.feature_development.steps]] +name = "reviewer" +task = "Review {{task}} in {{component}}" +profile = "reviewer" +worktree = false +"#, + ) + .unwrap(); + + let vars = BTreeMap::from([ + ("task".to_string(), "stabilize auth callback".to_string()), + ("project".to_string(), "ecc-core".to_string()), + ("task_group".to_string(), "auth callback".to_string()), + ("component".to_string(), "billing".to_string()), + ]); + let template = config + .resolve_orchestration_template("feature_development", &vars) + .unwrap(); + + assert_eq!(template.template_name, "feature_development"); + assert_eq!( + template.description.as_deref(), + Some("Ship stabilize auth callback") + ); + assert_eq!(template.project.as_deref(), Some("ecc-core")); + assert_eq!(template.task_group.as_deref(), Some("auth callback")); + assert_eq!(template.steps.len(), 2); + assert_eq!(template.steps[0].name, "planner"); + assert_eq!(template.steps[0].task, "Plan stabilize auth callback"); + assert_eq!(template.steps[0].agent.as_deref(), Some("claude")); + assert_eq!(template.steps[0].profile.as_deref(), Some("reviewer")); + assert!(template.steps[0].worktree); + assert_eq!( + template.steps[1].task, + "Review stabilize auth callback in billing" + ); + assert!(!template.steps[1].worktree); + } + + #[test] + fn orchestration_templates_fail_when_required_variables_are_missing() { + let config: Config = toml::from_str( + r#" +[orchestration_templates.feature_development] +[[orchestration_templates.feature_development.steps]] +task = "Plan {{task}} for {{component}}" +"#, + ) + .unwrap(); + + let error = config + .resolve_orchestration_template( + "feature_development", + &BTreeMap::from([("task".to_string(), "fix retry".to_string())]), + ) + .expect_err("missing template variables must fail"); + let error_text = format!("{error:#}"); + assert!(error_text + .contains("resolve task for orchestration template feature_development step 1")); + assert!(error_text.contains("missing orchestration template variable(s): component")); + } + #[test] fn completion_summary_notifications_deserialize_from_toml() { let config: Config = toml::from_str( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index ee1fcb50..d7b88de7 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -9,6 +9,7 @@ mod worktree; use anyhow::Result; use clap::Parser; use serde::Serialize; +use std::collections::BTreeMap; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -78,6 +79,20 @@ enum Commands { #[command(flatten)] worktree: WorktreePolicyArgs, }, + /// Launch a named orchestration template + Template { + /// Template name defined in ecc2.toml + name: String, + /// Optional task injected into the template context + #[arg(short, long)] + task: Option, + /// Source session to delegate the template from + #[arg(long)] + from_session: Option, + /// Template variables in key=value form + #[arg(long = "var")] + vars: Vec, + }, /// Route work to an existing delegate when possible, otherwise spawn a new one Assign { /// Lead session ID or alias @@ -458,20 +473,21 @@ async fn main() -> Result<()> { ) }); - let session_id = session::manager::create_session_from_source_with_profile_and_grouping( - &db, - &cfg, - &task, - &agent, - use_worktree, - profile.as_deref(), - &source.id, - session::SessionGrouping { - project: Some(source.project.clone()), - task_group: Some(source.task_group.clone()), - }, - ) - .await?; + let session_id = + session::manager::create_session_from_source_with_profile_and_grouping( + &db, + &cfg, + &task, + &agent, + use_worktree, + profile.as_deref(), + &source.id, + session::SessionGrouping { + project: Some(source.project.clone()), + task_group: Some(source.task_group.clone()), + }, + ) + .await?; send_handoff_message(&db, &source.id, &session_id)?; println!( "Delegated session started: {} <- {}", @@ -479,6 +495,43 @@ async fn main() -> Result<()> { short_session(&source.id) ); } + Some(Commands::Template { + name, + task, + from_session, + vars, + }) => { + let source_session_id = from_session + .as_deref() + .map(|session_id| resolve_session_id(&db, session_id)) + .transpose()?; + let outcome = session::manager::launch_orchestration_template( + &db, + &cfg, + &name, + source_session_id.as_deref(), + task.as_deref(), + parse_template_vars(&vars)?, + ) + .await?; + println!( + "Template launched: {} ({} step{})", + outcome.template_name, + outcome.created.len(), + if outcome.created.len() == 1 { "" } else { "s" } + ); + if let Some(anchor_session_id) = outcome.anchor_session_id.as_deref() { + println!("Anchor session: {}", short_session(anchor_session_id)); + } + for step in outcome.created { + println!( + "- {} -> {} | {}", + step.step_name, + short_session(&step.session_id), + step.task + ); + } + } Some(Commands::Assign { from_session, task, @@ -2174,6 +2227,22 @@ fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: & ) } +fn parse_template_vars(values: &[String]) -> Result> { + let mut vars = BTreeMap::new(); + for value in values { + let (key, raw_value) = value + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("template vars must use key=value form: {value}"))?; + let key = key.trim(); + let raw_value = raw_value.trim(); + if key.is_empty() || raw_value.is_empty() { + anyhow::bail!("template vars must use non-empty key=value form: {value}"); + } + vars.insert(key.to_string(), raw_value.to_string()); + } + Ok(vars) +} + #[cfg(test)] mod tests { use super::*; @@ -2424,6 +2493,70 @@ mod tests { } } + #[test] + fn cli_parses_template_command() { + let cli = Cli::try_parse_from([ + "ecc", + "template", + "feature_development", + "--task", + "stabilize auth callback", + "--from-session", + "lead", + "--var", + "component=billing", + "--var", + "area=oauth", + ]) + .expect("template should parse"); + + match cli.command { + Some(Commands::Template { + name, + task, + from_session, + vars, + }) => { + assert_eq!(name, "feature_development"); + assert_eq!(task.as_deref(), Some("stabilize auth callback")); + assert_eq!(from_session.as_deref(), Some("lead")); + assert_eq!( + vars, + vec!["component=billing".to_string(), "area=oauth".to_string(),] + ); + } + _ => panic!("expected template subcommand"), + } + } + + #[test] + fn parse_template_vars_builds_map() { + let vars = + parse_template_vars(&["component=billing".to_string(), "area=oauth".to_string()]) + .expect("template vars"); + + assert_eq!( + vars, + BTreeMap::from([ + ("area".to_string(), "oauth".to_string()), + ("component".to_string(), "billing".to_string()), + ]) + ); + } + + #[test] + fn parse_template_vars_rejects_invalid_entries() { + let error = parse_template_vars(&["missing-delimiter".to_string()]) + .expect_err("invalid template var should fail"); + + assert!( + error + .to_string() + .contains("template vars must use key=value form"), + "unexpected error: {error}" + ); + } + #[test] fn cli_parses_team_command() { let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"]) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 28669e9d..3f5f2c2c 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -150,6 +150,197 @@ pub fn get_team_status(db: &StateStore, id: &str, depth: usize) -> Result, + pub created: Vec, +} + +pub async fn launch_orchestration_template( + db: &StateStore, + cfg: &Config, + template_name: &str, + source_session_id: Option<&str>, + task: Option<&str>, + variables: BTreeMap, +) -> Result { + let repo_root = + std::env::current_dir().context("Failed to resolve current working directory")?; + let runner_program = + std::env::current_exe().context("Failed to resolve ECC executable path")?; + let source_session = source_session_id + .map(|id| resolve_session(db, id)) + .transpose()?; + let vars = build_template_variables(&repo_root, source_session.as_ref(), task, variables); + let template = cfg.resolve_orchestration_template(template_name, &vars)?; + let live_sessions = db + .list_sessions()? + .into_iter() + .filter(|session| { + matches!( + session.state, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale + ) + }) + .count(); + let available_slots = cfg.max_parallel_sessions.saturating_sub(live_sessions); + if template.steps.len() > available_slots { + anyhow::bail!( + "template {template_name} requires {} session slots but only {available_slots} available", + template.steps.len() + ); + } + + let default_profile = cfg + .default_agent_profile + .as_deref() + .map(|name| cfg.resolve_agent_profile(name)) + .transpose()?; + let base_grouping = SessionGrouping { + project: Some( + source_session + .as_ref() + .map(|session| session.project.clone()) + .unwrap_or_else(|| default_project_label(&repo_root)), + ), + task_group: Some( + source_session + .as_ref() + .map(|session| session.task_group.clone()) + .or_else(|| task.map(default_task_group_label)) + .unwrap_or_else(|| template_name.replace(['_', '-'], " ")), + ), + }; + + let mut created = Vec::with_capacity(template.steps.len()); + let mut anchor_session_id = source_session.as_ref().map(|session| session.id.clone()); + let mut created_anchor_id: Option = None; + + for step in template.steps { + let profile = match step.profile.as_deref() { + Some(name) => Some(cfg.resolve_agent_profile(name)?), + None if step.agent.is_some() => None, + None => default_profile.clone(), + }; + let agent = step + .agent + .as_deref() + .unwrap_or(&cfg.default_agent) + .to_string(); + let grouping = SessionGrouping { + project: step + .project + .clone() + .or_else(|| base_grouping.project.clone()), + task_group: step + .task_group + .clone() + .or_else(|| base_grouping.task_group.clone()), + }; + let session_id = queue_session_with_resolved_profile_and_runner_program( + db, + cfg, + &step.task, + &agent, + step.worktree, + &repo_root, + &runner_program, + profile, + grouping, + ) + .await?; + + if let Some(parent_id) = anchor_session_id.as_deref() { + let parent = resolve_session(db, parent_id)?; + send_task_handoff( + db, + &parent, + &session_id, + &step.task, + &format!("template {} | {}", template_name, step.name), + )?; + } else { + created_anchor_id = Some(session_id.clone()); + anchor_session_id = Some(session_id.clone()); + } + + if created_anchor_id.is_none() { + created_anchor_id = Some(session_id.clone()); + } + + created.push(TemplateLaunchStepOutcome { + step_name: step.name, + session_id, + task: step.task, + }); + } + + Ok(TemplateLaunchOutcome { + template_name: template_name.to_string(), + step_count: created.len(), + anchor_session_id: source_session + .as_ref() + .map(|session| session.id.clone()) + .or(created_anchor_id), + created, + }) +} + +pub(crate) fn build_template_variables( + repo_root: &Path, + source_session: Option<&Session>, + task: Option<&str>, + mut variables: BTreeMap, +) -> BTreeMap { + if let Some(source) = source_session { + variables + .entry("source_task".to_string()) + .or_insert_with(|| source.task.clone()); + variables + .entry("source_project".to_string()) + .or_insert_with(|| source.project.clone()); + variables + .entry("source_task_group".to_string()) + .or_insert_with(|| source.task_group.clone()); + variables + .entry("source_agent".to_string()) + .or_insert_with(|| source.agent_type.clone()); + } + + let effective_task = task + .map(ToOwned::to_owned) + .or_else(|| source_session.map(|session| session.task.clone())); + if let Some(task) = effective_task { + variables.entry("task".to_string()).or_insert(task.clone()); + variables + .entry("task_group".to_string()) + .or_insert_with(|| default_task_group_label(&task)); + } + + variables.entry("project".to_string()).or_insert_with(|| { + source_session + .map(|session| session.project.clone()) + .unwrap_or_else(|| default_project_label(repo_root)) + }); + variables + .entry("cwd".to_string()) + .or_insert_with(|| repo_root.display().to_string()); + + variables +} + #[derive(Debug, Clone, Default, Serialize)] pub struct HeartbeatEnforcementOutcome { pub stale_sessions: Vec, @@ -1743,7 +1934,13 @@ pub async fn run_session( let agent_program = agent_program(agent_type)?; let profile = db.get_session_profile(session_id)?; - let command = build_agent_command(&agent_program, task, session_id, working_dir, profile.as_ref()); + let command = build_agent_command( + &agent_program, + task, + session_id, + working_dir, + profile.as_ref(), + ); capture_command_output( cfg.db_path.clone(), session_id.to_string(), @@ -1901,8 +2098,32 @@ async fn queue_session_in_dir_with_runner_program( inherited_profile_session_id: Option<&str>, grouping: SessionGrouping, ) -> Result { - let profile = - resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?; + let profile = resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?; + queue_session_with_resolved_profile_and_runner_program( + db, + cfg, + task, + agent_type, + use_worktree, + repo_root, + runner_program, + profile, + grouping, + ) + .await +} + +async fn queue_session_with_resolved_profile_and_runner_program( + db: &StateStore, + cfg: &Config, + task: &str, + agent_type: &str, + use_worktree: bool, + repo_root: &Path, + runner_program: &Path, + profile: Option, + grouping: SessionGrouping, +) -> Result { let effective_agent_type = profile .as_ref() .and_then(|profile| profile.agent.as_deref()) @@ -2060,7 +2281,9 @@ fn resolve_launch_profile( inherited_profile_session_id: Option<&str>, ) -> Result> { let inherited_profile_name = match inherited_profile_session_id { - Some(session_id) => db.get_session_profile(session_id)?.map(|profile| profile.profile_name), + Some(session_id) => db + .get_session_profile(session_id)? + .map(|profile| profile.profile_name), None => None, }; let profile_name = explicit_profile_name @@ -2275,7 +2498,10 @@ fn build_agent_command( command.arg("--append-system-prompt").arg(prompt); } } - command.arg(task).current_dir(working_dir).stdin(Stdio::null()); + command + .arg(task) + .current_dir(working_dir) + .stdin(Stdio::null()); command } @@ -2844,6 +3070,7 @@ mod tests { default_agent: "claude".to_string(), default_agent_profile: None, agent_profiles: Default::default(), + orchestration_templates: Default::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -3364,7 +3591,8 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()> { + async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()> + { let tempdir = TestDir::new("manager-default-agent-profile")?; let repo_root = tempdir.path().join("repo"); init_git_repo(&repo_root)?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index d0b82686..8d028e76 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -591,8 +591,8 @@ impl StateStore { .context("serialize allowed agent profile tools")?; let disallowed_tools_json = serde_json::to_string(&profile.disallowed_tools) .context("serialize disallowed agent profile tools")?; - let add_dirs_json = serde_json::to_string(&profile.add_dirs) - .context("serialize agent profile add_dirs")?; + let add_dirs_json = + serde_json::to_string(&profile.add_dirs).context("serialize agent profile add_dirs")?; self.conn.execute( "INSERT INTO session_profiles ( @@ -2683,7 +2683,10 @@ mod tests { assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]); assert_eq!(profile.disallowed_tools, vec!["Bash"]); assert_eq!(profile.permission_mode.as_deref(), Some("plan")); - assert_eq!(profile.add_dirs, vec![PathBuf::from("docs"), PathBuf::from("specs")]); + assert_eq!( + profile.add_dirs, + vec![PathBuf::from("docs"), PathBuf::from("specs")] + ); assert_eq!(profile.max_budget_usd, Some(1.5)); assert_eq!(profile.token_budget, Some(1200)); assert_eq!( diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a471d637..e137653c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -8,7 +8,7 @@ use ratatui::{ }, }; use regex::Regex; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::time::UNIX_EPOCH; use tokio::sync::broadcast; @@ -273,16 +273,31 @@ struct TimelineEvent { } #[derive(Debug, Clone, PartialEq, Eq)] -struct SpawnRequest { - requested_count: usize, - task: String, +enum SpawnRequest { + AdHoc { + requested_count: usize, + task: String, + }, + Template { + name: String, + task: Option, + variables: BTreeMap, + }, } #[derive(Debug, Clone, PartialEq, Eq)] -struct SpawnPlan { - requested_count: usize, - spawn_count: usize, - task: String, +enum SpawnPlan { + AdHoc { + requested_count: usize, + spawn_count: usize, + task: String, + }, + Template { + name: String, + task: Option, + variables: BTreeMap, + step_count: usize, + }, } #[derive(Debug, Clone, Copy)] @@ -1357,7 +1372,7 @@ impl Dashboard { "Keyboard Shortcuts:".to_string(), "".to_string(), " n New session".to_string(), - " N Natural-language multi-agent spawn prompt".to_string(), + " N Natural-language multi-agent or template spawn prompt".to_string(), " a Assign follow-up work from selected session".to_string(), " b Rebalance backed-up delegate handoff backlog for selected lead".to_string(), " B Rebalance backed-up delegate handoff backlog across lead teams".to_string(), @@ -3062,7 +3077,7 @@ impl Dashboard { self.spawn_input = Some(self.spawn_prompt_seed()); self.set_operator_note( - "spawn mode | try: give me 3 agents working on fix flaky tests".to_string(), + "spawn mode | try: give me 3 agents working on fix flaky tests | or: template feature_development for fix flaky tests".to_string(), ); } @@ -3419,64 +3434,96 @@ impl Dashboard { let agent = self.cfg.default_agent.clone(); let mut created_ids = Vec::new(); - for task in expand_spawn_tasks(&plan.task, plan.spawn_count) { - let session_id = match manager::create_session_with_grouping( + match &plan { + SpawnPlan::AdHoc { + requested_count: _, + spawn_count, + task, + } => { + for task in expand_spawn_tasks(task, *spawn_count) { + let session_id = match manager::create_session_with_grouping( + &self.db, + &self.cfg, + &task, + &agent, + self.cfg.auto_create_worktrees, + source_grouping.clone(), + ) + .await + { + Ok(session_id) => session_id, + Err(error) => { + let preferred_selection = + post_spawn_selection_id(source_session_id.as_deref(), &created_ids); + self.refresh_after_spawn(preferred_selection.as_deref()); + let mut summary = if created_ids.is_empty() { + format!("spawn failed: {error}") + } else { + format!( + "spawn partially completed: {} of {} queued before failure: {error}", + created_ids.len(), + spawn_count + ) + }; + if let Some(layout_note) = + self.auto_split_layout_after_spawn(created_ids.len()) + { + summary.push_str(" | "); + summary.push_str(&layout_note); + } + self.set_operator_note(summary); + return; + } + }; + + if let (Some(source_id), Some(task), Some(context)) = ( + source_session_id.as_ref(), + source_task.as_ref(), + handoff_context.as_ref(), + ) { + if let Err(error) = comms::send( + &self.db, + source_id, + &session_id, + &comms::MessageType::TaskHandoff { + task: task.clone(), + context: context.clone(), + }, + ) { + tracing::warn!( + "Failed to send handoff from session {} to {}: {error}", + source_id, + session_id + ); + } + } + + created_ids.push(session_id); + } + } + SpawnPlan::Template { + name, + task, + variables, + .. + } => match manager::launch_orchestration_template( &self.db, &self.cfg, - &task, - &agent, - self.cfg.auto_create_worktrees, - source_grouping.clone(), + name, + source_session_id.as_deref(), + task.as_deref(), + variables.clone(), ) .await { - Ok(session_id) => session_id, + Ok(outcome) => { + created_ids.extend(outcome.created.into_iter().map(|step| step.session_id)); + } Err(error) => { - let preferred_selection = - post_spawn_selection_id(source_session_id.as_deref(), &created_ids); - self.refresh_after_spawn(preferred_selection.as_deref()); - let mut summary = if created_ids.is_empty() { - format!("spawn failed: {error}") - } else { - format!( - "spawn partially completed: {} of {} queued before failure: {error}", - created_ids.len(), - plan.spawn_count - ) - }; - if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len()) - { - summary.push_str(" | "); - summary.push_str(&layout_note); - } - self.set_operator_note(summary); + self.set_operator_note(format!("template launch failed: {error}")); return; } - }; - - if let (Some(source_id), Some(task), Some(context)) = ( - source_session_id.as_ref(), - source_task.as_ref(), - handoff_context.as_ref(), - ) { - if let Err(error) = comms::send( - &self.db, - source_id, - &session_id, - &comms::MessageType::TaskHandoff { - task: task.clone(), - context: context.clone(), - }, - ) { - tracing::warn!( - "Failed to send handoff from session {} to {}: {error}", - source_id, - session_id - ); - } - } - - created_ids.push(session_id); + }, } let preferred_selection = @@ -5392,11 +5439,7 @@ impl Dashboard { fn selected_session_metrics_text(&self) -> String { if let Some(session) = self.sessions.get(self.selected_session) { let metrics = &session.metrics; - let selected_profile = self - .db - .get_session_profile(&session.id) - .ok() - .flatten(); + let selected_profile = self.db.get_session_profile(&session.id).ok().flatten(); let group_peers = self .sessions .iter() @@ -5433,10 +5476,8 @@ impl Dashboard { )); } if let Some(max_budget_usd) = profile.max_budget_usd { - profile_details.push(format!( - "Profile cost {}", - format_currency(max_budget_usd) - )); + profile_details + .push(format!("Profile cost {}", format_currency(max_budget_usd))); } if !profile.allowed_tools.is_empty() { profile_details.push(format!( @@ -5958,18 +5999,58 @@ impl Dashboard { .max_parallel_sessions .saturating_sub(self.active_session_count()); - if available_slots == 0 { - return Err(format!( - "cannot queue sessions: active session limit reached ({})", - self.cfg.max_parallel_sessions - )); - } + match request { + SpawnRequest::AdHoc { + requested_count, + task, + } => { + if available_slots == 0 { + return Err(format!( + "cannot queue sessions: active session limit reached ({})", + self.cfg.max_parallel_sessions + )); + } - Ok(SpawnPlan { - requested_count: request.requested_count, - spawn_count: request.requested_count.min(available_slots), - task: request.task, - }) + Ok(SpawnPlan::AdHoc { + requested_count, + spawn_count: requested_count.min(available_slots), + task, + }) + } + SpawnRequest::Template { + name, + task, + variables, + } => { + let repo_root = std::env::current_dir().map_err(|error| { + format!("failed to resolve cwd for template preview: {error}") + })?; + let source_session = self.sessions.get(self.selected_session); + let preview_vars = manager::build_template_variables( + &repo_root, + source_session, + task.as_deref(), + variables.clone(), + ); + let template = self + .cfg + .resolve_orchestration_template(&name, &preview_vars) + .map_err(|error| error.to_string())?; + if available_slots < template.steps.len() { + return Err(format!( + "template {name} requires {} session slots but only {available_slots} available", + template.steps.len() + )); + } + + Ok(SpawnPlan::Template { + name, + task, + variables, + step_count: template.steps.len(), + }) + } + } } fn pane_areas(&self, area: Rect) -> PaneAreas { @@ -6289,6 +6370,10 @@ fn parse_spawn_request(input: &str) -> Result { return Err("spawn request cannot be empty".to_string()); } + if let Some(template_request) = parse_template_spawn_request(trimmed)? { + return Ok(template_request); + } + let count = Regex::new(r"\b([1-9]\d*)\b") .expect("spawn count regex") .captures(trimmed) @@ -6301,12 +6386,66 @@ fn parse_spawn_request(input: &str) -> Result { return Err("spawn request must include a task description".to_string()); } - Ok(SpawnRequest { + Ok(SpawnRequest::AdHoc { requested_count: count, task, }) } +fn parse_template_spawn_request(input: &str) -> Result, String> { + let captures = Regex::new( + r"(?is)^\s*template\s+(?P[A-Za-z0-9_-]+)(?:\s+for\s+(?P.*?))?(?:\s+with\s+(?P.+))?\s*$", + ) + .expect("template spawn regex") + .captures(input); + + let Some(captures) = captures else { + return Ok(None); + }; + + let name = captures + .name("name") + .map(|value| value.as_str().trim().to_string()) + .ok_or_else(|| "template request must include a template name".to_string())?; + let task = captures + .name("task") + .map(|value| value.as_str().trim().to_string()) + .filter(|value| !value.is_empty()); + let variables = captures + .name("vars") + .map(|value| parse_template_request_variables(value.as_str())) + .transpose()? + .unwrap_or_default(); + + Ok(Some(SpawnRequest::Template { + name, + task, + variables, + })) +} + +fn parse_template_request_variables(input: &str) -> Result, String> { + let mut variables = BTreeMap::new(); + for entry in input + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + { + let (key, value) = entry + .split_once('=') + .ok_or_else(|| format!("template vars must use key=value form: {entry}"))?; + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return Err(format!( + "template vars must use non-empty key=value form: {entry}" + )); + } + variables.insert(key.to_string(), value.to_string()); + } + Ok(variables) +} + fn extract_spawn_task(input: &str) -> String { let trimmed = input.trim(); let lower = trimmed.to_ascii_lowercase(); @@ -6344,14 +6483,33 @@ fn expand_spawn_tasks(task: &str, count: usize) -> Vec { } fn build_spawn_note(plan: &SpawnPlan, created_count: usize, queued_count: usize) -> String { - let task = truncate_for_dashboard(&plan.task, 72); - let mut note = if plan.spawn_count < plan.requested_count { - format!( - "spawned {created_count} session(s) for {task} (requested {}, capped at {})", - plan.requested_count, plan.spawn_count - ) - } else { - format!("spawned {created_count} session(s) for {task}") + let mut note = match plan { + SpawnPlan::AdHoc { + requested_count, + spawn_count, + task, + } => { + let task = truncate_for_dashboard(task, 72); + if spawn_count < requested_count { + format!( + "spawned {created_count} session(s) for {task} (requested {requested_count}, capped at {spawn_count})" + ) + } else { + format!("spawned {created_count} session(s) for {task}") + } + } + SpawnPlan::Template { + name, + task, + step_count, + .. + } => { + let scope = task + .as_ref() + .map(|task| format!(" for {}", truncate_for_dashboard(task, 72))) + .unwrap_or_default(); + format!("launched template {name} ({created_count}/{step_count} step(s)){scope}") + } }; if queued_count > 0 { @@ -11053,7 +11211,7 @@ diff --git a/src/lib.rs b/src/lib.rs assert_eq!( request, - SpawnRequest { + SpawnRequest::AdHoc { requested_count: 10, task: "stabilize the queue".to_string(), } @@ -11066,13 +11224,33 @@ diff --git a/src/lib.rs b/src/lib.rs assert_eq!( request, - SpawnRequest { + SpawnRequest::AdHoc { requested_count: 1, task: "stabilize the queue".to_string(), } ); } + #[test] + fn parse_spawn_request_extracts_template_request() { + let request = parse_spawn_request( + "template feature_development for stabilize auth callback with component=billing, area=oauth", + ) + .expect("template request should parse"); + + assert_eq!( + request, + SpawnRequest::Template { + name: "feature_development".to_string(), + task: Some("stabilize auth callback".to_string()), + variables: BTreeMap::from([ + ("area".to_string(), "oauth".to_string()), + ("component".to_string(), "billing".to_string()), + ]), + } + ); + } + #[test] fn build_spawn_plan_caps_requested_count_to_available_slots() { let dashboard = test_dashboard( @@ -11090,7 +11268,7 @@ diff --git a/src/lib.rs b/src/lib.rs assert_eq!( plan, - SpawnPlan { + SpawnPlan::AdHoc { requested_count: 9, spawn_count: 5, task: "ship release notes".to_string(), @@ -11098,6 +11276,145 @@ diff --git a/src/lib.rs b/src/lib.rs ); } + #[test] + fn build_spawn_plan_resolves_template_steps() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.orchestration_templates = BTreeMap::from([( + "feature_development".to_string(), + crate::config::OrchestrationTemplateConfig { + description: None, + project: None, + task_group: None, + agent: Some("claude".to_string()), + profile: None, + worktree: Some(true), + steps: vec![ + crate::config::OrchestrationTemplateStepConfig { + name: Some("planner".to_string()), + task: "Plan {{task}}".to_string(), + project: None, + task_group: None, + agent: None, + profile: None, + worktree: None, + }, + crate::config::OrchestrationTemplateStepConfig { + name: Some("builder".to_string()), + task: "Build {{task}} in {{component}}".to_string(), + project: None, + task_group: None, + agent: None, + profile: None, + worktree: None, + }, + ], + }, + )]); + + let plan = dashboard + .build_spawn_plan( + "template feature_development for stabilize auth callback with component=billing", + ) + .expect("template spawn plan"); + + assert_eq!( + plan, + SpawnPlan::Template { + name: "feature_development".to_string(), + task: Some("stabilize auth callback".to_string()), + variables: BTreeMap::from([("component".to_string(), "billing".to_string(),)]), + step_count: 2, + } + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_spawn_prompt_launches_orchestration_template() -> Result<()> { + let tempdir = std::env::temp_dir().join(format!("dashboard-template-{}", Uuid::new_v4())); + let repo_root = tempdir.join("repo"); + init_git_repo(&repo_root)?; + + let original_dir = std::env::current_dir()?; + std::env::set_current_dir(&repo_root)?; + + let mut cfg = build_config(&tempdir); + cfg.orchestration_templates = BTreeMap::from([( + "feature_development".to_string(), + crate::config::OrchestrationTemplateConfig { + description: None, + project: Some("ecc2-smoke".to_string()), + task_group: Some("{{task}}".to_string()), + agent: Some("claude".to_string()), + profile: None, + worktree: Some(false), + steps: vec![ + crate::config::OrchestrationTemplateStepConfig { + name: Some("planner".to_string()), + task: "Plan {{task}}".to_string(), + project: None, + task_group: None, + agent: None, + profile: None, + worktree: None, + }, + crate::config::OrchestrationTemplateStepConfig { + name: Some("builder".to_string()), + task: "Build {{task}} in {{component}}".to_string(), + project: None, + task_group: None, + agent: None, + profile: None, + worktree: None, + }, + ], + }, + )]); + + let db = StateStore::open(&cfg.db_path)?; + let mut dashboard = Dashboard::new(db, cfg); + dashboard.spawn_input = Some( + "template feature_development for stabilize auth callback with component=billing" + .to_string(), + ); + + dashboard.submit_spawn_prompt().await; + + let operator_note = dashboard + .operator_note + .clone() + .expect("template launch should set an operator note"); + assert!( + operator_note + .contains("launched template feature_development (2/2 step(s)) for stabilize auth callback"), + "unexpected operator note: {operator_note}" + ); + assert_eq!(dashboard.sessions.len(), 2); + assert!(dashboard + .sessions + .iter() + .all(|session| session.project == "ecc2-smoke")); + assert!(dashboard + .sessions + .iter() + .all(|session| session.task_group == "stabilize auth callback")); + let tasks = dashboard + .sessions + .iter() + .map(|session| session.task.as_str()) + .collect::>(); + assert_eq!( + tasks, + std::collections::BTreeSet::from([ + "Build stabilize auth callback in billing", + "Plan stabilize auth callback", + ]) + ); + + std::env::set_current_dir(original_dir)?; + let _ = std::fs::remove_dir_all(&tempdir); + Ok(()) + } + #[test] fn expand_spawn_tasks_suffixes_multi_session_requests() { assert_eq!( @@ -13074,6 +13391,7 @@ diff --git a/src/lib.rs b/src/lib.rs default_agent: "claude".to_string(), default_agent_profile: None, agent_profiles: Default::default(), + orchestration_templates: Default::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, From 8653d6d5d5cd785a11bf9841f0d0739227b74f89 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 03:50:21 -0700 Subject: [PATCH 121/459] feat: add ecc2 shared context graph cli --- ecc2/src/main.rs | 477 ++++++++++++++++++++++++++++++++++++- ecc2/src/session/mod.rs | 36 +++ ecc2/src/session/store.rs | 489 +++++++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 5 +- 4 files changed, 999 insertions(+), 8 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index d7b88de7..04b6a016 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -315,6 +315,11 @@ enum Commands { #[arg(long, default_value_t = 20)] limit: usize, }, + /// Read and write the shared context graph + Graph { + #[command(subcommand)] + command: GraphCommands, + }, /// Export sessions, tool spans, and metrics in OTLP-compatible JSON ExportOtel { /// Session ID or alias. Omit to export all sessions. @@ -378,6 +383,93 @@ enum MessageCommands { }, } +#[derive(clap::Subcommand, Debug)] +enum GraphCommands { + /// Create or update a graph entity + AddEntity { + /// Optional source session ID or alias for provenance + #[arg(long)] + session_id: Option, + /// Entity type such as file, function, type, or decision + #[arg(long = "type")] + entity_type: String, + /// Stable entity name + #[arg(long)] + name: String, + /// Optional path associated with the entity + #[arg(long)] + path: Option, + /// Short human summary + #[arg(long, default_value = "")] + summary: String, + /// Metadata in key=value form + #[arg(long = "meta")] + metadata: Vec, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Create or update a relation between two entities + Link { + /// Optional source session ID or alias for provenance + #[arg(long)] + session_id: Option, + /// Source entity ID + #[arg(long)] + from: i64, + /// Target entity ID + #[arg(long)] + to: i64, + /// Relation type such as references, defines, or depends_on + #[arg(long)] + relation: String, + /// Short human summary + #[arg(long, default_value = "")] + summary: String, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// List entities in the shared context graph + Entities { + /// Filter by source session ID or alias + #[arg(long)] + session_id: Option, + /// Filter by entity type + #[arg(long = "type")] + entity_type: Option, + /// Maximum entities to return + #[arg(long, default_value_t = 20)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// List relations in the shared context graph + Relations { + /// Filter to relations touching a specific entity ID + #[arg(long)] + entity_id: Option, + /// Maximum relations to return + #[arg(long, default_value_t = 20)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Show one entity plus its incoming and outgoing relations + Show { + /// Entity ID + entity_id: i64, + /// Maximum incoming/outgoing relations to return + #[arg(long, default_value_t = 10)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, +} + #[derive(clap::ValueEnum, Clone, Debug)] enum MessageKindArg { Handoff, @@ -1033,6 +1125,113 @@ async fn main() -> Result<()> { println!("{}", format_decisions_human(&entries, all)); } } + Some(Commands::Graph { command }) => match command { + GraphCommands::AddEntity { + session_id, + entity_type, + name, + path, + summary, + metadata, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let metadata = parse_key_value_pairs(&metadata, "graph metadata")?; + let entity = db.upsert_context_entity( + resolved_session_id.as_deref(), + &entity_type, + &name, + path.as_deref(), + &summary, + &metadata, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&entity)?); + } else { + println!("{}", format_graph_entity_human(&entity)); + } + } + GraphCommands::Link { + session_id, + from, + to, + relation, + summary, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let relation = db.upsert_context_relation( + resolved_session_id.as_deref(), + from, + to, + &relation, + &summary, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&relation)?); + } else { + println!("{}", format_graph_relation_human(&relation)); + } + } + GraphCommands::Entities { + session_id, + entity_type, + limit, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let entities = db.list_context_entities( + resolved_session_id.as_deref(), + entity_type.as_deref(), + limit, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&entities)?); + } else { + println!( + "{}", + format_graph_entities_human(&entities, resolved_session_id.is_some()) + ); + } + } + GraphCommands::Relations { + entity_id, + limit, + json, + } => { + let relations = db.list_context_relations(entity_id, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&relations)?); + } else { + println!("{}", format_graph_relations_human(&relations)); + } + } + GraphCommands::Show { + entity_id, + limit, + json, + } => { + let detail = db + .get_context_entity_detail(entity_id, limit)? + .ok_or_else(|| { + anyhow::anyhow!("Context graph entity not found: {entity_id}") + })?; + if json { + println!("{}", serde_json::to_string_pretty(&detail)?); + } else { + println!("{}", format_graph_entity_detail_human(&detail)); + } + } + }, Some(Commands::ExportOtel { session_id, output }) => { sync_runtime_session_metrics(&db, &cfg)?; let resolved_session_id = session_id @@ -1859,6 +2058,158 @@ fn format_decisions_human(entries: &[session::DecisionLogEntry], include_session lines.join("\n") } +fn format_graph_entity_human(entity: &session::ContextGraphEntity) -> String { + let mut lines = vec![ + format!("Context graph entity #{}", entity.id), + format!("Type: {}", entity.entity_type), + format!("Name: {}", entity.name), + ]; + if let Some(path) = &entity.path { + lines.push(format!("Path: {path}")); + } + if let Some(session_id) = &entity.session_id { + lines.push(format!("Session: {}", short_session(session_id))); + } + if entity.summary.is_empty() { + lines.push("Summary: none recorded".to_string()); + } else { + lines.push(format!("Summary: {}", entity.summary)); + } + if entity.metadata.is_empty() { + lines.push("Metadata: none recorded".to_string()); + } else { + lines.push("Metadata:".to_string()); + for (key, value) in &entity.metadata { + lines.push(format!("- {key}={value}")); + } + } + lines.push(format!( + "Updated: {}", + entity.updated_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + lines.join("\n") +} + +fn format_graph_entities_human( + entities: &[session::ContextGraphEntity], + include_session: bool, +) -> String { + if entities.is_empty() { + return "No context graph entities found.".to_string(); + } + + let mut lines = vec![format!("Context graph entities: {}", entities.len())]; + for entity in entities { + let mut line = format!("- #{} [{}] {}", entity.id, entity.entity_type, entity.name); + if include_session { + line.push_str(&format!( + " | {}", + entity + .session_id + .as_deref() + .map(short_session) + .unwrap_or_else(|| "global".to_string()) + )); + } + if let Some(path) = &entity.path { + line.push_str(&format!(" | {path}")); + } + lines.push(line); + if !entity.summary.is_empty() { + lines.push(format!(" summary {}", entity.summary)); + } + } + + lines.join("\n") +} + +fn format_graph_relation_human(relation: &session::ContextGraphRelation) -> String { + let mut lines = vec![ + format!("Context graph relation #{}", relation.id), + format!( + "Edge: #{} [{}] {} -> #{} [{}] {}", + relation.from_entity_id, + relation.from_entity_type, + relation.from_entity_name, + relation.to_entity_id, + relation.to_entity_type, + relation.to_entity_name + ), + format!("Relation: {}", relation.relation_type), + ]; + if let Some(session_id) = &relation.session_id { + lines.push(format!("Session: {}", short_session(session_id))); + } + if relation.summary.is_empty() { + lines.push("Summary: none recorded".to_string()); + } else { + lines.push(format!("Summary: {}", relation.summary)); + } + lines.push(format!( + "Created: {}", + relation.created_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + lines.join("\n") +} + +fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) -> String { + if relations.is_empty() { + return "No context graph relations found.".to_string(); + } + + let mut lines = vec![format!("Context graph relations: {}", relations.len())]; + for relation in relations { + lines.push(format!( + "- #{} {} -> {} [{}]", + relation.id, relation.from_entity_name, relation.to_entity_name, relation.relation_type + )); + if !relation.summary.is_empty() { + lines.push(format!(" summary {}", relation.summary)); + } + } + lines.join("\n") +} + +fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String { + let mut lines = vec![format_graph_entity_human(&detail.entity)]; + lines.push(String::new()); + lines.push(format!("Outgoing relations: {}", detail.outgoing.len())); + if detail.outgoing.is_empty() { + lines.push("- none".to_string()); + } else { + for relation in &detail.outgoing { + lines.push(format!( + "- [{}] {} -> #{} {}", + relation.relation_type, + detail.entity.name, + relation.to_entity_id, + relation.to_entity_name + )); + if !relation.summary.is_empty() { + lines.push(format!(" summary {}", relation.summary)); + } + } + } + lines.push(format!("Incoming relations: {}", detail.incoming.len())); + if detail.incoming.is_empty() { + lines.push("- none".to_string()); + } else { + for relation in &detail.incoming { + lines.push(format!( + "- [{}] #{} {} -> {}", + relation.relation_type, + relation.from_entity_id, + relation.from_entity_name, + detail.entity.name + )); + if !relation.summary.is_empty() { + lines.push(format!(" summary {}", relation.summary)); + } + } + } + lines.join("\n") +} + fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> String { let mut lines = Vec::new(); lines.push(format!( @@ -2228,15 +2579,19 @@ fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: & } fn parse_template_vars(values: &[String]) -> Result> { + parse_key_value_pairs(values, "template vars") +} + +fn parse_key_value_pairs(values: &[String], label: &str) -> Result> { let mut vars = BTreeMap::new(); for value in values { let (key, raw_value) = value .split_once('=') - .ok_or_else(|| anyhow::anyhow!("template vars must use key=value form: {value}"))?; + .ok_or_else(|| anyhow::anyhow!("{label} must use key=value form: {value}"))?; let key = key.trim(); let raw_value = raw_value.trim(); if key.is_empty() || raw_value.is_empty() { - anyhow::bail!("template vars must use non-empty key=value form: {value}"); + anyhow::bail!("{label} must use non-empty key=value form: {value}"); } vars.insert(key.to_string(), raw_value.to_string()); } @@ -2557,6 +2912,19 @@ mod tests { ); } + #[test] + fn parse_key_value_pairs_rejects_empty_values() { + let error = parse_key_value_pairs(&["language=".to_string()], "graph metadata") + .expect_err("invalid metadata should fail"); + + assert!( + error + .to_string() + .contains("graph metadata must use non-empty key=value form"), + "unexpected error: {error}" + ); + } + #[test] fn cli_parses_team_command() { let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"]) @@ -3614,6 +3982,53 @@ mod tests { } } + #[test] + fn cli_parses_graph_add_entity_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "add-entity", + "--session-id", + "latest", + "--type", + "file", + "--name", + "dashboard.rs", + "--path", + "ecc2/src/tui/dashboard.rs", + "--summary", + "Primary TUI surface", + "--meta", + "language=rust", + "--json", + ]) + .expect("graph add-entity should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::AddEntity { + session_id, + entity_type, + name, + path, + summary, + metadata, + json, + }, + }) => { + assert_eq!(session_id.as_deref(), Some("latest")); + assert_eq!(entity_type, "file"); + assert_eq!(name, "dashboard.rs"); + assert_eq!(path.as_deref(), Some("ecc2/src/tui/dashboard.rs")); + assert_eq!(summary, "Primary TUI surface"); + assert_eq!(metadata, vec!["language=rust"]); + assert!(json); + } + _ => panic!("expected graph add-entity subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -3638,6 +4053,64 @@ mod tests { assert!(text.contains("alternative memory only")); } + #[test] + fn format_graph_entity_detail_human_renders_relations() { + let detail = session::ContextGraphEntityDetail { + entity: session::ContextGraphEntity { + id: 7, + session_id: Some("sess-12345678".to_string()), + entity_type: "function".to_string(), + name: "render_metrics".to_string(), + path: Some("ecc2/src/tui/dashboard.rs".to_string()), + summary: "Renders the metrics pane".to_string(), + metadata: BTreeMap::from([("language".to_string(), "rust".to_string())]), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }, + outgoing: vec![session::ContextGraphRelation { + id: 9, + session_id: Some("sess-12345678".to_string()), + from_entity_id: 7, + from_entity_type: "function".to_string(), + from_entity_name: "render_metrics".to_string(), + to_entity_id: 10, + to_entity_type: "type".to_string(), + to_entity_name: "MetricsSnapshot".to_string(), + relation_type: "returns".to_string(), + summary: "Produces the rendered metrics model".to_string(), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }], + incoming: vec![session::ContextGraphRelation { + id: 8, + session_id: Some("sess-12345678".to_string()), + from_entity_id: 6, + from_entity_type: "file".to_string(), + from_entity_name: "dashboard.rs".to_string(), + to_entity_id: 7, + to_entity_type: "function".to_string(), + to_entity_name: "render_metrics".to_string(), + relation_type: "contains".to_string(), + summary: "Dashboard owns the render path".to_string(), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }], + }; + + let text = format_graph_entity_detail_human(&detail); + assert!(text.contains("Context graph entity #7")); + assert!(text.contains("Outgoing relations: 1")); + assert!(text.contains("[returns] render_metrics -> #10 MetricsSnapshot")); + assert!(text.contains("Incoming relations: 1")); + assert!(text.contains("[contains] #6 dashboard.rs -> render_metrics")); + } + #[test] fn cli_parses_coordination_status_json_flag() { let cli = Cli::try_parse_from(["ecc", "coordination-status", "--json"]) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 301f3384..30ddc6da 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -6,6 +6,7 @@ pub mod store; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::fmt; use std::path::Path; use std::path::PathBuf; @@ -154,6 +155,41 @@ pub struct DecisionLogEntry { pub timestamp: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphEntity { + pub id: i64, + pub session_id: Option, + pub entity_type: String, + pub name: String, + pub path: Option, + pub summary: String, + pub metadata: BTreeMap, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphRelation { + pub id: i64, + pub session_id: Option, + pub from_entity_id: i64, + pub from_entity_type: String, + pub from_entity_name: String, + pub to_entity_id: i64, + pub to_entity_type: String, + pub to_entity_name: String, + pub relation_type: String, + pub summary: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphEntityDetail { + pub entity: ContextGraphEntity, + pub outgoing: Vec, + pub incoming: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FileActivityAction { diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 8d028e76..551ed77d 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; use serde::Serialize; use std::cmp::Reverse; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; @@ -13,9 +13,10 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ - default_project_label, default_task_group_label, normalize_group_label, DecisionLogEntry, - FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, SessionMessage, - SessionMetrics, SessionState, WorktreeInfo, + default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity, + ContextGraphEntityDetail, ContextGraphRelation, DecisionLogEntry, FileActivityAction, + FileActivityEntry, Session, SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, + WorktreeInfo, }; pub struct StateStore { @@ -234,6 +235,30 @@ impl StateStore { timestamp TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS context_graph_entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + entity_key TEXT NOT NULL UNIQUE, + entity_type TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT, + summary TEXT NOT NULL DEFAULT '', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS context_graph_relations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + from_entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, + to_entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, + relation_type TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(from_entity_id, to_entity_id, relation_type) + ); + CREATE TABLE IF NOT EXISTS pending_worktree_queue ( session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, repo_root TEXT NOT NULL, @@ -288,6 +313,12 @@ impl StateStore { ON session_output(session_id, id); CREATE INDEX IF NOT EXISTS idx_decision_log_session ON decision_log(session_id, timestamp, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_entities_session + ON context_graph_entities(session_id, entity_type, updated_at, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_relations_from + ON context_graph_relations(from_entity_id, created_at, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_relations_to + ON context_graph_relations(to_entity_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at); CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at @@ -1652,6 +1683,241 @@ impl StateStore { Ok(entries) } + pub fn upsert_context_entity( + &self, + session_id: Option<&str>, + entity_type: &str, + name: &str, + path: Option<&str>, + summary: &str, + metadata: &BTreeMap, + ) -> Result { + let entity_type = entity_type.trim(); + if entity_type.is_empty() { + return Err(anyhow::anyhow!("Context graph entity type cannot be empty")); + } + let name = name.trim(); + if name.is_empty() { + return Err(anyhow::anyhow!("Context graph entity name cannot be empty")); + } + + let normalized_path = path.map(str::trim).filter(|value| !value.is_empty()); + let summary = summary.trim(); + let entity_key = context_graph_entity_key(entity_type, name, normalized_path); + let metadata_json = serde_json::to_string(metadata) + .context("Failed to serialize context graph metadata")?; + let timestamp = chrono::Utc::now().to_rfc3339(); + + self.conn.execute( + "INSERT INTO context_graph_entities ( + session_id, entity_key, entity_type, name, path, summary, metadata_json, created_at, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8) + ON CONFLICT(entity_key) DO UPDATE SET + session_id = COALESCE(excluded.session_id, context_graph_entities.session_id), + summary = CASE + WHEN excluded.summary <> '' THEN excluded.summary + ELSE context_graph_entities.summary + END, + metadata_json = excluded.metadata_json, + updated_at = excluded.updated_at", + rusqlite::params![ + session_id, + entity_key, + entity_type, + name, + normalized_path, + summary, + metadata_json, + timestamp, + ], + )?; + + self.conn + .query_row( + "SELECT id, session_id, entity_type, name, path, summary, metadata_json, created_at, updated_at + FROM context_graph_entities + WHERE entity_key = ?1", + rusqlite::params![entity_key], + map_context_graph_entity, + ) + .map_err(Into::into) + } + + pub fn list_context_entities( + &self, + session_id: Option<&str>, + entity_type: Option<&str>, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, session_id, entity_type, name, path, summary, metadata_json, created_at, updated_at + FROM context_graph_entities + WHERE (?1 IS NULL OR session_id = ?1) + AND (?2 IS NULL OR entity_type = ?2) + ORDER BY updated_at DESC, id DESC + LIMIT ?3", + )?; + + let entries = stmt + .query_map( + rusqlite::params![session_id, entity_type, limit as i64], + map_context_graph_entity, + )? + .collect::, _>>()?; + + Ok(entries) + } + + pub fn get_context_entity_detail( + &self, + entity_id: i64, + relation_limit: usize, + ) -> Result> { + let entity = self + .conn + .query_row( + "SELECT id, session_id, entity_type, name, path, summary, metadata_json, created_at, updated_at + FROM context_graph_entities + WHERE id = ?1", + rusqlite::params![entity_id], + map_context_graph_entity, + ) + .optional()?; + + let Some(entity) = entity else { + return Ok(None); + }; + + let mut outgoing_stmt = self.conn.prepare( + "SELECT r.id, r.session_id, + r.from_entity_id, src.entity_type, src.name, + r.to_entity_id, dst.entity_type, dst.name, + r.relation_type, r.summary, r.created_at + FROM context_graph_relations r + JOIN context_graph_entities src ON src.id = r.from_entity_id + JOIN context_graph_entities dst ON dst.id = r.to_entity_id + WHERE r.from_entity_id = ?1 + ORDER BY r.created_at DESC, r.id DESC + LIMIT ?2", + )?; + let outgoing = outgoing_stmt + .query_map( + rusqlite::params![entity_id, relation_limit as i64], + map_context_graph_relation, + )? + .collect::, _>>()?; + + let mut incoming_stmt = self.conn.prepare( + "SELECT r.id, r.session_id, + r.from_entity_id, src.entity_type, src.name, + r.to_entity_id, dst.entity_type, dst.name, + r.relation_type, r.summary, r.created_at + FROM context_graph_relations r + JOIN context_graph_entities src ON src.id = r.from_entity_id + JOIN context_graph_entities dst ON dst.id = r.to_entity_id + WHERE r.to_entity_id = ?1 + ORDER BY r.created_at DESC, r.id DESC + LIMIT ?2", + )?; + let incoming = incoming_stmt + .query_map( + rusqlite::params![entity_id, relation_limit as i64], + map_context_graph_relation, + )? + .collect::, _>>()?; + + Ok(Some(ContextGraphEntityDetail { + entity, + outgoing, + incoming, + })) + } + + pub fn upsert_context_relation( + &self, + session_id: Option<&str>, + from_entity_id: i64, + to_entity_id: i64, + relation_type: &str, + summary: &str, + ) -> Result { + let relation_type = relation_type.trim(); + if relation_type.is_empty() { + return Err(anyhow::anyhow!( + "Context graph relation type cannot be empty" + )); + } + let summary = summary.trim(); + let timestamp = chrono::Utc::now().to_rfc3339(); + + self.conn.execute( + "INSERT INTO context_graph_relations ( + session_id, from_entity_id, to_entity_id, relation_type, summary, created_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(from_entity_id, to_entity_id, relation_type) DO UPDATE SET + session_id = COALESCE(excluded.session_id, context_graph_relations.session_id), + summary = CASE + WHEN excluded.summary <> '' THEN excluded.summary + ELSE context_graph_relations.summary + END", + rusqlite::params![ + session_id, + from_entity_id, + to_entity_id, + relation_type, + summary, + timestamp, + ], + )?; + + self.conn + .query_row( + "SELECT r.id, r.session_id, + r.from_entity_id, src.entity_type, src.name, + r.to_entity_id, dst.entity_type, dst.name, + r.relation_type, r.summary, r.created_at + FROM context_graph_relations r + JOIN context_graph_entities src ON src.id = r.from_entity_id + JOIN context_graph_entities dst ON dst.id = r.to_entity_id + WHERE r.from_entity_id = ?1 + AND r.to_entity_id = ?2 + AND r.relation_type = ?3", + rusqlite::params![from_entity_id, to_entity_id, relation_type], + map_context_graph_relation, + ) + .map_err(Into::into) + } + + pub fn list_context_relations( + &self, + entity_id: Option, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT r.id, r.session_id, + r.from_entity_id, src.entity_type, src.name, + r.to_entity_id, dst.entity_type, dst.name, + r.relation_type, r.summary, r.created_at + FROM context_graph_relations r + JOIN context_graph_entities src ON src.id = r.from_entity_id + JOIN context_graph_entities dst ON dst.id = r.to_entity_id + WHERE (?1 IS NULL OR r.from_entity_id = ?1 OR r.to_entity_id = ?1) + ORDER BY r.created_at DESC, r.id DESC + LIMIT ?2", + )?; + + let relations = stmt + .query_map( + rusqlite::params![entity_id, limit as i64], + map_context_graph_relation, + )? + .collect::, _>>()?; + + Ok(relations) + } + pub fn daemon_activity(&self) -> Result { self.conn .query_row( @@ -2509,6 +2775,71 @@ fn map_decision_log_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + let metadata_json = row + .get::<_, Option>(6)? + .unwrap_or_else(|| "{}".to_string()); + let metadata = serde_json::from_str(&metadata_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(6, rusqlite::types::Type::Text, Box::new(error)) + })?; + let created_at = parse_store_timestamp(row.get::<_, String>(7)?, 7)?; + let updated_at = parse_store_timestamp(row.get::<_, String>(8)?, 8)?; + + Ok(ContextGraphEntity { + id: row.get(0)?, + session_id: row.get(1)?, + entity_type: row.get(2)?, + name: row.get(3)?, + path: row.get(4)?, + summary: row.get(5)?, + metadata, + created_at, + updated_at, + }) +} + +fn map_context_graph_relation(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let created_at = parse_store_timestamp(row.get::<_, String>(10)?, 10)?; + + Ok(ContextGraphRelation { + id: row.get(0)?, + session_id: row.get(1)?, + from_entity_id: row.get(2)?, + from_entity_type: row.get(3)?, + from_entity_name: row.get(4)?, + to_entity_id: row.get(5)?, + to_entity_type: row.get(6)?, + to_entity_name: row.get(7)?, + relation_type: row.get(8)?, + summary: row.get(9)?, + created_at, + }) +} + +fn parse_store_timestamp( + raw: String, + column: usize, +) -> rusqlite::Result> { + chrono::DateTime::parse_from_rfc3339(&raw) + .map(|value| value.with_timezone(&chrono::Utc)) + .map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + column, + rusqlite::types::Type::Text, + Box::new(error), + ) + }) +} + +fn context_graph_entity_key(entity_type: &str, name: &str, path: Option<&str>) -> String { + format!( + "{}::{}::{}", + entity_type.trim().to_ascii_lowercase(), + name.trim().to_ascii_lowercase(), + path.unwrap_or("").trim() + ) +} + fn file_overlap_is_relevant(current: &FileActivityEntry, other: &FileActivityEntry) -> bool { current.path == other.path && !(matches!(current.action, FileActivityAction::Read) @@ -3194,6 +3525,156 @@ mod tests { Ok(()) } + #[test] + fn upsert_and_filter_context_graph_entities() -> Result<()> { + let tempdir = TestDir::new("store-context-entities")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "context graph".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let mut metadata = BTreeMap::new(); + metadata.insert("language".to_string(), "rust".to_string()); + let file = db.upsert_context_entity( + Some("session-1"), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "Primary dashboard surface", + &metadata, + )?; + let updated = db.upsert_context_entity( + Some("session-1"), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "Updated dashboard summary", + &metadata, + )?; + let decision = db.upsert_context_entity( + None, + "decision", + "Prefer SQLite graph storage", + None, + "Keeps graph queryable from CLI and TUI", + &BTreeMap::new(), + )?; + + assert_eq!(file.id, updated.id); + assert_eq!(updated.summary, "Updated dashboard summary"); + + let session_entities = db.list_context_entities(Some("session-1"), Some("file"), 10)?; + assert_eq!(session_entities.len(), 1); + assert_eq!(session_entities[0].id, file.id); + assert_eq!( + session_entities[0].metadata.get("language"), + Some(&"rust".to_string()) + ); + + let all_entities = db.list_context_entities(None, None, 10)?; + assert_eq!(all_entities.len(), 2); + assert!(all_entities.iter().any(|entity| entity.id == decision.id)); + + Ok(()) + } + + #[test] + fn context_graph_detail_includes_incoming_and_outgoing_relations() -> Result<()> { + let tempdir = TestDir::new("store-context-relations")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "context graph".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let file = db.upsert_context_entity( + Some("session-1"), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "", + &BTreeMap::new(), + )?; + let function = db.upsert_context_entity( + Some("session-1"), + "function", + "render_metrics", + Some("ecc2/src/tui/dashboard.rs"), + "", + &BTreeMap::new(), + )?; + let decision = db.upsert_context_entity( + Some("session-1"), + "decision", + "Persist graph in sqlite", + None, + "", + &BTreeMap::new(), + )?; + + db.upsert_context_relation( + Some("session-1"), + file.id, + function.id, + "contains", + "Dashboard file contains metrics rendering logic", + )?; + db.upsert_context_relation( + Some("session-1"), + decision.id, + function.id, + "drives", + "Storage choice drives the function implementation", + )?; + + let detail = db + .get_context_entity_detail(function.id, 10)? + .expect("detail should exist"); + assert_eq!(detail.entity.name, "render_metrics"); + assert_eq!(detail.incoming.len(), 2); + assert!(detail.outgoing.is_empty()); + + let relation_types = detail + .incoming + .iter() + .map(|relation| relation.relation_type.as_str()) + .collect::>(); + assert!(relation_types.contains(&"contains")); + assert!(relation_types.contains(&"drives")); + + let filtered_relations = db.list_context_relations(Some(function.id), 10)?; + assert_eq!(filtered_relations.len(), 2); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index e137653c..3bf6ce12 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -11384,8 +11384,9 @@ diff --git a/src/lib.rs b/src/lib.rs .clone() .expect("template launch should set an operator note"); assert!( - operator_note - .contains("launched template feature_development (2/2 step(s)) for stabilize auth callback"), + operator_note.contains( + "launched template feature_development (2/2 step(s)) for stabilize auth callback" + ), "unexpected operator note: {operator_note}" ); assert_eq!(dashboard.sessions.len(), 2); From 08f0e86d76bc9cc7cd924e3198a46d07f47f4602 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 03:59:04 -0700 Subject: [PATCH 122/459] feat: auto-populate ecc2 shared context graph --- ecc2/src/main.rs | 101 +++++++++++++ ecc2/src/session/mod.rs | 7 + ecc2/src/session/store.rs | 288 +++++++++++++++++++++++++++++++++++++- 3 files changed, 393 insertions(+), 3 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 04b6a016..434707aa 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -468,6 +468,20 @@ enum GraphCommands { #[arg(long)] json: bool, }, + /// Backfill the context graph from existing decisions and file activity + Sync { + /// Source session ID or alias. Omit to backfill the latest session. + session_id: Option, + /// Backfill across all sessions + #[arg(long)] + all: bool, + /// Maximum decisions and file events to scan per session + #[arg(long, default_value_t = 64)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -1231,6 +1245,36 @@ async fn main() -> Result<()> { println!("{}", format_graph_entity_detail_human(&detail)); } } + GraphCommands::Sync { + session_id, + all, + limit, + json, + } => { + if all && session_id.is_some() { + return Err(anyhow::anyhow!( + "graph sync does not accept a session ID when --all is set" + )); + } + sync_runtime_session_metrics(&db, &cfg)?; + let resolved_session_id = if all { + None + } else { + Some(resolve_session_id( + &db, + session_id.as_deref().unwrap_or("latest"), + )?) + }; + let stats = db.sync_context_graph_history(resolved_session_id.as_deref(), limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&stats)?); + } else { + println!( + "{}", + format_graph_sync_stats_human(&stats, resolved_session_id.as_deref()) + ); + } + } }, Some(Commands::ExportOtel { session_id, output }) => { sync_runtime_session_metrics(&db, &cfg)?; @@ -2210,6 +2254,22 @@ fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) lines.join("\n") } +fn format_graph_sync_stats_human( + stats: &session::ContextGraphSyncStats, + session_id: Option<&str>, +) -> String { + let scope = session_id + .map(short_session) + .unwrap_or_else(|| "all sessions".to_string()); + vec![ + format!("Context graph sync complete for {scope}"), + format!("- sessions scanned {}", stats.sessions_scanned), + format!("- decisions processed {}", stats.decisions_processed), + format!("- file events processed {}", stats.file_events_processed), + ] + .join("\n") +} + fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> String { let mut lines = Vec::new(); lines.push(format!( @@ -4029,6 +4089,30 @@ mod tests { } } + #[test] + fn cli_parses_graph_sync_command() { + let cli = Cli::try_parse_from(["ecc", "graph", "sync", "--all", "--limit", "12", "--json"]) + .expect("graph sync should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::Sync { + session_id, + all, + limit, + json, + }, + }) => { + assert!(session_id.is_none()); + assert!(all); + assert_eq!(limit, 12); + assert!(json); + } + _ => panic!("expected graph sync subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -4111,6 +4195,23 @@ mod tests { assert!(text.contains("[contains] #6 dashboard.rs -> render_metrics")); } + #[test] + fn format_graph_sync_stats_human_renders_counts() { + let text = format_graph_sync_stats_human( + &session::ContextGraphSyncStats { + sessions_scanned: 2, + decisions_processed: 3, + file_events_processed: 5, + }, + Some("sess-12345678"), + ); + + assert!(text.contains("Context graph sync complete for sess-123")); + assert!(text.contains("- sessions scanned 2")); + assert!(text.contains("- decisions processed 3")); + assert!(text.contains("- file events processed 5")); + } + #[test] fn cli_parses_coordination_status_json_flag() { let cli = Cli::try_parse_from(["ecc", "coordination-status", "--json"]) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 30ddc6da..1fce45e9 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -190,6 +190,13 @@ pub struct ContextGraphEntityDetail { pub incoming: Vec, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphSyncStats { + pub sessions_scanned: usize, + pub decisions_processed: usize, + pub file_events_processed: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FileActivityAction { diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 551ed77d..64d1bad3 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -14,9 +14,9 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity, - ContextGraphEntityDetail, ContextGraphRelation, DecisionLogEntry, FileActivityAction, - FileActivityEntry, Session, SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, - WorktreeInfo, + ContextGraphEntityDetail, ContextGraphRelation, ContextGraphSyncStats, DecisionLogEntry, + FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, SessionMessage, + SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { @@ -1237,6 +1237,9 @@ impl StateStore { for file_path in file_paths { aggregate.file_paths.insert(file_path); } + for event in &file_events { + self.sync_context_graph_file_event(&row.session_id, &row.tool_name, event)?; + } } for session in self.list_sessions()? { @@ -1252,6 +1255,67 @@ impl StateStore { Ok(()) } + fn sync_context_graph_decision( + &self, + session_id: &str, + decision: &str, + alternatives: &[String], + reasoning: &str, + ) -> Result<()> { + let mut metadata = BTreeMap::new(); + metadata.insert( + "alternatives_count".to_string(), + alternatives.len().to_string(), + ); + if !alternatives.is_empty() { + metadata.insert("alternatives".to_string(), alternatives.join(" | ")); + } + self.upsert_context_entity( + Some(session_id), + "decision", + decision, + None, + reasoning, + &metadata, + )?; + Ok(()) + } + + fn sync_context_graph_file_event( + &self, + session_id: &str, + tool_name: &str, + event: &PersistedFileEvent, + ) -> Result<()> { + let mut metadata = BTreeMap::new(); + metadata.insert( + "last_action".to_string(), + file_activity_action_value(&event.action).to_string(), + ); + metadata.insert("last_tool".to_string(), tool_name.trim().to_string()); + if let Some(diff_preview) = &event.diff_preview { + metadata.insert("diff_preview".to_string(), diff_preview.clone()); + } + + let action = file_activity_action_value(&event.action); + let tool_name = tool_name.trim(); + let summary = if let Some(diff_preview) = &event.diff_preview { + format!("Last activity: {action} via {tool_name} | {diff_preview}") + } else { + format!("Last activity: {action} via {tool_name}") + }; + let name = context_graph_file_name(&event.path); + self.upsert_context_entity( + Some(session_id), + "file", + &name, + Some(&event.path), + &summary, + &metadata, + )?; + Ok(()) + } + pub fn increment_tool_calls(&self, session_id: &str) -> Result<()> { self.conn.execute( "UPDATE sessions @@ -1628,6 +1692,8 @@ impl StateStore { ], )?; + self.sync_context_graph_decision(session_id, decision, alternatives, reasoning)?; + Ok(DecisionLogEntry { id: self.conn.last_insert_rowid(), session_id: session_id.to_string(), @@ -1683,6 +1749,49 @@ impl StateStore { Ok(entries) } + pub fn sync_context_graph_history( + &self, + session_id: Option<&str>, + per_session_limit: usize, + ) -> Result { + let sessions = if let Some(session_id) = session_id { + let session = self + .get_session(session_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found: {session_id}"))?; + vec![session] + } else { + self.list_sessions()? + }; + + let mut stats = ContextGraphSyncStats::default(); + for session in sessions { + stats.sessions_scanned = stats.sessions_scanned.saturating_add(1); + + for entry in self.list_decisions_for_session(&session.id, per_session_limit)? { + self.sync_context_graph_decision( + &session.id, + &entry.decision, + &entry.alternatives, + &entry.reasoning, + )?; + stats.decisions_processed = stats.decisions_processed.saturating_add(1); + } + + for entry in self.list_file_activity(&session.id, per_session_limit)? { + let persisted = PersistedFileEvent { + path: entry.path.clone(), + action: entry.action.clone(), + diff_preview: entry.diff_preview.clone(), + patch_preview: entry.patch_preview.clone(), + }; + self.sync_context_graph_file_event(&session.id, "history", &persisted)?; + stats.file_events_processed = stats.file_events_processed.saturating_add(1); + } + } + + Ok(stats) + } + pub fn upsert_context_entity( &self, session_id: Option<&str>, @@ -2840,6 +2949,14 @@ fn context_graph_entity_key(entity_type: &str, name: &str, path: Option<&str>) - ) } +fn context_graph_file_name(path: &str) -> String { + Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .map(|value| value.to_string()) + .unwrap_or_else(|| path.to_string()) +} + fn file_overlap_is_relevant(current: &FileActivityEntry, other: &FileActivityEntry) -> bool { current.path == other.path && !(matches!(current.action, FileActivityAction::Read) @@ -3675,6 +3792,171 @@ mod tests { Ok(()) } + #[test] + fn insert_decision_automatically_upserts_context_graph_entity() -> Result<()> { + let tempdir = TestDir::new("store-context-decision-auto")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "context graph".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + db.insert_decision( + "session-1", + "Use sqlite for shared context", + &["json files".to_string(), "memory only".to_string()], + "SQLite keeps the graph queryable from CLI and TUI", + )?; + + let entities = db.list_context_entities(Some("session-1"), Some("decision"), 10)?; + assert_eq!(entities.len(), 1); + assert_eq!(entities[0].name, "Use sqlite for shared context"); + assert_eq!( + entities[0].metadata.get("alternatives_count"), + Some(&"2".to_string()) + ); + assert!(entities[0] + .summary + .contains("SQLite keeps the graph queryable")); + + Ok(()) + } + + #[test] + fn sync_tool_activity_metrics_automatically_upserts_file_entities() -> Result<()> { + let tempdir = TestDir::new("store-context-file-auto")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "context graph".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join(".claude/metrics"); + std::fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + std::fs::write( + &metrics_path, + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/config.ts\",\"output_summary\":\"updated config\",\"file_events\":[{\"path\":\"src/config.ts\",\"action\":\"modify\",\"diff_preview\":\"old -> new\"}],\"timestamp\":\"2026-04-10T00:00:00Z\"}\n", + )?; + + db.sync_tool_activity_metrics(&metrics_path)?; + + let entities = db.list_context_entities(Some("session-1"), Some("file"), 10)?; + assert_eq!(entities.len(), 1); + assert_eq!(entities[0].name, "config.ts"); + assert_eq!(entities[0].path.as_deref(), Some("src/config.ts")); + assert_eq!( + entities[0].metadata.get("last_action"), + Some(&"modify".to_string()) + ); + assert_eq!( + entities[0].metadata.get("last_tool"), + Some(&"Edit".to_string()) + ); + assert!(entities[0] + .summary + .contains("Last activity: modify via Edit")); + + Ok(()) + } + + #[test] + fn sync_context_graph_history_backfills_existing_activity() -> Result<()> { + let tempdir = TestDir::new("store-context-backfill")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "context graph".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + db.conn.execute( + "INSERT INTO decision_log (session_id, decision, alternatives_json, reasoning, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + "session-1", + "Backfill historical decision", + "[]", + "Historical reasoning", + "2026-04-10T00:00:00Z", + ], + )?; + db.conn.execute( + "INSERT INTO tool_log ( + hook_event_id, session_id, tool_name, input_summary, input_params_json, output_summary, + trigger_summary, duration_ms, risk_score, timestamp, file_paths_json, file_events_json + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + rusqlite::params![ + "evt-backfill", + "session-1", + "Write", + "Write src/backfill.rs", + "{}", + "updated file", + "context graph", + 0u64, + 0.0f64, + "2026-04-10T00:01:00Z", + "[\"src/backfill.rs\"]", + "[{\"path\":\"src/backfill.rs\",\"action\":\"modify\"}]", + ], + )?; + + let stats = db.sync_context_graph_history(Some("session-1"), 10)?; + assert_eq!(stats.sessions_scanned, 1); + assert_eq!(stats.decisions_processed, 1); + assert_eq!(stats.file_events_processed, 1); + + let entities = db.list_context_entities(Some("session-1"), None, 10)?; + assert!(entities + .iter() + .any(|entity| entity.entity_type == "decision" + && entity.name == "Backfill historical decision")); + assert!(entities.iter().any(|entity| entity.entity_type == "file" + && entity.path.as_deref() == Some("src/backfill.rs"))); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; From 4adb3324ef9e517485148547d8b5dd2c45c80711 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 04:10:08 -0700 Subject: [PATCH 123/459] feat: add ecc2 context graph dashboard view --- ecc2/src/tui/app.rs | 4 + ecc2/src/tui/dashboard.rs | 607 +++++++++++++++++++++++++++++++++++--- 2 files changed, 577 insertions(+), 34 deletions(-) diff --git a/ecc2/src/tui/app.rs b/ecc2/src/tui/app.rs index 78da92b0..46f3cffb 100644 --- a/ecc2/src/tui/app.rs +++ b/ecc2/src/tui/app.rs @@ -98,9 +98,13 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { (_, KeyCode::Char('I')) => dashboard.focus_next_approval_target(), (_, KeyCode::Char('g')) => dashboard.auto_dispatch_backlog().await, (_, KeyCode::Char('G')) => dashboard.coordinate_backlog().await, + (_, KeyCode::Char('K')) => dashboard.toggle_context_graph_mode(), (_, KeyCode::Char('h')) => dashboard.collapse_selected_pane(), (_, KeyCode::Char('H')) => dashboard.restore_collapsed_panes(), (_, KeyCode::Char('y')) => dashboard.toggle_timeline_mode(), + (_, KeyCode::Char('E')) if dashboard.is_context_graph_mode() => { + dashboard.cycle_graph_entity_filter() + } (_, KeyCode::Char('E')) => dashboard.cycle_timeline_event_filter(), (_, KeyCode::Char('v')) => dashboard.toggle_output_mode(), (_, KeyCode::Char('z')) => dashboard.toggle_git_status_mode(), diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 3bf6ce12..3a3a240b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -117,6 +117,7 @@ pub struct Dashboard { selected_git_patch_hunk_offsets_split: Vec, selected_git_patch_hunk: usize, output_mode: OutputMode, + graph_entity_filter: GraphEntityFilter, output_filter: OutputFilter, output_time_filter: OutputTimeFilter, timeline_event_filter: TimelineEventFilter, @@ -182,12 +183,22 @@ enum Pane { enum OutputMode { SessionOutput, Timeline, + ContextGraph, WorktreeDiff, ConflictProtocol, GitStatus, GitPatch, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GraphEntityFilter { + All, + Decisions, + Files, + Functions, + Sessions, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DiffViewMode { Split, @@ -246,6 +257,12 @@ struct SearchMatch { line_index: usize, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct GraphDisplayLine { + session_id: String, + text: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] struct PrPromptSpec { title: String, @@ -535,6 +552,7 @@ impl Dashboard { selected_git_patch_hunk_offsets_split: Vec::new(), selected_git_patch_hunk: 0, output_mode: OutputMode::SessionOutput, + graph_entity_filter: GraphEntityFilter::All, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, timeline_event_filter: TimelineEventFilter::All, @@ -817,6 +835,21 @@ impl Dashboard { }; (self.output_title(), content) } + OutputMode::ContextGraph => { + let lines = self.visible_graph_lines(); + let content = if lines.is_empty() { + Text::from(self.empty_graph_message()) + } else if self.search_query.is_some() { + self.render_searchable_graph(&lines) + } else { + Text::from( + lines.into_iter() + .map(|line| Line::from(line.text)) + .collect::>(), + ) + }; + (self.output_title(), content) + } OutputMode::WorktreeDiff => { let content = if let Some(patch) = self.selected_diff_patch.as_ref() { build_unified_diff_text(patch, self.theme_palette()) @@ -924,6 +957,25 @@ impl Dashboard { ); } + if self.output_mode == OutputMode::ContextGraph { + let scope = self.search_scope.title_suffix(); + let filter = self.graph_entity_filter.title_suffix(); + let time = self.output_time_filter.title_suffix(); + if let Some(input) = self.search_input.as_ref() { + return format!(" Graph{scope}{filter}{time} /{input}_ "); + } + if let Some(query) = self.search_query.as_ref() { + let total = self.search_matches.len(); + let current = if total == 0 { + 0 + } else { + self.selected_search_match.min(total.saturating_sub(1)) + 1 + }; + return format!(" Graph{scope}{filter}{time} /{query} {current}/{total} "); + } + return format!(" Graph{scope}{filter}{time} "); + } + if self.output_mode == OutputMode::WorktreeDiff { return format!( " Diff{}{} ", @@ -1116,6 +1168,34 @@ impl Dashboard { } } + fn empty_graph_message(&self) -> &'static str { + match ( + self.search_scope, + self.graph_entity_filter, + self.output_time_filter, + ) { + (SearchScope::SelectedSession, GraphEntityFilter::All, OutputTimeFilter::AllTime) => { + "No graph entities for this session yet." + } + (_, GraphEntityFilter::Decisions, OutputTimeFilter::AllTime) => { + "No decision graph entities in the current scope yet." + } + (_, GraphEntityFilter::Files, OutputTimeFilter::AllTime) => { + "No file graph entities in the current scope yet." + } + (_, GraphEntityFilter::Functions, OutputTimeFilter::AllTime) => { + "No function graph entities in the current scope yet." + } + (_, GraphEntityFilter::Sessions, OutputTimeFilter::AllTime) => { + "No session graph entities in the current scope yet." + } + (SearchScope::AllSessions, GraphEntityFilter::All, OutputTimeFilter::AllTime) => { + "No graph entities across all sessions yet." + } + (_, _, _) => "No graph entities in the selected filter/time range.", + } + } + fn render_searchable_output(&self, lines: &[&OutputLine]) -> Text<'static> { let Some(query) = self.search_query.as_deref() else { return Text::from( @@ -1147,6 +1227,39 @@ impl Dashboard { self.theme_palette(), ) }) + .collect::>(), + ) + } + + fn render_searchable_graph(&self, lines: &[GraphDisplayLine]) -> Text<'static> { + let Some(query) = self.search_query.as_deref() else { + return Text::from( + lines + .iter() + .map(|line| Line::from(line.text.clone())) + .collect::>(), + ); + }; + + let active_match = self.search_matches.get(self.selected_search_match); + + Text::from( + lines + .iter() + .enumerate() + .map(|(index, line)| { + highlight_output_line( + &line.text, + query, + active_match + .map(|search_match| { + search_match.session_id == line.session_id + && search_match.line_index == index + }) + .unwrap_or(false), + self.theme_palette(), + ) + }) .collect::>(), ) } @@ -1380,10 +1493,11 @@ impl Dashboard { " I Jump to the next unread approval/conflict target session".to_string(), " g Auto-dispatch unread handoffs across lead sessions".to_string(), " G Dispatch then rebalance backlog across lead teams".to_string(), + " K Toggle selected-session context graph view".to_string(), " h Collapse the focused non-session pane".to_string(), " H Restore all collapsed panes".to_string(), " y Toggle selected-session timeline view".to_string(), - " E Cycle timeline event filter".to_string(), + " E Cycle timeline event filter or graph entity filter".to_string(), " v Toggle selected worktree diff or selected-file patch in output pane" .to_string(), " z Toggle selected worktree git status in output pane".to_string(), @@ -1396,7 +1510,7 @@ impl Dashboard { .to_string(), " e Cycle output content filter: all/errors/tool calls/file changes".to_string(), " f Cycle output or timeline time range between all/15m/1h/24h".to_string(), - " A Toggle search or timeline scope between selected session and all sessions" + " A Toggle search, graph, or timeline scope between selected session and all sessions" .to_string(), " o Toggle search agent filter between all agents and selected agent type" .to_string(), @@ -1430,7 +1544,7 @@ impl Dashboard { " k/↑ Scroll up".to_string(), " [ or ] Focus previous/next delegate in lead Metrics board".to_string(), " Enter Open focused delegate from lead Metrics board".to_string(), - " / Search current session output".to_string(), + " / Search session output or graph lines".to_string(), " n/N Next/previous search match when search is active".to_string(), " Esc Clear active search or cancel search input".to_string(), " +/= Increase pane size and persist it".to_string(), @@ -2113,6 +2227,11 @@ impl Dashboard { self.reset_output_view(); self.set_operator_note("showing session output".to_string()); } + OutputMode::ContextGraph => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } OutputMode::ConflictProtocol => { self.output_mode = OutputMode::SessionOutput; self.reset_output_view(); @@ -3057,6 +3176,10 @@ impl Dashboard { self.search_query.is_some() } + pub fn is_context_graph_mode(&self) -> bool { + self.output_mode == OutputMode::ContextGraph + } + pub fn has_active_completion_popup(&self) -> bool { self.active_completion_popup.is_some() } @@ -3092,9 +3215,27 @@ impl Dashboard { return; } + if self.output_mode == OutputMode::ContextGraph { + self.search_scope = self.search_scope.next(); + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + + if self.search_query.is_some() { + self.set_operator_note(format!( + "graph scope set to {} | {} match(es)", + self.search_scope.label(), + self.search_matches.len() + )); + } else { + self.set_operator_note(format!("graph scope set to {}", self.search_scope.label())); + } + return; + } + if self.output_mode != OutputMode::SessionOutput { self.set_operator_note( - "scope toggle is only available in session output or timeline view".to_string(), + "scope toggle is only available in session output, graph, or timeline view" + .to_string(), ); return; } @@ -3154,13 +3295,20 @@ impl Dashboard { return; } - if self.output_mode != OutputMode::SessionOutput { - self.set_operator_note("search is only available in session output view".to_string()); + if !matches!(self.output_mode, OutputMode::SessionOutput | OutputMode::ContextGraph) { + self.set_operator_note( + "search is only available in session output or graph view".to_string(), + ); return; } self.search_input = Some(self.search_query.clone().unwrap_or_default()); - self.set_operator_note("search mode | type a query and press Enter".to_string()); + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "search" + }; + self.set_operator_note(format!("{mode} mode | type a query and press Enter")); } pub fn push_input_char(&mut self, ch: char) { @@ -3327,10 +3475,20 @@ impl Dashboard { self.search_query = Some(query.clone()); self.recompute_search_matches(); if self.search_matches.is_empty() { - self.set_operator_note(format!("search /{query} found no matches")); + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "search" + }; + self.set_operator_note(format!("{mode} /{query} found no matches")); } else { + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "search" + }; self.set_operator_note(format!( - "search /{query} matched {} line(s) across {} session(s) | n/N navigate matches", + "{mode} /{query} matched {} line(s) across {} session(s) | n/N navigate matches", self.search_matches.len(), self.search_match_session_count() )); @@ -3551,7 +3709,12 @@ impl Dashboard { self.search_matches.clear(); self.selected_search_match = 0; if had_query || had_input { - self.set_operator_note("cleared output search".to_string()); + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "output search" + }; + self.set_operator_note(format!("cleared {mode}")); } } @@ -3601,23 +3764,27 @@ impl Dashboard { pub fn cycle_output_time_filter(&mut self) { if !matches!( self.output_mode, - OutputMode::SessionOutput | OutputMode::Timeline + OutputMode::SessionOutput | OutputMode::Timeline | OutputMode::ContextGraph ) { self.set_operator_note( - "time filters are only available in session output or timeline view".to_string(), + "time filters are only available in session output, graph, or timeline view" + .to_string(), ); return; } self.output_time_filter = self.output_time_filter.next(); - if self.output_mode == OutputMode::SessionOutput { + if matches!( + self.output_mode, + OutputMode::SessionOutput | OutputMode::ContextGraph + ) { self.recompute_search_matches(); } self.sync_output_scroll(self.last_output_height.max(1)); - let note_prefix = if self.output_mode == OutputMode::Timeline { - "timeline range" - } else { - "output time filter" + let note_prefix = match self.output_mode { + OutputMode::Timeline => "timeline range", + OutputMode::ContextGraph => "graph range", + _ => "output time filter", }; self.set_operator_note(format!( "{note_prefix} set to {}", @@ -3641,6 +3808,41 @@ impl Dashboard { )); } + pub fn toggle_context_graph_mode(&mut self) { + match self.output_mode { + OutputMode::ContextGraph => { + self.output_mode = OutputMode::SessionOutput; + self.reset_output_view(); + self.set_operator_note("showing session output".to_string()); + } + _ => { + self.output_mode = OutputMode::ContextGraph; + self.selected_pane = Pane::Output; + self.output_follow = false; + self.output_scroll_offset = 0; + self.recompute_search_matches(); + self.set_operator_note("showing selected session context graph".to_string()); + } + } + } + + pub fn cycle_graph_entity_filter(&mut self) { + if self.output_mode != OutputMode::ContextGraph { + self.set_operator_note( + "graph entity filters are only available in context graph view".to_string(), + ); + return; + } + + self.graph_entity_filter = self.graph_entity_filter.next(); + self.recompute_search_matches(); + self.sync_output_scroll(self.last_output_height.max(1)); + self.set_operator_note(format!( + "graph filter set to {}", + self.graph_entity_filter.label() + )); + } + pub fn toggle_auto_dispatch_policy(&mut self) { self.cfg.auto_dispatch_unread_handoffs = !self.cfg.auto_dispatch_unread_handoffs; match self.cfg.save() { @@ -4842,6 +5044,97 @@ impl Dashboard { .unwrap_or_default() } + fn visible_graph_lines(&self) -> Vec { + let session_scope = match self.search_scope { + SearchScope::SelectedSession => self.selected_session_id(), + SearchScope::AllSessions => None, + }; + let entity_type = self.graph_entity_filter.entity_type(); + let entities = self + .db + .list_context_entities(session_scope, entity_type, 48) + .unwrap_or_default(); + let show_session_label = self.search_scope == SearchScope::AllSessions; + + entities + .into_iter() + .filter(|entity| self.output_time_filter.matches_timestamp(entity.updated_at)) + .flat_map(|entity| self.graph_lines_for_entity(entity, show_session_label)) + .collect() + } + + fn graph_lines_for_entity( + &self, + entity: crate::session::ContextGraphEntity, + show_session_label: bool, + ) -> Vec { + let session_id = entity.session_id.clone().unwrap_or_default(); + let session_label = if show_session_label { + if session_id.is_empty() { + "global ".to_string() + } else { + format!("{} ", format_session_id(&session_id)) + } + } else { + String::new() + }; + let entity_title = format!( + "[{}] {}{:<8} {}", + entity.updated_at.format("%H:%M:%S"), + session_label, + entity.entity_type, + entity.name + ); + let mut lines = vec![GraphDisplayLine { + session_id: session_id.clone(), + text: entity_title, + }]; + + if let Some(path) = entity.path.as_ref() { + lines.push(GraphDisplayLine { + session_id: session_id.clone(), + text: format!(" path {}", truncate_for_dashboard(path, 96)), + }); + } + + if !entity.summary.trim().is_empty() { + lines.push(GraphDisplayLine { + session_id: session_id.clone(), + text: format!( + " summary {}", + truncate_for_dashboard(&entity.summary, 96) + ), + }); + } + + if let Ok(Some(detail)) = self.db.get_context_entity_detail(entity.id, 2) { + for relation in detail.outgoing { + lines.push(GraphDisplayLine { + session_id: session_id.clone(), + text: format!( + " -> {} {}:{}", + relation.relation_type, + relation.to_entity_type, + truncate_for_dashboard(&relation.to_entity_name, 72) + ), + }); + } + for relation in detail.incoming { + lines.push(GraphDisplayLine { + session_id: session_id.clone(), + text: format!( + " <- {} {}:{}", + relation.relation_type, + relation.from_entity_type, + truncate_for_dashboard(&relation.from_entity_name, 72) + ), + }); + } + } + + lines + } + fn visible_git_status_lines(&self) -> Vec> { self.selected_git_status_entries .iter() @@ -5066,22 +5359,34 @@ impl Dashboard { return; }; - self.search_matches = self - .search_target_session_ids() - .into_iter() - .flat_map(|session_id| { - self.visible_output_lines_for_session(session_id) - .into_iter() - .enumerate() - .filter_map(|(index, line)| { - regex.is_match(&line.text).then_some(SearchMatch { - session_id: session_id.to_string(), - line_index: index, - }) + self.search_matches = if self.output_mode == OutputMode::ContextGraph { + self.visible_graph_lines() + .into_iter() + .enumerate() + .filter_map(|(index, line)| { + regex.is_match(&line.text).then_some(SearchMatch { + session_id: line.session_id, + line_index: index, }) - .collect::>() - }) - .collect(); + }) + .collect() + } else { + self.search_target_session_ids() + .into_iter() + .flat_map(|session_id| { + self.visible_output_lines_for_session(session_id) + .into_iter() + .enumerate() + .filter_map(|(index, line)| { + regex.is_match(&line.text).then_some(SearchMatch { + session_id: session_id.to_string(), + line_index: index, + }) + }) + .collect::>() + }) + .collect() + }; if self.search_matches.is_empty() { self.selected_search_match = 0; @@ -5100,7 +5405,9 @@ impl Dashboard { return; }; - if self.selected_session_id() != Some(search_match.session_id.as_str()) { + if !search_match.session_id.is_empty() + && self.selected_session_id() != Some(search_match.session_id.as_str()) + { self.sync_selection_by_id(Some(&search_match.session_id)); self.sync_selected_output(); self.sync_selected_diff(); @@ -5126,8 +5433,13 @@ impl Dashboard { self.selected_search_match.min(total.saturating_sub(1)) + 1 }; + let mode = if self.output_mode == OutputMode::ContextGraph { + "graph search" + } else { + "search" + }; format!( - "search /{query} match {current}/{total} | {}", + "{mode} /{query} match {current}/{total} | {}", self.search_scope.label() ) } @@ -5135,6 +5447,7 @@ impl Dashboard { fn search_match_session_count(&self) -> usize { self.search_matches .iter() + .filter(|search_match| !search_match.session_id.is_empty()) .map(|search_match| search_match.session_id.as_str()) .collect::>() .len() @@ -5224,6 +5537,8 @@ impl Dashboard { self.active_patch_text() .map(|patch| patch.lines().count()) .unwrap_or(0) + } else if self.output_mode == OutputMode::ContextGraph { + self.visible_graph_lines().len() } else if self.output_mode == OutputMode::Timeline { self.visible_timeline_lines().len() } else { @@ -6705,6 +7020,48 @@ impl TimelineEventFilter { } } +impl GraphEntityFilter { + fn next(self) -> Self { + match self { + Self::All => Self::Decisions, + Self::Decisions => Self::Files, + Self::Files => Self::Functions, + Self::Functions => Self::Sessions, + Self::Sessions => Self::All, + } + } + + fn entity_type(self) -> Option<&'static str> { + match self { + Self::All => None, + Self::Decisions => Some("decision"), + Self::Files => Some("file"), + Self::Functions => Some("function"), + Self::Sessions => Some("session"), + } + } + + fn label(self) -> &'static str { + match self { + Self::All => "all entities", + Self::Decisions => "decisions", + Self::Files => "files", + Self::Functions => "functions", + Self::Sessions => "sessions", + } + } + + fn title_suffix(self) -> &'static str { + match self { + Self::All => "", + Self::Decisions => " decisions", + Self::Files => " files", + Self::Functions => " functions", + Self::Sessions => " sessions", + } + } +} + impl TimelineEventType { fn label(self) -> &'static str { match self { @@ -9583,6 +9940,187 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ assert!(rendered.contains("tool git")); } + #[test] + fn toggle_context_graph_mode_renders_selected_session_entities_and_relations() -> Result<()> { + let session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session)?; + + let file = dashboard.db.upsert_context_entity( + Some(&session.id), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "dashboard renderer", + &std::collections::BTreeMap::new(), + )?; + let function = dashboard.db.upsert_context_entity( + Some(&session.id), + "function", + "render_output", + None, + "renders the output pane", + &std::collections::BTreeMap::new(), + )?; + dashboard.db.upsert_context_relation( + Some(&session.id), + file.id, + function.id, + "contains", + "output rendering path", + )?; + + dashboard.toggle_context_graph_mode(); + + assert_eq!(dashboard.output_mode, OutputMode::ContextGraph); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("showing selected session context graph") + ); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Graph")); + assert!(rendered.contains("dashboard.rs")); + assert!(rendered.contains("summary dashboard renderer")); + assert!(rendered.contains("-> contains function:render_output")); + Ok(()) + } + + #[test] + fn cycle_graph_entity_filter_limits_rendered_entities() -> Result<()> { + let session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session)?; + dashboard.db.insert_decision( + &session.id, + "Use sqlite graph sync", + &[], + "Keeps shared memory queryable", + )?; + dashboard.db.upsert_context_entity( + Some(&session.id), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "dashboard renderer", + &std::collections::BTreeMap::new(), + )?; + + dashboard.toggle_context_graph_mode(); + dashboard.cycle_graph_entity_filter(); + + assert_eq!(dashboard.graph_entity_filter, GraphEntityFilter::Decisions); + assert_eq!(dashboard.output_title(), " Graph decisions "); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("Use sqlite graph sync")); + assert!(!rendered.contains("dashboard.rs")); + + dashboard.cycle_graph_entity_filter(); + assert_eq!(dashboard.graph_entity_filter, GraphEntityFilter::Files); + assert_eq!(dashboard.output_title(), " Graph files "); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("dashboard.rs")); + assert!(!rendered.contains("Use sqlite graph sync")); + Ok(()) + } + + #[test] + fn graph_scope_all_sessions_renders_cross_session_entities() -> Result<()> { + let focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let review = sample_session( + "review-87654321", + "reviewer", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0); + dashboard.db.insert_session(&focus)?; + dashboard.db.insert_session(&review)?; + dashboard.db.insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?; + dashboard.db.insert_decision(&review.id, "Beta graph path", &[], "review path")?; + + dashboard.toggle_context_graph_mode(); + dashboard.toggle_search_scope(); + + assert_eq!(dashboard.search_scope, SearchScope::AllSessions); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("graph scope set to all sessions") + ); + assert_eq!(dashboard.output_title(), " Graph all sessions "); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("focus-12")); + assert!(rendered.contains("review-8")); + assert!(rendered.contains("Alpha graph path")); + assert!(rendered.contains("Beta graph path")); + Ok(()) + } + + #[test] + fn graph_search_matches_and_switches_selected_session() -> Result<()> { + let focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let review = sample_session( + "review-87654321", + "reviewer", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0); + dashboard.db.insert_session(&focus)?; + dashboard.db.insert_session(&review)?; + dashboard.db.insert_decision(&focus.id, "alpha local graph", &[], "planner path")?; + dashboard.db.insert_decision(&review.id, "alpha remote graph", &[], "review path")?; + + dashboard.toggle_context_graph_mode(); + dashboard.toggle_search_scope(); + dashboard.begin_search(); + for ch in "alpha.*".chars() { + dashboard.push_input_char(ch); + } + dashboard.submit_search(); + + assert_eq!(dashboard.search_matches.len(), 2); + let first_session = dashboard.selected_session_id().map(str::to_string); + dashboard.next_search_match(); + assert_eq!( + dashboard.operator_note.as_deref(), + Some("graph search /alpha.* match 2/2 | all sessions") + ); + assert_ne!(dashboard.selected_session_id().map(str::to_string), first_session); + Ok(()) + } + #[test] fn worktree_diff_columns_split_removed_and_added_lines() { let patch = "\ @@ -13343,6 +13881,7 @@ diff --git a/src/lib.rs b/src/lib.rs selected_git_patch_hunk_offsets_split: Vec::new(), selected_git_patch_hunk: 0, output_mode: OutputMode::SessionOutput, + graph_entity_filter: GraphEntityFilter::All, output_filter: OutputFilter::All, output_time_filter: OutputTimeFilter::AllTime, timeline_event_filter: TimelineEventFilter::All, From 315b87d3911c70b8087c29f2992d3e7180cd021e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 04:18:18 -0700 Subject: [PATCH 124/459] feat: add ecc2 automatic graph relations --- ecc2/src/session/store.rs | 92 ++++++++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 36 +++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 64d1bad3..5786298a 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1262,6 +1262,7 @@ impl StateStore { alternatives: &[String], reasoning: &str, ) -> Result<()> { + let session_entity = self.sync_context_graph_session(session_id)?; let mut metadata = BTreeMap::new(); metadata.insert( "alternatives_count".to_string(), @@ -1270,7 +1271,7 @@ impl StateStore { if !alternatives.is_empty() { metadata.insert("alternatives".to_string(), alternatives.join(" | ")); } - self.upsert_context_entity( + let decision_entity = self.upsert_context_entity( Some(session_id), "decision", decision, @@ -1278,6 +1279,14 @@ impl StateStore { reasoning, &metadata, )?; + let relation_summary = format!("{} recorded this decision", session_entity.name); + self.upsert_context_relation( + Some(session_id), + session_entity.id, + decision_entity.id, + "decided", + &relation_summary, + )?; Ok(()) } @@ -1287,6 +1296,7 @@ impl StateStore { tool_name: &str, event: &PersistedFileEvent, ) -> Result<()> { + let session_entity = self.sync_context_graph_session(session_id)?; let mut metadata = BTreeMap::new(); metadata.insert( "last_action".to_string(), @@ -1305,7 +1315,7 @@ impl StateStore { format!("Last activity: {action} via {tool_name}") }; let name = context_graph_file_name(&event.path); - self.upsert_context_entity( + let file_entity = self.upsert_context_entity( Some(session_id), "file", &name, @@ -1313,9 +1323,57 @@ impl StateStore { &summary, &metadata, )?; + self.upsert_context_relation( + Some(session_id), + session_entity.id, + file_entity.id, + action, + &summary, + )?; Ok(()) } + fn sync_context_graph_session(&self, session_id: &str) -> Result { + let session = self + .get_session(session_id)? + .ok_or_else(|| anyhow::anyhow!("Session not found for context graph sync: {session_id}"))?; + + let mut metadata = BTreeMap::new(); + metadata.insert("task".to_string(), session.task.clone()); + metadata.insert("project".to_string(), session.project.clone()); + metadata.insert("task_group".to_string(), session.task_group.clone()); + metadata.insert("agent_type".to_string(), session.agent_type.clone()); + metadata.insert("state".to_string(), session.state.to_string()); + metadata.insert( + "working_dir".to_string(), + session.working_dir.display().to_string(), + ); + if let Some(pid) = session.pid { + metadata.insert("pid".to_string(), pid.to_string()); + } + if let Some(worktree) = &session.worktree { + metadata.insert( + "worktree_path".to_string(), + worktree.path.display().to_string(), + ); + metadata.insert("worktree_branch".to_string(), worktree.branch.clone()); + metadata.insert("base_branch".to_string(), worktree.base_branch.clone()); + } + + let summary = format!( + "{} | {} | {} / {}", + session.state, session.agent_type, session.project, session.task_group + ); + self.upsert_context_entity( + Some(&session.id), + "session", + &session.id, + None, + &summary, + &metadata, + ) + } + pub fn increment_tool_calls(&self, session_id: &str) -> Result<()> { self.conn.execute( "UPDATE sessions @@ -3832,6 +3890,20 @@ mod tests { .summary .contains("SQLite keeps the graph queryable")); + let session_entities = db.list_context_entities(Some("session-1"), Some("session"), 10)?; + assert_eq!(session_entities.len(), 1); + assert_eq!(session_entities[0].name, "session-1"); + assert_eq!( + session_entities[0].metadata.get("task"), + Some(&"context graph".to_string()) + ); + + let relations = db.list_context_relations(Some(session_entities[0].id), 10)?; + assert_eq!(relations.len(), 1); + assert_eq!(relations[0].relation_type, "decided"); + assert_eq!(relations[0].to_entity_type, "decision"); + assert_eq!(relations[0].to_entity_name, "Use sqlite for shared context"); + Ok(()) } @@ -3883,6 +3955,14 @@ mod tests { .summary .contains("Last activity: modify via Edit")); + let session_entities = db.list_context_entities(Some("session-1"), Some("session"), 10)?; + assert_eq!(session_entities.len(), 1); + let relations = db.list_context_relations(Some(session_entities[0].id), 10)?; + assert_eq!(relations.len(), 1); + assert_eq!(relations[0].relation_type, "modify"); + assert_eq!(relations[0].to_entity_type, "file"); + assert_eq!(relations[0].to_entity_name, "config.ts"); + Ok(()) } @@ -3953,6 +4033,14 @@ mod tests { && entity.name == "Backfill historical decision")); assert!(entities.iter().any(|entity| entity.entity_type == "file" && entity.path.as_deref() == Some("src/backfill.rs"))); + let session_entity = entities + .iter() + .find(|entity| entity.entity_type == "session" && entity.name == "session-1") + .expect("session entity should exist"); + let relations = db.list_context_relations(Some(session_entity.id), 10)?; + assert_eq!(relations.len(), 2); + assert!(relations.iter().any(|relation| relation.relation_type == "decided")); + assert!(relations.iter().any(|relation| relation.relation_type == "modify")); Ok(()) } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 3a3a240b..b0fc1b88 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -10104,12 +10104,14 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ dashboard.toggle_context_graph_mode(); dashboard.toggle_search_scope(); + dashboard.cycle_graph_entity_filter(); dashboard.begin_search(); for ch in "alpha.*".chars() { dashboard.push_input_char(ch); } dashboard.submit_search(); + assert_eq!(dashboard.graph_entity_filter, GraphEntityFilter::Decisions); assert_eq!(dashboard.search_matches.len(), 2); let first_session = dashboard.selected_session_id().map(str::to_string); dashboard.next_search_match(); @@ -10121,6 +10123,40 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ Ok(()) } + #[test] + fn graph_sessions_filter_renders_auto_session_relations() -> Result<()> { + let session = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let mut dashboard = test_dashboard(vec![session.clone()], 0); + dashboard.db.insert_session(&session)?; + dashboard.db.insert_decision( + &session.id, + "Use graph relations", + &[], + "Edges make the context graph navigable", + )?; + + dashboard.toggle_context_graph_mode(); + dashboard.cycle_graph_entity_filter(); + dashboard.cycle_graph_entity_filter(); + dashboard.cycle_graph_entity_filter(); + dashboard.cycle_graph_entity_filter(); + + assert_eq!(dashboard.graph_entity_filter, GraphEntityFilter::Sessions); + assert_eq!(dashboard.output_title(), " Graph sessions "); + let rendered = dashboard.rendered_output_text(180, 30); + assert!(rendered.contains("focus-12345678")); + assert!(rendered.contains("summary running | planner |")); + assert!(rendered.contains("-> decided decision:Use graph relations")); + Ok(()) + } + #[test] fn worktree_diff_columns_split_removed_and_added_lines() { let patch = "\ From beaba1ca15487cfa4b94d5a3a144eba05ec0905a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 04:30:32 -0700 Subject: [PATCH 125/459] feat: add ecc2 graph coordination edges --- ecc2/src/main.rs | 3 + ecc2/src/session/mod.rs | 1 + ecc2/src/session/store.rs | 184 ++++++++++++++++++++++++++++++++------ 3 files changed, 159 insertions(+), 29 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 434707aa..24038d34 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -2266,6 +2266,7 @@ fn format_graph_sync_stats_human( format!("- sessions scanned {}", stats.sessions_scanned), format!("- decisions processed {}", stats.decisions_processed), format!("- file events processed {}", stats.file_events_processed), + format!("- messages processed {}", stats.messages_processed), ] .join("\n") } @@ -4202,6 +4203,7 @@ mod tests { sessions_scanned: 2, decisions_processed: 3, file_events_processed: 5, + messages_processed: 4, }, Some("sess-12345678"), ); @@ -4210,6 +4212,7 @@ mod tests { assert!(text.contains("- sessions scanned 2")); assert!(text.contains("- decisions processed 3")); assert!(text.contains("- file events processed 5")); + assert!(text.contains("- messages processed 4")); } #[test] diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 1fce45e9..583d8bde 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -195,6 +195,7 @@ pub struct ContextGraphSyncStats { pub sessions_scanned: usize, pub decisions_processed: usize, pub file_events_processed: usize, + pub messages_processed: usize, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 5786298a..b32bb0ea 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1334,46 +1334,88 @@ impl StateStore { } fn sync_context_graph_session(&self, session_id: &str) -> Result { - let session = self - .get_session(session_id)? - .ok_or_else(|| anyhow::anyhow!("Session not found for context graph sync: {session_id}"))?; - + let session = self.get_session(session_id)?; let mut metadata = BTreeMap::new(); - metadata.insert("task".to_string(), session.task.clone()); - metadata.insert("project".to_string(), session.project.clone()); - metadata.insert("task_group".to_string(), session.task_group.clone()); - metadata.insert("agent_type".to_string(), session.agent_type.clone()); - metadata.insert("state".to_string(), session.state.to_string()); - metadata.insert( - "working_dir".to_string(), - session.working_dir.display().to_string(), - ); - if let Some(pid) = session.pid { - metadata.insert("pid".to_string(), pid.to_string()); - } - if let Some(worktree) = &session.worktree { + let persisted_session_id = if session.is_some() { + Some(session_id) + } else { + None + }; + let summary = if let Some(session) = session { + metadata.insert("task".to_string(), session.task.clone()); + metadata.insert("project".to_string(), session.project.clone()); + metadata.insert("task_group".to_string(), session.task_group.clone()); + metadata.insert("agent_type".to_string(), session.agent_type.clone()); + metadata.insert("state".to_string(), session.state.to_string()); metadata.insert( - "worktree_path".to_string(), - worktree.path.display().to_string(), + "working_dir".to_string(), + session.working_dir.display().to_string(), ); - metadata.insert("worktree_branch".to_string(), worktree.branch.clone()); - metadata.insert("base_branch".to_string(), worktree.base_branch.clone()); - } + if let Some(pid) = session.pid { + metadata.insert("pid".to_string(), pid.to_string()); + } + if let Some(worktree) = &session.worktree { + metadata.insert( + "worktree_path".to_string(), + worktree.path.display().to_string(), + ); + metadata.insert("worktree_branch".to_string(), worktree.branch.clone()); + metadata.insert("base_branch".to_string(), worktree.base_branch.clone()); + } - let summary = format!( - "{} | {} | {} / {}", - session.state, session.agent_type, session.project, session.task_group - ); + format!( + "{} | {} | {} / {}", + session.state, session.agent_type, session.project, session.task_group + ) + } else { + metadata.insert("state".to_string(), "unknown".to_string()); + "session placeholder".to_string() + }; self.upsert_context_entity( - Some(&session.id), + persisted_session_id, "session", - &session.id, + session_id, None, &summary, &metadata, ) } + fn sync_context_graph_message( + &self, + from_session_id: &str, + to_session_id: &str, + content: &str, + msg_type: &str, + ) -> Result<()> { + let relation_session_id = self + .get_session(from_session_id)? + .map(|session| session.id) + .filter(|id| !id.is_empty()); + let from_entity = self.sync_context_graph_session(from_session_id)?; + let to_entity = self.sync_context_graph_session(to_session_id)?; + + let relation_type = match msg_type { + "task_handoff" => "delegates_to", + "query" => "queries", + "response" => "responds_to", + "completed" => "completed_for", + "conflict" => "conflicts_with", + other => other, + }; + let summary = crate::comms::preview(msg_type, content); + + self.upsert_context_relation( + relation_session_id.as_deref(), + from_entity.id, + to_entity.id, + relation_type, + &summary, + )?; + + Ok(()) + } + pub fn increment_tool_calls(&self, session_id: &str) -> Result<()> { self.conn.execute( "UPDATE sessions @@ -1503,9 +1545,45 @@ impl StateStore { VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params![from, to, content, msg_type, chrono::Utc::now().to_rfc3339()], )?; + self.sync_context_graph_message(from, to, content, msg_type)?; Ok(()) } + fn list_messages_sent_by_session( + &self, + session_id: &str, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, from_session, to_session, content, msg_type, read, timestamp + FROM messages + WHERE from_session = ?1 + ORDER BY id DESC + LIMIT ?2", + )?; + + let mut messages = stmt + .query_map(rusqlite::params![session_id, limit as i64], |row| { + let timestamp: String = row.get(6)?; + + Ok(SessionMessage { + id: row.get(0)?, + from_session: row.get(1)?, + to_session: row.get(2)?, + content: row.get(3)?, + msg_type: row.get(4)?, + read: row.get::<_, i64>(5)? != 0, + timestamp: chrono::DateTime::parse_from_rfc3339(×tamp) + .unwrap_or_default() + .with_timezone(&chrono::Utc), + }) + })? + .collect::, _>>()?; + + messages.reverse(); + Ok(messages) + } + pub fn list_messages_for_session( &self, session_id: &str, @@ -1845,6 +1923,16 @@ impl StateStore { self.sync_context_graph_file_event(&session.id, "history", &persisted)?; stats.file_events_processed = stats.file_events_processed.saturating_add(1); } + + for message in self.list_messages_sent_by_session(&session.id, per_session_limit)? { + self.sync_context_graph_message( + &message.from_session, + &message.to_session, + &message.content, + &message.msg_type, + )?; + stats.messages_processed = stats.messages_processed.saturating_add(1); + } } Ok(stats) @@ -4020,11 +4108,23 @@ mod tests { "[{\"path\":\"src/backfill.rs\",\"action\":\"modify\"}]", ], )?; + db.conn.execute( + "INSERT INTO messages (from_session, to_session, content, msg_type, timestamp) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + "session-1", + "session-2", + "{\"task\":\"Review backfill output\",\"context\":\"graph sync\"}", + "task_handoff", + "2026-04-10T00:02:00Z", + ], + )?; let stats = db.sync_context_graph_history(Some("session-1"), 10)?; assert_eq!(stats.sessions_scanned, 1); assert_eq!(stats.decisions_processed, 1); assert_eq!(stats.file_events_processed, 1); + assert_eq!(stats.messages_processed, 1); let entities = db.list_context_entities(Some("session-1"), None, 10)?; assert!(entities @@ -4038,9 +4138,12 @@ mod tests { .find(|entity| entity.entity_type == "session" && entity.name == "session-1") .expect("session entity should exist"); let relations = db.list_context_relations(Some(session_entity.id), 10)?; - assert_eq!(relations.len(), 2); + assert_eq!(relations.len(), 3); assert!(relations.iter().any(|relation| relation.relation_type == "decided")); assert!(relations.iter().any(|relation| relation.relation_type == "modify")); + assert!(relations + .iter() + .any(|relation| relation.relation_type == "delegates_to")); Ok(()) } @@ -4229,6 +4332,29 @@ mod tests { vec![("worker-2".to_string(), 1), ("worker-3".to_string(), 1),] ); + let planner_entities = db.list_context_entities(Some("planner"), Some("session"), 10)?; + assert_eq!(planner_entities.len(), 1); + let planner_relations = db.list_context_relations(Some(planner_entities[0].id), 10)?; + assert!(planner_relations.iter().any(|relation| { + relation.relation_type == "queries" && relation.to_entity_name == "worker" + })); + assert!(planner_relations.iter().any(|relation| { + relation.relation_type == "delegates_to" && relation.to_entity_name == "worker-2" + })); + assert!(planner_relations.iter().any(|relation| { + relation.relation_type == "delegates_to" && relation.to_entity_name == "worker-3" + })); + + let worker_entity = db + .list_context_entities(Some("worker"), Some("session"), 10)? + .into_iter() + .find(|entity| entity.name == "worker") + .expect("worker session entity should exist"); + let worker_relations = db.list_context_relations(Some(worker_entity.id), 10)?; + assert!(worker_relations.iter().any(|relation| { + relation.relation_type == "completed_for" && relation.to_entity_name == "planner" + })); + Ok(()) } From 4b1ff4821996602a698f03a271f720805d297839 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 04:35:34 -0700 Subject: [PATCH 126/459] feat: surface ecc2 graph context in metrics --- ecc2/src/tui/dashboard.rs | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index b0fc1b88..9c7854c5 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -38,6 +38,7 @@ const PANE_RESIZE_STEP_PERCENT: u16 = 5; const MAX_LOG_ENTRIES: u64 = 12; const MAX_DIFF_PREVIEW_LINES: usize = 6; const MAX_DIFF_PATCH_LINES: usize = 80; +const MAX_METRICS_GRAPH_RELATIONS: usize = 6; const MAX_FILE_ACTIVITY_PATCH_LINES: usize = 3; #[derive(Debug, Clone, PartialEq, Eq)] @@ -5135,6 +5136,60 @@ impl Dashboard { lines } + fn session_graph_metrics_lines(&self, session_id: &str) -> Vec { + let entity = self + .db + .list_context_entities(Some(session_id), Some("session"), 4) + .unwrap_or_default() + .into_iter() + .find(|entity| { + entity.session_id.as_deref() == Some(session_id) || entity.name == session_id + }); + let Some(entity) = entity else { + return Vec::new(); + }; + + let Ok(Some(detail)) = self + .db + .get_context_entity_detail(entity.id, MAX_METRICS_GRAPH_RELATIONS) + else { + return Vec::new(); + }; + + if detail.outgoing.is_empty() && detail.incoming.is_empty() { + return Vec::new(); + } + + let mut lines = vec![ + "Context graph".to_string(), + format!( + "- outgoing {} | incoming {}", + detail.outgoing.len(), + detail.incoming.len() + ), + ]; + + for relation in detail.outgoing.iter().take(4) { + lines.push(format!( + "- -> {} {}:{}", + relation.relation_type, + relation.to_entity_type, + truncate_for_dashboard(&relation.to_entity_name, 72) + )); + } + + for relation in detail.incoming.iter().take(2) { + lines.push(format!( + "- <- {} {}:{}", + relation.relation_type, + relation.from_entity_type, + truncate_for_dashboard(&relation.from_entity_name, 72) + )); + } + + lines + } + fn visible_git_status_lines(&self) -> Vec> { self.selected_git_status_entries .iter() @@ -6100,6 +6155,7 @@ impl Dashboard { } } } + lines.extend(self.session_graph_metrics_lines(&session.id)); let file_overlaps = self .db .list_file_overlaps(&session.id, 3) @@ -10157,6 +10213,48 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ Ok(()) } + #[test] + fn selected_session_metrics_text_includes_context_graph_relations() -> Result<()> { + let focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + let delegate = sample_session( + "delegate-87654321", + "coder", + SessionState::Idle, + None, + 1, + 1, + ); + let dashboard = test_dashboard(vec![focus.clone(), delegate.clone()], 0); + dashboard.db.insert_session(&focus)?; + dashboard.db.insert_session(&delegate)?; + dashboard.db.insert_decision( + &focus.id, + "Use sqlite graph sync", + &[], + "Keeps shared memory queryable", + )?; + dashboard.db.send_message( + &focus.id, + &delegate.id, + "{\"task\":\"Review graph edge\",\"context\":\"coordination smoke\"}", + "task_handoff", + )?; + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Context graph")); + assert!(text.contains("outgoing 2 | incoming 0")); + assert!(text.contains("-> decided decision:Use sqlite graph sync")); + assert!(text.contains("-> delegates_to session:delegate-87654321")); + Ok(()) + } + #[test] fn worktree_diff_columns_split_removed_and_added_lines() { let patch = "\ From 0b68af123c03b6e63d27c6d07d048aaf046b4c09 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 04:41:00 -0700 Subject: [PATCH 127/459] feat: route ecc2 delegates by graph context --- ecc2/src/session/manager.rs | 172 ++++++++++++++++++++++++++++++++++-- 1 file changed, 167 insertions(+), 5 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 3f5f2c2c..d9fbc2d5 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1156,7 +1156,7 @@ async fn assign_session_in_dir_with_runner_program( .unwrap_or(0) == 0 }) - .min_by_key(|session| session.updated_at) + .max_by_key(|session| delegate_selection_key(db, session, task)) { send_task_handoff(db, &lead, &idle_delegate.id, task, "reused idle delegate")?; return Ok(AssignmentOutcome { @@ -1208,13 +1208,14 @@ async fn assign_session_in_dir_with_runner_program( if let Some(active_delegate) = delegates .iter() .filter(|session| matches!(session.state, SessionState::Running | SessionState::Pending)) - .min_by_key(|session| { + .max_by_key(|session| { ( - delegate_handoff_backlog + graph_context_match_score(db, &session.id, task), + -(delegate_handoff_backlog .get(&session.id) .copied() - .unwrap_or(0), - session.updated_at, + .unwrap_or(0) as i64), + -session.updated_at.timestamp_millis(), ) }) { @@ -2358,6 +2359,61 @@ fn direct_delegate_sessions( Ok(sessions) } +fn delegate_selection_key(db: &StateStore, session: &Session, task: &str) -> (usize, i64) { + ( + graph_context_match_score(db, &session.id, task), + -session.updated_at.timestamp_millis(), + ) +} + +fn graph_context_match_score(db: &StateStore, session_id: &str, task: &str) -> usize { + let terms = graph_match_terms(task); + if terms.is_empty() { + return 0; + } + + let entities = match db.list_context_entities(Some(session_id), None, 48) { + Ok(entities) => entities, + Err(_) => return 0, + }; + + let mut matched = HashSet::new(); + for entity in entities { + let mut haystacks = vec![entity.name.to_lowercase(), entity.summary.to_lowercase()]; + if let Some(path) = entity.path.as_ref() { + haystacks.push(path.to_lowercase()); + } + for (key, value) in entity.metadata { + haystacks.push(key.to_lowercase()); + haystacks.push(value.to_lowercase()); + } + + for term in &terms { + if haystacks.iter().any(|haystack| haystack.contains(term)) { + matched.insert(term.clone()); + } + } + } + + matched.len() +} + +fn graph_match_terms(task: &str) -> Vec { + let mut terms = Vec::new(); + let mut seen = HashSet::new(); + for token in task + .split(|ch: char| !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '-'))) + .map(str::trim) + .filter(|token| token.len() >= 3) + { + let lowered = token.to_ascii_lowercase(); + if seen.insert(lowered.clone()) { + terms.push(lowered); + } + } + terms +} + fn summarize_backlog_pressure( db: &StateStore, cfg: &Config, @@ -4740,6 +4796,112 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn assign_session_prefers_idle_delegate_with_graph_context_match() -> Result<()> { + let tempdir = TestDir::new("manager-assign-graph-context-idle")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(4), + updated_at: now - Duration::minutes(4), + last_heartbeat_at: now - Duration::minutes(4), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "older-worker".to_string(), + task: "legacy delegated task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Idle, + pid: Some(100), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "auth-worker".to_string(), + task: "auth delegated task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Idle, + pid: Some(101), + worktree: None, + created_at: now - Duration::minutes(2), + updated_at: now - Duration::minutes(2), + last_heartbeat_at: now - Duration::minutes(2), + metrics: SessionMetrics::default(), + })?; + db.send_message( + "lead", + "older-worker", + "{\"task\":\"legacy delegated task\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + db.send_message( + "lead", + "auth-worker", + "{\"task\":\"auth delegated task\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + db.mark_messages_read("older-worker")?; + db.mark_messages_read("auth-worker")?; + + db.upsert_context_entity( + Some("auth-worker"), + "file", + "auth-callback.ts", + Some("src/auth/callback.ts"), + "Auth callback recovery edge cases", + &BTreeMap::new(), + )?; + + let (fake_runner, _) = write_fake_claude(tempdir.path())?; + let outcome = assign_session_in_dir_with_runner_program( + &db, + &cfg, + "lead", + "Investigate auth callback recovery", + "claude", + true, + &repo_root, + &fake_runner, + None, + SessionGrouping::default(), + ) + .await?; + + assert_eq!(outcome.action, AssignmentAction::ReusedIdle); + assert_eq!(outcome.session_id, "auth-worker"); + + let auth_messages = db.list_messages_for_session("auth-worker", 10)?; + assert!(auth_messages.iter().any(|message| { + message.msg_type == "task_handoff" + && message.content.contains("Investigate auth callback recovery") + })); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn assign_session_spawns_instead_of_reusing_backed_up_idle_delegate() -> Result<()> { let tempdir = TestDir::new("manager-assign-spawn-backed-up-idle")?; From 23348a21a68be5aee366c84957c2a283d2e5e94b Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 04:49:14 -0700 Subject: [PATCH 128/459] feat: preview ecc2 graph-aware routing --- ecc2/src/session/manager.rs | 186 +++++++++++++++++++++++++++++++---- ecc2/src/tui/dashboard.rs | 190 +++++++++++++++++++++++++++++++++++- 2 files changed, 351 insertions(+), 25 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index d9fbc2d5..1fbbbaaf 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -480,11 +480,8 @@ pub async fn drain_inbox( let mut outcomes = Vec::new(); for message in messages { - let task = match comms::parse(&message.content) { - Some(MessageType::TaskHandoff { task, .. }) => task, - _ => extract_legacy_handoff_task(&message.content) - .unwrap_or_else(|| message.content.clone()), - }; + let task = parse_task_handoff_task(&message.content) + .unwrap_or_else(|| message.content.clone()); let outcome = assign_session_in_dir_with_runner_program( db, @@ -687,11 +684,8 @@ pub async fn rebalance_team_backlog( continue; } - let task = match comms::parse(&message.content) { - Some(MessageType::TaskHandoff { task, .. }) => task, - _ => extract_legacy_handoff_task(&message.content) - .unwrap_or_else(|| message.content.clone()), - }; + let task = parse_task_handoff_task(&message.content) + .unwrap_or_else(|| message.content.clone()); let outcome = assign_session_in_dir_with_runner_program( db, @@ -2367,19 +2361,24 @@ fn delegate_selection_key(db: &StateStore, session: &Session, task: &str) -> (us } fn graph_context_match_score(db: &StateStore, session_id: &str, task: &str) -> usize { + graph_context_matched_terms(db, session_id, task).len() +} + +fn graph_context_matched_terms(db: &StateStore, session_id: &str, task: &str) -> Vec { let terms = graph_match_terms(task); if terms.is_empty() { - return 0; + return Vec::new(); } let entities = match db.list_context_entities(Some(session_id), None, 48) { Ok(entities) => entities, - Err(_) => return 0, + Err(_) => return Vec::new(), }; - let mut matched = HashSet::new(); + let mut haystacks = Vec::new(); for entity in entities { - let mut haystacks = vec![entity.name.to_lowercase(), entity.summary.to_lowercase()]; + haystacks.push(entity.name.to_lowercase()); + haystacks.push(entity.summary.to_lowercase()); if let Some(path) = entity.path.as_ref() { haystacks.push(path.to_lowercase()); } @@ -2387,15 +2386,12 @@ fn graph_context_match_score(db: &StateStore, session_id: &str, task: &str) -> u haystacks.push(key.to_lowercase()); haystacks.push(value.to_lowercase()); } - - for term in &terms { - if haystacks.iter().any(|haystack| haystack.contains(term)) { - matched.insert(term.clone()); - } - } } - matched.len() + terms + .into_iter() + .filter(|term| haystacks.iter().any(|haystack| haystack.contains(term))) + .collect() } fn graph_match_terms(task: &str) -> Vec { @@ -2475,6 +2471,13 @@ fn send_task_handoff( ) } +pub(crate) fn parse_task_handoff_task(content: &str) -> Option { + match comms::parse(content) { + Some(MessageType::TaskHandoff { task, .. }) => Some(task), + _ => extract_legacy_handoff_task(content), + } +} + fn extract_legacy_handoff_task(content: &str) -> Option { let value: serde_json::Value = serde_json::from_str(content).ok()?; value @@ -2684,6 +2687,15 @@ pub struct AssignmentOutcome { pub action: AssignmentAction, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AssignmentPreview { + pub session_id: Option, + pub action: AssignmentAction, + pub delegate_state: Option, + pub handoff_backlog: usize, + pub graph_match_terms: Vec, +} + pub struct InboxDrainOutcome { pub message_id: i64, pub task: String, @@ -2759,6 +2771,120 @@ pub enum AssignmentAction { DeferredSaturated, } +pub fn preview_assignment_for_task( + db: &StateStore, + cfg: &Config, + lead_id: &str, + task: &str, + agent_type: &str, +) -> Result { + let lead = resolve_session(db, lead_id)?; + let delegates = direct_delegate_sessions(db, &lead.id, agent_type)?; + let delegate_handoff_backlog = delegates + .iter() + .map(|session| { + db.unread_task_handoff_count(&session.id) + .map(|count| (session.id.clone(), count)) + }) + .collect::>>()?; + + if let Some(idle_delegate) = delegates + .iter() + .filter(|session| { + session.state == SessionState::Idle + && delegate_handoff_backlog + .get(&session.id) + .copied() + .unwrap_or(0) + == 0 + }) + .max_by_key(|session| delegate_selection_key(db, session, task)) + { + return Ok(AssignmentPreview { + session_id: Some(idle_delegate.id.clone()), + action: AssignmentAction::ReusedIdle, + delegate_state: Some(idle_delegate.state.clone()), + handoff_backlog: 0, + graph_match_terms: graph_context_matched_terms(db, &idle_delegate.id, task), + }); + } + + if delegates.len() < cfg.max_parallel_sessions { + return Ok(AssignmentPreview { + session_id: None, + action: AssignmentAction::Spawned, + delegate_state: None, + handoff_backlog: 0, + graph_match_terms: Vec::new(), + }); + } + + if let Some(idle_delegate) = delegates + .iter() + .filter(|session| session.state == SessionState::Idle) + .min_by_key(|session| { + ( + delegate_handoff_backlog + .get(&session.id) + .copied() + .unwrap_or(0), + session.updated_at, + ) + }) + { + let handoff_backlog = delegate_handoff_backlog + .get(&idle_delegate.id) + .copied() + .unwrap_or(0); + return Ok(AssignmentPreview { + session_id: Some(idle_delegate.id.clone()), + action: AssignmentAction::DeferredSaturated, + delegate_state: Some(idle_delegate.state.clone()), + handoff_backlog, + graph_match_terms: graph_context_matched_terms(db, &idle_delegate.id, task), + }); + } + + if let Some(active_delegate) = delegates + .iter() + .filter(|session| matches!(session.state, SessionState::Running | SessionState::Pending)) + .max_by_key(|session| { + ( + graph_context_match_score(db, &session.id, task), + -(delegate_handoff_backlog + .get(&session.id) + .copied() + .unwrap_or(0) as i64), + -session.updated_at.timestamp_millis(), + ) + }) + { + let handoff_backlog = delegate_handoff_backlog + .get(&active_delegate.id) + .copied() + .unwrap_or(0); + return Ok(AssignmentPreview { + session_id: Some(active_delegate.id.clone()), + action: if handoff_backlog > 0 { + AssignmentAction::DeferredSaturated + } else { + AssignmentAction::ReusedActive + }, + delegate_state: Some(active_delegate.state.clone()), + handoff_backlog, + graph_match_terms: graph_context_matched_terms(db, &active_delegate.id, task), + }); + } + + Ok(AssignmentPreview { + session_id: None, + action: AssignmentAction::Spawned, + delegate_state: None, + handoff_backlog: 0, + graph_match_terms: Vec::new(), + }) +} + pub fn assignment_action_routes_work(action: AssignmentAction) -> bool { !matches!(action, AssignmentAction::DeferredSaturated) } @@ -4875,6 +5001,24 @@ mod tests { &BTreeMap::new(), )?; + let preview = preview_assignment_for_task( + &db, + &cfg, + "lead", + "Investigate auth callback recovery", + "claude", + )?; + assert_eq!(preview.action, AssignmentAction::ReusedIdle); + assert_eq!(preview.session_id.as_deref(), Some("auth-worker")); + assert_eq!( + preview.graph_match_terms, + vec![ + "auth".to_string(), + "callback".to_string(), + "recovery".to_string() + ] + ); + let (fake_runner, _) = write_fake_claude(tempdir.path())?; let outcome = assign_session_in_dir_with_runner_program( &db, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 9c7854c5..396026fb 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -4910,8 +4910,12 @@ impl Dashboard { } self.selected_team_summary = if team.total > 0 { Some(team) } else { None }; + let selected_agent_type = self + .selected_agent_type() + .unwrap_or(self.cfg.default_agent.as_str()) + .to_string(); self.selected_route_preview = - self.build_route_preview(team.total, &route_candidates); + self.build_route_preview(&session_id, &selected_agent_type, team.total, &route_candidates); delegated.sort_by_key(|delegate| { ( delegate_attention_priority(delegate), @@ -4934,9 +4938,23 @@ impl Dashboard { fn build_route_preview( &self, + lead_id: &str, + lead_agent_type: &str, delegate_count: usize, delegates: &[DelegatedChildSummary], ) -> Option { + if let Some(task) = self.latest_route_task(lead_id) { + if let Ok(preview) = manager::preview_assignment_for_task( + &self.db, + &self.cfg, + lead_id, + &task, + lead_agent_type, + ) { + return Some(self.format_assignment_preview(&task, &preview)); + } + } + if let Some(idle_clear) = delegates .iter() .filter(|delegate| { @@ -4960,7 +4978,7 @@ impl Dashboard { .min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str())) { return Some(format!( - "reuse idle {} with backlog {}", + "defer; idle {} backlog {}", format_session_id(&idle_backed_up.session_id), idle_backed_up.handoff_backlog )); @@ -4977,9 +4995,18 @@ impl Dashboard { .min_by_key(|delegate| (delegate.handoff_backlog, delegate.session_id.as_str())) { return Some(format!( - "reuse active {} with backlog {}", + "{} active {}{}", + if active_delegate.handoff_backlog > 0 { + "defer;" + } else { + "reuse" + }, format_session_id(&active_delegate.session_id), - active_delegate.handoff_backlog + if active_delegate.handoff_backlog > 0 { + format!(" backlog {}", active_delegate.handoff_backlog) + } else { + String::new() + } )); } @@ -4990,6 +5017,78 @@ impl Dashboard { } } + fn latest_route_task(&self, session_id: &str) -> Option { + self.db + .list_messages_for_session(session_id, 16) + .ok()? + .into_iter() + .rev() + .find_map(|message| { + if message.to_session != session_id || message.msg_type != "task_handoff" { + return None; + } + manager::parse_task_handoff_task(&message.content) + .or_else(|| Some(message.content)) + }) + } + + fn format_assignment_preview( + &self, + task: &str, + preview: &manager::AssignmentPreview, + ) -> String { + let task_preview = truncate_for_dashboard(task, 40); + let graph_suffix = if preview.graph_match_terms.is_empty() { + String::new() + } else { + format!( + " | graph {}", + truncate_for_dashboard(&preview.graph_match_terms.join(", "), 36) + ) + }; + + match preview.action { + manager::AssignmentAction::Spawned => { + format!("for `{task_preview}` spawn new delegate") + } + manager::AssignmentAction::ReusedIdle => format!( + "for `{task_preview}` reuse idle {}{}", + preview + .session_id + .as_deref() + .map(format_session_id) + .unwrap_or_else(|| "unknown".to_string()), + graph_suffix + ), + manager::AssignmentAction::ReusedActive => format!( + "for `{task_preview}` reuse active {}{}", + preview + .session_id + .as_deref() + .map(format_session_id) + .unwrap_or_else(|| "unknown".to_string()), + graph_suffix + ), + manager::AssignmentAction::DeferredSaturated => { + let state_label = match preview.delegate_state { + Some(SessionState::Idle) => "idle", + Some(SessionState::Running) | Some(SessionState::Pending) => "active", + _ => "delegate", + }; + format!( + "for `{task_preview}` defer; {state_label} {} backlog {}{}", + preview + .session_id + .as_deref() + .map(format_session_id) + .unwrap_or_else(|| "unknown".to_string()), + preview.handoff_backlog, + graph_suffix + ) + } + } + } + fn selected_session_id(&self) -> Option<&str> { self.sessions .get(self.selected_session) @@ -11052,6 +11151,89 @@ diff --git a/src/lib.rs b/src/lib.rs assert!(!text.contains("Backlog focus-12")); } + #[test] + fn route_preview_uses_graph_context_for_latest_incoming_handoff() { + let lead = sample_session( + "lead-12345678", + "planner", + SessionState::Running, + Some("ecc/lead"), + 512, + 42, + ); + let older_worker = sample_session( + "older-worker", + "planner", + SessionState::Idle, + Some("ecc/older"), + 128, + 12, + ); + let auth_worker = sample_session( + "auth-worker", + "planner", + SessionState::Idle, + Some("ecc/auth"), + 256, + 24, + ); + + let mut dashboard = + test_dashboard(vec![lead.clone(), older_worker.clone(), auth_worker.clone()], 0); + dashboard.db.insert_session(&lead).unwrap(); + dashboard.db.insert_session(&older_worker).unwrap(); + dashboard.db.insert_session(&auth_worker).unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "older-worker", + "{\"task\":\"Legacy delegated work\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + dashboard + .db + .send_message( + "lead-12345678", + "auth-worker", + "{\"task\":\"Auth delegated work\",\"context\":\"Delegated from lead\"}", + "task_handoff", + ) + .unwrap(); + dashboard.db.mark_messages_read("older-worker").unwrap(); + dashboard.db.mark_messages_read("auth-worker").unwrap(); + dashboard + .db + .send_message( + "planner-root", + "lead-12345678", + "{\"task\":\"Investigate auth callback recovery\",\"context\":\"Delegated from planner-root\"}", + "task_handoff", + ) + .unwrap(); + dashboard + .db + .upsert_context_entity( + Some("auth-worker"), + "file", + "auth-callback.ts", + Some("src/auth/callback.ts"), + "Auth callback recovery edge cases", + &BTreeMap::new(), + ) + .unwrap(); + + dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap(); + dashboard.sync_selected_messages(); + dashboard.sync_selected_lineage(); + + assert_eq!( + dashboard.selected_route_preview.as_deref(), + Some("for `Investigate auth callback recovery` reuse idle auth-wor | graph auth, callback, recovery") + ); + } + #[test] fn route_preview_ignores_non_handoff_inbox_noise() { let lead = sample_session( From 7a13564a8bef2ca774b7ec75f2a7a4a8fb92e6fc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 05:49:43 -0700 Subject: [PATCH 129/459] feat: add ecc2 graph recall memory ranking --- ecc2/src/main.rs | 149 ++++++++++++++++++++++++ ecc2/src/session/mod.rs | 8 ++ ecc2/src/session/store.rs | 232 +++++++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 144 +++++++++++++++++++---- 4 files changed, 506 insertions(+), 27 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 24038d34..7b8d8e7a 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -457,6 +457,20 @@ enum GraphCommands { #[arg(long)] json: bool, }, + /// Recall relevant context graph entities for a query + Recall { + /// Filter by source session ID or alias + #[arg(long)] + session_id: Option, + /// Natural-language query used for recall scoring + query: String, + /// Maximum entities to return + #[arg(long, default_value_t = 8)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Show one entity plus its incoming and outgoing relations Show { /// Entity ID @@ -1229,6 +1243,27 @@ async fn main() -> Result<()> { println!("{}", format_graph_relations_human(&relations)); } } + GraphCommands::Recall { + session_id, + query, + limit, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let entries = + db.recall_context_entities(resolved_session_id.as_deref(), &query, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&entries)?); + } else { + println!( + "{}", + format_graph_recall_human(&entries, resolved_session_id.as_deref(), &query) + ); + } + } GraphCommands::Show { entity_id, limit, @@ -2214,6 +2249,49 @@ fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) -> lines.join("\n") } +fn format_graph_recall_human( + entries: &[session::ContextGraphRecallEntry], + session_id: Option<&str>, + query: &str, +) -> String { + if entries.is_empty() { + return format!("No relevant context graph entities found for query: {query}"); + } + + let scope = session_id + .map(short_session) + .unwrap_or_else(|| "all sessions".to_string()); + let mut lines = vec![format!( + "Relevant memory: {} entries for \"{}\" ({scope})", + entries.len(), + query + )]; + for entry in entries { + let mut line = format!( + "- #{} [{}] {} | score {} | relations {}", + entry.entity.id, + entry.entity.entity_type, + entry.entity.name, + entry.score, + entry.relation_count + ); + if let Some(session_id) = entry.entity.session_id.as_deref() { + line.push_str(&format!(" | {}", short_session(session_id))); + } + lines.push(line); + if !entry.matched_terms.is_empty() { + lines.push(format!(" matches {}", entry.matched_terms.join(", "))); + } + if let Some(path) = entry.entity.path.as_deref() { + lines.push(format!(" path {path}")); + } + if !entry.entity.summary.is_empty() { + lines.push(format!(" summary {}", entry.entity.summary)); + } + } + lines.join("\n") +} + fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String { let mut lines = vec![format_graph_entity_human(&detail.entity)]; lines.push(String::new()); @@ -4114,6 +4192,40 @@ mod tests { } } + #[test] + fn cli_parses_graph_recall_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "recall", + "--session-id", + "latest", + "--limit", + "4", + "--json", + "auth callback recovery", + ]) + .expect("graph recall should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::Recall { + session_id, + query, + limit, + json, + }, + }) => { + assert_eq!(session_id.as_deref(), Some("latest")); + assert_eq!(query, "auth callback recovery"); + assert_eq!(limit, 4); + assert!(json); + } + _ => panic!("expected graph recall subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -4196,6 +4308,43 @@ mod tests { assert!(text.contains("[contains] #6 dashboard.rs -> render_metrics")); } + #[test] + fn format_graph_recall_human_renders_scores_and_matches() { + let text = format_graph_recall_human( + &[session::ContextGraphRecallEntry { + entity: session::ContextGraphEntity { + id: 11, + session_id: Some("sess-12345678".to_string()), + entity_type: "file".to_string(), + name: "callback.ts".to_string(), + path: Some("src/routes/auth/callback.ts".to_string()), + summary: "Handles auth callback recovery".to_string(), + metadata: BTreeMap::new(), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + updated_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }, + score: 319, + matched_terms: vec![ + "auth".to_string(), + "callback".to_string(), + "recovery".to_string(), + ], + relation_count: 2, + }], + Some("sess-12345678"), + "auth callback recovery", + ); + + assert!(text.contains("Relevant memory: 1 entries")); + assert!(text.contains("[file] callback.ts | score 319 | relations 2")); + assert!(text.contains("matches auth, callback, recovery")); + assert!(text.contains("path src/routes/auth/callback.ts")); + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 583d8bde..7bd380f1 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -190,6 +190,14 @@ pub struct ContextGraphEntityDetail { pub incoming: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphRecallEntry { + pub entity: ContextGraphEntity, + pub score: u64, + pub matched_terms: Vec, + pub relation_count: usize, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphSyncStats { pub sessions_scanned: usize, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index b32bb0ea..c0f465d3 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -14,9 +14,9 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity, - ContextGraphEntityDetail, ContextGraphRelation, ContextGraphSyncStats, DecisionLogEntry, - FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, SessionMessage, - SessionMetrics, SessionState, WorktreeInfo, + ContextGraphEntityDetail, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, + DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, + SessionMessage, SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { @@ -2024,6 +2024,82 @@ impl StateStore { Ok(entries) } + pub fn recall_context_entities( + &self, + session_id: Option<&str>, + query: &str, + limit: usize, + ) -> Result> { + if limit == 0 { + return Ok(Vec::new()); + } + + let terms = context_graph_recall_terms(query); + if terms.is_empty() { + return Ok(Vec::new()); + } + + let candidate_limit = (limit.saturating_mul(12)).clamp(24, 512); + let mut stmt = self.conn.prepare( + "SELECT e.id, e.session_id, e.entity_type, e.name, e.path, e.summary, e.metadata_json, + e.created_at, e.updated_at, + ( + SELECT COUNT(*) + FROM context_graph_relations r + WHERE r.from_entity_id = e.id OR r.to_entity_id = e.id + ) AS relation_count + FROM context_graph_entities e + WHERE (?1 IS NULL OR e.session_id = ?1) + ORDER BY e.updated_at DESC, e.id DESC + LIMIT ?2", + )?; + + let candidates = stmt + .query_map( + rusqlite::params![session_id, candidate_limit as i64], + |row| { + let entity = map_context_graph_entity(row)?; + let relation_count = row.get::<_, i64>(9)?.max(0) as usize; + Ok((entity, relation_count)) + }, + )? + .collect::, _>>()?; + + let now = chrono::Utc::now(); + let mut entries = candidates + .into_iter() + .filter_map(|(entity, relation_count)| { + let matched_terms = context_graph_matched_terms(&entity, &terms); + if matched_terms.is_empty() { + return None; + } + + Some(ContextGraphRecallEntry { + score: context_graph_recall_score( + matched_terms.len(), + relation_count, + entity.updated_at, + now, + ), + entity, + matched_terms, + relation_count, + }) + }) + .collect::>(); + + entries.sort_by(|left, right| { + right + .score + .cmp(&left.score) + .then_with(|| right.entity.updated_at.cmp(&left.entity.updated_at)) + .then_with(|| right.entity.id.cmp(&left.entity.id)) + }); + entries.truncate(limit); + + Ok(entries) + } + pub fn get_context_entity_detail( &self, entity_id: i64, @@ -3071,6 +3147,65 @@ fn map_context_graph_relation(row: &rusqlite::Row<'_>) -> rusqlite::Result Vec { + let mut terms = Vec::new(); + for raw_term in + query.split(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/'))) + { + let term = raw_term.trim().to_ascii_lowercase(); + if term.len() < 3 || terms.iter().any(|existing| existing == &term) { + continue; + } + terms.push(term); + } + terms +} + +fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> Vec { + let mut haystacks = vec![ + entity.entity_type.to_ascii_lowercase(), + entity.name.to_ascii_lowercase(), + entity.summary.to_ascii_lowercase(), + ]; + if let Some(path) = entity.path.as_ref() { + haystacks.push(path.to_ascii_lowercase()); + } + for (key, value) in &entity.metadata { + haystacks.push(key.to_ascii_lowercase()); + haystacks.push(value.to_ascii_lowercase()); + } + + let mut matched = Vec::new(); + for term in terms { + if haystacks.iter().any(|value| value.contains(term)) { + matched.push(term.clone()); + } + } + matched +} + +fn context_graph_recall_score( + matched_term_count: usize, + relation_count: usize, + updated_at: chrono::DateTime, + now: chrono::DateTime, +) -> u64 { + let recency_bonus = { + let age = now.signed_duration_since(updated_at); + if age <= chrono::Duration::hours(1) { + 9 + } else if age <= chrono::Duration::hours(24) { + 6 + } else if age <= chrono::Duration::days(7) { + 3 + } else { + 0 + } + }; + + (matched_term_count as u64 * 100) + (relation_count.min(9) as u64 * 10) + recency_bonus +} + fn parse_store_timestamp( raw: String, column: usize, @@ -3855,6 +3990,89 @@ mod tests { Ok(()) } + #[test] + fn recall_context_entities_ranks_matching_entities() -> Result<()> { + let tempdir = TestDir::new("store-context-recall")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "Investigate auth callback recovery".to_string(), + project: "ecc-tools".to_string(), + task_group: "incident".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let callback = db.upsert_context_entity( + Some("session-1"), + "file", + "callback.ts", + Some("src/routes/auth/callback.ts"), + "Handles auth callback recovery and billing portal fallback", + &BTreeMap::from([("area".to_string(), "auth".to_string())]), + )?; + let recovery = db.upsert_context_entity( + Some("session-1"), + "decision", + "Use recovery-first callback routing", + None, + "Auth callback recovery should prefer the billing portal", + &BTreeMap::new(), + )?; + let unrelated = db.upsert_context_entity( + Some("session-1"), + "file", + "dashboard.rs", + Some("ecc2/src/tui/dashboard.rs"), + "Renders the TUI dashboard", + &BTreeMap::new(), + )?; + + db.upsert_context_relation( + Some("session-1"), + callback.id, + recovery.id, + "supports", + "Callback route supports recovery-first routing", + )?; + db.upsert_context_relation( + Some("session-1"), + callback.id, + unrelated.id, + "references", + "Callback route references the dashboard summary", + )?; + + let results = + db.recall_context_entities(Some("session-1"), "Investigate auth callback recovery", 3)?; + + assert_eq!(results.len(), 2); + assert_eq!(results[0].entity.id, callback.id); + assert!(results[0].matched_terms.iter().any(|term| term == "auth")); + assert!(results[0] + .matched_terms + .iter() + .any(|term| term == "callback")); + assert!(results[0] + .matched_terms + .iter() + .any(|term| term == "recovery")); + assert_eq!(results[0].relation_count, 2); + assert_eq!(results[1].entity.id, recovery.id); + assert!(!results.iter().any(|entry| entry.entity.id == unrelated.id)); + + Ok(()) + } + #[test] fn context_graph_detail_includes_incoming_and_outgoing_relations() -> Result<()> { let tempdir = TestDir::new("store-context-relations")?; @@ -4139,8 +4357,12 @@ mod tests { .expect("session entity should exist"); let relations = db.list_context_relations(Some(session_entity.id), 10)?; assert_eq!(relations.len(), 3); - assert!(relations.iter().any(|relation| relation.relation_type == "decided")); - assert!(relations.iter().any(|relation| relation.relation_type == "modify")); + assert!(relations + .iter() + .any(|relation| relation.relation_type == "decided")); + assert!(relations + .iter() + .any(|relation| relation.relation_type == "modify")); assert!(relations .iter() .any(|relation| relation.relation_type == "delegates_to")); diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 396026fb..824691a9 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -844,7 +844,8 @@ impl Dashboard { self.render_searchable_graph(&lines) } else { Text::from( - lines.into_iter() + lines + .into_iter() .map(|line| Line::from(line.text)) .collect::>(), ) @@ -1228,7 +1229,7 @@ impl Dashboard { self.theme_palette(), ) }) - .collect::>(), + .collect::>(), ) } @@ -3296,7 +3297,10 @@ impl Dashboard { return; } - if !matches!(self.output_mode, OutputMode::SessionOutput | OutputMode::ContextGraph) { + if !matches!( + self.output_mode, + OutputMode::SessionOutput | OutputMode::ContextGraph + ) { self.set_operator_note( "search is only available in session output or graph view".to_string(), ); @@ -4914,8 +4918,12 @@ impl Dashboard { .selected_agent_type() .unwrap_or(self.cfg.default_agent.as_str()) .to_string(); - self.selected_route_preview = - self.build_route_preview(&session_id, &selected_agent_type, team.total, &route_candidates); + self.selected_route_preview = self.build_route_preview( + &session_id, + &selected_agent_type, + team.total, + &route_candidates, + ); delegated.sort_by_key(|delegate| { ( delegate_attention_priority(delegate), @@ -5027,8 +5035,7 @@ impl Dashboard { if message.to_session != session_id || message.msg_type != "task_handoff" { return None; } - manager::parse_task_handoff_task(&message.content) - .or_else(|| Some(message.content)) + manager::parse_task_handoff_task(&message.content).or_else(|| Some(message.content)) }) } @@ -5289,6 +5296,60 @@ impl Dashboard { lines } + fn session_graph_recall_lines(&self, session: &Session) -> Vec { + let query = session.task.trim(); + if query.is_empty() { + return Vec::new(); + } + + let Ok(entries) = self.db.recall_context_entities(None, query, 4) else { + return Vec::new(); + }; + + let entries = entries + .into_iter() + .filter(|entry| { + !(entry.entity.entity_type == "session" && entry.entity.name == session.id) + }) + .take(3) + .collect::>(); + if entries.is_empty() { + return Vec::new(); + } + + let mut lines = vec!["Relevant memory".to_string()]; + for entry in entries { + let mut line = format!( + "- #{} [{}] {} | score {} | relations {}", + entry.entity.id, + entry.entity.entity_type, + truncate_for_dashboard(&entry.entity.name, 60), + entry.score, + entry.relation_count + ); + if let Some(session_id) = entry.entity.session_id.as_deref() { + if session_id != session.id { + line.push_str(&format!(" | {}", format_session_id(session_id))); + } + } + lines.push(line); + if !entry.matched_terms.is_empty() { + lines.push(format!(" matches {}", entry.matched_terms.join(", "))); + } + if let Some(path) = entry.entity.path.as_deref() { + lines.push(format!(" path {}", truncate_for_dashboard(path, 72))); + } + if !entry.entity.summary.is_empty() { + lines.push(format!( + " summary {}", + truncate_for_dashboard(&entry.entity.summary, 72) + )); + } + } + + lines + } + fn visible_git_status_lines(&self) -> Vec> { self.selected_git_status_entries .iter() @@ -6254,6 +6315,7 @@ impl Dashboard { } } } + lines.extend(self.session_graph_recall_lines(session)); lines.extend(self.session_graph_metrics_lines(&session.id)); let file_overlaps = self .db @@ -10213,8 +10275,12 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0); dashboard.db.insert_session(&focus)?; dashboard.db.insert_session(&review)?; - dashboard.db.insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?; - dashboard.db.insert_decision(&review.id, "Beta graph path", &[], "review path")?; + dashboard + .db + .insert_decision(&focus.id, "Alpha graph path", &[], "planner path")?; + dashboard + .db + .insert_decision(&review.id, "Beta graph path", &[], "review path")?; dashboard.toggle_context_graph_mode(); dashboard.toggle_search_scope(); @@ -10254,8 +10320,12 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ let mut dashboard = test_dashboard(vec![focus.clone(), review.clone()], 0); dashboard.db.insert_session(&focus)?; dashboard.db.insert_session(&review)?; - dashboard.db.insert_decision(&focus.id, "alpha local graph", &[], "planner path")?; - dashboard.db.insert_decision(&review.id, "alpha remote graph", &[], "review path")?; + dashboard + .db + .insert_decision(&focus.id, "alpha local graph", &[], "planner path")?; + dashboard + .db + .insert_decision(&review.id, "alpha remote graph", &[], "review path")?; dashboard.toggle_context_graph_mode(); dashboard.toggle_search_scope(); @@ -10274,7 +10344,10 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ dashboard.operator_note.as_deref(), Some("graph search /alpha.* match 2/2 | all sessions") ); - assert_ne!(dashboard.selected_session_id().map(str::to_string), first_session); + assert_ne!( + dashboard.selected_session_id().map(str::to_string), + first_session + ); Ok(()) } @@ -10322,14 +10395,7 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ 1, 1, ); - let delegate = sample_session( - "delegate-87654321", - "coder", - SessionState::Idle, - None, - 1, - 1, - ); + let delegate = sample_session("delegate-87654321", "coder", SessionState::Idle, None, 1, 1); let dashboard = test_dashboard(vec![focus.clone(), delegate.clone()], 0); dashboard.db.insert_session(&focus)?; dashboard.db.insert_session(&delegate)?; @@ -10354,6 +10420,38 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ Ok(()) } + #[test] + fn selected_session_metrics_text_includes_relevant_memory() -> Result<()> { + let mut focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + None, + 1, + 1, + ); + focus.task = "Investigate auth callback recovery".to_string(); + let mut memory = sample_session("memory-87654321", "coder", SessionState::Idle, None, 1, 1); + memory.task = "Auth callback recovery notes".to_string(); + let dashboard = test_dashboard(vec![focus.clone(), memory.clone()], 0); + dashboard.db.insert_session(&focus)?; + dashboard.db.insert_session(&memory)?; + dashboard.db.upsert_context_entity( + Some(&memory.id), + "file", + "callback.ts", + Some("src/routes/auth/callback.ts"), + "Handles auth callback recovery and billing fallback", + &BTreeMap::from([("area".to_string(), "auth".to_string())]), + )?; + + let text = dashboard.selected_session_metrics_text(); + assert!(text.contains("Relevant memory")); + assert!(text.contains("[file] callback.ts")); + assert!(text.contains("matches auth, callback, recovery")); + Ok(()) + } + #[test] fn worktree_diff_columns_split_removed_and_added_lines() { let patch = "\ @@ -11178,8 +11276,10 @@ diff --git a/src/lib.rs b/src/lib.rs 24, ); - let mut dashboard = - test_dashboard(vec![lead.clone(), older_worker.clone(), auth_worker.clone()], 0); + let mut dashboard = test_dashboard( + vec![lead.clone(), older_worker.clone(), auth_worker.clone()], + 0, + ); dashboard.db.insert_session(&lead).unwrap(); dashboard.db.insert_session(&older_worker).unwrap(); dashboard.db.insert_session(&auth_worker).unwrap(); From 727d9380cb4329102e1f7705b902c4c2d8747655 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 05:50:03 -0700 Subject: [PATCH 130/459] style: format ecc2 manager --- ecc2/src/session/manager.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 1fbbbaaf..2d7254f7 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -480,8 +480,8 @@ pub async fn drain_inbox( let mut outcomes = Vec::new(); for message in messages { - let task = parse_task_handoff_task(&message.content) - .unwrap_or_else(|| message.content.clone()); + let task = + parse_task_handoff_task(&message.content).unwrap_or_else(|| message.content.clone()); let outcome = assign_session_in_dir_with_runner_program( db, @@ -5040,7 +5040,9 @@ mod tests { let auth_messages = db.list_messages_for_session("auth-worker", 10)?; assert!(auth_messages.iter().any(|message| { message.msg_type == "task_handoff" - && message.content.contains("Investigate auth callback recovery") + && message + .content + .contains("Investigate auth callback recovery") })); Ok(()) From 77c9082deb071df218d4f5ccdce844604914a988 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:02:24 -0700 Subject: [PATCH 131/459] feat: add ecc2 graph observations --- ecc2/src/main.rs | 195 +++++++++++++++++++++++++++- ecc2/src/session/mod.rs | 14 ++ ecc2/src/session/store.rs | 264 ++++++++++++++++++++++++++++++++++---- ecc2/src/tui/dashboard.rs | 165 +++++++++++++++++++++++- 4 files changed, 610 insertions(+), 28 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 7b8d8e7a..226730f8 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -457,6 +457,39 @@ enum GraphCommands { #[arg(long)] json: bool, }, + /// Record an observation against a context graph entity + AddObservation { + /// Optional source session ID or alias for provenance + #[arg(long)] + session_id: Option, + /// Entity ID + #[arg(long)] + entity_id: i64, + /// Observation type such as completion_summary, incident_note, or reminder + #[arg(long = "type")] + observation_type: String, + /// Observation summary + #[arg(long)] + summary: String, + /// Details in key=value form + #[arg(long = "detail")] + details: Vec, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// List observations in the shared context graph + Observations { + /// Filter to observations for a specific entity ID + #[arg(long)] + entity_id: Option, + /// Maximum observations to return + #[arg(long, default_value_t = 20)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Recall relevant context graph entities for a query Recall { /// Filter by source session ID or alias @@ -1243,6 +1276,44 @@ async fn main() -> Result<()> { println!("{}", format_graph_relations_human(&relations)); } } + GraphCommands::AddObservation { + session_id, + entity_id, + observation_type, + summary, + details, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let details = parse_key_value_pairs(&details, "graph observation details")?; + let observation = db.add_context_observation( + resolved_session_id.as_deref(), + entity_id, + &observation_type, + &summary, + &details, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&observation)?); + } else { + println!("{}", format_graph_observation_human(&observation)); + } + } + GraphCommands::Observations { + entity_id, + limit, + json, + } => { + let observations = db.list_context_observations(entity_id, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&observations)?); + } else { + println!("{}", format_graph_observations_human(&observations)); + } + } GraphCommands::Recall { session_id, query, @@ -2249,6 +2320,58 @@ fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) -> lines.join("\n") } +fn format_graph_observation_human(observation: &session::ContextGraphObservation) -> String { + let mut lines = vec![ + format!("Context graph observation #{}", observation.id), + format!( + "Entity: #{} [{}] {}", + observation.entity_id, observation.entity_type, observation.entity_name + ), + format!("Type: {}", observation.observation_type), + format!("Summary: {}", observation.summary), + ]; + if let Some(session_id) = observation.session_id.as_deref() { + lines.push(format!("Session: {}", short_session(session_id))); + } + if observation.details.is_empty() { + lines.push("Details: none recorded".to_string()); + } else { + lines.push("Details:".to_string()); + for (key, value) in &observation.details { + lines.push(format!("- {key}={value}")); + } + } + lines.push(format!( + "Created: {}", + observation.created_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + lines.join("\n") +} + +fn format_graph_observations_human(observations: &[session::ContextGraphObservation]) -> String { + if observations.is_empty() { + return "No context graph observations found.".to_string(); + } + + let mut lines = vec![format!( + "Context graph observations: {}", + observations.len() + )]; + for observation in observations { + let mut line = format!( + "- #{} [{}] {}", + observation.id, observation.observation_type, observation.entity_name + ); + if let Some(session_id) = observation.session_id.as_deref() { + line.push_str(&format!(" | {}", short_session(session_id))); + } + lines.push(line); + lines.push(format!(" summary {}", observation.summary)); + } + + lines.join("\n") +} + fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, @@ -2268,12 +2391,13 @@ fn format_graph_recall_human( )]; for entry in entries { let mut line = format!( - "- #{} [{}] {} | score {} | relations {}", + "- #{} [{}] {} | score {} | relations {} | observations {}", entry.entity.id, entry.entity.entity_type, entry.entity.name, entry.score, - entry.relation_count + entry.relation_count, + entry.observation_count ); if let Some(session_id) = entry.entity.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); @@ -4226,6 +4350,49 @@ mod tests { } } + #[test] + fn cli_parses_graph_add_observation_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "add-observation", + "--session-id", + "latest", + "--entity-id", + "7", + "--type", + "completion_summary", + "--summary", + "Finished auth callback recovery", + "--detail", + "tests_run=2", + "--json", + ]) + .expect("graph add-observation should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::AddObservation { + session_id, + entity_id, + observation_type, + summary, + details, + json, + }, + }) => { + assert_eq!(session_id.as_deref(), Some("latest")); + assert_eq!(entity_id, 7); + assert_eq!(observation_type, "completion_summary"); + assert_eq!(summary, "Finished auth callback recovery"); + assert_eq!(details, vec!["tests_run=2"]); + assert!(json); + } + _ => panic!("expected graph add-observation subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -4334,17 +4501,39 @@ mod tests { "recovery".to_string(), ], relation_count: 2, + observation_count: 1, }], Some("sess-12345678"), "auth callback recovery", ); assert!(text.contains("Relevant memory: 1 entries")); - assert!(text.contains("[file] callback.ts | score 319 | relations 2")); + assert!(text.contains("[file] callback.ts | score 319 | relations 2 | observations 1")); assert!(text.contains("matches auth, callback, recovery")); assert!(text.contains("path src/routes/auth/callback.ts")); } + #[test] + fn format_graph_observations_human_renders_summaries() { + let text = format_graph_observations_human(&[session::ContextGraphObservation { + id: 5, + session_id: Some("sess-12345678".to_string()), + entity_id: 11, + entity_type: "session".to_string(), + entity_name: "sess-12345678".to_string(), + observation_type: "completion_summary".to_string(), + summary: "Finished auth callback recovery with 2 tests".to_string(), + details: BTreeMap::from([("tests_run".to_string(), "2".to_string())]), + created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") + .unwrap() + .with_timezone(&chrono::Utc), + }]); + + assert!(text.contains("Context graph observations: 1")); + assert!(text.contains("[completion_summary] sess-12345678")); + assert!(text.contains("summary Finished auth callback recovery with 2 tests")); + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 7bd380f1..40e15ea7 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -190,12 +190,26 @@ pub struct ContextGraphEntityDetail { pub incoming: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphObservation { + pub id: i64, + pub session_id: Option, + pub entity_id: i64, + pub entity_type: String, + pub entity_name: String, + pub observation_type: String, + pub summary: String, + pub details: BTreeMap, + pub created_at: DateTime, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ContextGraphRecallEntry { pub entity: ContextGraphEntity, pub score: u64, pub matched_terms: Vec, pub relation_count: usize, + pub observation_count: usize, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index c0f465d3..01b1fa06 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -14,9 +14,10 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity, - ContextGraphEntityDetail, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, - DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, - SessionMessage, SessionMetrics, SessionState, WorktreeInfo, + ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, + ContextGraphRelation, ContextGraphSyncStats, DecisionLogEntry, FileActivityAction, + FileActivityEntry, Session, SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, + WorktreeInfo, }; pub struct StateStore { @@ -259,6 +260,16 @@ impl StateStore { UNIQUE(from_entity_id, to_entity_id, relation_type) ); + CREATE TABLE IF NOT EXISTS context_graph_observations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, + observation_type TEXT NOT NULL, + summary TEXT NOT NULL, + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS pending_worktree_queue ( session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, repo_root TEXT NOT NULL, @@ -319,6 +330,8 @@ impl StateStore { ON context_graph_relations(from_entity_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_context_graph_relations_to ON context_graph_relations(to_entity_id, created_at, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_observations_entity + ON context_graph_observations(entity_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at); CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at @@ -2047,7 +2060,22 @@ impl StateStore { SELECT COUNT(*) FROM context_graph_relations r WHERE r.from_entity_id = e.id OR r.to_entity_id = e.id - ) AS relation_count + ) AS relation_count, + COALESCE(( + SELECT group_concat(summary, ' ') + FROM ( + SELECT summary + FROM context_graph_observations o + WHERE o.entity_id = e.id + ORDER BY o.created_at DESC, o.id DESC + LIMIT 4 + ) + ), '') AS observation_text, + ( + SELECT COUNT(*) + FROM context_graph_observations o + WHERE o.entity_id = e.id + ) AS observation_count FROM context_graph_entities e WHERE (?1 IS NULL OR e.session_id = ?1) ORDER BY e.updated_at DESC, e.id DESC @@ -2060,7 +2088,9 @@ impl StateStore { |row| { let entity = map_context_graph_entity(row)?; let relation_count = row.get::<_, i64>(9)?.max(0) as usize; - Ok((entity, relation_count)) + let observation_text = row.get::<_, String>(10)?; + let observation_count = row.get::<_, i64>(11)?.max(0) as usize; + Ok((entity, relation_count, observation_text, observation_count)) }, )? .collect::, _>>()?; @@ -2068,24 +2098,29 @@ impl StateStore { let now = chrono::Utc::now(); let mut entries = candidates .into_iter() - .filter_map(|(entity, relation_count)| { - let matched_terms = context_graph_matched_terms(&entity, &terms); - if matched_terms.is_empty() { - return None; - } + .filter_map( + |(entity, relation_count, observation_text, observation_count)| { + let matched_terms = + context_graph_matched_terms(&entity, &observation_text, &terms); + if matched_terms.is_empty() { + return None; + } - Some(ContextGraphRecallEntry { - score: context_graph_recall_score( - matched_terms.len(), + Some(ContextGraphRecallEntry { + score: context_graph_recall_score( + matched_terms.len(), + relation_count, + observation_count, + entity.updated_at, + now, + ), + entity, + matched_terms, relation_count, - entity.updated_at, - now, - ), - entity, - matched_terms, - relation_count, - }) - }) + observation_count, + }) + }, + ) .collect::>(); entries.sort_by(|left, right| { @@ -2165,6 +2200,95 @@ impl StateStore { })) } + pub fn add_context_observation( + &self, + session_id: Option<&str>, + entity_id: i64, + observation_type: &str, + summary: &str, + details: &BTreeMap, + ) -> Result { + if observation_type.trim().is_empty() { + return Err(anyhow::anyhow!( + "Context graph observation type cannot be empty" + )); + } + if summary.trim().is_empty() { + return Err(anyhow::anyhow!( + "Context graph observation summary cannot be empty" + )); + } + + let now = chrono::Utc::now().to_rfc3339(); + let details_json = serde_json::to_string(details)?; + self.conn.execute( + "INSERT INTO context_graph_observations ( + session_id, entity_id, observation_type, summary, details_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + session_id, + entity_id, + observation_type.trim(), + summary.trim(), + details_json, + now, + ], + )?; + let observation_id = self.conn.last_insert_rowid(); + self.conn + .query_row( + "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, + o.observation_type, o.summary, o.details_json, o.created_at + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE o.id = ?1", + rusqlite::params![observation_id], + map_context_graph_observation, + ) + .map_err(Into::into) + } + + pub fn add_session_observation( + &self, + session_id: &str, + observation_type: &str, + summary: &str, + details: &BTreeMap, + ) -> Result { + let session_entity = self.sync_context_graph_session(session_id)?; + self.add_context_observation( + Some(session_id), + session_entity.id, + observation_type, + summary, + details, + ) + } + + pub fn list_context_observations( + &self, + entity_id: Option, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, + o.observation_type, o.summary, o.details_json, o.created_at + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE (?1 IS NULL OR o.entity_id = ?1) + ORDER BY o.created_at DESC, o.id DESC + LIMIT ?2", + )?; + + let entries = stmt + .query_map( + rusqlite::params![entity_id, limit as i64], + map_context_graph_observation, + )? + .collect::, _>>()?; + Ok(entries) + } + pub fn upsert_context_relation( &self, session_id: Option<&str>, @@ -3147,6 +3271,30 @@ fn map_context_graph_relation(row: &rusqlite::Row<'_>) -> rusqlite::Result, +) -> rusqlite::Result { + let details_json = row + .get::<_, Option>(7)? + .unwrap_or_else(|| "{}".to_string()); + let details = serde_json::from_str(&details_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error)) + })?; + let created_at = parse_store_timestamp(row.get::<_, String>(8)?, 8)?; + + Ok(ContextGraphObservation { + id: row.get(0)?, + session_id: row.get(1)?, + entity_id: row.get(2)?, + entity_type: row.get(3)?, + entity_name: row.get(4)?, + observation_type: row.get(5)?, + summary: row.get(6)?, + details, + created_at, + }) +} + fn context_graph_recall_terms(query: &str) -> Vec { let mut terms = Vec::new(); for raw_term in @@ -3161,7 +3309,11 @@ fn context_graph_recall_terms(query: &str) -> Vec { terms } -fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> Vec { +fn context_graph_matched_terms( + entity: &ContextGraphEntity, + observation_text: &str, + terms: &[String], +) -> Vec { let mut haystacks = vec![ entity.entity_type.to_ascii_lowercase(), entity.name.to_ascii_lowercase(), @@ -3174,6 +3326,9 @@ fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> haystacks.push(key.to_ascii_lowercase()); haystacks.push(value.to_ascii_lowercase()); } + if !observation_text.trim().is_empty() { + haystacks.push(observation_text.to_ascii_lowercase()); + } let mut matched = Vec::new(); for term in terms { @@ -3187,6 +3342,7 @@ fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> fn context_graph_recall_score( matched_term_count: usize, relation_count: usize, + observation_count: usize, updated_at: chrono::DateTime, now: chrono::DateTime, ) -> u64 { @@ -3203,7 +3359,10 @@ fn context_graph_recall_score( } }; - (matched_term_count as u64 * 100) + (relation_count.min(9) as u64 * 10) + recency_bonus + (matched_term_count as u64 * 100) + + (relation_count.min(9) as u64 * 10) + + (observation_count.min(6) as u64 * 8) + + recency_bonus } fn parse_store_timestamp( @@ -3990,6 +4149,57 @@ mod tests { Ok(()) } + #[test] + fn add_and_list_context_observations() -> Result<()> { + let tempdir = TestDir::new("store-context-observations")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "deep memory".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let entity = db.upsert_context_entity( + Some("session-1"), + "decision", + "Prefer recovery-first routing", + None, + "Recovered installs should go through the portal first", + &BTreeMap::new(), + )?; + let observation = db.add_context_observation( + Some("session-1"), + entity.id, + "note", + "Customer wiped setup and got charged twice", + &BTreeMap::from([("customer".to_string(), "viktor".to_string())]), + )?; + + let observations = db.list_context_observations(Some(entity.id), 10)?; + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].id, observation.id); + assert_eq!(observations[0].entity_name, "Prefer recovery-first routing"); + assert_eq!(observations[0].observation_type, "note"); + assert_eq!( + observations[0].details.get("customer"), + Some(&"viktor".to_string()) + ); + + Ok(()) + } + #[test] fn recall_context_entities_ranks_matching_entities() -> Result<()> { let tempdir = TestDir::new("store-context-recall")?; @@ -4051,6 +4261,13 @@ mod tests { "references", "Callback route references the dashboard summary", )?; + db.add_context_observation( + Some("session-1"), + recovery.id, + "incident_note", + "Previous auth callback recovery incident affected Viktor after a wipe", + &BTreeMap::new(), + )?; let results = db.recall_context_entities(Some("session-1"), "Investigate auth callback recovery", 3)?; @@ -4068,6 +4285,7 @@ mod tests { .any(|term| term == "recovery")); assert_eq!(results[0].relation_count, 2); assert_eq!(results[1].entity.id, recovery.id); + assert_eq!(results[1].observation_count, 1); assert!(!results.iter().any(|entry| entry.entity.id == unrelated.id)); Ok(()) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 824691a9..b4cf65be 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -4153,6 +4153,11 @@ impl Dashboard { } SessionState::Completed => { let summary = self.build_completion_summary(session); + self.persist_completion_summary_observation( + session, + &summary, + "completion_summary", + ); if self.cfg.completion_summary_notifications.enabled { completion_summaries.push(summary.clone()); } else if self.cfg.desktop_notifications.session_completed { @@ -4174,6 +4179,11 @@ impl Dashboard { } SessionState::Failed => { let summary = self.build_completion_summary(session); + self.persist_completion_summary_observation( + session, + &summary, + "failure_summary", + ); failed_notifications.push(( "ECC 2.0: Session failed".to_string(), format!( @@ -4226,6 +4236,34 @@ impl Dashboard { self.last_session_states = next_states; } + fn persist_completion_summary_observation( + &self, + session: &Session, + summary: &SessionCompletionSummary, + observation_type: &str, + ) { + let observation_summary = format!( + "{} | files {} | tests {}/{} | warnings {}", + truncate_for_dashboard(&summary.task, 72), + summary.files_changed, + summary.tests_passed, + summary.tests_run, + summary.warnings.len() + ); + let details = completion_summary_observation_details(summary, session); + if let Err(error) = self.db.add_session_observation( + &session.id, + observation_type, + &observation_summary, + &details, + ) { + tracing::warn!( + "Failed to persist completion observation for {}: {error}", + session.id + ); + } + } + fn sync_approval_notifications(&mut self) { let latest_message = match self.db.latest_unread_approval_message() { Ok(message) => message, @@ -5320,12 +5358,13 @@ impl Dashboard { let mut lines = vec!["Relevant memory".to_string()]; for entry in entries { let mut line = format!( - "- #{} [{}] {} | score {} | relations {}", + "- #{} [{}] {} | score {} | relations {} | observations {}", entry.entity.id, entry.entity.entity_type, truncate_for_dashboard(&entry.entity.name, 60), entry.score, - entry.relation_count + entry.relation_count, + entry.observation_count ); if let Some(session_id) = entry.entity.session_id.as_deref() { if session_id != session.id { @@ -5345,6 +5384,14 @@ impl Dashboard { truncate_for_dashboard(&entry.entity.summary, 72) )); } + if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) { + if let Some(observation) = observations.first() { + lines.push(format!( + " memory {}", + truncate_for_dashboard(&observation.summary, 72) + )); + } + } } lines @@ -8517,6 +8564,39 @@ fn summarize_completion_warnings( warnings } +fn completion_summary_observation_details( + summary: &SessionCompletionSummary, + session: &Session, +) -> BTreeMap { + let mut details = BTreeMap::new(); + details.insert("state".to_string(), session.state.to_string()); + details.insert( + "files_changed".to_string(), + summary.files_changed.to_string(), + ); + details.insert("tokens_used".to_string(), summary.tokens_used.to_string()); + details.insert( + "duration_secs".to_string(), + summary.duration_secs.to_string(), + ); + details.insert("cost_usd".to_string(), format!("{:.4}", summary.cost_usd)); + details.insert("tests_run".to_string(), summary.tests_run.to_string()); + details.insert("tests_passed".to_string(), summary.tests_passed.to_string()); + if !summary.recent_files.is_empty() { + details.insert("recent_files".to_string(), summary.recent_files.join(" | ")); + } + if !summary.key_decisions.is_empty() { + details.insert( + "key_decisions".to_string(), + summary.key_decisions.join(" | "), + ); + } + if !summary.warnings.is_empty() { + details.insert("warnings".to_string(), summary.warnings.join(" | ")); + } + details +} + fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String { let mut lines = vec![ "*ECC 2.0: Session started*".to_string(), @@ -10444,11 +10524,25 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ "Handles auth callback recovery and billing fallback", &BTreeMap::from([("area".to_string(), "auth".to_string())]), )?; + let entity = dashboard + .db + .list_context_entities(Some(&memory.id), Some("file"), 10)? + .into_iter() + .find(|entry| entry.name == "callback.ts") + .expect("callback entity"); + dashboard.db.add_context_observation( + Some(&memory.id), + entity.id, + "completion_summary", + "Recovered auth callback incident with billing fallback", + &BTreeMap::new(), + )?; let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Relevant memory")); assert!(text.contains("[file] callback.ts")); assert!(text.contains("matches auth, callback, recovery")); + assert!(text.contains("memory Recovered auth callback incident with billing fallback")); Ok(()) } @@ -11876,6 +11970,73 @@ diff --git a/src/lib.rs b/src/lib.rs Ok(()) } + #[test] + fn refresh_persists_completion_summary_observation() -> Result<()> { + let root = + std::env::temp_dir().join(format!("ecc2-completion-observation-{}", Uuid::new_v4())); + fs::create_dir_all(root.join(".claude").join("metrics"))?; + + let mut cfg = build_config(&root.join(".claude")); + cfg.completion_summary_notifications.delivery = + crate::notifications::CompletionSummaryDelivery::TuiPopup; + cfg.desktop_notifications.session_completed = false; + + let db = StateStore::open(&cfg.db_path)?; + let mut session = sample_session( + "done-observation", + "claude", + SessionState::Running, + Some("ecc/observation"), + 144, + 42, + ); + session.task = "Recover auth callback after wipe".to_string(); + db.insert_session(&session)?; + + let metrics_path = cfg.tool_activity_metrics_path(); + fs::create_dir_all(metrics_path.parent().unwrap())?; + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"done-observation\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"done-observation\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/routes/auth/callback.ts\",\"output_summary\":\"updated callback\",\"file_events\":[{\"path\":\"src/routes/auth/callback.ts\",\"action\":\"modify\",\"diff_preview\":\"portal first\",\"patch_preview\":\"+ portal first\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + + let mut dashboard = Dashboard::new(db, cfg); + dashboard + .db + .update_state("done-observation", &SessionState::Completed)?; + + dashboard.refresh(); + + let session_entity = dashboard + .db + .list_context_entities(Some("done-observation"), Some("session"), 10)? + .into_iter() + .find(|entity| entity.name == "done-observation") + .expect("session entity"); + let observations = dashboard + .db + .list_context_observations(Some(session_entity.id), 10)?; + assert!(!observations.is_empty()); + assert_eq!(observations[0].observation_type, "completion_summary"); + assert!(observations[0] + .summary + .contains("Recover auth callback after wipe")); + assert_eq!( + observations[0].details.get("tests_run"), + Some(&"1".to_string()) + ); + assert!(observations[0] + .details + .get("recent_files") + .is_some_and(|value| value.contains("modify src/routes/auth/callback.ts"))); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn dismiss_completion_popup_promotes_the_next_summary() { let mut dashboard = test_dashboard(Vec::new(), 0); From 8cc92c59a64d7f1d0b820b98981934d5a69e1252 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:07:12 -0700 Subject: [PATCH 132/459] feat: add ecc2 graph compaction --- ecc2/src/main.rs | 116 +++++++++++++++++++ ecc2/src/session/mod.rs | 8 ++ ecc2/src/session/store.rs | 237 +++++++++++++++++++++++++++++++++++++- 3 files changed, 356 insertions(+), 5 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 226730f8..9329af08 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -490,6 +490,18 @@ enum GraphCommands { #[arg(long)] json: bool, }, + /// Compact stored observations in the shared context graph + Compact { + /// Filter by source session ID or alias + #[arg(long)] + session_id: Option, + /// Maximum observations to retain per entity after compaction + #[arg(long, default_value_t = 12)] + keep_observations_per_entity: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Recall relevant context graph entities for a query Recall { /// Filter by source session ID or alias @@ -1314,6 +1326,32 @@ async fn main() -> Result<()> { println!("{}", format_graph_observations_human(&observations)); } } + GraphCommands::Compact { + session_id, + keep_observations_per_entity, + json, + } => { + let resolved_session_id = session_id + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let stats = db.compact_context_graph( + resolved_session_id.as_deref(), + keep_observations_per_entity, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&stats)?); + } else { + println!( + "{}", + format_graph_compaction_stats_human( + &stats, + resolved_session_id.as_deref(), + keep_observations_per_entity, + ) + ); + } + } GraphCommands::Recall { session_id, query, @@ -2416,6 +2454,32 @@ fn format_graph_recall_human( lines.join("\n") } +fn format_graph_compaction_stats_human( + stats: &session::ContextGraphCompactionStats, + session_id: Option<&str>, + keep_observations_per_entity: usize, +) -> String { + let scope = session_id + .map(short_session) + .unwrap_or_else(|| "all sessions".to_string()); + [ + format!( + "Context graph compaction complete for {scope} (keep {keep_observations_per_entity} observations per entity)" + ), + format!("- entities scanned {}", stats.entities_scanned), + format!( + "- duplicate observations deleted {}", + stats.duplicate_observations_deleted + ), + format!( + "- overflow observations deleted {}", + stats.overflow_observations_deleted + ), + format!("- observations retained {}", stats.observations_retained), + ] + .join("\n") +} + fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String { let mut lines = vec![format_graph_entity_human(&detail.entity)]; lines.push(String::new()); @@ -4393,6 +4457,37 @@ mod tests { } } + #[test] + fn cli_parses_graph_compact_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "compact", + "--session-id", + "latest", + "--keep-observations-per-entity", + "6", + "--json", + ]) + .expect("graph compact should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::Compact { + session_id, + keep_observations_per_entity, + json, + }, + }) => { + assert_eq!(session_id.as_deref(), Some("latest")); + assert_eq!(keep_observations_per_entity, 6); + assert!(json); + } + _ => panic!("expected graph compact subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -4534,6 +4629,27 @@ mod tests { assert!(text.contains("summary Finished auth callback recovery with 2 tests")); } + #[test] + fn format_graph_compaction_stats_human_renders_counts() { + let text = format_graph_compaction_stats_human( + &session::ContextGraphCompactionStats { + entities_scanned: 3, + duplicate_observations_deleted: 2, + overflow_observations_deleted: 4, + observations_retained: 9, + }, + Some("sess-12345678"), + 6, + ); + + assert!(text.contains("Context graph compaction complete for sess-123")); + assert!(text.contains("keep 6 observations per entity")); + assert!(text.contains("- entities scanned 3")); + assert!(text.contains("- duplicate observations deleted 2")); + assert!(text.contains("- overflow observations deleted 4")); + assert!(text.contains("- observations retained 9")); + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 40e15ea7..727a7c9f 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -220,6 +220,14 @@ pub struct ContextGraphSyncStats { pub messages_processed: usize, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContextGraphCompactionStats { + pub entities_scanned: usize, + pub duplicate_observations_deleted: usize, + pub overflow_observations_deleted: usize, + pub observations_retained: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum FileActivityAction { diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 01b1fa06..31d93ce6 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -13,17 +13,19 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT}; use super::{ - default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity, - ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, - ContextGraphRelation, ContextGraphSyncStats, DecisionLogEntry, FileActivityAction, - FileActivityEntry, Session, SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, - WorktreeInfo, + default_project_label, default_task_group_label, normalize_group_label, + ContextGraphCompactionStats, ContextGraphEntity, ContextGraphEntityDetail, + ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, + DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, + SessionMessage, SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { conn: Connection, } +const DEFAULT_CONTEXT_GRAPH_OBSERVATION_RETENTION: usize = 12; + #[derive(Debug, Clone)] pub struct PendingWorktreeRequest { pub session_id: String, @@ -2235,6 +2237,11 @@ impl StateStore { ], )?; let observation_id = self.conn.last_insert_rowid(); + self.compact_context_graph_observations( + None, + Some(entity_id), + DEFAULT_CONTEXT_GRAPH_OBSERVATION_RETENTION, + )?; self.conn .query_row( "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, @@ -2248,6 +2255,14 @@ impl StateStore { .map_err(Into::into) } + pub fn compact_context_graph( + &self, + session_id: Option<&str>, + keep_observations_per_entity: usize, + ) -> Result { + self.compact_context_graph_observations(session_id, None, keep_observations_per_entity) + } + pub fn add_session_observation( &self, session_id: &str, @@ -2289,6 +2304,94 @@ impl StateStore { Ok(entries) } + fn compact_context_graph_observations( + &self, + session_id: Option<&str>, + entity_id: Option, + keep_observations_per_entity: usize, + ) -> Result { + let entities_scanned = self.conn.query_row( + "SELECT COUNT(DISTINCT o.entity_id) + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE (?1 IS NULL OR e.session_id = ?1) + AND (?2 IS NULL OR o.entity_id = ?2)", + rusqlite::params![session_id, entity_id], + |row| row.get::<_, i64>(0), + )? as usize; + + let duplicate_observations_deleted = self.conn.execute( + "DELETE FROM context_graph_observations + WHERE id IN ( + SELECT id + FROM ( + SELECT o.id, + ROW_NUMBER() OVER ( + PARTITION BY o.entity_id, o.observation_type, o.summary + ORDER BY o.created_at DESC, o.id DESC + ) AS rn + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE (?1 IS NULL OR e.session_id = ?1) + AND (?2 IS NULL OR o.entity_id = ?2) + ) ranked + WHERE ranked.rn > 1 + )", + rusqlite::params![session_id, entity_id], + )?; + + let overflow_observations_deleted = if keep_observations_per_entity == 0 { + self.conn.execute( + "DELETE FROM context_graph_observations + WHERE id IN ( + SELECT o.id + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE (?1 IS NULL OR e.session_id = ?1) + AND (?2 IS NULL OR o.entity_id = ?2) + )", + rusqlite::params![session_id, entity_id], + )? + } else { + self.conn.execute( + "DELETE FROM context_graph_observations + WHERE id IN ( + SELECT id + FROM ( + SELECT o.id, + ROW_NUMBER() OVER ( + PARTITION BY o.entity_id + ORDER BY o.created_at DESC, o.id DESC + ) AS rn + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE (?1 IS NULL OR e.session_id = ?1) + AND (?2 IS NULL OR o.entity_id = ?2) + ) ranked + WHERE ranked.rn > ?3 + )", + rusqlite::params![session_id, entity_id, keep_observations_per_entity as i64], + )? + }; + + let observations_retained = self.conn.query_row( + "SELECT COUNT(*) + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE (?1 IS NULL OR e.session_id = ?1) + AND (?2 IS NULL OR o.entity_id = ?2)", + rusqlite::params![session_id, entity_id], + |row| row.get::<_, i64>(0), + )? as usize; + + Ok(ContextGraphCompactionStats { + entities_scanned, + duplicate_observations_deleted, + overflow_observations_deleted, + observations_retained, + }) + } + pub fn upsert_context_relation( &self, session_id: Option<&str>, @@ -4200,6 +4303,130 @@ mod tests { Ok(()) } + #[test] + fn compact_context_graph_prunes_duplicate_and_overflow_observations() -> Result<()> { + let tempdir = TestDir::new("store-context-compaction")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "deep memory".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let entity = db.upsert_context_entity( + Some("session-1"), + "decision", + "Prefer recovery-first routing", + None, + "Recovered installs should go through the portal first", + &BTreeMap::new(), + )?; + + for summary in [ + "old duplicate", + "keep me", + "old duplicate", + "recent", + "latest", + ] { + db.conn.execute( + "INSERT INTO context_graph_observations ( + session_id, entity_id, observation_type, summary, details_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + "session-1", + entity.id, + "note", + summary, + "{}", + chrono::Utc::now().to_rfc3339(), + ], + )?; + std::thread::sleep(std::time::Duration::from_millis(2)); + } + + let stats = db.compact_context_graph(None, 3)?; + assert_eq!(stats.entities_scanned, 1); + assert_eq!(stats.duplicate_observations_deleted, 1); + assert_eq!(stats.overflow_observations_deleted, 1); + assert_eq!(stats.observations_retained, 3); + + let observations = db.list_context_observations(Some(entity.id), 10)?; + let summaries = observations + .iter() + .map(|observation| observation.summary.as_str()) + .collect::>(); + assert_eq!(summaries, vec!["latest", "recent", "old duplicate"]); + + Ok(()) + } + + #[test] + fn add_context_observation_auto_compacts_entity_history() -> Result<()> { + let tempdir = TestDir::new("store-context-auto-compaction")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "deep memory".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let entity = db.upsert_context_entity( + Some("session-1"), + "session", + "session-1", + None, + "Deep-memory worker", + &BTreeMap::new(), + )?; + + for index in 0..(DEFAULT_CONTEXT_GRAPH_OBSERVATION_RETENTION + 2) { + let summary = format!("completion summary {}", index); + db.add_context_observation( + Some("session-1"), + entity.id, + "completion_summary", + &summary, + &BTreeMap::new(), + )?; + std::thread::sleep(std::time::Duration::from_millis(2)); + } + + let observations = db.list_context_observations(Some(entity.id), 20)?; + assert_eq!( + observations.len(), + DEFAULT_CONTEXT_GRAPH_OBSERVATION_RETENTION + ); + assert_eq!(observations[0].summary, "completion summary 13"); + assert_eq!(observations.last().unwrap().summary, "completion summary 2"); + + Ok(()) + } + #[test] fn recall_context_entities_ranks_matching_entities() -> Result<()> { let tempdir = TestDir::new("store-context-recall")?; From d49ceacb7ddcc528685c4fe24e5049b4a057720f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:14:13 -0700 Subject: [PATCH 133/459] feat: add ecc2 memory connectors --- ecc2/src/config/mod.rs | 48 ++++++ ecc2/src/main.rs | 301 +++++++++++++++++++++++++++++++++++- ecc2/src/session/manager.rs | 1 + ecc2/src/tui/dashboard.rs | 1 + 4 files changed, 349 insertions(+), 2 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 4e00bc9b..702e66af 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -103,6 +103,21 @@ pub struct OrchestrationTemplateStepConfig { pub task_group: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum MemoryConnectorConfig { + JsonlFile(MemoryConnectorJsonlFileConfig), +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MemoryConnectorJsonlFileConfig { + pub path: PathBuf, + pub session_id: Option, + pub default_entity_type: Option, + pub default_observation_type: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedOrchestrationTemplate { pub template_name: String, @@ -139,6 +154,7 @@ pub struct Config { pub default_agent_profile: Option, pub agent_profiles: BTreeMap, pub orchestration_templates: BTreeMap, + pub memory_connectors: BTreeMap, pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_limit_per_session: usize, pub auto_create_worktrees: bool, @@ -203,6 +219,7 @@ impl Default for Config { default_agent_profile: None, agent_profiles: BTreeMap::new(), orchestration_templates: BTreeMap::new(), + memory_connectors: BTreeMap::new(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -1231,6 +1248,37 @@ task = "Plan {{task}} for {{component}}" assert!(error_text.contains("missing orchestration template variable(s): component")); } + #[test] + fn memory_connectors_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[memory_connectors.hermes_notes] +kind = "jsonl_file" +path = "/tmp/hermes-memory.jsonl" +session_id = "latest" +default_entity_type = "incident" +default_observation_type = "external_note" +"#, + ) + .unwrap(); + + let connector = config + .memory_connectors + .get("hermes_notes") + .expect("connector should deserialize"); + match connector { + crate::config::MemoryConnectorConfig::JsonlFile(settings) => { + assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory.jsonl")); + assert_eq!(settings.session_id.as_deref(), Some("latest")); + assert_eq!(settings.default_entity_type.as_deref(), Some("incident")); + assert_eq!( + settings.default_observation_type.as_deref(), + Some("external_note") + ); + } + } + } + #[test] fn completion_summary_notifications_deserialize_from_toml() { let config: Config = toml::from_str( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 9329af08..993f92dd 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -6,10 +6,12 @@ mod session; mod tui; mod worktree; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Parser; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -502,6 +504,17 @@ enum GraphCommands { #[arg(long)] json: bool, }, + /// Import external memory from a configured connector + ConnectorSync { + /// Connector name from ecc2.toml + name: String, + /// Maximum non-empty records to process + #[arg(long, default_value_t = 256)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Recall relevant context graph entities for a query Recall { /// Filter by source session ID or alias @@ -552,6 +565,29 @@ enum MessageKindArg { Conflict, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct GraphConnectorSyncStats { + connector_name: String, + records_read: usize, + entities_upserted: usize, + observations_added: usize, + skipped_records: usize, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +struct JsonlMemoryConnectorRecord { + session_id: Option, + entity_type: Option, + entity_name: String, + path: Option, + entity_summary: Option, + metadata: BTreeMap, + observation_type: Option, + summary: String, + details: BTreeMap, +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -1352,6 +1388,14 @@ async fn main() -> Result<()> { ); } } + GraphCommands::ConnectorSync { name, limit, json } => { + let stats = sync_memory_connector(&db, &cfg, &name, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&stats)?); + } else { + println!("{}", format_graph_connector_sync_stats_human(&stats)); + } + } GraphCommands::Recall { session_id, query, @@ -1532,6 +1576,133 @@ fn sync_runtime_session_metrics( Ok(()) } +fn sync_memory_connector( + db: &session::store::StateStore, + cfg: &config::Config, + name: &str, + limit: usize, +) -> Result { + let connector = cfg + .memory_connectors + .get(name) + .ok_or_else(|| anyhow::anyhow!("Unknown memory connector: {name}"))?; + + match connector { + config::MemoryConnectorConfig::JsonlFile(settings) => { + sync_jsonl_memory_connector(db, name, settings, limit) + } + } +} + +fn sync_jsonl_memory_connector( + db: &session::store::StateStore, + name: &str, + settings: &config::MemoryConnectorJsonlFileConfig, + limit: usize, +) -> Result { + if settings.path.as_os_str().is_empty() { + anyhow::bail!("memory connector {name} has no path configured"); + } + + let default_session_id = settings + .session_id + .as_deref() + .map(|value| resolve_session_id(db, value)) + .transpose()?; + let file = File::open(&settings.path) + .with_context(|| format!("open memory connector file {}", settings.path.display()))?; + let reader = BufReader::new(file); + + let mut stats = GraphConnectorSyncStats { + connector_name: name.to_string(), + ..Default::default() + }; + + for line in reader.lines() { + let line = line?; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if stats.records_read >= limit { + break; + } + stats.records_read += 1; + + let record: JsonlMemoryConnectorRecord = match serde_json::from_str(trimmed) { + Ok(record) => record, + Err(_) => { + stats.skipped_records += 1; + continue; + } + }; + + let session_id = match record.session_id.as_deref() { + Some(value) => match resolve_session_id(db, value) { + Ok(resolved) => Some(resolved), + Err(_) => { + stats.skipped_records += 1; + continue; + } + }, + None => default_session_id.clone(), + }; + let entity_type = record + .entity_type + .as_deref() + .or(settings.default_entity_type.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let observation_type = record + .observation_type + .as_deref() + .or(settings.default_observation_type.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()); + let entity_name = record.entity_name.trim(); + let summary = record.summary.trim(); + + let Some(entity_type) = entity_type else { + stats.skipped_records += 1; + continue; + }; + let Some(observation_type) = observation_type else { + stats.skipped_records += 1; + continue; + }; + if entity_name.is_empty() || summary.is_empty() { + stats.skipped_records += 1; + continue; + } + + let entity_summary = record + .entity_summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(summary); + let entity = db.upsert_context_entity( + session_id.as_deref(), + entity_type, + entity_name, + record.path.as_deref(), + entity_summary, + &record.metadata, + )?; + db.add_context_observation( + session_id.as_deref(), + entity.id, + observation_type, + summary, + &record.details, + )?; + stats.entities_upserted += 1; + stats.observations_added += 1; + } + + Ok(stats) +} + fn build_message( kind: MessageKindArg, text: String, @@ -2480,6 +2651,17 @@ fn format_graph_compaction_stats_human( .join("\n") } +fn format_graph_connector_sync_stats_human(stats: &GraphConnectorSyncStats) -> String { + [ + format!("Memory connector sync complete: {}", stats.connector_name), + format!("- records read {}", stats.records_read), + format!("- entities upserted {}", stats.entities_upserted), + format!("- observations added {}", stats.observations_added), + format!("- skipped records {}", stats.skipped_records), + ] + .join("\n") +} + fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String { let mut lines = vec![format_graph_entity_human(&detail.entity)]; lines.push(String::new()); @@ -4488,6 +4670,31 @@ mod tests { } } + #[test] + fn cli_parses_graph_connector_sync_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "connector-sync", + "hermes_notes", + "--limit", + "32", + "--json", + ]) + .expect("graph connector-sync should parse"); + + match cli.command { + Some(Commands::Graph { + command: GraphCommands::ConnectorSync { name, limit, json }, + }) => { + assert_eq!(name, "hermes_notes"); + assert_eq!(limit, 32); + assert!(json); + } + _ => panic!("expected graph connector-sync subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -4650,6 +4857,96 @@ mod tests { assert!(text.contains("- observations retained 9")); } + #[test] + fn format_graph_connector_sync_stats_human_renders_counts() { + let text = format_graph_connector_sync_stats_human(&GraphConnectorSyncStats { + connector_name: "hermes_notes".to_string(), + records_read: 4, + entities_upserted: 3, + observations_added: 3, + skipped_records: 1, + }); + + assert!(text.contains("Memory connector sync complete: hermes_notes")); + assert!(text.contains("- records read 4")); + assert!(text.contains("- entities upserted 3")); + assert!(text.contains("- observations added 3")); + assert!(text.contains("- skipped records 1")); + } + + #[test] + fn sync_memory_connector_imports_jsonl_observations() -> Result<()> { + let tempdir = TestDir::new("graph-connector-sync")?; + let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; + let now = chrono::Utc::now(); + db.insert_session(&session::Session { + id: "session-1".to_string(), + task: "recovery incident".to_string(), + project: "ecc-tools".to_string(), + task_group: "incident".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: session::SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: session::SessionMetrics::default(), + })?; + + let connector_path = tempdir.path().join("hermes-memory.jsonl"); + std::fs::write( + &connector_path, + [ + serde_json::json!({ + "entity_name": "Auth callback recovery", + "summary": "Customer wiped setup and got charged twice", + "details": {"customer": "viktor"} + }) + .to_string(), + serde_json::json!({ + "session_id": "latest", + "entity_type": "file", + "entity_name": "callback.ts", + "path": "src/routes/auth/callback.ts", + "observation_type": "incident_note", + "summary": "Recovery flow needs portal-first routing" + }) + .to_string(), + ] + .join("\n"), + )?; + + let mut cfg = config::Config::default(); + cfg.memory_connectors.insert( + "hermes_notes".to_string(), + config::MemoryConnectorConfig::JsonlFile(config::MemoryConnectorJsonlFileConfig { + path: connector_path, + session_id: Some("latest".to_string()), + default_entity_type: Some("incident".to_string()), + default_observation_type: Some("external_note".to_string()), + }), + ); + + let stats = sync_memory_connector(&db, &cfg, "hermes_notes", 10)?; + assert_eq!(stats.records_read, 2); + assert_eq!(stats.entities_upserted, 2); + assert_eq!(stats.observations_added, 2); + assert_eq!(stats.skipped_records, 0); + + let recalled = db.recall_context_entities(None, "charged twice routing", 5)?; + assert_eq!(recalled.len(), 2); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Auth callback recovery")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "callback.ts")); + + Ok(()) + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 2d7254f7..093d7bf1 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -3253,6 +3253,7 @@ mod tests { default_agent_profile: None, agent_profiles: Default::default(), orchestration_templates: Default::default(), + memory_connectors: Default::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index b4cf65be..c0a013fa 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -14509,6 +14509,7 @@ diff --git a/src/lib.rs b/src/lib.rs default_agent_profile: None, agent_profiles: Default::default(), orchestration_templates: Default::default(), + memory_connectors: Default::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, From d3b680b6db0557f10080ee3c3c270fd5a67a2e00 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:20:15 -0700 Subject: [PATCH 134/459] feat: add ecc2 directory memory connectors --- ecc2/src/config/mod.rs | 44 +++++++++ ecc2/src/main.rs | 209 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 247 insertions(+), 6 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 702e66af..0443384f 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -107,6 +107,7 @@ pub struct OrchestrationTemplateStepConfig { #[serde(tag = "kind", rename_all = "snake_case")] pub enum MemoryConnectorConfig { JsonlFile(MemoryConnectorJsonlFileConfig), + JsonlDirectory(MemoryConnectorJsonlDirectoryConfig), } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -118,6 +119,16 @@ pub struct MemoryConnectorJsonlFileConfig { pub default_observation_type: Option, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MemoryConnectorJsonlDirectoryConfig { + pub path: PathBuf, + pub recurse: bool, + pub session_id: Option, + pub default_entity_type: Option, + pub default_observation_type: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedOrchestrationTemplate { pub template_name: String, @@ -1276,6 +1287,39 @@ default_observation_type = "external_note" Some("external_note") ); } + _ => panic!("expected jsonl_file connector"), + } + } + + #[test] + fn memory_jsonl_directory_connectors_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[memory_connectors.hermes_dir] +kind = "jsonl_directory" +path = "/tmp/hermes-memory" +recurse = true +default_entity_type = "incident" +default_observation_type = "external_note" +"#, + ) + .unwrap(); + + let connector = config + .memory_connectors + .get("hermes_dir") + .expect("connector should deserialize"); + match connector { + crate::config::MemoryConnectorConfig::JsonlDirectory(settings) => { + assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory")); + assert!(settings.recurse); + assert_eq!(settings.default_entity_type.as_deref(), Some("incident")); + assert_eq!( + settings.default_observation_type.as_deref(), + Some("external_note") + ); + } + _ => panic!("expected jsonl_directory connector"), } } diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 993f92dd..8ba123ab 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs::File; use std::io::{BufRead, BufReader}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tracing_subscriber::EnvFilter; #[derive(Parser, Debug)] @@ -1591,6 +1591,9 @@ fn sync_memory_connector( config::MemoryConnectorConfig::JsonlFile(settings) => { sync_jsonl_memory_connector(db, name, settings, limit) } + config::MemoryConnectorConfig::JsonlDirectory(settings) => { + sync_jsonl_directory_memory_connector(db, name, settings, limit) + } } } @@ -1604,15 +1607,91 @@ fn sync_jsonl_memory_connector( anyhow::bail!("memory connector {name} has no path configured"); } + let file = File::open(&settings.path) + .with_context(|| format!("open memory connector file {}", settings.path.display()))?; + let reader = BufReader::new(file); let default_session_id = settings .session_id .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; - let file = File::open(&settings.path) - .with_context(|| format!("open memory connector file {}", settings.path.display()))?; - let reader = BufReader::new(file); + sync_jsonl_memory_reader( + db, + name, + reader, + default_session_id.as_deref(), + settings.default_entity_type.as_deref(), + settings.default_observation_type.as_deref(), + limit, + ) +} + +fn sync_jsonl_directory_memory_connector( + db: &session::store::StateStore, + name: &str, + settings: &config::MemoryConnectorJsonlDirectoryConfig, + limit: usize, +) -> Result { + if settings.path.as_os_str().is_empty() { + anyhow::bail!("memory connector {name} has no path configured"); + } + if !settings.path.is_dir() { + anyhow::bail!( + "memory connector {name} path is not a directory: {}", + settings.path.display() + ); + } + + let paths = collect_jsonl_paths(&settings.path, settings.recurse)?; + let default_session_id = settings + .session_id + .as_deref() + .map(|value| resolve_session_id(db, value)) + .transpose()?; + + let mut stats = GraphConnectorSyncStats { + connector_name: name.to_string(), + ..Default::default() + }; + + let mut remaining = limit; + for path in paths { + if remaining == 0 { + break; + } + let file = File::open(&path) + .with_context(|| format!("open memory connector file {}", path.display()))?; + let reader = BufReader::new(file); + let file_stats = sync_jsonl_memory_reader( + db, + name, + reader, + default_session_id.as_deref(), + settings.default_entity_type.as_deref(), + settings.default_observation_type.as_deref(), + remaining, + )?; + remaining = remaining.saturating_sub(file_stats.records_read); + stats.records_read += file_stats.records_read; + stats.entities_upserted += file_stats.entities_upserted; + stats.observations_added += file_stats.observations_added; + stats.skipped_records += file_stats.skipped_records; + } + + Ok(stats) +} + +fn sync_jsonl_memory_reader( + db: &session::store::StateStore, + name: &str, + reader: R, + default_session_id: Option<&str>, + default_entity_type: Option<&str>, + default_observation_type: Option<&str>, + limit: usize, +) -> Result { + let default_session_id = default_session_id.map(str::to_string); let mut stats = GraphConnectorSyncStats { connector_name: name.to_string(), ..Default::default() @@ -1650,13 +1729,13 @@ fn sync_jsonl_memory_connector( let entity_type = record .entity_type .as_deref() - .or(settings.default_entity_type.as_deref()) + .or(default_entity_type) .map(str::trim) .filter(|value| !value.is_empty()); let observation_type = record .observation_type .as_deref() - .or(settings.default_observation_type.as_deref()) + .or(default_observation_type) .map(str::trim) .filter(|value| !value.is_empty()); let entity_name = record.entity_name.trim(); @@ -1703,6 +1782,36 @@ fn sync_jsonl_memory_connector( Ok(stats) } +fn collect_jsonl_paths(root: &Path, recurse: bool) -> Result> { + let mut paths = Vec::new(); + collect_jsonl_paths_inner(root, recurse, &mut paths)?; + paths.sort(); + Ok(paths) +} + +fn collect_jsonl_paths_inner(root: &Path, recurse: bool, paths: &mut Vec) -> Result<()> { + for entry in std::fs::read_dir(root) + .with_context(|| format!("read memory connector directory {}", root.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if recurse { + collect_jsonl_paths_inner(&path, recurse, paths)?; + } + continue; + } + if path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("jsonl")) + { + paths.push(path); + } + } + Ok(()) +} + fn build_message( kind: MessageKindArg, text: String, @@ -4947,6 +5056,94 @@ mod tests { Ok(()) } + #[test] + fn sync_memory_connector_imports_jsonl_directory_observations() -> Result<()> { + let tempdir = TestDir::new("graph-connector-sync-dir")?; + let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; + let now = chrono::Utc::now(); + db.insert_session(&session::Session { + id: "session-1".to_string(), + task: "recovery incident".to_string(), + project: "ecc-tools".to_string(), + task_group: "incident".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: session::SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: session::SessionMetrics::default(), + })?; + + let connector_dir = tempdir.path().join("hermes-memory"); + fs::create_dir_all(connector_dir.join("nested"))?; + fs::write( + connector_dir.join("a.jsonl"), + [ + serde_json::json!({ + "entity_name": "Auth callback recovery", + "summary": "Customer wiped setup and got charged twice", + }) + .to_string(), + serde_json::json!({ + "entity_name": "Portal routing", + "summary": "Route existing installs to portal first", + }) + .to_string(), + ] + .join("\n"), + )?; + fs::write( + connector_dir.join("nested").join("b.jsonl"), + [ + serde_json::json!({ + "entity_name": "Billing UX note", + "summary": "Warn against buying twice after wiping setup", + }) + .to_string(), + "{invalid json}".to_string(), + ] + .join("\n"), + )?; + fs::write(connector_dir.join("ignore.txt"), "not imported")?; + + let mut cfg = config::Config::default(); + cfg.memory_connectors.insert( + "hermes_dir".to_string(), + config::MemoryConnectorConfig::JsonlDirectory( + config::MemoryConnectorJsonlDirectoryConfig { + path: connector_dir, + recurse: true, + session_id: Some("latest".to_string()), + default_entity_type: Some("incident".to_string()), + default_observation_type: Some("external_note".to_string()), + }, + ), + ); + + let stats = sync_memory_connector(&db, &cfg, "hermes_dir", 10)?; + assert_eq!(stats.records_read, 4); + assert_eq!(stats.entities_upserted, 3); + assert_eq!(stats.observations_added, 3); + assert_eq!(stats.skipped_records, 1); + + let recalled = db.recall_context_entities(None, "charged twice portal billing", 10)?; + assert_eq!(recalled.len(), 3); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Auth callback recovery")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Portal routing")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Billing UX note")); + + Ok(()) + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( From 22a5a8de6d81855372b06603d87529c784e645dc Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:26:42 -0700 Subject: [PATCH 135/459] feat: add ecc2 markdown memory connectors --- ecc2/src/config/mod.rs | 45 ++++ ecc2/src/main.rs | 453 +++++++++++++++++++++++++++++++++++------ 2 files changed, 438 insertions(+), 60 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 0443384f..e058ba78 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -108,6 +108,7 @@ pub struct OrchestrationTemplateStepConfig { pub enum MemoryConnectorConfig { JsonlFile(MemoryConnectorJsonlFileConfig), JsonlDirectory(MemoryConnectorJsonlDirectoryConfig), + MarkdownFile(MemoryConnectorMarkdownFileConfig), } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -129,6 +130,15 @@ pub struct MemoryConnectorJsonlDirectoryConfig { pub default_observation_type: Option, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MemoryConnectorMarkdownFileConfig { + pub path: PathBuf, + pub session_id: Option, + pub default_entity_type: Option, + pub default_observation_type: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedOrchestrationTemplate { pub template_name: String, @@ -1323,6 +1333,41 @@ default_observation_type = "external_note" } } + #[test] + fn memory_markdown_file_connectors_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[memory_connectors.workspace_note] +kind = "markdown_file" +path = "/tmp/hermes-memory.md" +session_id = "latest" +default_entity_type = "note_section" +default_observation_type = "external_note" +"#, + ) + .unwrap(); + + let connector = config + .memory_connectors + .get("workspace_note") + .expect("connector should deserialize"); + match connector { + crate::config::MemoryConnectorConfig::MarkdownFile(settings) => { + assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory.md")); + assert_eq!(settings.session_id.as_deref(), Some("latest")); + assert_eq!( + settings.default_entity_type.as_deref(), + Some("note_section") + ); + assert_eq!( + settings.default_observation_type.as_deref(), + Some("external_note") + ); + } + _ => panic!("expected markdown_file connector"), + } + } + #[test] fn completion_summary_notifications_deserialize_from_toml() { let config: Config = toml::from_str( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 8ba123ab..acf3015c 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -588,6 +588,18 @@ struct JsonlMemoryConnectorRecord { details: BTreeMap, } +const MARKDOWN_CONNECTOR_SUMMARY_LIMIT: usize = 160; +const MARKDOWN_CONNECTOR_BODY_LIMIT: usize = 4000; + +#[derive(Debug, Clone)] +struct MarkdownMemorySection { + heading: String, + path: String, + summary: String, + body: String, + line_number: usize, +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -1594,6 +1606,9 @@ fn sync_memory_connector( config::MemoryConnectorConfig::JsonlDirectory(settings) => { sync_jsonl_directory_memory_connector(db, name, settings, limit) } + config::MemoryConnectorConfig::MarkdownFile(settings) => { + sync_markdown_memory_connector(db, name, settings, limit) + } } } @@ -1716,72 +1731,152 @@ fn sync_jsonl_memory_reader( } }; - let session_id = match record.session_id.as_deref() { - Some(value) => match resolve_session_id(db, value) { - Ok(resolved) => Some(resolved), - Err(_) => { - stats.skipped_records += 1; - continue; - } - }, - None => default_session_id.clone(), - }; - let entity_type = record - .entity_type - .as_deref() - .or(default_entity_type) - .map(str::trim) - .filter(|value| !value.is_empty()); - let observation_type = record - .observation_type - .as_deref() - .or(default_observation_type) - .map(str::trim) - .filter(|value| !value.is_empty()); - let entity_name = record.entity_name.trim(); - let summary = record.summary.trim(); - - let Some(entity_type) = entity_type else { - stats.skipped_records += 1; - continue; - }; - let Some(observation_type) = observation_type else { - stats.skipped_records += 1; - continue; - }; - if entity_name.is_empty() || summary.is_empty() { - stats.skipped_records += 1; - continue; - } - - let entity_summary = record - .entity_summary - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(summary); - let entity = db.upsert_context_entity( - session_id.as_deref(), - entity_type, - entity_name, - record.path.as_deref(), - entity_summary, - &record.metadata, + import_memory_connector_record( + db, + &mut stats, + default_session_id.as_deref(), + default_entity_type, + default_observation_type, + record, )?; - db.add_context_observation( - session_id.as_deref(), - entity.id, - observation_type, - summary, - &record.details, - )?; - stats.entities_upserted += 1; - stats.observations_added += 1; } Ok(stats) } +fn sync_markdown_memory_connector( + db: &session::store::StateStore, + name: &str, + settings: &config::MemoryConnectorMarkdownFileConfig, + limit: usize, +) -> Result { + if settings.path.as_os_str().is_empty() { + anyhow::bail!("memory connector {name} has no path configured"); + } + + let body = std::fs::read_to_string(&settings.path) + .with_context(|| format!("read memory connector file {}", settings.path.display()))?; + let default_session_id = settings + .session_id + .as_deref() + .map(|value| resolve_session_id(db, value)) + .transpose()?; + let sections = parse_markdown_memory_sections(&settings.path, &body, limit); + let mut stats = GraphConnectorSyncStats { + connector_name: name.to_string(), + ..Default::default() + }; + + for section in sections { + stats.records_read += 1; + let mut details = BTreeMap::new(); + if !section.body.is_empty() { + details.insert("body".to_string(), section.body.clone()); + } + details.insert( + "source_path".to_string(), + settings.path.display().to_string(), + ); + details.insert("line".to_string(), section.line_number.to_string()); + + let mut metadata = BTreeMap::new(); + metadata.insert("connector".to_string(), "markdown_file".to_string()); + + import_memory_connector_record( + db, + &mut stats, + default_session_id.as_deref(), + settings.default_entity_type.as_deref(), + settings.default_observation_type.as_deref(), + JsonlMemoryConnectorRecord { + session_id: None, + entity_type: None, + entity_name: section.heading, + path: Some(section.path), + entity_summary: Some(section.summary.clone()), + metadata, + observation_type: None, + summary: section.summary, + details, + }, + )?; + } + + Ok(stats) +} + +fn import_memory_connector_record( + db: &session::store::StateStore, + stats: &mut GraphConnectorSyncStats, + default_session_id: Option<&str>, + default_entity_type: Option<&str>, + default_observation_type: Option<&str>, + record: JsonlMemoryConnectorRecord, +) -> Result<()> { + let session_id = match record.session_id.as_deref() { + Some(value) => match resolve_session_id(db, value) { + Ok(resolved) => Some(resolved), + Err(_) => { + stats.skipped_records += 1; + return Ok(()); + } + }, + None => default_session_id.map(str::to_string), + }; + let entity_type = record + .entity_type + .as_deref() + .or(default_entity_type) + .map(str::trim) + .filter(|value| !value.is_empty()); + let observation_type = record + .observation_type + .as_deref() + .or(default_observation_type) + .map(str::trim) + .filter(|value| !value.is_empty()); + let entity_name = record.entity_name.trim(); + let summary = record.summary.trim(); + + let Some(entity_type) = entity_type else { + stats.skipped_records += 1; + return Ok(()); + }; + let Some(observation_type) = observation_type else { + stats.skipped_records += 1; + return Ok(()); + }; + if entity_name.is_empty() || summary.is_empty() { + stats.skipped_records += 1; + return Ok(()); + } + + let entity_summary = record + .entity_summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(summary); + let entity = db.upsert_context_entity( + session_id.as_deref(), + entity_type, + entity_name, + record.path.as_deref(), + entity_summary, + &record.metadata, + )?; + db.add_context_observation( + session_id.as_deref(), + entity.id, + observation_type, + summary, + &record.details, + )?; + stats.entities_upserted += 1; + stats.observations_added += 1; + Ok(()) +} + fn collect_jsonl_paths(root: &Path, recurse: bool) -> Result> { let mut paths = Vec::new(); collect_jsonl_paths_inner(root, recurse, &mut paths)?; @@ -1812,6 +1907,157 @@ fn collect_jsonl_paths_inner(root: &Path, recurse: bool, paths: &mut Vec Vec { + if limit == 0 { + return Vec::new(); + } + + let source_path = path.display().to_string(); + let fallback_heading = path + .file_stem() + .and_then(|value| value.to_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("note") + .trim() + .to_string(); + + let mut sections = Vec::new(); + let mut preamble = Vec::new(); + let mut current_heading: Option<(String, usize)> = None; + let mut current_body = Vec::new(); + + for (index, line) in body.lines().enumerate() { + let line_number = index + 1; + if let Some(heading) = markdown_heading_title(line) { + if let Some((title, start_line)) = current_heading.take() { + if let Some(section) = markdown_memory_section( + &source_path, + &title, + start_line, + ¤t_body.join("\n"), + ) { + sections.push(section); + } + } else if !preamble.join("\n").trim().is_empty() { + if let Some(section) = markdown_memory_section( + &source_path, + &fallback_heading, + 1, + &preamble.join("\n"), + ) { + sections.push(section); + } + } + + current_heading = Some((heading.to_string(), line_number)); + current_body.clear(); + continue; + } + + if current_heading.is_some() { + current_body.push(line.to_string()); + } else { + preamble.push(line.to_string()); + } + } + + if let Some((title, start_line)) = current_heading { + if let Some(section) = + markdown_memory_section(&source_path, &title, start_line, ¤t_body.join("\n")) + { + sections.push(section); + } + } else if let Some(section) = + markdown_memory_section(&source_path, &fallback_heading, 1, &preamble.join("\n")) + { + sections.push(section); + } + + sections.truncate(limit); + sections +} + +fn markdown_heading_title(line: &str) -> Option<&str> { + let trimmed = line.trim_start(); + let hashes = trimmed.chars().take_while(|ch| *ch == '#').count(); + if hashes == 0 || hashes > 6 { + return None; + } + let title = trimmed[hashes..].trim_start(); + if title.is_empty() { + return None; + } + Some(title.trim()) +} + +fn markdown_memory_section( + source_path: &str, + heading: &str, + line_number: usize, + body: &str, +) -> Option { + let heading = heading.trim(); + if heading.is_empty() { + return None; + } + let normalized_body = body.trim(); + let summary = markdown_section_summary(heading, normalized_body); + if summary.is_empty() { + return None; + } + let slug = markdown_heading_slug(heading); + let path = if slug.is_empty() { + source_path.to_string() + } else { + format!("{source_path}#{slug}") + }; + + Some(MarkdownMemorySection { + heading: truncate_connector_text(heading, MARKDOWN_CONNECTOR_SUMMARY_LIMIT), + path, + summary, + body: truncate_connector_text(normalized_body, MARKDOWN_CONNECTOR_BODY_LIMIT), + line_number, + }) +} + +fn markdown_section_summary(heading: &str, body: &str) -> String { + let candidate = body + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or(heading); + truncate_connector_text(candidate, MARKDOWN_CONNECTOR_SUMMARY_LIMIT) +} + +fn markdown_heading_slug(value: &str) -> String { + let mut slug = String::new(); + let mut last_dash = false; + for ch in value.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_dash = false; + } else if !last_dash { + slug.push('-'); + last_dash = true; + } + } + slug.trim_matches('-').to_string() +} + +fn truncate_connector_text(value: &str, max_chars: usize) -> String { + let trimmed = value.trim(); + if trimmed.chars().count() <= max_chars { + return trimmed.to_string(); + } + let truncated: String = trimmed.chars().take(max_chars.saturating_sub(1)).collect(); + format!("{truncated}…") +} + fn build_message( kind: MessageKindArg, text: String, @@ -5144,6 +5390,93 @@ mod tests { Ok(()) } + #[test] + fn sync_memory_connector_imports_markdown_file_sections() -> Result<()> { + let tempdir = TestDir::new("graph-connector-sync-markdown")?; + let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; + let now = chrono::Utc::now(); + db.insert_session(&session::Session { + id: "session-1".to_string(), + task: "knowledge import".to_string(), + project: "everything-claude-code".to_string(), + task_group: "memory".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: session::SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: session::SessionMetrics::default(), + })?; + + let connector_path = tempdir.path().join("workspace-memory.md"); + fs::write( + &connector_path, + r#"# Billing incident +Customer wiped setup and got charged twice after reinstalling. + +## Portal routing +Route existing installs to portal first before presenting checkout again. + +## Docs fix +Guide users to repair before reinstall so wiped setups do not buy twice. +"#, + )?; + + let mut cfg = config::Config::default(); + cfg.memory_connectors.insert( + "workspace_note".to_string(), + config::MemoryConnectorConfig::MarkdownFile( + config::MemoryConnectorMarkdownFileConfig { + path: connector_path.clone(), + session_id: Some("latest".to_string()), + default_entity_type: Some("note_section".to_string()), + default_observation_type: Some("external_note".to_string()), + }, + ), + ); + + let stats = sync_memory_connector(&db, &cfg, "workspace_note", 10)?; + assert_eq!(stats.records_read, 3); + assert_eq!(stats.entities_upserted, 3); + assert_eq!(stats.observations_added, 3); + assert_eq!(stats.skipped_records, 0); + + let recalled = db.recall_context_entities(None, "charged twice reinstall", 10)?; + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Billing incident")); + assert!(recalled.iter().any(|entry| entry.entity.name == "Docs fix")); + + let billing = recalled + .iter() + .find(|entry| entry.entity.name == "Billing incident") + .expect("billing section should exist"); + let expected_anchor_path = format!("{}#billing-incident", connector_path.display()); + assert_eq!( + billing.entity.path.as_deref(), + Some(expected_anchor_path.as_str()) + ); + let observations = db.list_context_observations(Some(billing.entity.id), 5)?; + assert_eq!(observations.len(), 1); + let expected_source_path = connector_path.display().to_string(); + assert_eq!( + observations[0] + .details + .get("source_path") + .map(String::as_str), + Some(expected_source_path.as_str()) + ); + assert!(observations[0] + .details + .get("body") + .is_some_and(|value: &String| value.contains("charged twice"))); + + Ok(()) + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( From 966af37f89341c8abe05c42fa15a916393219082 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:30:32 -0700 Subject: [PATCH 136/459] feat: add ecc2 dotenv memory connectors --- ecc2/src/config/mod.rs | 57 ++++++++ ecc2/src/main.rs | 297 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 354 insertions(+) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index e058ba78..4ef420be 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -109,6 +109,7 @@ pub enum MemoryConnectorConfig { JsonlFile(MemoryConnectorJsonlFileConfig), JsonlDirectory(MemoryConnectorJsonlDirectoryConfig), MarkdownFile(MemoryConnectorMarkdownFileConfig), + DotenvFile(MemoryConnectorDotenvFileConfig), } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -139,6 +140,19 @@ pub struct MemoryConnectorMarkdownFileConfig { pub default_observation_type: Option, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MemoryConnectorDotenvFileConfig { + pub path: PathBuf, + pub session_id: Option, + pub default_entity_type: Option, + pub default_observation_type: Option, + pub key_prefixes: Vec, + pub include_keys: Vec, + pub exclude_keys: Vec, + pub include_safe_values: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedOrchestrationTemplate { pub template_name: String, @@ -1368,6 +1382,49 @@ default_observation_type = "external_note" } } + #[test] + fn memory_dotenv_file_connectors_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[memory_connectors.hermes_env] +kind = "dotenv_file" +path = "/tmp/hermes.env" +session_id = "latest" +default_entity_type = "service_config" +default_observation_type = "external_config" +key_prefixes = ["STRIPE_", "PUBLIC_"] +include_keys = ["PUBLIC_BASE_URL"] +exclude_keys = ["STRIPE_WEBHOOK_SECRET"] +include_safe_values = true +"#, + ) + .unwrap(); + + let connector = config + .memory_connectors + .get("hermes_env") + .expect("connector should deserialize"); + match connector { + crate::config::MemoryConnectorConfig::DotenvFile(settings) => { + assert_eq!(settings.path, PathBuf::from("/tmp/hermes.env")); + assert_eq!(settings.session_id.as_deref(), Some("latest")); + assert_eq!( + settings.default_entity_type.as_deref(), + Some("service_config") + ); + assert_eq!( + settings.default_observation_type.as_deref(), + Some("external_config") + ); + assert_eq!(settings.key_prefixes, vec!["STRIPE_", "PUBLIC_"]); + assert_eq!(settings.include_keys, vec!["PUBLIC_BASE_URL"]); + assert_eq!(settings.exclude_keys, vec!["STRIPE_WEBHOOK_SECRET"]); + assert!(settings.include_safe_values); + } + _ => panic!("expected dotenv_file connector"), + } + } + #[test] fn completion_summary_notifications_deserialize_from_toml() { let config: Config = toml::from_str( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index acf3015c..7dcceb0d 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -590,6 +590,7 @@ struct JsonlMemoryConnectorRecord { const MARKDOWN_CONNECTOR_SUMMARY_LIMIT: usize = 160; const MARKDOWN_CONNECTOR_BODY_LIMIT: usize = 4000; +const DOTENV_CONNECTOR_VALUE_LIMIT: usize = 160; #[derive(Debug, Clone)] struct MarkdownMemorySection { @@ -600,6 +601,14 @@ struct MarkdownMemorySection { line_number: usize, } +#[derive(Debug, Clone)] +struct DotenvMemoryEntry { + key: String, + path: String, + summary: String, + details: BTreeMap, +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -1609,6 +1618,9 @@ fn sync_memory_connector( config::MemoryConnectorConfig::MarkdownFile(settings) => { sync_markdown_memory_connector(db, name, settings, limit) } + config::MemoryConnectorConfig::DotenvFile(settings) => { + sync_dotenv_memory_connector(db, name, settings, limit) + } } } @@ -1805,6 +1817,54 @@ fn sync_markdown_memory_connector( Ok(stats) } +fn sync_dotenv_memory_connector( + db: &session::store::StateStore, + name: &str, + settings: &config::MemoryConnectorDotenvFileConfig, + limit: usize, +) -> Result { + if settings.path.as_os_str().is_empty() { + anyhow::bail!("memory connector {name} has no path configured"); + } + + let body = std::fs::read_to_string(&settings.path) + .with_context(|| format!("read memory connector file {}", settings.path.display()))?; + let default_session_id = settings + .session_id + .as_deref() + .map(|value| resolve_session_id(db, value)) + .transpose()?; + let entries = parse_dotenv_memory_entries(&settings.path, &body, settings, limit); + let mut stats = GraphConnectorSyncStats { + connector_name: name.to_string(), + ..Default::default() + }; + + for entry in entries { + stats.records_read += 1; + import_memory_connector_record( + db, + &mut stats, + default_session_id.as_deref(), + settings.default_entity_type.as_deref(), + settings.default_observation_type.as_deref(), + JsonlMemoryConnectorRecord { + session_id: None, + entity_type: None, + entity_name: entry.key, + path: Some(entry.path), + entity_summary: Some(entry.summary.clone()), + metadata: BTreeMap::from([("connector".to_string(), "dotenv_file".to_string())]), + observation_type: None, + summary: entry.summary, + details: entry.details, + }, + )?; + } + + Ok(stats) +} + fn import_memory_connector_record( db: &session::store::StateStore, stats: &mut GraphConnectorSyncStats, @@ -1907,6 +1967,72 @@ fn collect_jsonl_paths_inner(root: &Path, recurse: bool, paths: &mut Vec Vec { + if limit == 0 { + return Vec::new(); + } + + let mut entries = Vec::new(); + let source_path = path.display().to_string(); + + for (index, raw_line) in body.lines().enumerate() { + if entries.len() >= limit { + break; + } + + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let Some((key, value)) = parse_dotenv_assignment(line) else { + continue; + }; + if !dotenv_key_included(key, settings) { + continue; + } + + let value = parse_dotenv_value(value); + let secret_like = dotenv_key_is_secret(key); + let mut details = BTreeMap::new(); + details.insert("source_path".to_string(), source_path.clone()); + details.insert("line".to_string(), (index + 1).to_string()); + details.insert("key".to_string(), key.to_string()); + details.insert("secret_redacted".to_string(), secret_like.to_string()); + if settings.include_safe_values && !secret_like && !value.is_empty() { + details.insert( + "value".to_string(), + truncate_connector_text(&value, DOTENV_CONNECTOR_VALUE_LIMIT), + ); + } + + let summary = if secret_like { + format!("{key} configured (secret redacted)") + } else if settings.include_safe_values && !value.is_empty() { + format!( + "{key}={}", + truncate_connector_text(&value, DOTENV_CONNECTOR_VALUE_LIMIT) + ) + } else { + format!("{key} configured") + }; + + entries.push(DotenvMemoryEntry { + key: key.to_string(), + path: format!("{source_path}#{key}"), + summary, + details, + }); + } + + entries +} + fn parse_markdown_memory_sections( path: &Path, body: &str, @@ -2058,6 +2184,73 @@ fn truncate_connector_text(value: &str, max_chars: usize) -> String { format!("{truncated}…") } +fn parse_dotenv_assignment(line: &str) -> Option<(&str, &str)> { + let trimmed = line.strip_prefix("export ").unwrap_or(line).trim(); + let (key, value) = trimmed.split_once('=')?; + let key = key.trim(); + if key.is_empty() { + return None; + } + Some((key, value.trim())) +} + +fn parse_dotenv_value(raw: &str) -> String { + let trimmed = raw.trim(); + if let Some(unquoted) = trimmed + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + return unquoted.to_string(); + } + if let Some(unquoted) = trimmed + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + { + return unquoted.to_string(); + } + trimmed.to_string() +} + +fn dotenv_key_included(key: &str, settings: &config::MemoryConnectorDotenvFileConfig) -> bool { + if settings + .exclude_keys + .iter() + .any(|candidate| candidate == key) + { + return false; + } + if !settings.include_keys.is_empty() + && settings + .include_keys + .iter() + .any(|candidate| candidate == key) + { + return true; + } + if settings.key_prefixes.is_empty() { + return settings.include_keys.is_empty(); + } + settings + .key_prefixes + .iter() + .any(|prefix| !prefix.is_empty() && key.starts_with(prefix)) +} + +fn dotenv_key_is_secret(key: &str) -> bool { + let upper = key.to_ascii_uppercase(); + [ + "SECRET", + "TOKEN", + "PASSWORD", + "PRIVATE_KEY", + "API_KEY", + "CLIENT_SECRET", + "ACCESS_KEY", + ] + .iter() + .any(|marker| upper.contains(marker)) +} + fn build_message( kind: MessageKindArg, text: String, @@ -5477,6 +5670,110 @@ Guide users to repair before reinstall so wiped setups do not buy twice. Ok(()) } + #[test] + fn sync_memory_connector_imports_dotenv_entries_safely() -> Result<()> { + let tempdir = TestDir::new("graph-connector-sync-dotenv")?; + let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; + let now = chrono::Utc::now(); + db.insert_session(&session::Session { + id: "session-1".to_string(), + task: "service config import".to_string(), + project: "ecc-tools".to_string(), + task_group: "memory".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: session::SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: session::SessionMetrics::default(), + })?; + + let connector_path = tempdir.path().join("hermes.env"); + fs::write( + &connector_path, + r#"# Hermes service config +STRIPE_SECRET_KEY=sk_test_secret +STRIPE_PRO_PRICE_ID=price_pro_monthly +PUBLIC_BASE_URL="https://ecc.tools" +STRIPE_WEBHOOK_SECRET=whsec_secret +GITHUB_TOKEN=ghp_should_not_import +INVALID LINE +"#, + )?; + + let mut cfg = config::Config::default(); + cfg.memory_connectors.insert( + "hermes_env".to_string(), + config::MemoryConnectorConfig::DotenvFile(config::MemoryConnectorDotenvFileConfig { + path: connector_path.clone(), + session_id: Some("latest".to_string()), + default_entity_type: Some("service_config".to_string()), + default_observation_type: Some("external_config".to_string()), + key_prefixes: vec!["STRIPE_".to_string(), "PUBLIC_".to_string()], + include_keys: Vec::new(), + exclude_keys: vec!["STRIPE_WEBHOOK_SECRET".to_string()], + include_safe_values: true, + }), + ); + + let stats = sync_memory_connector(&db, &cfg, "hermes_env", 10)?; + assert_eq!(stats.records_read, 3); + assert_eq!(stats.entities_upserted, 3); + assert_eq!(stats.observations_added, 3); + assert_eq!(stats.skipped_records, 0); + + let recalled = db.recall_context_entities(None, "stripe ecc.tools", 10)?; + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "STRIPE_SECRET_KEY")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "STRIPE_PRO_PRICE_ID")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "PUBLIC_BASE_URL")); + assert!(!recalled + .iter() + .any(|entry| entry.entity.name == "STRIPE_WEBHOOK_SECRET")); + assert!(!recalled + .iter() + .any(|entry| entry.entity.name == "GITHUB_TOKEN")); + + let secret = recalled + .iter() + .find(|entry| entry.entity.name == "STRIPE_SECRET_KEY") + .expect("secret entry should exist"); + let secret_observations = db.list_context_observations(Some(secret.entity.id), 5)?; + assert_eq!(secret_observations.len(), 1); + assert_eq!( + secret_observations[0] + .details + .get("secret_redacted") + .map(String::as_str), + Some("true") + ); + assert!(!secret_observations[0].details.contains_key("value")); + + let public_base = recalled + .iter() + .find(|entry| entry.entity.name == "PUBLIC_BASE_URL") + .expect("public base url should exist"); + let public_observations = db.list_context_observations(Some(public_base.entity.id), 5)?; + assert_eq!(public_observations.len(), 1); + assert_eq!( + public_observations[0] + .details + .get("value") + .map(String::as_str), + Some("https://ecc.tools") + ); + + Ok(()) + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( From 5258a753820134cbcaf7bff106a95338928313a6 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:34:40 -0700 Subject: [PATCH 137/459] feat: add ecc2 bulk memory connector sync --- ecc2/src/main.rs | 251 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 243 insertions(+), 8 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 7dcceb0d..b85ba625 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -507,7 +507,11 @@ enum GraphCommands { /// Import external memory from a configured connector ConnectorSync { /// Connector name from ecc2.toml - name: String, + #[arg(required_unless_present = "all", conflicts_with = "all")] + name: Option, + /// Sync every configured memory connector + #[arg(long, required_unless_present = "name")] + all: bool, /// Maximum non-empty records to process #[arg(long, default_value_t = 256)] limit: usize, @@ -574,6 +578,16 @@ struct GraphConnectorSyncStats { skipped_records: usize, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct GraphConnectorSyncReport { + connectors_synced: usize, + records_read: usize, + entities_upserted: usize, + observations_added: usize, + skipped_records: usize, + connectors: Vec, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct JsonlMemoryConnectorRecord { @@ -1409,12 +1423,29 @@ async fn main() -> Result<()> { ); } } - GraphCommands::ConnectorSync { name, limit, json } => { - let stats = sync_memory_connector(&db, &cfg, &name, limit)?; - if json { - println!("{}", serde_json::to_string_pretty(&stats)?); + GraphCommands::ConnectorSync { + name, + all, + limit, + json, + } => { + if all { + let report = sync_all_memory_connectors(&db, &cfg, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_graph_connector_sync_report_human(&report)); + } } else { - println!("{}", format_graph_connector_sync_stats_human(&stats)); + let name = name.as_deref().ok_or_else(|| { + anyhow::anyhow!("connector name required unless --all is set") + })?; + let stats = sync_memory_connector(&db, &cfg, name, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&stats)?); + } else { + println!("{}", format_graph_connector_sync_stats_human(&stats)); + } } } GraphCommands::Recall { @@ -1624,6 +1655,26 @@ fn sync_memory_connector( } } +fn sync_all_memory_connectors( + db: &session::store::StateStore, + cfg: &config::Config, + limit: usize, +) -> Result { + let mut report = GraphConnectorSyncReport::default(); + + for name in cfg.memory_connectors.keys() { + let stats = sync_memory_connector(db, cfg, name, limit)?; + report.connectors_synced += 1; + report.records_read += stats.records_read; + report.entities_upserted += stats.entities_upserted; + report.observations_added += stats.observations_added; + report.skipped_records += stats.skipped_records; + report.connectors.push(stats); + } + + Ok(report) +} + fn sync_jsonl_memory_connector( db: &session::store::StateStore, name: &str, @@ -3210,6 +3261,33 @@ fn format_graph_connector_sync_stats_human(stats: &GraphConnectorSyncStats) -> S .join("\n") } +fn format_graph_connector_sync_report_human(report: &GraphConnectorSyncReport) -> String { + let mut lines = vec![ + format!( + "Memory connector sync complete: {} connector(s)", + report.connectors_synced + ), + format!("- records read {}", report.records_read), + format!("- entities upserted {}", report.entities_upserted), + format!("- observations added {}", report.observations_added), + format!("- skipped records {}", report.skipped_records), + ]; + + if !report.connectors.is_empty() { + lines.push(String::new()); + lines.push("Connectors:".to_string()); + for stats in &report.connectors { + lines.push(format!("- {}", stats.connector_name)); + lines.push(format!(" records read {}", stats.records_read)); + lines.push(format!(" entities upserted {}", stats.entities_upserted)); + lines.push(format!(" observations added {}", stats.observations_added)); + lines.push(format!(" skipped records {}", stats.skipped_records)); + } + } + + lines.join("\n") +} + fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String { let mut lines = vec![format_graph_entity_human(&detail.entity)]; lines.push(String::new()); @@ -5233,9 +5311,16 @@ mod tests { match cli.command { Some(Commands::Graph { - command: GraphCommands::ConnectorSync { name, limit, json }, + command: + GraphCommands::ConnectorSync { + name, + all, + limit, + json, + }, }) => { - assert_eq!(name, "hermes_notes"); + assert_eq!(name.as_deref(), Some("hermes_notes")); + assert!(!all); assert_eq!(limit, 32); assert!(json); } @@ -5243,6 +5328,38 @@ mod tests { } } + #[test] + fn cli_parses_graph_connector_sync_all_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "connector-sync", + "--all", + "--limit", + "16", + "--json", + ]) + .expect("graph connector-sync --all should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::ConnectorSync { + name, + all, + limit, + json, + }, + }) => { + assert_eq!(name, None); + assert!(all); + assert_eq!(limit, 16); + assert!(json); + } + _ => panic!("expected graph connector-sync --all subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -5422,6 +5539,39 @@ mod tests { assert!(text.contains("- skipped records 1")); } + #[test] + fn format_graph_connector_sync_report_human_renders_totals_and_connectors() { + let text = format_graph_connector_sync_report_human(&GraphConnectorSyncReport { + connectors_synced: 2, + records_read: 7, + entities_upserted: 5, + observations_added: 5, + skipped_records: 2, + connectors: vec![ + GraphConnectorSyncStats { + connector_name: "hermes_notes".to_string(), + records_read: 4, + entities_upserted: 3, + observations_added: 3, + skipped_records: 1, + }, + GraphConnectorSyncStats { + connector_name: "workspace_note".to_string(), + records_read: 3, + entities_upserted: 2, + observations_added: 2, + skipped_records: 1, + }, + ], + }); + + assert!(text.contains("Memory connector sync complete: 2 connector(s)")); + assert!(text.contains("- records read 7")); + assert!(text.contains("Connectors:")); + assert!(text.contains("- hermes_notes")); + assert!(text.contains("- workspace_note")); + } + #[test] fn sync_memory_connector_imports_jsonl_observations() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync")?; @@ -5774,6 +5924,91 @@ INVALID LINE Ok(()) } + #[test] + fn sync_all_memory_connectors_aggregates_results() -> Result<()> { + let tempdir = TestDir::new("graph-connector-sync-all")?; + let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; + let now = chrono::Utc::now(); + db.insert_session(&session::Session { + id: "session-1".to_string(), + task: "memory import".to_string(), + project: "everything-claude-code".to_string(), + task_group: "memory".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: session::SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: session::SessionMetrics::default(), + })?; + + let jsonl_path = tempdir.path().join("hermes-memory.jsonl"); + fs::write( + &jsonl_path, + serde_json::json!({ + "entity_name": "Portal routing", + "summary": "Route reinstalls to portal before checkout", + }) + .to_string(), + )?; + + let markdown_path = tempdir.path().join("workspace-memory.md"); + fs::write( + &markdown_path, + r#"# Billing incident +Customer wiped setup and got charged twice after reinstalling. + +## Docs fix +Guide users to repair before reinstall. +"#, + )?; + + let mut cfg = config::Config::default(); + cfg.memory_connectors.insert( + "hermes_notes".to_string(), + config::MemoryConnectorConfig::JsonlFile(config::MemoryConnectorJsonlFileConfig { + path: jsonl_path, + session_id: Some("latest".to_string()), + default_entity_type: Some("incident".to_string()), + default_observation_type: Some("external_note".to_string()), + }), + ); + cfg.memory_connectors.insert( + "workspace_note".to_string(), + config::MemoryConnectorConfig::MarkdownFile( + config::MemoryConnectorMarkdownFileConfig { + path: markdown_path, + session_id: Some("latest".to_string()), + default_entity_type: Some("note_section".to_string()), + default_observation_type: Some("external_note".to_string()), + }, + ), + ); + + let report = sync_all_memory_connectors(&db, &cfg, 10)?; + assert_eq!(report.connectors_synced, 2); + assert_eq!(report.records_read, 3); + assert_eq!(report.entities_upserted, 3); + assert_eq!(report.observations_added, 3); + assert_eq!(report.skipped_records, 0); + assert_eq!( + report + .connectors + .iter() + .map(|stats| stats.connector_name.as_str()) + .collect::>(), + vec!["hermes_notes", "workspace_note"] + ); + + let recalled = db.recall_context_entities(None, "charged twice portal reinstall", 10)?; + assert_eq!(recalled.len(), 3); + + Ok(()) + } + #[test] fn format_graph_sync_stats_human_renders_counts() { let text = format_graph_sync_stats_human( From 406722b5efa096ebe5cf17ccc35c4e3169dee871 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:38:33 -0700 Subject: [PATCH 138/459] feat: add ecc2 markdown directory memory connector --- ecc2/src/config/mod.rs | 48 +++++++++ ecc2/src/main.rs | 218 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 255 insertions(+), 11 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 4ef420be..2f2309d0 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -109,6 +109,7 @@ pub enum MemoryConnectorConfig { JsonlFile(MemoryConnectorJsonlFileConfig), JsonlDirectory(MemoryConnectorJsonlDirectoryConfig), MarkdownFile(MemoryConnectorMarkdownFileConfig), + MarkdownDirectory(MemoryConnectorMarkdownDirectoryConfig), DotenvFile(MemoryConnectorDotenvFileConfig), } @@ -140,6 +141,16 @@ pub struct MemoryConnectorMarkdownFileConfig { pub default_observation_type: Option, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct MemoryConnectorMarkdownDirectoryConfig { + pub path: PathBuf, + pub recurse: bool, + pub session_id: Option, + pub default_entity_type: Option, + pub default_observation_type: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default)] pub struct MemoryConnectorDotenvFileConfig { @@ -1382,6 +1393,43 @@ default_observation_type = "external_note" } } + #[test] + fn memory_markdown_directory_connectors_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[memory_connectors.workspace_notes] +kind = "markdown_directory" +path = "/tmp/hermes-memory" +recurse = true +session_id = "latest" +default_entity_type = "note_section" +default_observation_type = "external_note" +"#, + ) + .unwrap(); + + let connector = config + .memory_connectors + .get("workspace_notes") + .expect("connector should deserialize"); + match connector { + crate::config::MemoryConnectorConfig::MarkdownDirectory(settings) => { + assert_eq!(settings.path, PathBuf::from("/tmp/hermes-memory")); + assert!(settings.recurse); + assert_eq!(settings.session_id.as_deref(), Some("latest")); + assert_eq!( + settings.default_entity_type.as_deref(), + Some("note_section") + ); + assert_eq!( + settings.default_observation_type.as_deref(), + Some("external_note") + ); + } + _ => panic!("expected markdown_directory connector"), + } + } + #[test] fn memory_dotenv_file_connectors_deserialize_from_toml() { let config: Config = toml::from_str( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index b85ba625..e0b4ea36 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1649,6 +1649,9 @@ fn sync_memory_connector( config::MemoryConnectorConfig::MarkdownFile(settings) => { sync_markdown_memory_connector(db, name, settings, limit) } + config::MemoryConnectorConfig::MarkdownDirectory(settings) => { + sync_markdown_directory_memory_connector(db, name, settings, limit) + } config::MemoryConnectorConfig::DotenvFile(settings) => { sync_dotenv_memory_connector(db, name, settings, limit) } @@ -1817,14 +1820,89 @@ fn sync_markdown_memory_connector( anyhow::bail!("memory connector {name} has no path configured"); } - let body = std::fs::read_to_string(&settings.path) - .with_context(|| format!("read memory connector file {}", settings.path.display()))?; let default_session_id = settings .session_id .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; - let sections = parse_markdown_memory_sections(&settings.path, &body, limit); + sync_markdown_memory_path( + db, + name, + "markdown_file", + &settings.path, + default_session_id.as_deref(), + settings.default_entity_type.as_deref(), + settings.default_observation_type.as_deref(), + limit, + ) +} + +fn sync_markdown_directory_memory_connector( + db: &session::store::StateStore, + name: &str, + settings: &config::MemoryConnectorMarkdownDirectoryConfig, + limit: usize, +) -> Result { + if settings.path.as_os_str().is_empty() { + anyhow::bail!("memory connector {name} has no path configured"); + } + if !settings.path.is_dir() { + anyhow::bail!( + "memory connector {name} path is not a directory: {}", + settings.path.display() + ); + } + + let paths = collect_markdown_paths(&settings.path, settings.recurse)?; + let default_session_id = settings + .session_id + .as_deref() + .map(|value| resolve_session_id(db, value)) + .transpose()?; + + let mut stats = GraphConnectorSyncStats { + connector_name: name.to_string(), + ..Default::default() + }; + + let mut remaining = limit; + for path in paths { + if remaining == 0 { + break; + } + let file_stats = sync_markdown_memory_path( + db, + name, + "markdown_directory", + &path, + default_session_id.as_deref(), + settings.default_entity_type.as_deref(), + settings.default_observation_type.as_deref(), + remaining, + )?; + remaining = remaining.saturating_sub(file_stats.records_read); + stats.records_read += file_stats.records_read; + stats.entities_upserted += file_stats.entities_upserted; + stats.observations_added += file_stats.observations_added; + stats.skipped_records += file_stats.skipped_records; + } + + Ok(stats) +} + +fn sync_markdown_memory_path( + db: &session::store::StateStore, + name: &str, + connector_kind: &str, + path: &Path, + default_session_id: Option<&str>, + default_entity_type: Option<&str>, + default_observation_type: Option<&str>, + limit: usize, +) -> Result { + let body = std::fs::read_to_string(path) + .with_context(|| format!("read memory connector file {}", path.display()))?; + let sections = parse_markdown_memory_sections(path, &body, limit); let mut stats = GraphConnectorSyncStats { connector_name: name.to_string(), ..Default::default() @@ -1836,21 +1914,18 @@ fn sync_markdown_memory_connector( if !section.body.is_empty() { details.insert("body".to_string(), section.body.clone()); } - details.insert( - "source_path".to_string(), - settings.path.display().to_string(), - ); + details.insert("source_path".to_string(), path.display().to_string()); details.insert("line".to_string(), section.line_number.to_string()); let mut metadata = BTreeMap::new(); - metadata.insert("connector".to_string(), "markdown_file".to_string()); + metadata.insert("connector".to_string(), connector_kind.to_string()); import_memory_connector_record( db, &mut stats, - default_session_id.as_deref(), - settings.default_entity_type.as_deref(), - settings.default_observation_type.as_deref(), + default_session_id, + default_entity_type, + default_observation_type, JsonlMemoryConnectorRecord { session_id: None, entity_type: None, @@ -1995,6 +2070,13 @@ fn collect_jsonl_paths(root: &Path, recurse: bool) -> Result> { Ok(paths) } +fn collect_markdown_paths(root: &Path, recurse: bool) -> Result> { + let mut paths = Vec::new(); + collect_markdown_paths_inner(root, recurse, &mut paths)?; + paths.sort(); + Ok(paths) +} + fn collect_jsonl_paths_inner(root: &Path, recurse: bool, paths: &mut Vec) -> Result<()> { for entry in std::fs::read_dir(root) .with_context(|| format!("read memory connector directory {}", root.display()))? @@ -2018,6 +2100,35 @@ fn collect_jsonl_paths_inner(root: &Path, recurse: bool, paths: &mut Vec, +) -> Result<()> { + for entry in std::fs::read_dir(root) + .with_context(|| format!("read memory connector directory {}", root.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if recurse { + collect_markdown_paths_inner(&path, recurse, paths)?; + } + continue; + } + let is_markdown = path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + value.eq_ignore_ascii_case("md") || value.eq_ignore_ascii_case("markdown") + }); + if is_markdown { + paths.push(path); + } + } + Ok(()) +} + fn parse_dotenv_memory_entries( path: &Path, body: &str, @@ -5820,6 +5931,91 @@ Guide users to repair before reinstall so wiped setups do not buy twice. Ok(()) } + #[test] + fn sync_memory_connector_imports_markdown_directory_sections() -> Result<()> { + let tempdir = TestDir::new("graph-connector-sync-markdown-dir")?; + let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; + let now = chrono::Utc::now(); + db.insert_session(&session::Session { + id: "session-1".to_string(), + task: "knowledge import".to_string(), + project: "everything-claude-code".to_string(), + task_group: "memory".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: session::SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: session::SessionMetrics::default(), + })?; + + let connector_dir = tempdir.path().join("workspace-notes"); + fs::create_dir_all(connector_dir.join("nested"))?; + fs::write( + connector_dir.join("incident.md"), + r#"# Billing incident +Customer wiped setup and got charged twice after reinstalling. + +## Portal routing +Route existing installs to portal first before presenting checkout again. +"#, + )?; + fs::write( + connector_dir.join("nested").join("docs.markdown"), + r#"# Docs fix +Guide users to repair before reinstall so wiped setups do not buy twice. +"#, + )?; + fs::write(connector_dir.join("ignore.txt"), "not imported")?; + + let mut cfg = config::Config::default(); + cfg.memory_connectors.insert( + "workspace_notes".to_string(), + config::MemoryConnectorConfig::MarkdownDirectory( + config::MemoryConnectorMarkdownDirectoryConfig { + path: connector_dir.clone(), + recurse: true, + session_id: Some("latest".to_string()), + default_entity_type: Some("note_section".to_string()), + default_observation_type: Some("external_note".to_string()), + }, + ), + ); + + let stats = sync_memory_connector(&db, &cfg, "workspace_notes", 10)?; + assert_eq!(stats.records_read, 3); + assert_eq!(stats.entities_upserted, 3); + assert_eq!(stats.observations_added, 3); + assert_eq!(stats.skipped_records, 0); + + let recalled = db.recall_context_entities(None, "charged twice portal docs", 10)?; + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Billing incident")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Portal routing")); + assert!(recalled.iter().any(|entry| entry.entity.name == "Docs fix")); + + let docs_fix = recalled + .iter() + .find(|entry| entry.entity.name == "Docs fix") + .expect("docs section should exist"); + let expected_anchor_path = format!( + "{}#docs-fix", + connector_dir.join("nested").join("docs.markdown").display() + ); + assert_eq!( + docs_fix.entity.path.as_deref(), + Some(expected_anchor_path.as_str()) + ); + + Ok(()) + } + #[test] fn sync_memory_connector_imports_dotenv_entries_safely() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync-dotenv")?; From 9523575721cba6285c95d688f08736017e0d2615 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:44:05 -0700 Subject: [PATCH 139/459] feat: add ecc2 connector sync checkpoints --- ecc2/src/main.rs | 158 +++++++++++++++++++++++++++++++++++++- ecc2/src/session/store.rs | 50 ++++++++++++ 2 files changed, 204 insertions(+), 4 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index e0b4ea36..982b5ef2 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -576,6 +576,7 @@ struct GraphConnectorSyncStats { entities_upserted: usize, observations_added: usize, skipped_records: usize, + skipped_unchanged_sources: usize, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -585,6 +586,7 @@ struct GraphConnectorSyncReport { entities_upserted: usize, observations_added: usize, skipped_records: usize, + skipped_unchanged_sources: usize, connectors: Vec, } @@ -1672,6 +1674,7 @@ fn sync_all_memory_connectors( report.entities_upserted += stats.entities_upserted; report.observations_added += stats.observations_added; report.skipped_records += stats.skipped_records; + report.skipped_unchanged_sources += stats.skipped_unchanged_sources; report.connectors.push(stats); } @@ -1696,8 +1699,17 @@ fn sync_jsonl_memory_connector( .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; + let source_path = settings.path.display().to_string(); + let signature = connector_source_signature(&settings.path)?; + if db.connector_source_is_unchanged(name, &source_path, &signature)? { + return Ok(GraphConnectorSyncStats { + connector_name: name.to_string(), + skipped_unchanged_sources: 1, + ..Default::default() + }); + } - sync_jsonl_memory_reader( + let stats = sync_jsonl_memory_reader( db, name, reader, @@ -1705,7 +1717,11 @@ fn sync_jsonl_memory_connector( settings.default_entity_type.as_deref(), settings.default_observation_type.as_deref(), limit, - ) + )?; + if stats.records_read < limit { + db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; + } + Ok(stats) } fn sync_jsonl_directory_memory_connector( @@ -1741,9 +1757,16 @@ fn sync_jsonl_directory_memory_connector( if remaining == 0 { break; } + let source_path = path.display().to_string(); + let signature = connector_source_signature(&path)?; + if db.connector_source_is_unchanged(name, &source_path, &signature)? { + stats.skipped_unchanged_sources += 1; + continue; + } let file = File::open(&path) .with_context(|| format!("open memory connector file {}", path.display()))?; let reader = BufReader::new(file); + let remaining_before = remaining; let file_stats = sync_jsonl_memory_reader( db, name, @@ -1758,6 +1781,10 @@ fn sync_jsonl_directory_memory_connector( stats.entities_upserted += file_stats.entities_upserted; stats.observations_added += file_stats.observations_added; stats.skipped_records += file_stats.skipped_records; + stats.skipped_unchanged_sources += file_stats.skipped_unchanged_sources; + if file_stats.records_read < remaining_before { + db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; + } } Ok(stats) @@ -1825,7 +1852,16 @@ fn sync_markdown_memory_connector( .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; - sync_markdown_memory_path( + let source_path = settings.path.display().to_string(); + let signature = connector_source_signature(&settings.path)?; + if db.connector_source_is_unchanged(name, &source_path, &signature)? { + return Ok(GraphConnectorSyncStats { + connector_name: name.to_string(), + skipped_unchanged_sources: 1, + ..Default::default() + }); + } + let stats = sync_markdown_memory_path( db, name, "markdown_file", @@ -1834,7 +1870,11 @@ fn sync_markdown_memory_connector( settings.default_entity_type.as_deref(), settings.default_observation_type.as_deref(), limit, - ) + )?; + if stats.records_read < limit { + db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; + } + Ok(stats) } fn sync_markdown_directory_memory_connector( @@ -1870,6 +1910,13 @@ fn sync_markdown_directory_memory_connector( if remaining == 0 { break; } + let source_path = path.display().to_string(); + let signature = connector_source_signature(&path)?; + if db.connector_source_is_unchanged(name, &source_path, &signature)? { + stats.skipped_unchanged_sources += 1; + continue; + } + let remaining_before = remaining; let file_stats = sync_markdown_memory_path( db, name, @@ -1885,6 +1932,10 @@ fn sync_markdown_directory_memory_connector( stats.entities_upserted += file_stats.entities_upserted; stats.observations_added += file_stats.observations_added; stats.skipped_records += file_stats.skipped_records; + stats.skipped_unchanged_sources += file_stats.skipped_unchanged_sources; + if file_stats.records_read < remaining_before { + db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; + } } Ok(stats) @@ -1960,6 +2011,15 @@ fn sync_dotenv_memory_connector( .as_deref() .map(|value| resolve_session_id(db, value)) .transpose()?; + let source_path = settings.path.display().to_string(); + let signature = connector_source_signature(&settings.path)?; + if db.connector_source_is_unchanged(name, &source_path, &signature)? { + return Ok(GraphConnectorSyncStats { + connector_name: name.to_string(), + skipped_unchanged_sources: 1, + ..Default::default() + }); + } let entries = parse_dotenv_memory_entries(&settings.path, &body, settings, limit); let mut stats = GraphConnectorSyncStats { connector_name: name.to_string(), @@ -1988,6 +2048,10 @@ fn sync_dotenv_memory_connector( )?; } + if stats.records_read < limit { + db.upsert_connector_source_checkpoint(name, &source_path, &signature)?; + } + Ok(stats) } @@ -2077,6 +2141,18 @@ fn collect_markdown_paths(root: &Path, recurse: bool) -> Result> { Ok(paths) } +fn connector_source_signature(path: &Path) -> Result { + let metadata = std::fs::metadata(path) + .with_context(|| format!("read memory connector metadata {}", path.display()))?; + let modified = metadata + .modified() + .ok() + .and_then(|timestamp| timestamp.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + Ok(format!("{}:{modified}", metadata.len())) +} + fn collect_jsonl_paths_inner(root: &Path, recurse: bool, paths: &mut Vec) -> Result<()> { for entry in std::fs::read_dir(root) .with_context(|| format!("read memory connector directory {}", root.display()))? @@ -3368,6 +3444,10 @@ fn format_graph_connector_sync_stats_human(stats: &GraphConnectorSyncStats) -> S format!("- entities upserted {}", stats.entities_upserted), format!("- observations added {}", stats.observations_added), format!("- skipped records {}", stats.skipped_records), + format!( + "- skipped unchanged sources {}", + stats.skipped_unchanged_sources + ), ] .join("\n") } @@ -3382,6 +3462,10 @@ fn format_graph_connector_sync_report_human(report: &GraphConnectorSyncReport) - format!("- entities upserted {}", report.entities_upserted), format!("- observations added {}", report.observations_added), format!("- skipped records {}", report.skipped_records), + format!( + "- skipped unchanged sources {}", + report.skipped_unchanged_sources + ), ]; if !report.connectors.is_empty() { @@ -3393,6 +3477,10 @@ fn format_graph_connector_sync_report_human(report: &GraphConnectorSyncReport) - lines.push(format!(" entities upserted {}", stats.entities_upserted)); lines.push(format!(" observations added {}", stats.observations_added)); lines.push(format!(" skipped records {}", stats.skipped_records)); + lines.push(format!( + " skipped unchanged sources {}", + stats.skipped_unchanged_sources + )); } } @@ -5641,6 +5729,7 @@ mod tests { entities_upserted: 3, observations_added: 3, skipped_records: 1, + skipped_unchanged_sources: 2, }); assert!(text.contains("Memory connector sync complete: hermes_notes")); @@ -5648,6 +5737,7 @@ mod tests { assert!(text.contains("- entities upserted 3")); assert!(text.contains("- observations added 3")); assert!(text.contains("- skipped records 1")); + assert!(text.contains("- skipped unchanged sources 2")); } #[test] @@ -5658,6 +5748,7 @@ mod tests { entities_upserted: 5, observations_added: 5, skipped_records: 2, + skipped_unchanged_sources: 3, connectors: vec![ GraphConnectorSyncStats { connector_name: "hermes_notes".to_string(), @@ -5665,6 +5756,7 @@ mod tests { entities_upserted: 3, observations_added: 3, skipped_records: 1, + skipped_unchanged_sources: 2, }, GraphConnectorSyncStats { connector_name: "workspace_note".to_string(), @@ -5672,15 +5764,18 @@ mod tests { entities_upserted: 2, observations_added: 2, skipped_records: 1, + skipped_unchanged_sources: 1, }, ], }); assert!(text.contains("Memory connector sync complete: 2 connector(s)")); assert!(text.contains("- records read 7")); + assert!(text.contains("- skipped unchanged sources 3")); assert!(text.contains("Connectors:")); assert!(text.contains("- hermes_notes")); assert!(text.contains("- workspace_note")); + assert!(text.contains(" skipped unchanged sources 2")); } #[test] @@ -5756,6 +5851,61 @@ mod tests { Ok(()) } + #[test] + fn sync_memory_connector_skips_unchanged_jsonl_sources() -> Result<()> { + let tempdir = TestDir::new("graph-connector-sync-unchanged")?; + let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; + let now = chrono::Utc::now(); + db.insert_session(&session::Session { + id: "session-1".to_string(), + task: "recovery incident".to_string(), + project: "ecc-tools".to_string(), + task_group: "incident".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: session::SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: session::SessionMetrics::default(), + })?; + + let connector_path = tempdir.path().join("hermes-memory.jsonl"); + fs::write( + &connector_path, + serde_json::json!({ + "entity_name": "Portal routing", + "summary": "Route reinstalls to portal before checkout", + }) + .to_string(), + )?; + + let mut cfg = config::Config::default(); + cfg.memory_connectors.insert( + "hermes_notes".to_string(), + config::MemoryConnectorConfig::JsonlFile(config::MemoryConnectorJsonlFileConfig { + path: connector_path, + session_id: Some("latest".to_string()), + default_entity_type: Some("incident".to_string()), + default_observation_type: Some("external_note".to_string()), + }), + ); + + let first = sync_memory_connector(&db, &cfg, "hermes_notes", 10)?; + assert_eq!(first.records_read, 1); + assert_eq!(first.skipped_unchanged_sources, 0); + + let second = sync_memory_connector(&db, &cfg, "hermes_notes", 10)?; + assert_eq!(second.records_read, 0); + assert_eq!(second.entities_upserted, 0); + assert_eq!(second.observations_added, 0); + assert_eq!(second.skipped_unchanged_sources, 1); + + Ok(()) + } + #[test] fn sync_memory_connector_imports_jsonl_directory_observations() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync-dir")?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 31d93ce6..356131d9 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -272,6 +272,14 @@ impl StateStore { created_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS context_graph_connector_checkpoints ( + connector_name TEXT NOT NULL, + source_path TEXT NOT NULL, + source_signature TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (connector_name, source_path) + ); + CREATE TABLE IF NOT EXISTS pending_worktree_queue ( session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, repo_root TEXT NOT NULL, @@ -334,6 +342,8 @@ impl StateStore { ON context_graph_relations(to_entity_id, created_at, id); CREATE INDEX IF NOT EXISTS idx_context_graph_observations_entity ON context_graph_observations(entity_id, created_at, id); + CREATE INDEX IF NOT EXISTS idx_context_graph_connector_checkpoints_updated_at + ON context_graph_connector_checkpoints(updated_at, connector_name, source_path); CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at); CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at @@ -2304,6 +2314,46 @@ impl StateStore { Ok(entries) } + pub fn connector_source_is_unchanged( + &self, + connector_name: &str, + source_path: &str, + source_signature: &str, + ) -> Result { + let stored_signature = self + .conn + .query_row( + "SELECT source_signature + FROM context_graph_connector_checkpoints + WHERE connector_name = ?1 AND source_path = ?2", + rusqlite::params![connector_name, source_path], + |row| row.get::<_, String>(0), + ) + .optional()?; + Ok(stored_signature + .as_deref() + .is_some_and(|stored| stored == source_signature)) + } + + pub fn upsert_connector_source_checkpoint( + &self, + connector_name: &str, + source_path: &str, + source_signature: &str, + ) -> Result<()> { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT INTO context_graph_connector_checkpoints ( + connector_name, source_path, source_signature, updated_at + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(connector_name, source_path) + DO UPDATE SET source_signature = excluded.source_signature, + updated_at = excluded.updated_at", + rusqlite::params![connector_name, source_path, source_signature, now], + )?; + Ok(()) + } + fn compact_context_graph_observations( &self, session_id: Option<&str>, From 766bf31737c417f2465cab177c8579f50c4c8069 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 06:56:26 -0700 Subject: [PATCH 140/459] feat: add ecc2 memory observation priorities --- ecc2/src/main.rs | 45 ++++++++++++++++--- ecc2/src/session/mod.rs | 48 ++++++++++++++++++++ ecc2/src/session/store.rs | 95 +++++++++++++++++++++++++++++++-------- ecc2/src/tui/dashboard.rs | 22 ++++++--- 4 files changed, 181 insertions(+), 29 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 982b5ef2..ce18d5fb 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -470,6 +470,9 @@ enum GraphCommands { /// Observation type such as completion_summary, incident_note, or reminder #[arg(long = "type")] observation_type: String, + /// Observation priority + #[arg(long, value_enum, default_value_t = ObservationPriorityArg::Normal)] + priority: ObservationPriorityArg, /// Observation summary #[arg(long)] summary: String, @@ -569,6 +572,25 @@ enum MessageKindArg { Conflict, } +#[derive(clap::ValueEnum, Clone, Debug)] +enum ObservationPriorityArg { + Low, + Normal, + High, + Critical, +} + +impl From for session::ContextObservationPriority { + fn from(value: ObservationPriorityArg) -> Self { + match value { + ObservationPriorityArg::Low => Self::Low, + ObservationPriorityArg::Normal => Self::Normal, + ObservationPriorityArg::High => Self::High, + ObservationPriorityArg::Critical => Self::Critical, + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct GraphConnectorSyncStats { connector_name: String, @@ -1365,6 +1387,7 @@ async fn main() -> Result<()> { session_id, entity_id, observation_type, + priority, summary, details, json, @@ -1378,6 +1401,7 @@ async fn main() -> Result<()> { resolved_session_id.as_deref(), entity_id, &observation_type, + priority.into(), &summary, &details, )?; @@ -2119,6 +2143,7 @@ fn import_memory_connector_record( session_id.as_deref(), entity.id, observation_type, + session::ContextObservationPriority::Normal, summary, &record.details, )?; @@ -3323,6 +3348,7 @@ fn format_graph_observation_human(observation: &session::ContextGraphObservation observation.entity_id, observation.entity_type, observation.entity_name ), format!("Type: {}", observation.observation_type), + format!("Priority: {}", observation.priority), format!("Summary: {}", observation.summary), ]; if let Some(session_id) = observation.session_id.as_deref() { @@ -3354,8 +3380,11 @@ fn format_graph_observations_human(observations: &[session::ContextGraphObservat )]; for observation in observations { let mut line = format!( - "- #{} [{}] {}", - observation.id, observation.observation_type, observation.entity_name + "- #{} [{}/{}] {}", + observation.id, + observation.observation_type, + observation.priority, + observation.entity_name ); if let Some(session_id) = observation.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); @@ -3386,13 +3415,14 @@ fn format_graph_recall_human( )]; for entry in entries { let mut line = format!( - "- #{} [{}] {} | score {} | relations {} | observations {}", + "- #{} [{}] {} | score {} | relations {} | observations {} | priority {}", entry.entity.id, entry.entity.entity_type, entry.entity.name, entry.score, entry.relation_count, - entry.observation_count + entry.observation_count, + entry.max_observation_priority ); if let Some(session_id) = entry.entity.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); @@ -5448,6 +5478,7 @@ mod tests { session_id, entity_id, observation_type, + priority, summary, details, json, @@ -5456,6 +5487,7 @@ mod tests { assert_eq!(session_id.as_deref(), Some("latest")); assert_eq!(entity_id, 7); assert_eq!(observation_type, "completion_summary"); + assert!(matches!(priority, ObservationPriorityArg::Normal)); assert_eq!(summary, "Finished auth callback recovery"); assert_eq!(details, vec!["tests_run=2"]); assert!(json); @@ -5668,6 +5700,7 @@ mod tests { ], relation_count: 2, observation_count: 1, + max_observation_priority: session::ContextObservationPriority::High, }], Some("sess-12345678"), "auth callback recovery", @@ -5675,6 +5708,7 @@ mod tests { assert!(text.contains("Relevant memory: 1 entries")); assert!(text.contains("[file] callback.ts | score 319 | relations 2 | observations 1")); + assert!(text.contains("priority high")); assert!(text.contains("matches auth, callback, recovery")); assert!(text.contains("path src/routes/auth/callback.ts")); } @@ -5688,6 +5722,7 @@ mod tests { entity_type: "session".to_string(), entity_name: "sess-12345678".to_string(), observation_type: "completion_summary".to_string(), + priority: session::ContextObservationPriority::High, summary: "Finished auth callback recovery with 2 tests".to_string(), details: BTreeMap::from([("tests_run".to_string(), "2".to_string())]), created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") @@ -5696,7 +5731,7 @@ mod tests { }]); assert!(text.contains("Context graph observations: 1")); - assert!(text.contains("[completion_summary] sess-12345678")); + assert!(text.contains("[completion_summary/high] sess-12345678")); assert!(text.contains("summary Finished auth callback recovery with 2 tests")); } diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 727a7c9f..878d88bc 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -198,6 +198,7 @@ pub struct ContextGraphObservation { pub entity_type: String, pub entity_name: String, pub observation_type: String, + pub priority: ContextObservationPriority, pub summary: String, pub details: BTreeMap, pub created_at: DateTime, @@ -210,6 +211,53 @@ pub struct ContextGraphRecallEntry { pub matched_terms: Vec, pub relation_count: usize, pub observation_count: usize, + pub max_observation_priority: ContextObservationPriority, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum ContextObservationPriority { + Low, + Normal, + High, + Critical, +} + +impl Default for ContextObservationPriority { + fn default() -> Self { + Self::Normal + } +} + +impl fmt::Display for ContextObservationPriority { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Low => write!(f, "low"), + Self::Normal => write!(f, "normal"), + Self::High => write!(f, "high"), + Self::Critical => write!(f, "critical"), + } + } +} + +impl ContextObservationPriority { + pub fn from_db_value(value: i64) -> Self { + match value { + 0 => Self::Low, + 2 => Self::High, + 3 => Self::Critical, + _ => Self::Normal, + } + } + + pub fn as_db_value(self) -> i64 { + match self { + Self::Low => 0, + Self::Normal => 1, + Self::High => 2, + Self::Critical => 3, + } + } } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 356131d9..46025b0c 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -16,8 +16,8 @@ use super::{ default_project_label, default_task_group_label, normalize_group_label, ContextGraphCompactionStats, ContextGraphEntity, ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, - DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile, - SessionMessage, SessionMetrics, SessionState, WorktreeInfo, + ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, + SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { @@ -267,6 +267,7 @@ impl StateStore { session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, observation_type TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 1, summary TEXT NOT NULL, details_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL @@ -464,6 +465,15 @@ impl StateStore { .context("Failed to add trigger_summary column to tool_log table")?; } + if !self.has_column("context_graph_observations", "priority")? { + self.conn + .execute( + "ALTER TABLE context_graph_observations ADD COLUMN priority INTEGER NOT NULL DEFAULT 1", + [], + ) + .context("Failed to add priority column to context_graph_observations table")?; + } + if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn .execute( @@ -2088,6 +2098,12 @@ impl StateStore { FROM context_graph_observations o WHERE o.entity_id = e.id ) AS observation_count + , + COALESCE(( + SELECT MAX(priority) + FROM context_graph_observations o + WHERE o.entity_id = e.id + ), 1) AS max_observation_priority FROM context_graph_entities e WHERE (?1 IS NULL OR e.session_id = ?1) ORDER BY e.updated_at DESC, e.id DESC @@ -2102,7 +2118,15 @@ impl StateStore { let relation_count = row.get::<_, i64>(9)?.max(0) as usize; let observation_text = row.get::<_, String>(10)?; let observation_count = row.get::<_, i64>(11)?.max(0) as usize; - Ok((entity, relation_count, observation_text, observation_count)) + let max_observation_priority = + ContextObservationPriority::from_db_value(row.get::<_, i64>(12)?); + Ok(( + entity, + relation_count, + observation_text, + observation_count, + max_observation_priority, + )) }, )? .collect::, _>>()?; @@ -2111,7 +2135,13 @@ impl StateStore { let mut entries = candidates .into_iter() .filter_map( - |(entity, relation_count, observation_text, observation_count)| { + |( + entity, + relation_count, + observation_text, + observation_count, + max_observation_priority, + )| { let matched_terms = context_graph_matched_terms(&entity, &observation_text, &terms); if matched_terms.is_empty() { @@ -2123,6 +2153,7 @@ impl StateStore { matched_terms.len(), relation_count, observation_count, + max_observation_priority, entity.updated_at, now, ), @@ -2130,6 +2161,7 @@ impl StateStore { matched_terms, relation_count, observation_count, + max_observation_priority, }) }, ) @@ -2217,6 +2249,7 @@ impl StateStore { session_id: Option<&str>, entity_id: i64, observation_type: &str, + priority: ContextObservationPriority, summary: &str, details: &BTreeMap, ) -> Result { @@ -2235,12 +2268,13 @@ impl StateStore { let details_json = serde_json::to_string(details)?; self.conn.execute( "INSERT INTO context_graph_observations ( - session_id, entity_id, observation_type, summary, details_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + session_id, entity_id, observation_type, priority, summary, details_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ session_id, entity_id, observation_type.trim(), + priority.as_db_value(), summary.trim(), details_json, now, @@ -2255,7 +2289,7 @@ impl StateStore { self.conn .query_row( "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, - o.observation_type, o.summary, o.details_json, o.created_at + o.observation_type, o.priority, o.summary, o.details_json, o.created_at FROM context_graph_observations o JOIN context_graph_entities e ON e.id = o.entity_id WHERE o.id = ?1", @@ -2277,6 +2311,7 @@ impl StateStore { &self, session_id: &str, observation_type: &str, + priority: ContextObservationPriority, summary: &str, details: &BTreeMap, ) -> Result { @@ -2285,6 +2320,7 @@ impl StateStore { Some(session_id), session_entity.id, observation_type, + priority, summary, details, ) @@ -2297,7 +2333,7 @@ impl StateStore { ) -> Result> { let mut stmt = self.conn.prepare( "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, - o.observation_type, o.summary, o.details_json, o.created_at + o.observation_type, o.priority, o.summary, o.details_json, o.created_at FROM context_graph_observations o JOIN context_graph_entities e ON e.id = o.entity_id WHERE (?1 IS NULL OR o.entity_id = ?1) @@ -3428,12 +3464,12 @@ fn map_context_graph_observation( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { let details_json = row - .get::<_, Option>(7)? + .get::<_, Option>(8)? .unwrap_or_else(|| "{}".to_string()); let details = serde_json::from_str(&details_json).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error)) + rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(error)) })?; - let created_at = parse_store_timestamp(row.get::<_, String>(8)?, 8)?; + let created_at = parse_store_timestamp(row.get::<_, String>(9)?, 9)?; Ok(ContextGraphObservation { id: row.get(0)?, @@ -3442,7 +3478,8 @@ fn map_context_graph_observation( entity_type: row.get(3)?, entity_name: row.get(4)?, observation_type: row.get(5)?, - summary: row.get(6)?, + priority: ContextObservationPriority::from_db_value(row.get::<_, i64>(6)?), + summary: row.get(7)?, details, created_at, }) @@ -3496,6 +3533,7 @@ fn context_graph_recall_score( matched_term_count: usize, relation_count: usize, observation_count: usize, + max_observation_priority: ContextObservationPriority, updated_at: chrono::DateTime, now: chrono::DateTime, ) -> u64 { @@ -3515,6 +3553,7 @@ fn context_graph_recall_score( (matched_term_count as u64 * 100) + (relation_count.min(9) as u64 * 10) + (observation_count.min(6) as u64 * 8) + + (max_observation_priority.as_db_value() as u64 * 18) + recency_bonus } @@ -4336,6 +4375,7 @@ mod tests { Some("session-1"), entity.id, "note", + ContextObservationPriority::Normal, "Customer wiped setup and got charged twice", &BTreeMap::from([("customer".to_string(), "viktor".to_string())]), )?; @@ -4345,6 +4385,7 @@ mod tests { assert_eq!(observations[0].id, observation.id); assert_eq!(observations[0].entity_name, "Prefer recovery-first routing"); assert_eq!(observations[0].observation_type, "note"); + assert_eq!(observations[0].priority, ContextObservationPriority::Normal); assert_eq!( observations[0].details.get("customer"), Some(&"viktor".to_string()) @@ -4393,12 +4434,13 @@ mod tests { ] { db.conn.execute( "INSERT INTO context_graph_observations ( - session_id, entity_id, observation_type, summary, details_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + session_id, entity_id, observation_type, priority, summary, details_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", rusqlite::params![ "session-1", entity.id, "note", + ContextObservationPriority::Normal.as_db_value(), summary, "{}", chrono::Utc::now().to_rfc3339(), @@ -4460,6 +4502,7 @@ mod tests { Some("session-1"), entity.id, "completion_summary", + ContextObservationPriority::Normal, &summary, &BTreeMap::new(), )?; @@ -4542,6 +4585,7 @@ mod tests { Some("session-1"), recovery.id, "incident_note", + ContextObservationPriority::High, "Previous auth callback recovery incident affected Viktor after a wipe", &BTreeMap::new(), )?; @@ -4550,19 +4594,32 @@ mod tests { db.recall_context_entities(Some("session-1"), "Investigate auth callback recovery", 3)?; assert_eq!(results.len(), 2); - assert_eq!(results[0].entity.id, callback.id); + assert_eq!(results[0].entity.id, recovery.id); assert!(results[0].matched_terms.iter().any(|term| term == "auth")); assert!(results[0] + .matched_terms + .iter() + .any(|term| term == "recovery")); + assert_eq!(results[0].observation_count, 1); + assert_eq!( + results[0].max_observation_priority, + ContextObservationPriority::High + ); + assert_eq!(results[1].entity.id, callback.id); + assert!(results[1] .matched_terms .iter() .any(|term| term == "callback")); - assert!(results[0] + assert!(results[1] .matched_terms .iter() .any(|term| term == "recovery")); - assert_eq!(results[0].relation_count, 2); - assert_eq!(results[1].entity.id, recovery.id); - assert_eq!(results[1].observation_count, 1); + assert_eq!(results[1].relation_count, 2); + assert_eq!(results[1].observation_count, 0); + assert_eq!( + results[1].max_observation_priority, + ContextObservationPriority::Normal + ); assert!(!results.iter().any(|entry| entry.entity.id == unrelated.id)); Ok(()) diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c0a013fa..a9e7464b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -23,7 +23,8 @@ use crate::session::output::{ }; use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; use crate::session::{ - DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, SessionMessage, SessionState, + ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, + SessionMessage, SessionState, }; use crate::worktree; @@ -4251,9 +4252,15 @@ impl Dashboard { summary.warnings.len() ); let details = completion_summary_observation_details(summary, session); + let priority = if observation_type == "failure_summary" { + ContextObservationPriority::High + } else { + ContextObservationPriority::Normal + }; if let Err(error) = self.db.add_session_observation( &session.id, observation_type, + priority, &observation_summary, &details, ) { @@ -5358,13 +5365,14 @@ impl Dashboard { let mut lines = vec!["Relevant memory".to_string()]; for entry in entries { let mut line = format!( - "- #{} [{}] {} | score {} | relations {} | observations {}", + "- #{} [{}] {} | score {} | relations {} | observations {} | priority {}", entry.entity.id, entry.entity.entity_type, truncate_for_dashboard(&entry.entity.name, 60), entry.score, entry.relation_count, - entry.observation_count + entry.observation_count, + entry.max_observation_priority ); if let Some(session_id) = entry.entity.session_id.as_deref() { if session_id != session.id { @@ -5387,7 +5395,8 @@ impl Dashboard { if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) { if let Some(observation) = observations.first() { lines.push(format!( - " memory {}", + " memory [{}] {}", + observation.priority, truncate_for_dashboard(&observation.summary, 72) )); } @@ -10534,6 +10543,7 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ Some(&memory.id), entity.id, "completion_summary", + ContextObservationPriority::Normal, "Recovered auth callback incident with billing fallback", &BTreeMap::new(), )?; @@ -10542,7 +10552,9 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ assert!(text.contains("Relevant memory")); assert!(text.contains("[file] callback.ts")); assert!(text.contains("matches auth, callback, recovery")); - assert!(text.contains("memory Recovered auth callback incident with billing fallback")); + assert!( + text.contains("memory [normal] Recovered auth callback incident with billing fallback") + ); Ok(()) } From 9c294f78157db9069cf83723e6b7b37ff5680df0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 07:06:37 -0700 Subject: [PATCH 141/459] feat: add ecc2 pinned memory observations --- ecc2/src/main.rs | 125 ++++++++++++++++++++++- ecc2/src/session/mod.rs | 2 + ecc2/src/session/store.rs | 206 ++++++++++++++++++++++++++++++++++++-- ecc2/src/tui/dashboard.rs | 15 ++- 4 files changed, 331 insertions(+), 17 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index ce18d5fb..fa145fba 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -473,6 +473,9 @@ enum GraphCommands { /// Observation priority #[arg(long, value_enum, default_value_t = ObservationPriorityArg::Normal)] priority: ObservationPriorityArg, + /// Keep this observation across aggressive compaction + #[arg(long)] + pinned: bool, /// Observation summary #[arg(long)] summary: String, @@ -483,6 +486,24 @@ enum GraphCommands { #[arg(long)] json: bool, }, + /// Pin an existing observation so compaction preserves it + PinObservation { + /// Observation ID + #[arg(long)] + observation_id: i64, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Remove the pin from an existing observation + UnpinObservation { + /// Observation ID + #[arg(long)] + observation_id: i64, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// List observations in the shared context graph Observations { /// Filter to observations for a specific entity ID @@ -1388,6 +1409,7 @@ async fn main() -> Result<()> { entity_id, observation_type, priority, + pinned, summary, details, json, @@ -1402,6 +1424,7 @@ async fn main() -> Result<()> { entity_id, &observation_type, priority.into(), + pinned, &summary, &details, )?; @@ -1411,6 +1434,38 @@ async fn main() -> Result<()> { println!("{}", format_graph_observation_human(&observation)); } } + GraphCommands::PinObservation { + observation_id, + json, + } => { + let Some(observation) = db.set_context_observation_pinned(observation_id, true)? + else { + return Err(anyhow::anyhow!( + "Context graph observation #{observation_id} was not found" + )); + }; + if json { + println!("{}", serde_json::to_string_pretty(&observation)?); + } else { + println!("{}", format_graph_observation_human(&observation)); + } + } + GraphCommands::UnpinObservation { + observation_id, + json, + } => { + let Some(observation) = db.set_context_observation_pinned(observation_id, false)? + else { + return Err(anyhow::anyhow!( + "Context graph observation #{observation_id} was not found" + )); + }; + if json { + println!("{}", serde_json::to_string_pretty(&observation)?); + } else { + println!("{}", format_graph_observation_human(&observation)); + } + } GraphCommands::Observations { entity_id, limit, @@ -2144,6 +2199,7 @@ fn import_memory_connector_record( entity.id, observation_type, session::ContextObservationPriority::Normal, + false, summary, &record.details, )?; @@ -3349,6 +3405,7 @@ fn format_graph_observation_human(observation: &session::ContextGraphObservation ), format!("Type: {}", observation.observation_type), format!("Priority: {}", observation.priority), + format!("Pinned: {}", if observation.pinned { "yes" } else { "no" }), format!("Summary: {}", observation.summary), ]; if let Some(session_id) = observation.session_id.as_deref() { @@ -3380,10 +3437,11 @@ fn format_graph_observations_human(observations: &[session::ContextGraphObservat )]; for observation in observations { let mut line = format!( - "- #{} [{}/{}] {}", + "- #{} [{}/{}{}] {}", observation.id, observation.observation_type, observation.priority, + if observation.pinned { "/pinned" } else { "" }, observation.entity_name ); if let Some(session_id) = observation.session_id.as_deref() { @@ -3424,6 +3482,9 @@ fn format_graph_recall_human( entry.observation_count, entry.max_observation_priority ); + if entry.has_pinned_observation { + line.push_str(" | pinned"); + } if let Some(session_id) = entry.entity.session_id.as_deref() { line.push_str(&format!(" | {}", short_session(session_id))); } @@ -5463,6 +5524,7 @@ mod tests { "7", "--type", "completion_summary", + "--pinned", "--summary", "Finished auth callback recovery", "--detail", @@ -5479,6 +5541,7 @@ mod tests { entity_id, observation_type, priority, + pinned, summary, details, json, @@ -5488,6 +5551,7 @@ mod tests { assert_eq!(entity_id, 7); assert_eq!(observation_type, "completion_summary"); assert!(matches!(priority, ObservationPriorityArg::Normal)); + assert!(pinned); assert_eq!(summary, "Finished auth callback recovery"); assert_eq!(details, vec!["tests_run=2"]); assert!(json); @@ -5496,6 +5560,60 @@ mod tests { } } + #[test] + fn cli_parses_graph_pin_observation_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "pin-observation", + "--observation-id", + "42", + "--json", + ]) + .expect("graph pin-observation should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::PinObservation { + observation_id, + json, + }, + }) => { + assert_eq!(observation_id, 42); + assert!(json); + } + _ => panic!("expected graph pin-observation subcommand"), + } + } + + #[test] + fn cli_parses_graph_unpin_observation_command() { + let cli = Cli::try_parse_from([ + "ecc", + "graph", + "unpin-observation", + "--observation-id", + "42", + "--json", + ]) + .expect("graph unpin-observation should parse"); + + match cli.command { + Some(Commands::Graph { + command: + GraphCommands::UnpinObservation { + observation_id, + json, + }, + }) => { + assert_eq!(observation_id, 42); + assert!(json); + } + _ => panic!("expected graph unpin-observation subcommand"), + } + } + #[test] fn cli_parses_graph_compact_command() { let cli = Cli::try_parse_from([ @@ -5701,6 +5819,7 @@ mod tests { relation_count: 2, observation_count: 1, max_observation_priority: session::ContextObservationPriority::High, + has_pinned_observation: true, }], Some("sess-12345678"), "auth callback recovery", @@ -5709,6 +5828,7 @@ mod tests { assert!(text.contains("Relevant memory: 1 entries")); assert!(text.contains("[file] callback.ts | score 319 | relations 2 | observations 1")); assert!(text.contains("priority high")); + assert!(text.contains("| pinned")); assert!(text.contains("matches auth, callback, recovery")); assert!(text.contains("path src/routes/auth/callback.ts")); } @@ -5723,6 +5843,7 @@ mod tests { entity_name: "sess-12345678".to_string(), observation_type: "completion_summary".to_string(), priority: session::ContextObservationPriority::High, + pinned: true, summary: "Finished auth callback recovery with 2 tests".to_string(), details: BTreeMap::from([("tests_run".to_string(), "2".to_string())]), created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z") @@ -5731,7 +5852,7 @@ mod tests { }]); assert!(text.contains("Context graph observations: 1")); - assert!(text.contains("[completion_summary/high] sess-12345678")); + assert!(text.contains("[completion_summary/high/pinned] sess-12345678")); assert!(text.contains("summary Finished auth callback recovery with 2 tests")); } diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 878d88bc..ddde4cd4 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -199,6 +199,7 @@ pub struct ContextGraphObservation { pub entity_name: String, pub observation_type: String, pub priority: ContextObservationPriority, + pub pinned: bool, pub summary: String, pub details: BTreeMap, pub created_at: DateTime, @@ -212,6 +213,7 @@ pub struct ContextGraphRecallEntry { pub relation_count: usize, pub observation_count: usize, pub max_observation_priority: ContextObservationPriority, + pub has_pinned_observation: bool, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 46025b0c..b8465f62 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -268,6 +268,7 @@ impl StateStore { entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE, observation_type TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 1, + pinned INTEGER NOT NULL DEFAULT 0, summary TEXT NOT NULL, details_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL @@ -473,6 +474,14 @@ impl StateStore { ) .context("Failed to add priority column to context_graph_observations table")?; } + if !self.has_column("context_graph_observations", "pinned")? { + self.conn + .execute( + "ALTER TABLE context_graph_observations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add pinned column to context_graph_observations table")?; + } if !self.has_column("daemon_activity", "last_dispatch_deferred")? { self.conn @@ -2103,7 +2112,12 @@ impl StateStore { SELECT MAX(priority) FROM context_graph_observations o WHERE o.entity_id = e.id - ), 1) AS max_observation_priority + ), 1) AS max_observation_priority, + COALESCE(( + SELECT MAX(pinned) + FROM context_graph_observations o + WHERE o.entity_id = e.id + ), 0) AS has_pinned_observation FROM context_graph_entities e WHERE (?1 IS NULL OR e.session_id = ?1) ORDER BY e.updated_at DESC, e.id DESC @@ -2120,12 +2134,14 @@ impl StateStore { let observation_count = row.get::<_, i64>(11)?.max(0) as usize; let max_observation_priority = ContextObservationPriority::from_db_value(row.get::<_, i64>(12)?); + let has_pinned_observation = row.get::<_, i64>(13)? != 0; Ok(( entity, relation_count, observation_text, observation_count, max_observation_priority, + has_pinned_observation, )) }, )? @@ -2141,6 +2157,7 @@ impl StateStore { observation_text, observation_count, max_observation_priority, + has_pinned_observation, )| { let matched_terms = context_graph_matched_terms(&entity, &observation_text, &terms); @@ -2154,6 +2171,7 @@ impl StateStore { relation_count, observation_count, max_observation_priority, + has_pinned_observation, entity.updated_at, now, ), @@ -2162,6 +2180,7 @@ impl StateStore { relation_count, observation_count, max_observation_priority, + has_pinned_observation, }) }, ) @@ -2250,6 +2269,7 @@ impl StateStore { entity_id: i64, observation_type: &str, priority: ContextObservationPriority, + pinned: bool, summary: &str, details: &BTreeMap, ) -> Result { @@ -2268,13 +2288,14 @@ impl StateStore { let details_json = serde_json::to_string(details)?; self.conn.execute( "INSERT INTO context_graph_observations ( - session_id, entity_id, observation_type, priority, summary, details_json, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + session_id, entity_id, observation_type, priority, pinned, summary, details_json, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![ session_id, entity_id, observation_type.trim(), priority.as_db_value(), + pinned as i64, summary.trim(), details_json, now, @@ -2289,7 +2310,7 @@ impl StateStore { self.conn .query_row( "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, - o.observation_type, o.priority, o.summary, o.details_json, o.created_at + o.observation_type, o.priority, o.pinned, o.summary, o.details_json, o.created_at FROM context_graph_observations o JOIN context_graph_entities e ON e.id = o.entity_id WHERE o.id = ?1", @@ -2299,6 +2320,34 @@ impl StateStore { .map_err(Into::into) } + pub fn set_context_observation_pinned( + &self, + observation_id: i64, + pinned: bool, + ) -> Result> { + let changed = self.conn.execute( + "UPDATE context_graph_observations + SET pinned = ?2 + WHERE id = ?1", + rusqlite::params![observation_id, pinned as i64], + )?; + if changed == 0 { + return Ok(None); + } + self.conn + .query_row( + "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, + o.observation_type, o.priority, o.pinned, o.summary, o.details_json, o.created_at + FROM context_graph_observations o + JOIN context_graph_entities e ON e.id = o.entity_id + WHERE o.id = ?1", + rusqlite::params![observation_id], + map_context_graph_observation, + ) + .optional() + .map_err(Into::into) + } + pub fn compact_context_graph( &self, session_id: Option<&str>, @@ -2312,6 +2361,7 @@ impl StateStore { session_id: &str, observation_type: &str, priority: ContextObservationPriority, + pinned: bool, summary: &str, details: &BTreeMap, ) -> Result { @@ -2321,6 +2371,7 @@ impl StateStore { session_entity.id, observation_type, priority, + pinned, summary, details, ) @@ -2333,11 +2384,11 @@ impl StateStore { ) -> Result> { let mut stmt = self.conn.prepare( "SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name, - o.observation_type, o.priority, o.summary, o.details_json, o.created_at + o.observation_type, o.priority, o.pinned, o.summary, o.details_json, o.created_at FROM context_graph_observations o JOIN context_graph_entities e ON e.id = o.entity_id WHERE (?1 IS NULL OR o.entity_id = ?1) - ORDER BY o.created_at DESC, o.id DESC + ORDER BY o.pinned DESC, o.created_at DESC, o.id DESC LIMIT ?2", )?; @@ -2414,7 +2465,7 @@ impl StateStore { SELECT o.id, ROW_NUMBER() OVER ( PARTITION BY o.entity_id, o.observation_type, o.summary - ORDER BY o.created_at DESC, o.id DESC + ORDER BY o.pinned DESC, o.created_at DESC, o.id DESC ) AS rn FROM context_graph_observations o JOIN context_graph_entities e ON e.id = o.entity_id @@ -2435,6 +2486,7 @@ impl StateStore { JOIN context_graph_entities e ON e.id = o.entity_id WHERE (?1 IS NULL OR e.session_id = ?1) AND (?2 IS NULL OR o.entity_id = ?2) + AND o.pinned = 0 )", rusqlite::params![session_id, entity_id], )? @@ -2453,6 +2505,7 @@ impl StateStore { JOIN context_graph_entities e ON e.id = o.entity_id WHERE (?1 IS NULL OR e.session_id = ?1) AND (?2 IS NULL OR o.entity_id = ?2) + AND o.pinned = 0 ) ranked WHERE ranked.rn > ?3 )", @@ -3464,12 +3517,12 @@ fn map_context_graph_observation( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { let details_json = row - .get::<_, Option>(8)? + .get::<_, Option>(9)? .unwrap_or_else(|| "{}".to_string()); let details = serde_json::from_str(&details_json).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure(8, rusqlite::types::Type::Text, Box::new(error)) + rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(error)) })?; - let created_at = parse_store_timestamp(row.get::<_, String>(9)?, 9)?; + let created_at = parse_store_timestamp(row.get::<_, String>(10)?, 10)?; Ok(ContextGraphObservation { id: row.get(0)?, @@ -3479,7 +3532,8 @@ fn map_context_graph_observation( entity_name: row.get(4)?, observation_type: row.get(5)?, priority: ContextObservationPriority::from_db_value(row.get::<_, i64>(6)?), - summary: row.get(7)?, + pinned: row.get::<_, i64>(7)? != 0, + summary: row.get(8)?, details, created_at, }) @@ -3534,6 +3588,7 @@ fn context_graph_recall_score( relation_count: usize, observation_count: usize, max_observation_priority: ContextObservationPriority, + has_pinned_observation: bool, updated_at: chrono::DateTime, now: chrono::DateTime, ) -> u64 { @@ -3554,6 +3609,7 @@ fn context_graph_recall_score( + (relation_count.min(9) as u64 * 10) + (observation_count.min(6) as u64 * 8) + (max_observation_priority.as_db_value() as u64 * 18) + + if has_pinned_observation { 48 } else { 0 } + recency_bonus } @@ -4376,6 +4432,7 @@ mod tests { entity.id, "note", ContextObservationPriority::Normal, + false, "Customer wiped setup and got charged twice", &BTreeMap::from([("customer".to_string(), "viktor".to_string())]), )?; @@ -4386,6 +4443,7 @@ mod tests { assert_eq!(observations[0].entity_name, "Prefer recovery-first routing"); assert_eq!(observations[0].observation_type, "note"); assert_eq!(observations[0].priority, ContextObservationPriority::Normal); + assert!(!observations[0].pinned); assert_eq!( observations[0].details.get("customer"), Some(&"viktor".to_string()) @@ -4503,6 +4561,7 @@ mod tests { entity.id, "completion_summary", ContextObservationPriority::Normal, + false, &summary, &BTreeMap::new(), )?; @@ -4586,6 +4645,7 @@ mod tests { recovery.id, "incident_note", ContextObservationPriority::High, + true, "Previous auth callback recovery incident affected Viktor after a wipe", &BTreeMap::new(), )?; @@ -4605,6 +4665,7 @@ mod tests { results[0].max_observation_priority, ContextObservationPriority::High ); + assert!(results[0].has_pinned_observation); assert_eq!(results[1].entity.id, callback.id); assert!(results[1] .matched_terms @@ -4620,11 +4681,134 @@ mod tests { results[1].max_observation_priority, ContextObservationPriority::Normal ); + assert!(!results[1].has_pinned_observation); assert!(!results.iter().any(|entry| entry.entity.id == unrelated.id)); Ok(()) } + #[test] + fn compact_context_graph_preserves_pinned_observations() -> Result<()> { + let tempdir = TestDir::new("store-context-pinned-observations")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "deep memory".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let entity = db.upsert_context_entity( + Some("session-1"), + "incident", + "billing-recovery", + None, + "Recovery notes", + &BTreeMap::new(), + )?; + + db.add_context_observation( + Some("session-1"), + entity.id, + "incident_note", + ContextObservationPriority::High, + true, + "Pinned billing recovery memory", + &BTreeMap::new(), + )?; + std::thread::sleep(std::time::Duration::from_millis(2)); + db.add_context_observation( + Some("session-1"), + entity.id, + "incident_note", + ContextObservationPriority::Normal, + false, + "Newest unpinned memory", + &BTreeMap::new(), + )?; + + let stats = db.compact_context_graph(None, 1)?; + assert_eq!(stats.observations_retained, 2); + + let observations = db.list_context_observations(Some(entity.id), 10)?; + assert_eq!(observations.len(), 2); + assert!(observations.iter().any(|entry| entry.pinned)); + assert!(observations + .iter() + .any(|entry| entry.summary == "Pinned billing recovery memory")); + assert!(observations + .iter() + .any(|entry| entry.summary == "Newest unpinned memory")); + + Ok(()) + } + + #[test] + fn set_context_observation_pinned_updates_existing_observation() -> Result<()> { + let tempdir = TestDir::new("store-context-pin-toggle")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "deep memory".to_string(), + project: "workspace".to_string(), + task_group: "knowledge".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let entity = db.upsert_context_entity( + Some("session-1"), + "incident", + "billing-recovery", + None, + "Recovery notes", + &BTreeMap::new(), + )?; + + let observation = db.add_context_observation( + Some("session-1"), + entity.id, + "incident_note", + ContextObservationPriority::Normal, + false, + "Temporarily useful note", + &BTreeMap::new(), + )?; + assert!(!observation.pinned); + + let pinned = db + .set_context_observation_pinned(observation.id, true)? + .expect("observation should exist"); + assert!(pinned.pinned); + + let unpinned = db + .set_context_observation_pinned(observation.id, false)? + .expect("observation should still exist"); + assert!(!unpinned.pinned); + + Ok(()) + } + #[test] fn context_graph_detail_includes_incoming_and_outgoing_relations() -> Result<()> { let tempdir = TestDir::new("store-context-relations")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a9e7464b..c2e3712b 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -4261,6 +4261,7 @@ impl Dashboard { &session.id, observation_type, priority, + false, &observation_summary, &details, ) { @@ -5374,6 +5375,9 @@ impl Dashboard { entry.observation_count, entry.max_observation_priority ); + if entry.has_pinned_observation { + line.push_str(" | pinned"); + } if let Some(session_id) = entry.entity.session_id.as_deref() { if session_id != session.id { line.push_str(&format!(" | {}", format_session_id(session_id))); @@ -5395,8 +5399,9 @@ impl Dashboard { if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) { if let Some(observation) = observations.first() { lines.push(format!( - " memory [{}] {}", + " memory [{}{}] {}", observation.priority, + if observation.pinned { "/pinned" } else { "" }, truncate_for_dashboard(&observation.summary, 72) )); } @@ -10544,6 +10549,7 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ entity.id, "completion_summary", ContextObservationPriority::Normal, + true, "Recovered auth callback incident with billing fallback", &BTreeMap::new(), )?; @@ -10551,10 +10557,11 @@ diff --git a/src/lib.rs b/src/lib.rs\n\ let text = dashboard.selected_session_metrics_text(); assert!(text.contains("Relevant memory")); assert!(text.contains("[file] callback.ts")); + assert!(text.contains("| pinned")); assert!(text.contains("matches auth, callback, recovery")); - assert!( - text.contains("memory [normal] Recovered auth callback incident with billing fallback") - ); + assert!(text.contains( + "memory [normal/pinned] Recovered auth callback incident with billing fallback" + )); Ok(()) } From 9c525009d75af9e66286d86e8261985e6c8d52cb Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 07:16:41 -0700 Subject: [PATCH 142/459] feat: add ecc2 memory connector status reporting --- ecc2/src/main.rs | 316 ++++++++++++++++++++++++++++++++++++++ ecc2/src/session/store.rs | 58 +++++++ 2 files changed, 374 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index fa145fba..273ed1b1 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -543,6 +543,12 @@ enum GraphCommands { #[arg(long)] json: bool, }, + /// Show configured memory connectors plus checkpoint status + Connectors { + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Recall relevant context graph entities for a query Recall { /// Filter by source session ID or alias @@ -633,6 +639,25 @@ struct GraphConnectorSyncReport { connectors: Vec, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct GraphConnectorStatus { + connector_name: String, + connector_kind: String, + source_path: String, + recurse: bool, + default_session_id: Option, + default_entity_type: Option, + default_observation_type: Option, + synced_sources: usize, + last_synced_at: Option>, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct GraphConnectorStatusReport { + configured_connectors: usize, + connectors: Vec, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct JsonlMemoryConnectorRecord { @@ -1529,6 +1554,14 @@ async fn main() -> Result<()> { } } } + GraphCommands::Connectors { json } => { + let report = memory_connector_status_report(&db, &cfg)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_graph_connector_status_report_human(&report)); + } + } GraphCommands::Recall { session_id, query, @@ -1760,6 +1793,95 @@ fn sync_all_memory_connectors( Ok(report) } +fn memory_connector_status_report( + db: &session::store::StateStore, + cfg: &config::Config, +) -> Result { + let mut report = GraphConnectorStatusReport { + configured_connectors: cfg.memory_connectors.len(), + connectors: Vec::with_capacity(cfg.memory_connectors.len()), + }; + + for (name, connector) in &cfg.memory_connectors { + let checkpoint = db.connector_checkpoint_summary(name)?; + let ( + connector_kind, + source_path, + recurse, + default_session_id, + default_entity_type, + default_observation_type, + ) = describe_memory_connector(connector); + report.connectors.push(GraphConnectorStatus { + connector_name: name.to_string(), + connector_kind, + source_path, + recurse, + default_session_id, + default_entity_type, + default_observation_type, + synced_sources: checkpoint.synced_sources, + last_synced_at: checkpoint.last_synced_at, + }); + } + + Ok(report) +} + +fn describe_memory_connector( + connector: &config::MemoryConnectorConfig, +) -> ( + String, + String, + bool, + Option, + Option, + Option, +) { + match connector { + config::MemoryConnectorConfig::JsonlFile(settings) => ( + "jsonl_file".to_string(), + settings.path.display().to_string(), + false, + settings.session_id.clone(), + settings.default_entity_type.clone(), + settings.default_observation_type.clone(), + ), + config::MemoryConnectorConfig::JsonlDirectory(settings) => ( + "jsonl_directory".to_string(), + settings.path.display().to_string(), + settings.recurse, + settings.session_id.clone(), + settings.default_entity_type.clone(), + settings.default_observation_type.clone(), + ), + config::MemoryConnectorConfig::MarkdownFile(settings) => ( + "markdown_file".to_string(), + settings.path.display().to_string(), + false, + settings.session_id.clone(), + settings.default_entity_type.clone(), + settings.default_observation_type.clone(), + ), + config::MemoryConnectorConfig::MarkdownDirectory(settings) => ( + "markdown_directory".to_string(), + settings.path.display().to_string(), + settings.recurse, + settings.session_id.clone(), + settings.default_entity_type.clone(), + settings.default_observation_type.clone(), + ), + config::MemoryConnectorConfig::DotenvFile(settings) => ( + "dotenv_file".to_string(), + settings.path.display().to_string(), + false, + settings.session_id.clone(), + settings.default_entity_type.clone(), + settings.default_observation_type.clone(), + ), + } +} + fn sync_jsonl_memory_connector( db: &session::store::StateStore, name: &str, @@ -3578,6 +3700,48 @@ fn format_graph_connector_sync_report_human(report: &GraphConnectorSyncReport) - lines.join("\n") } +fn format_graph_connector_status_report_human(report: &GraphConnectorStatusReport) -> String { + let mut lines = vec![format!( + "Memory connectors: {} configured", + report.configured_connectors + )]; + + if report.connectors.is_empty() { + lines.push("- none".to_string()); + return lines.join("\n"); + } + + for connector in &report.connectors { + lines.push(format!( + "- {} [{}]", + connector.connector_name, connector.connector_kind + )); + lines.push(format!(" source {}", connector.source_path)); + if connector.recurse { + lines.push(" recurse true".to_string()); + } + lines.push(format!(" synced sources {}", connector.synced_sources)); + lines.push(format!( + " last synced {}", + connector + .last_synced_at + .map(|value| value.to_rfc3339()) + .unwrap_or_else(|| "never".to_string()) + )); + if let Some(session_id) = &connector.default_session_id { + lines.push(format!(" default session {}", session_id)); + } + if let Some(entity_type) = &connector.default_entity_type { + lines.push(format!(" default entity type {}", entity_type)); + } + if let Some(observation_type) = &connector.default_observation_type { + lines.push(format!(" default observation type {}", observation_type)); + } + } + + lines.join("\n") +} + fn format_graph_entity_detail_human(detail: &session::ContextGraphEntityDetail) -> String { let mut lines = vec![format_graph_entity_human(&detail.entity)]; lines.push(String::new()); @@ -5709,6 +5873,21 @@ mod tests { } } + #[test] + fn cli_parses_graph_connectors_command() { + let cli = Cli::try_parse_from(["ecc", "graph", "connectors", "--json"]) + .expect("graph connectors should parse"); + + match cli.command { + Some(Commands::Graph { + command: GraphCommands::Connectors { json }, + }) => { + assert!(json); + } + _ => panic!("expected graph connectors subcommand"), + } + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( @@ -5934,6 +6113,143 @@ mod tests { assert!(text.contains(" skipped unchanged sources 2")); } + #[test] + fn format_graph_connector_status_report_human_renders_connector_details() { + let text = format_graph_connector_status_report_human(&GraphConnectorStatusReport { + configured_connectors: 2, + connectors: vec![ + GraphConnectorStatus { + connector_name: "hermes_notes".to_string(), + connector_kind: "jsonl_directory".to_string(), + source_path: "/tmp/hermes-notes".to_string(), + recurse: true, + default_session_id: Some("latest".to_string()), + default_entity_type: Some("incident".to_string()), + default_observation_type: Some("external_note".to_string()), + synced_sources: 3, + last_synced_at: Some( + chrono::DateTime::parse_from_rfc3339("2026-04-10T12:34:56Z") + .unwrap() + .with_timezone(&chrono::Utc), + ), + }, + GraphConnectorStatus { + connector_name: "workspace_env".to_string(), + connector_kind: "dotenv_file".to_string(), + source_path: "/tmp/.env".to_string(), + recurse: false, + default_session_id: None, + default_entity_type: None, + default_observation_type: None, + synced_sources: 0, + last_synced_at: None, + }, + ], + }); + + assert!(text.contains("Memory connectors: 2 configured")); + assert!(text.contains("- hermes_notes [jsonl_directory]")); + assert!(text.contains(" source /tmp/hermes-notes")); + assert!(text.contains(" recurse true")); + assert!(text.contains(" synced sources 3")); + assert!(text.contains(" last synced 2026-04-10T12:34:56+00:00")); + assert!(text.contains(" default session latest")); + assert!(text.contains(" default entity type incident")); + assert!(text.contains(" default observation type external_note")); + assert!(text.contains("- workspace_env [dotenv_file]")); + assert!(text.contains(" last synced never")); + } + + #[test] + fn memory_connector_status_report_includes_checkpoint_state() -> Result<()> { + let tempdir = TestDir::new("graph-connector-status-report")?; + let db = session::store::StateStore::open(&tempdir.path().join("state.db"))?; + + let markdown_path = tempdir.path().join("workspace-memory.md"); + fs::write( + &markdown_path, + r#"# Billing incident +Customer wiped setup and got charged twice after reinstalling. +"#, + )?; + + let mut cfg = config::Config::default(); + cfg.memory_connectors.insert( + "workspace_note".to_string(), + config::MemoryConnectorConfig::MarkdownFile( + config::MemoryConnectorMarkdownFileConfig { + path: markdown_path.clone(), + session_id: Some("latest".to_string()), + default_entity_type: Some("note_section".to_string()), + default_observation_type: Some("external_note".to_string()), + }, + ), + ); + cfg.memory_connectors.insert( + "workspace_env".to_string(), + config::MemoryConnectorConfig::DotenvFile(config::MemoryConnectorDotenvFileConfig { + path: tempdir.path().join(".env"), + session_id: None, + default_entity_type: Some("service_config".to_string()), + default_observation_type: Some("external_config".to_string()), + key_prefixes: vec!["PUBLIC_".to_string()], + include_keys: Vec::new(), + exclude_keys: Vec::new(), + include_safe_values: true, + }), + ); + + db.upsert_connector_source_checkpoint( + "workspace_note", + &markdown_path.display().to_string(), + "sig-a", + )?; + + let report = memory_connector_status_report(&db, &cfg)?; + assert_eq!(report.configured_connectors, 2); + assert_eq!( + report + .connectors + .iter() + .map(|connector| connector.connector_name.as_str()) + .collect::>(), + vec!["workspace_env", "workspace_note"] + ); + + let workspace_env = report + .connectors + .iter() + .find(|connector| connector.connector_name == "workspace_env") + .expect("workspace_env connector should exist"); + assert_eq!(workspace_env.connector_kind, "dotenv_file"); + assert_eq!(workspace_env.synced_sources, 0); + assert!(workspace_env.last_synced_at.is_none()); + + let workspace_note = report + .connectors + .iter() + .find(|connector| connector.connector_name == "workspace_note") + .expect("workspace_note connector should exist"); + assert_eq!(workspace_note.connector_kind, "markdown_file"); + assert_eq!( + workspace_note.source_path, + markdown_path.display().to_string() + ); + assert_eq!(workspace_note.default_session_id.as_deref(), Some("latest")); + assert_eq!( + workspace_note.default_entity_type.as_deref(), + Some("note_section") + ); + assert_eq!( + workspace_note.default_observation_type.as_deref(), + Some("external_note") + ); + assert_eq!(workspace_note.synced_sources, 1); + assert!(workspace_note.last_synced_at.is_some()); + + Ok(()) + } + #[test] fn sync_memory_connector_imports_jsonl_observations() -> Result<()> { let tempdir = TestDir::new("graph-connector-sync")?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index b8465f62..4ec306be 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -43,6 +43,13 @@ pub struct FileActivityOverlap { pub timestamp: chrono::DateTime, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ConnectorCheckpointSummary { + pub connector_name: String, + pub synced_sources: usize, + pub last_synced_at: Option>, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ConflictIncident { pub id: i64, @@ -2441,6 +2448,32 @@ impl StateStore { Ok(()) } + pub fn connector_checkpoint_summary( + &self, + connector_name: &str, + ) -> Result { + self.conn + .query_row( + "SELECT COUNT(*), MAX(updated_at) + FROM context_graph_connector_checkpoints + WHERE connector_name = ?1", + rusqlite::params![connector_name], + |row| { + let synced_sources = row.get::<_, i64>(0)? as usize; + let last_synced_at = row + .get::<_, Option>(1)? + .map(|raw| parse_store_timestamp(raw, 1)) + .transpose()?; + Ok(ConnectorCheckpointSummary { + connector_name: connector_name.to_string(), + synced_sources, + last_synced_at, + }) + }, + ) + .map_err(Into::into) + } + fn compact_context_graph_observations( &self, session_id: Option<&str>, @@ -4809,6 +4842,31 @@ mod tests { Ok(()) } + #[test] + fn connector_checkpoint_summary_reports_synced_sources_and_timestamp() -> Result<()> { + let tempdir = TestDir::new("store-connector-checkpoint-summary")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + + let empty = db.connector_checkpoint_summary("workspace_notes")?; + assert_eq!(empty.connector_name, "workspace_notes"); + assert_eq!(empty.synced_sources, 0); + assert!(empty.last_synced_at.is_none()); + + db.upsert_connector_source_checkpoint( + "workspace_notes", + "/tmp/notes/incident.md", + "sig-a", + )?; + db.upsert_connector_source_checkpoint("workspace_notes", "/tmp/notes/docs.md", "sig-b")?; + + let summary = db.connector_checkpoint_summary("workspace_notes")?; + assert_eq!(summary.connector_name, "workspace_notes"); + assert_eq!(summary.synced_sources, 2); + assert!(summary.last_synced_at.is_some()); + + Ok(()) + } + #[test] fn context_graph_detail_includes_incoming_and_outgoing_relations() -> Result<()> { let tempdir = TestDir::new("store-context-relations")?; From 29ff44e23eea2752a9139dbc8e61417c961cffa3 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 07:46:46 -0700 Subject: [PATCH 143/459] feat: add ecc2 harness metadata detection --- ecc2/src/main.rs | 7 +- ecc2/src/session/manager.rs | 10 +- ecc2/src/session/mod.rs | 191 ++++++++++++++++++++++++++++++++++++ ecc2/src/session/store.rs | 186 ++++++++++++++++++++++++++++++++++- ecc2/src/tui/dashboard.rs | 64 +++++++++++- 5 files changed, 451 insertions(+), 7 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 273ed1b1..b5193bd3 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1150,8 +1150,13 @@ async fn main() -> Result<()> { Some(Commands::Sessions) => { sync_runtime_session_metrics(&db, &cfg)?; let sessions = session::manager::list_sessions(&db)?; + let harnesses = db.list_session_harnesses().unwrap_or_default(); for s in sessions { - println!("{} [{}] {}", s.id, s.state, s.task); + let harness = harnesses + .get(&s.id) + .map(|info| info.primary.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + println!("{} [{}] [{}] {}", s.id, s.state, harness, s.task); } } Some(Commands::Status { session_id }) => { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 093d7bf1..2c86e633 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -11,7 +11,7 @@ use super::runtime::capture_command_output; use super::store::StateStore; use super::{ default_project_label, default_task_group_label, normalize_group_label, Session, - SessionAgentProfile, SessionGrouping, SessionMetrics, SessionState, + SessionAgentProfile, SessionGrouping, SessionHarnessInfo, SessionMetrics, SessionState, }; use crate::comms::{self, MessageType}; use crate::config::Config; @@ -116,6 +116,11 @@ pub fn get_status(db: &StateStore, id: &str) -> Result { let session = resolve_session(db, id)?; let session_id = session.id.clone(); Ok(SessionStatus { + harness: db + .get_session_harness_info(&session_id)? + .unwrap_or_else(|| { + SessionHarnessInfo::detect(&session.agent_type, &session.working_dir) + }), profile: db.get_session_profile(&session_id)?, session, parent_session: db.latest_task_handoff_source(&session_id)?, @@ -2670,6 +2675,7 @@ async fn kill_process(pid: u32) -> Result<()> { } pub struct SessionStatus { + harness: SessionHarnessInfo, profile: Option, session: Session, parent_session: Option, @@ -2962,6 +2968,8 @@ impl fmt::Display for SessionStatus { writeln!(f, "Session: {}", s.id)?; writeln!(f, "Task: {}", s.task)?; writeln!(f, "Agent: {}", s.agent_type)?; + writeln!(f, "Harness: {}", self.harness.primary)?; + writeln!(f, "Detected: {}", self.harness.detected_summary())?; writeln!(f, "State: {}", s.state)?; if let Some(profile) = self.profile.as_ref() { writeln!(f, "Profile: {}", profile.profile_name)?; diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index ddde4cd4..0a1aa292 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -13,6 +13,139 @@ use std::path::PathBuf; pub type SessionAgentProfile = crate::config::ResolvedAgentProfile; +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum HarnessKind { + #[default] + Unknown, + Claude, + Codex, + OpenCode, + Gemini, + Cursor, + Kiro, + Trae, + Zed, + FactoryDroid, + Windsurf, +} + +impl HarnessKind { + pub fn from_agent_type(agent_type: &str) -> Self { + match agent_type.trim().to_ascii_lowercase().as_str() { + "claude" | "claude-code" => Self::Claude, + "codex" => Self::Codex, + "opencode" => Self::OpenCode, + "gemini" | "gemini-cli" => Self::Gemini, + "cursor" => Self::Cursor, + "kiro" => Self::Kiro, + "trae" => Self::Trae, + "zed" => Self::Zed, + "factory-droid" | "factory_droid" | "factorydroid" => Self::FactoryDroid, + "windsurf" => Self::Windsurf, + _ => Self::Unknown, + } + } + + pub fn from_db_value(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "claude" => Self::Claude, + "codex" => Self::Codex, + "opencode" => Self::OpenCode, + "gemini" => Self::Gemini, + "cursor" => Self::Cursor, + "kiro" => Self::Kiro, + "trae" => Self::Trae, + "zed" => Self::Zed, + "factory_droid" => Self::FactoryDroid, + "windsurf" => Self::Windsurf, + _ => Self::Unknown, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Claude => "claude", + Self::Codex => "codex", + Self::OpenCode => "opencode", + Self::Gemini => "gemini", + Self::Cursor => "cursor", + Self::Kiro => "kiro", + Self::Trae => "trae", + Self::Zed => "zed", + Self::FactoryDroid => "factory_droid", + Self::Windsurf => "windsurf", + } + } + + fn project_markers(self) -> &'static [&'static str] { + match self { + Self::Claude => &[".claude"], + Self::Codex => &[".codex", ".codex-plugin"], + Self::OpenCode => &[".opencode"], + Self::Gemini => &[".gemini"], + Self::Cursor => &[".cursor"], + Self::Kiro => &[".kiro"], + Self::Trae => &[".trae"], + Self::Unknown | Self::Zed | Self::FactoryDroid | Self::Windsurf => &[], + } + } +} + +impl fmt::Display for HarnessKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionHarnessInfo { + pub primary: HarnessKind, + pub detected: Vec, +} + +impl SessionHarnessInfo { + pub fn detect(agent_type: &str, working_dir: &Path) -> Self { + let detected = [ + HarnessKind::Claude, + HarnessKind::Codex, + HarnessKind::OpenCode, + HarnessKind::Gemini, + HarnessKind::Cursor, + HarnessKind::Kiro, + HarnessKind::Trae, + ] + .into_iter() + .filter(|harness| { + harness + .project_markers() + .iter() + .any(|marker| working_dir.join(marker).exists()) + }) + .collect::>(); + + let primary = match HarnessKind::from_agent_type(agent_type) { + HarnessKind::Unknown => detected.first().copied().unwrap_or(HarnessKind::Unknown), + harness => harness, + }; + + Self { primary, detected } + } + + pub fn detected_summary(&self) -> String { + if self.detected.is_empty() { + "none detected".to_string() + } else { + self.detected + .iter() + .map(|harness| harness.to_string()) + .collect::>() + .join(", ") + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub id: String, @@ -315,3 +448,61 @@ pub struct SessionGrouping { pub project: Option, pub task_group: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new(label: &str) -> Result> { + let path = + std::env::temp_dir().join(format!("ecc2-{}-{}", label, uuid::Uuid::new_v4())); + fs::create_dir_all(&path)?; + Ok(Self { path }) + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + #[test] + fn detect_session_harness_prefers_agent_type_and_collects_project_markers( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-detect")?; + fs::create_dir_all(repo.path().join(".codex"))?; + fs::create_dir_all(repo.path().join(".claude"))?; + + let harness = SessionHarnessInfo::detect("claude", repo.path()); + assert_eq!(harness.primary, HarnessKind::Claude); + assert_eq!( + harness.detected, + vec![HarnessKind::Claude, HarnessKind::Codex] + ); + assert_eq!(harness.detected_summary(), "claude, codex"); + Ok(()) + } + + #[test] + fn detect_session_harness_falls_back_to_project_markers_for_unknown_agent( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-markers")?; + fs::create_dir_all(repo.path().join(".gemini"))?; + + let harness = SessionHarnessInfo::detect("custom-runner", repo.path()); + assert_eq!(harness.primary, HarnessKind::Gemini); + assert_eq!(harness.detected, vec![HarnessKind::Gemini]); + Ok(()) + } +} diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 4ec306be..5109bef8 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -16,8 +16,9 @@ use super::{ default_project_label, default_task_group_label, normalize_group_label, ContextGraphCompactionStats, ContextGraphEntity, ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, - ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, - SessionAgentProfile, SessionMessage, SessionMetrics, SessionState, WorktreeInfo, + ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry, + HarnessKind, Session, SessionAgentProfile, SessionHarnessInfo, SessionMessage, SessionMetrics, + SessionState, WorktreeInfo, }; pub struct StateStore { @@ -171,6 +172,8 @@ impl StateStore { project TEXT NOT NULL DEFAULT '', task_group TEXT NOT NULL DEFAULT '', agent_type TEXT NOT NULL, + harness TEXT NOT NULL DEFAULT 'unknown', + detected_harnesses_json TEXT NOT NULL DEFAULT '[]', working_dir TEXT NOT NULL DEFAULT '.', state TEXT NOT NULL DEFAULT 'pending', pid INTEGER, @@ -399,6 +402,24 @@ impl StateStore { .context("Failed to add task_group column to sessions table")?; } + if !self.has_column("sessions", "harness")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN harness TEXT NOT NULL DEFAULT 'unknown'", + [], + ) + .context("Failed to add harness column to sessions table")?; + } + + if !self.has_column("sessions", "detected_harnesses_json")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN detected_harnesses_json TEXT NOT NULL DEFAULT '[]'", + [], + ) + .context("Failed to add detected_harnesses_json column to sessions table")?; + } + if !self.has_column("sessions", "input_tokens")? { self.conn .execute( @@ -624,6 +645,8 @@ impl StateStore { WHERE hook_event_id IS NOT NULL;", )?; + self.backfill_session_harnesses()?; + Ok(()) } @@ -637,16 +660,51 @@ impl StateStore { Ok(columns.iter().any(|existing| existing == column)) } + fn backfill_session_harnesses(&self) -> Result<()> { + let mut stmt = self + .conn + .prepare("SELECT id, agent_type, working_dir FROM sessions")?; + let updates = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })? + .collect::, _>>()?; + + for (session_id, agent_type, working_dir) in updates { + let harness = SessionHarnessInfo::detect(&agent_type, Path::new(&working_dir)); + let detected_json = + serde_json::to_string(&harness.detected).context("serialize detected harnesses")?; + self.conn.execute( + "UPDATE sessions + SET harness = ?2, + detected_harnesses_json = ?3 + WHERE id = ?1", + rusqlite::params![session_id, harness.primary.to_string(), detected_json], + )?; + } + + Ok(()) + } + pub fn insert_session(&self, session: &Session) -> Result<()> { + let harness = SessionHarnessInfo::detect(&session.agent_type, &session.working_dir); + let detected_json = + serde_json::to_string(&harness.detected).context("serialize detected harnesses")?; self.conn.execute( - "INSERT INTO sessions (id, task, project, task_group, agent_type, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, created_at, updated_at, last_heartbeat_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + "INSERT INTO sessions (id, task, project, task_group, agent_type, harness, detected_harnesses_json, working_dir, state, pid, worktree_path, worktree_branch, worktree_base, created_at, updated_at, last_heartbeat_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", rusqlite::params![ session.id, session.task, session.project, session.task_group, session.agent_type, + harness.primary.to_string(), + detected_json, session.working_dir.to_string_lossy().to_string(), session.state.to_string(), session.pid.map(i64::from), @@ -1553,6 +1611,55 @@ impl StateStore { Ok(sessions) } + pub fn list_session_harnesses(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, harness, detected_harnesses_json, agent_type, working_dir FROM sessions", + )?; + + let harnesses = stmt + .query_map([], |row| { + let session_id: String = row.get(0)?; + let primary = HarnessKind::from_db_value(&row.get::<_, String>(1)?); + let detected = serde_json::from_str::>(&row.get::<_, String>(2)?) + .unwrap_or_default(); + let agent_type: String = row.get(3)?; + let working_dir = PathBuf::from(row.get::<_, String>(4)?); + let info = if primary == HarnessKind::Unknown && detected.is_empty() { + SessionHarnessInfo::detect(&agent_type, &working_dir) + } else { + SessionHarnessInfo { primary, detected } + }; + Ok((session_id, info)) + })? + .collect::, _>>()?; + + Ok(harnesses) + } + + pub fn get_session_harness_info(&self, session_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT harness, detected_harnesses_json, agent_type, working_dir + FROM sessions + WHERE id = ?1", + )?; + + stmt.query_row([session_id], |row| { + let primary = HarnessKind::from_db_value(&row.get::<_, String>(0)?); + let detected = serde_json::from_str::>(&row.get::<_, String>(1)?) + .unwrap_or_default(); + let agent_type: String = row.get(2)?; + let working_dir = PathBuf::from(row.get::<_, String>(3)?); + let info = if primary == HarnessKind::Unknown && detected.is_empty() { + SessionHarnessInfo::detect(&agent_type, &working_dir) + } else { + SessionHarnessInfo { primary, detected } + }; + Ok(info) + }) + .optional() + .map_err(Into::into) + } + pub fn get_latest_session(&self) -> Result> { Ok(self.list_sessions()?.into_iter().next()) } @@ -3800,12 +3907,83 @@ mod tests { assert!(column_names.iter().any(|column| column == "pid")); assert!(column_names.iter().any(|column| column == "input_tokens")); assert!(column_names.iter().any(|column| column == "output_tokens")); + assert!(column_names.iter().any(|column| column == "harness")); + assert!(column_names + .iter() + .any(|column| column == "detected_harnesses_json")); assert!(column_names .iter() .any(|column| column == "last_heartbeat_at")); Ok(()) } + #[test] + fn open_backfills_session_harness_metadata_for_legacy_rows() -> Result<()> { + let tempdir = TestDir::new("store-harness-backfill")?; + let repo_root = tempdir.path().join("repo"); + fs::create_dir_all(repo_root.join(".codex"))?; + let db_path = tempdir.path().join("state.db"); + + let conn = Connection::open(&db_path)?; + conn.execute_batch( + " + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + task TEXT NOT NULL, + project TEXT NOT NULL DEFAULT '', + task_group TEXT NOT NULL DEFAULT '', + agent_type TEXT NOT NULL, + working_dir TEXT NOT NULL DEFAULT '.', + state TEXT NOT NULL DEFAULT 'pending', + pid INTEGER, + worktree_path TEXT, + worktree_branch TEXT, + worktree_base TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + tokens_used INTEGER DEFAULT 0, + tool_calls INTEGER DEFAULT 0, + files_changed INTEGER DEFAULT 0, + duration_secs INTEGER DEFAULT 0, + cost_usd REAL DEFAULT 0.0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_heartbeat_at TEXT NOT NULL + ); + ", + )?; + let now = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO sessions ( + id, task, project, task_group, agent_type, working_dir, state, pid, + worktree_path, worktree_branch, worktree_base, input_tokens, output_tokens, + tokens_used, tool_calls, files_changed, duration_secs, cost_usd, created_at, + updated_at, last_heartbeat_at + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, 'pending', NULL, + NULL, NULL, NULL, 0, 0, 0, 0, 0, 0, 0.0, ?7, ?7, ?7 + )", + rusqlite::params![ + "sess-legacy", + "Backfill harness metadata", + "ecc", + "legacy", + "claude", + repo_root.display().to_string(), + now, + ], + )?; + drop(conn); + + let db = StateStore::open(&db_path)?; + let harness = db + .get_session_harness_info("sess-legacy")? + .expect("legacy row should be backfilled"); + assert_eq!(harness.primary, HarnessKind::Claude); + assert_eq!(harness.detected, vec![HarnessKind::Codex]); + Ok(()) + } + #[test] fn session_profile_round_trips_with_launch_settings() -> Result<()> { let tempdir = TestDir::new("store-session-profile")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c2e3712b..eeb5dfec 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -24,7 +24,7 @@ use crate::session::output::{ use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; use crate::session::{ ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, - SessionMessage, SessionState, + SessionHarnessInfo, SessionMessage, SessionState, }; use crate::worktree; @@ -87,6 +87,7 @@ pub struct Dashboard { notifier: DesktopNotifier, webhook_notifier: WebhookNotifier, sessions: Vec, + session_harnesses: HashMap, session_output_cache: HashMap>, unread_message_counts: HashMap, approval_queue_counts: HashMap, @@ -497,6 +498,7 @@ impl Dashboard { let _ = db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path()); } let sessions = db.list_sessions().unwrap_or_default(); + let session_harnesses = db.list_session_harnesses().unwrap_or_default(); let initial_session_states = sessions .iter() .map(|session| (session.id.clone(), session.state.clone())) @@ -522,6 +524,7 @@ impl Dashboard { notifier, webhook_notifier, sessions, + session_harnesses, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), approval_queue_counts: HashMap::new(), @@ -4035,6 +4038,13 @@ impl Dashboard { Vec::new() } }; + self.session_harnesses = match self.db.list_session_harnesses() { + Ok(harnesses) => harnesses, + Err(error) => { + tracing::warn!("Failed to refresh session harnesses: {error}"); + HashMap::new() + } + }; self.unread_message_counts = match self.db.unread_message_counts() { Ok(counts) => counts, Err(error) => { @@ -6332,6 +6342,14 @@ impl Dashboard { } } + if let Some(harness) = self.session_harnesses.get(&session.id) { + lines.push(format!( + "Harness {} | Detected {}", + harness.primary, + harness.detected_summary() + )); + } + lines.push(format!( "Tokens {} total | In {} | Out {}", format_token_count(metrics.tokens_used), @@ -12281,6 +12299,40 @@ diff --git a/src/lib.rs b/src/lib.rs Ok(()) } + #[test] + fn selected_session_metrics_text_includes_harness_summary() -> Result<()> { + let tempdir = std::env::temp_dir().join(format!( + "ecc2-dashboard-harness-metrics-{}", + uuid::Uuid::new_v4() + )); + fs::create_dir_all(tempdir.join(".claude"))?; + fs::create_dir_all(tempdir.join(".codex"))?; + + let now = Utc::now(); + let session = Session { + id: "sess-harness".to_string(), + task: "Map harness metadata".to_string(), + project: "ecc".to_string(), + task_group: "compat".to_string(), + agent_type: "claude".to_string(), + working_dir: tempdir.clone(), + state: SessionState::Running, + pid: Some(4242), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(1), + last_heartbeat_at: now - Duration::minutes(1), + metrics: SessionMetrics::default(), + }; + + let dashboard = test_dashboard(vec![session], 0); + let metrics_text = dashboard.selected_session_metrics_text(); + assert!(metrics_text.contains("Harness claude | Detected claude, codex")); + + let _ = fs::remove_dir_all(tempdir); + Ok(()) + } + #[test] fn new_session_task_uses_selected_session_context() { let dashboard = test_dashboard( @@ -14429,6 +14481,15 @@ diff --git a/src/lib.rs b/src/lib.rs .iter() .map(|session| (session.id.clone(), session.state.clone())) .collect(); + let session_harnesses = sessions + .iter() + .map(|session| { + ( + session.id.clone(), + SessionHarnessInfo::detect(&session.agent_type, &session.working_dir), + ) + }) + .collect(); let output_store = SessionOutputStore::default(); let output_rx = output_store.subscribe(); let mut session_table_state = TableState::default(); @@ -14445,6 +14506,7 @@ diff --git a/src/lib.rs b/src/lib.rs notifier, webhook_notifier, sessions, + session_harnesses, session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), approval_queue_counts: HashMap::new(), From 97afd95451fd0dd1f61c1d96d710d01690a0a435 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 07:53:54 -0700 Subject: [PATCH 144/459] feat: add ecc2 codex and opencode runners --- ecc2/src/session/manager.rs | 221 +++++++++++++++++++++++++++++++----- 1 file changed, 190 insertions(+), 31 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 2c86e633..1b0e7d4e 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -10,7 +10,7 @@ use super::output::SessionOutputStore; use super::runtime::capture_command_output; use super::store::StateStore; use super::{ - default_project_label, default_task_group_label, normalize_group_label, Session, + default_project_label, default_task_group_label, normalize_group_label, HarnessKind, Session, SessionAgentProfile, SessionGrouping, SessionHarnessInfo, SessionMetrics, SessionState, }; use crate::comms::{self, MessageType}; @@ -1897,8 +1897,10 @@ pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { } fn agent_program(agent_type: &str) -> Result { - match agent_type { - "claude" => Ok(PathBuf::from("claude")), + match HarnessKind::from_agent_type(agent_type) { + HarnessKind::Claude => Ok(PathBuf::from("claude")), + HarnessKind::Codex => Ok(PathBuf::from("codex")), + HarnessKind::OpenCode => Ok(PathBuf::from("opencode")), other => anyhow::bail!("Unsupported agent type: {other}"), } } @@ -1935,6 +1937,7 @@ pub async fn run_session( let agent_program = agent_program(agent_type)?; let profile = db.get_session_profile(session_id)?; let command = build_agent_command( + agent_type, &agent_program, task, session_id, @@ -2521,46 +2524,86 @@ async fn spawn_session_runner_for_program( } fn build_agent_command( + agent_type: &str, agent_program: &Path, task: &str, session_id: &str, working_dir: &Path, profile: Option<&SessionAgentProfile>, ) -> Command { + let harness = HarnessKind::from_agent_type(agent_type); + let task = normalize_task_for_harness(harness, task, profile); let mut command = Command::new(agent_program); command.env("ECC_SESSION_ID", session_id); - command - .arg("--print") - .arg("--name") - .arg(format!("ecc-{session_id}")); - if let Some(profile) = profile { - if let Some(model) = profile.model.as_ref() { - command.arg("--model").arg(model); - } - if !profile.allowed_tools.is_empty() { + match harness { + HarnessKind::Claude => { command - .arg("--allowed-tools") - .arg(profile.allowed_tools.join(",")); + .arg("--print") + .arg("--name") + .arg(format!("ecc-{session_id}")); + if let Some(profile) = profile { + if let Some(model) = profile.model.as_ref() { + command.arg("--model").arg(model); + } + if !profile.allowed_tools.is_empty() { + command + .arg("--allowed-tools") + .arg(profile.allowed_tools.join(",")); + } + if !profile.disallowed_tools.is_empty() { + command + .arg("--disallowed-tools") + .arg(profile.disallowed_tools.join(",")); + } + if let Some(permission_mode) = profile.permission_mode.as_ref() { + command.arg("--permission-mode").arg(permission_mode); + } + for dir in &profile.add_dirs { + command.arg("--add-dir").arg(dir); + } + if let Some(max_budget_usd) = profile.max_budget_usd { + command + .arg("--max-budget-usd") + .arg(max_budget_usd.to_string()); + } + if let Some(prompt) = profile.append_system_prompt.as_ref() { + command.arg("--append-system-prompt").arg(prompt); + } + } } - if !profile.disallowed_tools.is_empty() { + HarnessKind::Codex => { command - .arg("--disallowed-tools") - .arg(profile.disallowed_tools.join(",")); + .arg("exec") + .arg("--skip-git-repo-check") + .arg("--sandbox") + .arg("workspace-write") + .arg("--cd") + .arg(working_dir) + .arg("--color") + .arg("never"); + if let Some(profile) = profile { + if let Some(model) = profile.model.as_ref() { + command.arg("--model").arg(model); + } + for dir in &profile.add_dirs { + command.arg("--add-dir").arg(dir); + } + } } - if let Some(permission_mode) = profile.permission_mode.as_ref() { - command.arg("--permission-mode").arg(permission_mode); - } - for dir in &profile.add_dirs { - command.arg("--add-dir").arg(dir); - } - if let Some(max_budget_usd) = profile.max_budget_usd { + HarnessKind::OpenCode => { command - .arg("--max-budget-usd") - .arg(max_budget_usd.to_string()); - } - if let Some(prompt) = profile.append_system_prompt.as_ref() { - command.arg("--append-system-prompt").arg(prompt); + .arg("run") + .arg("--dir") + .arg(working_dir) + .arg("--title") + .arg(format!("ecc-{session_id}")); + if let Some(profile) = profile { + if let Some(model) = profile.model.as_ref() { + command.arg("--model").arg(model); + } + } } + _ => {} } command .arg(task) @@ -2569,13 +2612,33 @@ fn build_agent_command( command } +fn normalize_task_for_harness( + harness: HarnessKind, + task: &str, + profile: Option<&SessionAgentProfile>, +) -> String { + let Some(system_prompt) = profile.and_then(|profile| profile.append_system_prompt.as_ref()) + else { + return task.to_string(); + }; + + match harness { + HarnessKind::Claude => task.to_string(), + HarnessKind::Codex | HarnessKind::OpenCode => { + format!("System instructions:\n{system_prompt}\n\nTask:\n{task}") + } + _ => task.to_string(), + } +} + async fn spawn_claude_code( agent_program: &Path, task: &str, session_id: &str, working_dir: &Path, ) -> Result { - let mut command = build_agent_command(agent_program, task, session_id, working_dir, None); + let mut command = + build_agent_command("claude", agent_program, task, session_id, working_dir, None); let child = command .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -3302,7 +3365,7 @@ mod tests { } #[test] - fn build_agent_command_applies_profile_runner_flags() { + fn build_agent_command_applies_profile_runner_flags_for_claude() { let profile = SessionAgentProfile { profile_name: "reviewer".to_string(), agent: None, @@ -3317,6 +3380,7 @@ mod tests { }; let command = build_agent_command( + "claude", Path::new("claude"), "review this change", "sess-1234", @@ -3356,6 +3420,101 @@ mod tests { ); } + #[test] + fn build_agent_command_normalizes_runner_flags_for_codex() { + let profile = SessionAgentProfile { + profile_name: "reviewer".to_string(), + agent: None, + model: Some("gpt-5.4".to_string()), + allowed_tools: vec!["Read".to_string()], + disallowed_tools: vec!["Bash".to_string()], + permission_mode: Some("plan".to_string()), + add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")], + max_budget_usd: Some(1.25), + token_budget: Some(750), + append_system_prompt: Some("Review thoroughly.".to_string()), + }; + + let command = build_agent_command( + "codex", + Path::new("codex"), + "review this change", + "sess-1234", + Path::new("/tmp/repo"), + Some(&profile), + ); + let args = command + .as_std() + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!( + args, + vec![ + "exec", + "--skip-git-repo-check", + "--sandbox", + "workspace-write", + "--cd", + "/tmp/repo", + "--color", + "never", + "--model", + "gpt-5.4", + "--add-dir", + "docs", + "--add-dir", + "specs", + "System instructions:\nReview thoroughly.\n\nTask:\nreview this change", + ] + ); + } + + #[test] + fn build_agent_command_normalizes_runner_flags_for_opencode() { + let profile = SessionAgentProfile { + profile_name: "builder".to_string(), + agent: None, + model: Some("anthropic/claude-sonnet-4".to_string()), + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), + permission_mode: None, + add_dirs: vec![PathBuf::from("docs")], + max_budget_usd: None, + token_budget: None, + append_system_prompt: Some("Build carefully.".to_string()), + }; + + let command = build_agent_command( + "opencode", + Path::new("opencode"), + "stabilize callback flow", + "sess-9999", + Path::new("/tmp/repo"), + Some(&profile), + ); + let args = command + .as_std() + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!( + args, + vec![ + "run", + "--dir", + "/tmp/repo", + "--title", + "ecc-sess-9999", + "--model", + "anthropic/claude-sonnet-4", + "System instructions:\nBuild carefully.\n\nTask:\nstabilize callback flow", + ] + ); + } + #[test] fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> { let tempdir = TestDir::new("manager-heartbeat-stale")?; From a4aaa30e9365e752f92a8af28611b7df44c2a7d8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 07:58:26 -0700 Subject: [PATCH 145/459] feat: add ecc2 gemini runner support --- ecc2/src/session/manager.rs | 62 ++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 1b0e7d4e..4ecd5569 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1901,6 +1901,7 @@ fn agent_program(agent_type: &str) -> Result { HarnessKind::Claude => Ok(PathBuf::from("claude")), HarnessKind::Codex => Ok(PathBuf::from("codex")), HarnessKind::OpenCode => Ok(PathBuf::from("opencode")), + HarnessKind::Gemini => Ok(PathBuf::from("gemini")), other => anyhow::bail!("Unsupported agent type: {other}"), } } @@ -2603,6 +2604,23 @@ fn build_agent_command( } } } + HarnessKind::Gemini => { + command.arg("-p"); + if let Some(profile) = profile { + if let Some(model) = profile.model.as_ref() { + command.arg("-m").arg(model); + } + if !profile.add_dirs.is_empty() { + let include_dirs = profile + .add_dirs + .iter() + .map(|dir| dir.to_string_lossy().to_string()) + .collect::>() + .join(","); + command.arg("--include-directories").arg(include_dirs); + } + } + } _ => {} } command @@ -2624,7 +2642,7 @@ fn normalize_task_for_harness( match harness { HarnessKind::Claude => task.to_string(), - HarnessKind::Codex | HarnessKind::OpenCode => { + HarnessKind::Codex | HarnessKind::OpenCode | HarnessKind::Gemini => { format!("System instructions:\n{system_prompt}\n\nTask:\n{task}") } _ => task.to_string(), @@ -3515,6 +3533,48 @@ mod tests { ); } + #[test] + fn build_agent_command_normalizes_runner_flags_for_gemini() { + let profile = SessionAgentProfile { + profile_name: "investigator".to_string(), + agent: None, + model: Some("gemini-2.5-pro".to_string()), + allowed_tools: vec!["Read".to_string()], + disallowed_tools: vec!["Bash".to_string()], + permission_mode: Some("plan".to_string()), + add_dirs: vec![PathBuf::from("docs"), PathBuf::from("../shared")], + max_budget_usd: Some(1.0), + token_budget: Some(500), + append_system_prompt: Some("Use repo context carefully.".to_string()), + }; + + let command = build_agent_command( + "gemini", + Path::new("gemini"), + "investigate auth regression", + "sess-gem1", + Path::new("/tmp/repo"), + Some(&profile), + ); + let args = command + .as_std() + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!( + args, + vec![ + "-p", + "-m", + "gemini-2.5-pro", + "--include-directories", + "docs,../shared", + "System instructions:\nUse repo context carefully.\n\nTask:\ninvestigate auth regression", + ] + ); + } + #[test] fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> { let tempdir = TestDir::new("manager-heartbeat-stale")?; From d84c64fa0ea9290375aead1d00f0099ba441cc59 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 08:03:25 -0700 Subject: [PATCH 146/459] feat: canonicalize ecc2 harness aliases --- ecc2/src/session/manager.rs | 91 +++++++++++++++++++++++++++++++++++-- ecc2/src/session/mod.rs | 21 +++++++++ ecc2/src/session/store.rs | 24 +++++++--- 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 4ecd5569..e36e2318 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -2103,11 +2103,12 @@ async fn queue_session_in_dir_with_runner_program( grouping: SessionGrouping, ) -> Result { let profile = resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?; + let canonical_agent_type = HarnessKind::canonical_agent_type(agent_type); queue_session_with_resolved_profile_and_runner_program( db, cfg, task, - agent_type, + &canonical_agent_type, use_worktree, repo_root, runner_program, @@ -2132,10 +2133,11 @@ async fn queue_session_with_resolved_profile_and_runner_program( .as_ref() .and_then(|profile| profile.agent.as_deref()) .unwrap_or(agent_type); + let effective_agent_type = HarnessKind::canonical_agent_type(effective_agent_type); let session = build_session_record( db, task, - effective_agent_type, + &effective_agent_type, use_worktree, cfg, repo_root, @@ -2188,6 +2190,7 @@ fn build_session_record( repo_root: &Path, grouping: SessionGrouping, ) -> Result { + let canonical_agent_type = HarnessKind::canonical_agent_type(agent_type); let id = uuid::Uuid::new_v4().to_string()[..8].to_string(); let now = chrono::Utc::now(); @@ -2216,7 +2219,7 @@ fn build_session_record( task: task.to_string(), project, task_group, - agent_type: agent_type.to_string(), + agent_type: canonical_agent_type, working_dir, state: SessionState::Pending, pid: None, @@ -2341,13 +2344,18 @@ fn direct_delegate_sessions( lead_id: &str, agent_type: &str, ) -> Result> { + let target_harness = HarnessKind::from_agent_type(agent_type); let mut sessions = Vec::new(); for child_id in db.delegated_children(lead_id, 50)? { let Some(session) = db.get_session(&child_id)? else { continue; }; - if session.agent_type != agent_type { + if target_harness != HarnessKind::Unknown { + if HarnessKind::from_agent_type(&session.agent_type) != target_harness { + continue; + } + } else if session.agent_type != HarnessKind::canonical_agent_type(agent_type) { continue; } @@ -3575,6 +3583,81 @@ mod tests { ); } + #[test] + fn build_session_record_canonicalizes_known_agent_aliases() -> Result<()> { + let tempdir = TestDir::new("manager-canonical-agent-type")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let session = build_session_record( + &db, + "Investigate auth callback", + "gemini-cli", + false, + &cfg, + &repo_root, + SessionGrouping::default(), + )?; + + assert_eq!(session.agent_type, "gemini"); + Ok(()) + } + + #[test] + fn direct_delegate_sessions_matches_harness_aliases_for_existing_rows() -> Result<()> { + let tempdir = TestDir::new("manager-delegate-alias-match")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "Lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "child".to_string(), + task: "Delegate task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude-code".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Idle, + pid: Some(7), + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.send_message( + "lead", + "child", + "{\"task\":\"Delegate task\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + + let delegates = direct_delegate_sessions(&db, "lead", "claude")?; + assert_eq!(delegates.len(), 1); + assert_eq!(delegates[0].id, "child"); + Ok(()) + } + #[test] fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> { let tempdir = TestDir::new("manager-heartbeat-stale")?; diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 0a1aa292..8ad6c54a 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -79,6 +79,13 @@ impl HarnessKind { } } + pub fn canonical_agent_type(agent_type: &str) -> String { + match Self::from_agent_type(agent_type) { + Self::Unknown => agent_type.trim().to_ascii_lowercase(), + harness => harness.as_str().to_string(), + } + } + fn project_markers(self) -> &'static [&'static str] { match self { Self::Claude => &[".claude"], @@ -505,4 +512,18 @@ mod tests { assert_eq!(harness.detected, vec![HarnessKind::Gemini]); Ok(()) } + + #[test] + fn canonical_agent_type_normalizes_known_aliases() { + assert_eq!(HarnessKind::canonical_agent_type("claude-code"), "claude"); + assert_eq!(HarnessKind::canonical_agent_type("gemini-cli"), "gemini"); + assert_eq!( + HarnessKind::canonical_agent_type("factory-droid"), + "factory_droid" + ); + assert_eq!( + HarnessKind::canonical_agent_type(" custom-runner "), + "custom-runner" + ); + } } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 5109bef8..fdebe58d 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -675,15 +675,23 @@ impl StateStore { .collect::, _>>()?; for (session_id, agent_type, working_dir) in updates { - let harness = SessionHarnessInfo::detect(&agent_type, Path::new(&working_dir)); + let canonical_agent_type = HarnessKind::canonical_agent_type(&agent_type); + let harness = + SessionHarnessInfo::detect(&canonical_agent_type, Path::new(&working_dir)); let detected_json = serde_json::to_string(&harness.detected).context("serialize detected harnesses")?; self.conn.execute( "UPDATE sessions - SET harness = ?2, - detected_harnesses_json = ?3 + SET agent_type = ?2, + harness = ?3, + detected_harnesses_json = ?4 WHERE id = ?1", - rusqlite::params![session_id, harness.primary.to_string(), detected_json], + rusqlite::params![ + session_id, + canonical_agent_type, + harness.primary.to_string(), + detected_json + ], )?; } @@ -3968,7 +3976,7 @@ mod tests { "Backfill harness metadata", "ecc", "legacy", - "claude", + "gemini-cli", repo_root.display().to_string(), now, ], @@ -3976,10 +3984,14 @@ mod tests { drop(conn); let db = StateStore::open(&db_path)?; + let session = db + .get_session("sess-legacy")? + .expect("legacy row should still exist"); + assert_eq!(session.agent_type, "gemini"); let harness = db .get_session_harness_info("sess-legacy")? .expect("legacy row should be backfilled"); - assert_eq!(harness.primary, HarnessKind::Claude); + assert_eq!(harness.primary, HarnessKind::Gemini); assert_eq!(harness.detected, vec![HarnessKind::Codex]); Ok(()) } From 52371f5016c3ef4449c8d452d642b5ada177abae Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 08:16:17 -0700 Subject: [PATCH 147/459] feat: prioritize ecc2 handoff queues --- ecc2/src/comms/mod.rs | 61 +++++++++++++++++++++++- ecc2/src/main.rs | 29 ++++++++++- ecc2/src/session/manager.rs | 58 ++++++++++++++++++++++ ecc2/src/session/store.rs | 95 +++++++++++++++++++++++++++++++------ ecc2/src/tui/dashboard.rs | 2 + 5 files changed, 228 insertions(+), 17 deletions(-) diff --git a/ecc2/src/comms/mod.rs b/ecc2/src/comms/mod.rs index 24dffa11..376dfd57 100644 --- a/ecc2/src/comms/mod.rs +++ b/ecc2/src/comms/mod.rs @@ -3,11 +3,26 @@ use serde::{Deserialize, Serialize}; use crate::session::store::StateStore; +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum TaskPriority { + Low, + #[default] + Normal, + High, + Critical, +} + /// Message types for inter-agent communication. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum MessageType { /// Task handoff from one agent to another - TaskHandoff { task: String, context: String }, + TaskHandoff { + task: String, + context: String, + #[serde(default)] + priority: TaskPriority, + }, /// Agent requesting information from another Query { question: String }, /// Response to a query @@ -46,7 +61,16 @@ pub fn parse(content: &str) -> Option { pub fn preview(msg_type: &str, content: &str) -> String { match parse(content) { Some(MessageType::TaskHandoff { task, .. }) => { - format!("handoff {}", truncate(&task, 56)) + let priority = handoff_priority(content); + if priority == TaskPriority::Normal { + format!("handoff {}", truncate(&task, 56)) + } else { + format!( + "handoff [{}] {}", + priority_label(priority), + truncate(&task, 48) + ) + } } Some(MessageType::Query { question }) => { format!("query {}", truncate(&question, 56)) @@ -75,6 +99,39 @@ pub fn preview(msg_type: &str, content: &str) -> String { } } +pub fn handoff_priority(content: &str) -> TaskPriority { + match parse(content) { + Some(MessageType::TaskHandoff { priority, .. }) => priority, + _ => extract_legacy_handoff_priority(content), + } +} + +fn extract_legacy_handoff_priority(content: &str) -> TaskPriority { + let value: serde_json::Value = match serde_json::from_str(content) { + Ok(value) => value, + Err(_) => return TaskPriority::Normal, + }; + match value + .get("priority") + .and_then(|priority| priority.as_str()) + .unwrap_or("normal") + { + "low" => TaskPriority::Low, + "high" => TaskPriority::High, + "critical" => TaskPriority::Critical, + _ => TaskPriority::Normal, + } +} + +fn priority_label(priority: TaskPriority) -> &'static str { + match priority { + TaskPriority::Low => "low", + TaskPriority::Normal => "normal", + TaskPriority::High => "high", + TaskPriority::Critical => "critical", + } +} + fn truncate(value: &str, max_chars: usize) -> String { let trimmed = value.trim(); if trimmed.chars().count() <= max_chars { diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index b5193bd3..f1eed4aa 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -374,6 +374,8 @@ enum MessageCommands { text: String, #[arg(long)] context: Option, + #[arg(long, value_enum, default_value_t = TaskPriorityArg::Normal)] + priority: TaskPriorityArg, #[arg(long)] file: Vec, }, @@ -599,6 +601,25 @@ enum MessageKindArg { Conflict, } +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +enum TaskPriorityArg { + Low, + Normal, + High, + Critical, +} + +impl From for comms::TaskPriority { + fn from(value: TaskPriorityArg) -> Self { + match value { + TaskPriorityArg::Low => Self::Low, + TaskPriorityArg::Normal => Self::Normal, + TaskPriorityArg::High => Self::High, + TaskPriorityArg::Critical => Self::Critical, + } + } +} + #[derive(clap::ValueEnum, Clone, Debug)] enum ObservationPriorityArg { Low, @@ -1665,11 +1686,12 @@ async fn main() -> Result<()> { kind, text, context, + priority, file, } => { let from = resolve_session_id(&db, &from)?; let to = resolve_session_id(&db, &to)?; - let message = build_message(kind, text, context, file)?; + let message = build_message(kind, text, context, priority, file)?; comms::send(&db, &from, &to, &message)?; println!( "Message sent: {} -> {}", @@ -2701,12 +2723,14 @@ fn build_message( kind: MessageKindArg, text: String, context: Option, + priority: TaskPriorityArg, files: Vec, ) -> Result { Ok(match kind { MessageKindArg::Handoff => comms::MessageType::TaskHandoff { task: text, context: context.unwrap_or_default(), + priority: priority.into(), }, MessageKindArg::Query => comms::MessageType::Query { question: text }, MessageKindArg::Response => comms::MessageType::Response { answer: text }, @@ -4168,6 +4192,7 @@ fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: & &comms::MessageType::TaskHandoff { task: from_session.task, context, + priority: comms::TaskPriority::Normal, }, ) } @@ -4345,6 +4370,7 @@ mod tests { to, kind, text, + priority, .. }, }) => { @@ -4352,6 +4378,7 @@ mod tests { assert_eq!(to, "worker"); assert!(matches!(kind, MessageKindArg::Query)); assert_eq!(text, "Need context"); + assert_eq!(priority, TaskPriorityArg::Normal); } _ => panic!("expected messages send subcommand"), } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index e36e2318..f651a08a 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -2484,6 +2484,7 @@ fn send_task_handoff( &crate::comms::MessageType::TaskHandoff { task: task.to_string(), context, + priority: crate::comms::TaskPriority::Normal, }, ) } @@ -5843,6 +5844,62 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn drain_inbox_routes_high_priority_handoff_first() -> Result<()> { + let tempdir = TestDir::new("manager-drain-inbox-priority")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now - Duration::minutes(3), + updated_at: now - Duration::minutes(3), + last_heartbeat_at: now - Duration::minutes(3), + metrics: SessionMetrics::default(), + })?; + + db.send_message( + "planner", + "lead", + "{\"task\":\"Document cleanup\",\"context\":\"Inbound request\",\"priority\":\"low\"}", + "task_handoff", + )?; + db.send_message( + "planner", + "lead", + "{\"task\":\"Critical auth outage\",\"context\":\"Inbound request\",\"priority\":\"critical\"}", + "task_handoff", + )?; + + let outcomes = drain_inbox(&db, &cfg, "lead", "claude", true, 1).await?; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].task, "Critical auth outage"); + assert_eq!(outcomes[0].action, AssignmentAction::Spawned); + + let unread = db.unread_task_handoffs_for_session("lead", 10)?; + assert_eq!(unread.len(), 1); + assert!(unread[0].content.contains("Document cleanup")); + + let messages = db.list_messages_for_session(&outcomes[0].session_id, 10)?; + assert!(messages.iter().any(|message| { + message.msg_type == "task_handoff" && message.content.contains("Critical auth outage") + })); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn auto_dispatch_backlog_routes_multiple_lead_inboxes() -> Result<()> { let tempdir = TestDir::new("manager-auto-dispatch")?; @@ -6307,6 +6364,7 @@ mod tests { &crate::comms::MessageType::TaskHandoff { task: "Review src/lib.rs".to_string(), context: "Lead delegated follow-up".to_string(), + priority: crate::comms::TaskPriority::Normal, }, )?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index fdebe58d..a1ee54a4 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -8,6 +8,7 @@ use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use std::time::Duration; +use crate::comms; use crate::config::Config; use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage}; @@ -1885,11 +1886,10 @@ impl StateStore { "SELECT id, from_session, to_session, content, msg_type, read, timestamp FROM messages WHERE to_session = ?1 AND msg_type = 'task_handoff' AND read = 0 - ORDER BY id ASC - LIMIT ?2", + ORDER BY id ASC", )?; - let messages = stmt.query_map(rusqlite::params![session_id, limit as i64], |row| { + let messages = stmt.query_map(rusqlite::params![session_id], |row| { let timestamp: String = row.get(6)?; Ok(SessionMessage { @@ -1905,7 +1905,16 @@ impl StateStore { }) })?; - messages.collect::, _>>().map_err(Into::into) + let mut messages = messages.collect::, _>>()?; + messages.sort_by(|left, right| { + let left_priority = comms::handoff_priority(&left.content); + let right_priority = comms::handoff_priority(&right.content); + Reverse(left_priority) + .cmp(&Reverse(right_priority)) + .then_with(|| left.id.cmp(&right.id)) + }); + messages.truncate(limit); + Ok(messages) } pub fn unread_task_handoff_count(&self, session_id: &str) -> Result { @@ -1923,19 +1932,49 @@ impl StateStore { pub fn unread_task_handoff_targets(&self, limit: usize) -> Result> { let mut stmt = self.conn.prepare( - "SELECT to_session, COUNT(*) as unread_count + "SELECT to_session, content, id FROM messages WHERE msg_type = 'task_handoff' AND read = 0 - GROUP BY to_session - ORDER BY unread_count DESC, MAX(id) ASC - LIMIT ?1", + ORDER BY id ASC", )?; - let targets = stmt.query_map(rusqlite::params![limit as i64], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize)) + let targets = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) })?; + let mut aggregated: HashMap = HashMap::new(); + for (to_session, content, id) in targets.collect::, _>>()? { + let priority = comms::handoff_priority(&content); + aggregated + .entry(to_session) + .and_modify(|entry| { + entry.0 += 1; + if priority > entry.1 { + entry.1 = priority; + } + if id < entry.2 { + entry.2 = id; + } + }) + .or_insert((1, priority, id)); + } - targets.collect::, _>>().map_err(Into::into) + let mut targets = aggregated.into_iter().collect::>(); + targets.sort_by(|(left_session, left), (right_session, right)| { + Reverse(left.1) + .cmp(&Reverse(right.1)) + .then_with(|| Reverse(left.0).cmp(&Reverse(right.0))) + .then_with(|| left.2.cmp(&right.2)) + .then_with(|| left_session.cmp(right_session)) + }); + targets.truncate(limit); + Ok(targets + .into_iter() + .map(|(session_id, (count, _, _))| (session_id, count)) + .collect()) } pub fn mark_messages_read(&self, session_id: &str) -> Result { @@ -5521,7 +5560,19 @@ mod tests { db.send_message( "planner", "worker-3", - "{\"task\":\"Check billing\",\"context\":\"Delegated from planner\"}", + "{\"task\":\"Check billing\",\"context\":\"Delegated from planner\",\"priority\":\"high\"}", + "task_handoff", + )?; + db.send_message( + "planner", + "worker-4", + "{\"task\":\"Low priority follow-up\",\"context\":\"Delegated from planner\",\"priority\":\"low\"}", + "task_handoff", + )?; + db.send_message( + "planner", + "worker-4", + "{\"task\":\"Critical production incident\",\"context\":\"Delegated from planner\",\"priority\":\"critical\"}", "task_handoff", )?; @@ -5531,12 +5582,28 @@ mod tests { ); assert_eq!( db.delegated_children("planner", 10)?, - vec!["worker-3".to_string(), "worker-2".to_string(),] + vec![ + "worker-4".to_string(), + "worker-3".to_string(), + "worker-2".to_string(), + ] ); assert_eq!( db.unread_task_handoff_targets(10)?, - vec![("worker-2".to_string(), 1), ("worker-3".to_string(), 1),] + vec![ + ("worker-4".to_string(), 2), + ("worker-3".to_string(), 1), + ("worker-2".to_string(), 1), + ] ); + let worker_4_handoffs = db.unread_task_handoffs_for_session("worker-4", 10)?; + assert_eq!(worker_4_handoffs.len(), 2); + assert!(worker_4_handoffs[0] + .content + .contains("Critical production incident")); + assert!(worker_4_handoffs[1] + .content + .contains("Low priority follow-up")); let planner_entities = db.list_context_entities(Some("planner"), Some("session"), 10)?; assert_eq!(planner_entities.len(), 1); diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index eeb5dfec..dab25e72 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -2174,6 +2174,7 @@ impl Dashboard { &comms::MessageType::TaskHandoff { task: source_session.task.clone(), context, + priority: comms::TaskPriority::Normal, }, ) { tracing::warn!( @@ -3655,6 +3656,7 @@ impl Dashboard { &comms::MessageType::TaskHandoff { task: task.clone(), context: context.clone(), + priority: comms::TaskPriority::Normal, }, ) { tracing::warn!( From 2e6eeafabdd739bb31757a4526aaabeec30768d7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 08:31:04 -0700 Subject: [PATCH 148/459] feat: add ecc2 persistent task scheduling --- ecc2/Cargo.lock | 12 +++ ecc2/Cargo.toml | 1 + ecc2/src/main.rs | 184 ++++++++++++++++++++++++++++++++ ecc2/src/session/daemon.rs | 12 +++ ecc2/src/session/manager.rs | 192 ++++++++++++++++++++++++++++++++- ecc2/src/session/mod.rs | 17 +++ ecc2/src/session/store.rs | 207 +++++++++++++++++++++++++++++++++++- 7 files changed, 621 insertions(+), 4 deletions(-) diff --git a/ecc2/Cargo.lock b/ecc2/Cargo.lock index 40cd4724..ff240c32 100644 --- a/ecc2/Cargo.lock +++ b/ecc2/Cargo.lock @@ -315,6 +315,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cron" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8c3e73077b4b4a6ab1ea5047c37c57aee77657bc8ecd6f29b0af082d0b0c07" +dependencies = [ + "chrono", + "nom", + "once_cell", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -507,6 +518,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "cron", "crossterm 0.28.1", "dirs", "git2", diff --git a/ecc2/Cargo.toml b/ecc2/Cargo.toml index 85399a3b..ea8d9733 100644 --- a/ecc2/Cargo.toml +++ b/ecc2/Cargo.toml @@ -43,6 +43,7 @@ libc = "0.2" # Time chrono = { version = "0.4", features = ["serde"] } +cron = "0.12" # UUID for session IDs uuid = { version = "1", features = ["v4"] } diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index f1eed4aa..d5668649 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -322,6 +322,11 @@ enum Commands { #[command(subcommand)] command: GraphCommands, }, + /// Manage persistent scheduled task dispatch + Schedule { + #[command(subcommand)] + command: ScheduleCommands, + }, /// Export sessions, tool spans, and metrics in OTLP-compatible JSON ExportOtel { /// Session ID or alias. Omit to export all sessions. @@ -387,6 +392,56 @@ enum MessageCommands { }, } +#[derive(clap::Subcommand, Debug)] +enum ScheduleCommands { + /// Add a persistent scheduled task + Add { + /// Cron expression in 5, 6, or 7-field form + #[arg(long)] + cron: String, + /// Task description to run on each schedule + #[arg(short, long)] + task: String, + /// Agent type (claude, codex, gemini, opencode) + #[arg(short, long)] + agent: Option, + /// Agent profile defined in ecc2.toml + #[arg(long)] + profile: Option, + #[command(flatten)] + worktree: WorktreePolicyArgs, + /// Optional project grouping override + #[arg(long)] + project: Option, + /// Optional task-group grouping override + #[arg(long)] + task_group: Option, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// List scheduled tasks + List { + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Remove a scheduled task + Remove { + /// Schedule ID + schedule_id: i64, + }, + /// Dispatch currently due scheduled tasks + RunDue { + /// Maximum due schedules to dispatch in one pass + #[arg(long, default_value_t = 10)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, +} + #[derive(clap::Subcommand, Debug)] enum GraphCommands { /// Create or update a graph entity @@ -1727,6 +1782,90 @@ async fn main() -> Result<()> { } } }, + Some(Commands::Schedule { command }) => match command { + ScheduleCommands::Add { + cron, + task, + agent, + profile, + worktree, + project, + task_group, + json, + } => { + let schedule = session::manager::create_scheduled_task( + &db, + &cfg, + &cron, + &task, + agent.as_deref().unwrap_or(&cfg.default_agent), + profile.as_deref(), + worktree.resolve(&cfg), + session::SessionGrouping { + project, + task_group, + }, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&schedule)?); + } else { + println!( + "Scheduled task {} next runs at {}", + schedule.id, + schedule.next_run_at.to_rfc3339() + ); + println!( + "- {} [{}] | {}", + schedule.task, schedule.agent_type, schedule.cron_expr + ); + } + } + ScheduleCommands::List { json } => { + let schedules = session::manager::list_scheduled_tasks(&db)?; + if json { + println!("{}", serde_json::to_string_pretty(&schedules)?); + } else if schedules.is_empty() { + println!("No scheduled tasks"); + } else { + println!("Scheduled tasks"); + for schedule in schedules { + println!( + "#{} {} [{}] | {} | next {}", + schedule.id, + schedule.task, + schedule.agent_type, + schedule.cron_expr, + schedule.next_run_at.to_rfc3339() + ); + } + } + } + ScheduleCommands::Remove { schedule_id } => { + if !session::manager::delete_scheduled_task(&db, schedule_id)? { + anyhow::bail!("Scheduled task not found: {schedule_id}"); + } + println!("Removed scheduled task {schedule_id}"); + } + ScheduleCommands::RunDue { limit, json } => { + let outcomes = session::manager::run_due_schedules(&db, &cfg, limit).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcomes)?); + } else if outcomes.is_empty() { + println!("No due scheduled tasks"); + } else { + println!("Dispatched {} scheduled task(s)", outcomes.len()); + for outcome in outcomes { + println!( + "#{} -> {} | {} | next {}", + outcome.schedule_id, + short_session(&outcome.session_id), + outcome.task, + outcome.next_run_at.to_rfc3339() + ); + } + } + } + }, Some(Commands::Daemon) => { println!("Starting ECC daemon..."); session::daemon::run(db, cfg).await?; @@ -4384,6 +4523,51 @@ mod tests { } } + #[test] + fn cli_parses_schedule_add_command() { + let cli = Cli::try_parse_from([ + "ecc", + "schedule", + "add", + "--cron", + "*/15 * * * *", + "--task", + "Check backlog health", + "--agent", + "codex", + "--profile", + "planner", + "--project", + "ecc-core", + "--task-group", + "scheduled maintenance", + ]) + .expect("schedule add should parse"); + + match cli.command { + Some(Commands::Schedule { + command: + ScheduleCommands::Add { + cron, + task, + agent, + profile, + project, + task_group, + .. + }, + }) => { + assert_eq!(cron, "*/15 * * * *"); + assert_eq!(task, "Check backlog health"); + assert_eq!(agent.as_deref(), Some("codex")); + assert_eq!(profile.as_deref(), Some("planner")); + assert_eq!(project.as_deref(), Some("ecc-core")); + assert_eq!(task_group.as_deref(), Some("scheduled maintenance")); + } + _ => panic!("expected schedule add subcommand"), + } + } + #[test] fn cli_parses_start_with_handoff_source() { let cli = Cli::try_parse_from([ diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 2f5096fb..9f55df04 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -27,6 +27,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Session check failed: {e}"); } + if let Err(e) = maybe_run_due_schedules(&db, &cfg).await { + tracing::error!("Scheduled task dispatch pass failed: {e}"); + } + if let Err(e) = coordinate_backlog_cycle(&db, &cfg).await { tracing::error!("Backlog coordination pass failed: {e}"); } @@ -89,6 +93,14 @@ fn check_sessions(db: &StateStore, cfg: &Config) -> Result<()> { Ok(()) } +async fn maybe_run_due_schedules(db: &StateStore, cfg: &Config) -> Result { + let outcomes = manager::run_due_schedules(db, cfg, cfg.max_parallel_sessions).await?; + if !outcomes.is_empty() { + tracing::info!("Dispatched {} scheduled task(s)", outcomes.len()); + } + Ok(outcomes.len()) +} + async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result { let summary = maybe_auto_dispatch_with_recorder( cfg, diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index f651a08a..150b4ef0 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1,17 +1,21 @@ use anyhow::{Context, Result}; +use chrono::Utc; +use cron::Schedule as CronSchedule; use serde::Serialize; use std::collections::{BTreeMap, HashMap, HashSet}; use std::fmt; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::str::FromStr; use tokio::process::Command; use super::output::SessionOutputStore; use super::runtime::capture_command_output; use super::store::StateStore; use super::{ - default_project_label, default_task_group_label, normalize_group_label, HarnessKind, Session, - SessionAgentProfile, SessionGrouping, SessionHarnessInfo, SessionMetrics, SessionState, + default_project_label, default_task_group_label, normalize_group_label, HarnessKind, + ScheduledTask, Session, SessionAgentProfile, SessionGrouping, SessionHarnessInfo, + SessionMetrics, SessionState, }; use crate::comms::{self, MessageType}; use crate::config::Config; @@ -108,6 +112,48 @@ pub async fn create_session_from_source_with_profile_and_grouping( .await } +async fn run_due_schedules_with_runner_program( + db: &StateStore, + cfg: &Config, + limit: usize, + runner_program: &Path, +) -> Result> { + let now = Utc::now(); + let schedules = db.list_due_scheduled_tasks(now, limit)?; + let mut outcomes = Vec::new(); + + for schedule in schedules { + let grouping = SessionGrouping { + project: normalize_group_label(&schedule.project), + task_group: normalize_group_label(&schedule.task_group), + }; + let session_id = queue_session_in_dir_with_runner_program( + db, + cfg, + &schedule.task, + &schedule.agent_type, + schedule.use_worktree, + &schedule.working_dir, + runner_program, + schedule.profile_name.as_deref(), + None, + grouping, + ) + .await?; + let next_run_at = next_schedule_run_at(&schedule.cron_expr, now)?; + db.record_scheduled_task_run(schedule.id, now, next_run_at)?; + outcomes.push(ScheduledRunOutcome { + schedule_id: schedule.id, + session_id, + task: schedule.task, + cron_expr: schedule.cron_expr, + next_run_at, + }); + } + + Ok(outcomes) +} + pub fn list_sessions(db: &StateStore) -> Result> { db.list_sessions() } @@ -155,6 +201,66 @@ pub fn get_team_status(db: &StateStore, id: &str, depth: usize) -> Result, + use_worktree: bool, + grouping: SessionGrouping, +) -> Result { + let working_dir = + std::env::current_dir().context("Failed to resolve current working directory")?; + let project = grouping + .project + .as_deref() + .and_then(normalize_group_label) + .unwrap_or_else(|| default_project_label(&working_dir)); + let task_group = grouping + .task_group + .as_deref() + .and_then(normalize_group_label) + .unwrap_or_else(|| default_task_group_label(task)); + let agent_type = HarnessKind::canonical_agent_type(agent_type); + + if let Some(profile_name) = profile_name { + cfg.resolve_agent_profile(profile_name)?; + } + + let next_run_at = next_schedule_run_at(cron_expr, Utc::now())?; + db.insert_scheduled_task( + cron_expr, + task, + &agent_type, + profile_name, + &working_dir, + &project, + &task_group, + use_worktree, + next_run_at, + ) +} + +pub fn list_scheduled_tasks(db: &StateStore) -> Result> { + db.list_scheduled_tasks() +} + +pub fn delete_scheduled_task(db: &StateStore, schedule_id: i64) -> Result { + Ok(db.delete_scheduled_task(schedule_id)? > 0) +} + +pub async fn run_due_schedules( + db: &StateStore, + cfg: &Config, + limit: usize, +) -> Result> { + let runner_program = + std::env::current_exe().context("Failed to resolve ECC executable path")?; + run_due_schedules_with_runner_program(db, cfg, limit, &runner_program).await +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct TemplateLaunchStepOutcome { pub step_name: String, @@ -1916,6 +2022,32 @@ fn resolve_session(db: &StateStore, id: &str) -> Result { session.ok_or_else(|| anyhow::anyhow!("Session not found: {id}")) } +fn parse_cron_schedule(expr: &str) -> Result { + let trimmed = expr.trim(); + let normalized = match trimmed.split_whitespace().count() { + 5 => format!("0 {trimmed}"), + 6 | 7 => trimmed.to_string(), + fields => { + anyhow::bail!( + "invalid cron expression `{trimmed}`: expected 5, 6, or 7 fields but found {fields}" + ) + } + }; + CronSchedule::from_str(&normalized) + .with_context(|| format!("invalid cron expression `{trimmed}`")) +} + +fn next_schedule_run_at( + expr: &str, + after: chrono::DateTime, +) -> Result> { + parse_cron_schedule(expr)? + .after(&after) + .next() + .map(|value| value.with_timezone(&chrono::Utc)) + .ok_or_else(|| anyhow::anyhow!("cron expression `{expr}` did not yield a future run time")) +} + pub async fn run_session( cfg: &Config, session_id: &str, @@ -2805,6 +2937,15 @@ pub struct LeadDispatchOutcome { pub routed: Vec, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ScheduledRunOutcome { + pub schedule_id: i64, + pub session_id: String, + pub task: String, + pub cron_expr: String, + pub next_run_at: chrono::DateTime, +} + pub struct RebalanceOutcome { pub from_session_id: String, pub message_id: i64, @@ -3891,6 +4032,53 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn run_due_schedules_dispatches_due_tasks_and_advances_next_run() -> Result<()> { + let tempdir = TestDir::new("manager-run-due-schedules")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let (fake_runner, log_path) = write_fake_claude(tempdir.path())?; + let due_at = Utc::now() - Duration::minutes(1); + + let schedule = db.insert_scheduled_task( + "*/15 * * * *", + "Check backlog health", + "claude", + None, + &repo_root, + "ecc-core", + "scheduled maintenance", + true, + due_at, + )?; + + let outcomes = run_due_schedules_with_runner_program(&db, &cfg, 10, &fake_runner).await?; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].schedule_id, schedule.id); + assert_eq!(outcomes[0].task, "Check backlog health"); + + let session = db + .get_session(&outcomes[0].session_id)? + .context("scheduled session should exist")?; + assert_eq!(session.project, "ecc-core"); + assert_eq!(session.task_group, "scheduled maintenance"); + + let refreshed = db + .get_scheduled_task(schedule.id)? + .context("scheduled task should still exist")?; + assert!(refreshed.last_run_at.is_some()); + assert!(refreshed.next_run_at > due_at); + + let log = wait_for_file(&log_path)?; + assert!(log.contains("Check backlog health")); + + stop_session_with_options(&db, &outcomes[0].session_id, true).await?; + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn stop_session_kills_process_and_optionally_cleans_worktree() -> Result<()> { let tempdir = TestDir::new("manager-stop-session")?; diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 8ad6c54a..ffff01e7 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -274,6 +274,23 @@ pub struct SessionMessage { pub timestamp: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScheduledTask { + pub id: i64, + pub cron_expr: String, + pub task: String, + pub agent_type: String, + pub profile_name: Option, + pub working_dir: PathBuf, + pub project: String, + pub task_group: String, + pub use_worktree: bool, + pub last_run_at: Option>, + pub next_run_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileActivityEntry { pub session_id: String, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index a1ee54a4..31196d3c 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -18,8 +18,8 @@ use super::{ ContextGraphCompactionStats, ContextGraphEntity, ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry, - HarnessKind, Session, SessionAgentProfile, SessionHarnessInfo, SessionMessage, SessionMetrics, - SessionState, WorktreeInfo, + HarnessKind, ScheduledTask, Session, SessionAgentProfile, SessionHarnessInfo, SessionMessage, + SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { @@ -299,6 +299,22 @@ impl StateStore { requested_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS scheduled_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + cron_expr TEXT NOT NULL, + task TEXT NOT NULL, + agent_type TEXT NOT NULL, + profile_name TEXT, + working_dir TEXT NOT NULL, + project TEXT NOT NULL DEFAULT '', + task_group TEXT NOT NULL DEFAULT '', + use_worktree INTEGER NOT NULL DEFAULT 1, + last_run_at TEXT, + next_run_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS conflict_incidents ( id INTEGER PRIMARY KEY AUTOINCREMENT, conflict_key TEXT NOT NULL UNIQUE, @@ -1029,6 +1045,125 @@ impl StateStore { Ok(rows) } + pub fn insert_scheduled_task( + &self, + cron_expr: &str, + task: &str, + agent_type: &str, + profile_name: Option<&str>, + working_dir: &Path, + project: &str, + task_group: &str, + use_worktree: bool, + next_run_at: chrono::DateTime, + ) -> Result { + let now = chrono::Utc::now(); + self.conn.execute( + "INSERT INTO scheduled_tasks ( + cron_expr, + task, + agent_type, + profile_name, + working_dir, + project, + task_group, + use_worktree, + next_run_at, + created_at, + updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + cron_expr, + task, + agent_type, + profile_name, + working_dir.display().to_string(), + project, + task_group, + if use_worktree { 1_i64 } else { 0_i64 }, + next_run_at.to_rfc3339(), + now.to_rfc3339(), + now.to_rfc3339(), + ], + )?; + let id = self.conn.last_insert_rowid(); + self.get_scheduled_task(id)? + .ok_or_else(|| anyhow::anyhow!("Scheduled task {id} was not found after insert")) + } + + pub fn list_scheduled_tasks(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, cron_expr, task, agent_type, profile_name, working_dir, project, task_group, + use_worktree, last_run_at, next_run_at, created_at, updated_at + FROM scheduled_tasks + ORDER BY next_run_at ASC, id ASC", + )?; + + let rows = stmt.query_map([], map_scheduled_task)?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn list_due_scheduled_tasks( + &self, + now: chrono::DateTime, + limit: usize, + ) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT id, cron_expr, task, agent_type, profile_name, working_dir, project, task_group, + use_worktree, last_run_at, next_run_at, created_at, updated_at + FROM scheduled_tasks + WHERE next_run_at <= ?1 + ORDER BY next_run_at ASC, id ASC + LIMIT ?2", + )?; + + let rows = stmt.query_map( + rusqlite::params![now.to_rfc3339(), limit as i64], + map_scheduled_task, + )?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn get_scheduled_task(&self, schedule_id: i64) -> Result> { + self.conn + .query_row( + "SELECT id, cron_expr, task, agent_type, profile_name, working_dir, project, task_group, + use_worktree, last_run_at, next_run_at, created_at, updated_at + FROM scheduled_tasks + WHERE id = ?1", + [schedule_id], + map_scheduled_task, + ) + .optional() + .map_err(Into::into) + } + + pub fn delete_scheduled_task(&self, schedule_id: i64) -> Result { + self.conn + .execute("DELETE FROM scheduled_tasks WHERE id = ?1", [schedule_id]) + .map_err(Into::into) + } + + pub fn record_scheduled_task_run( + &self, + schedule_id: i64, + last_run_at: chrono::DateTime, + next_run_at: chrono::DateTime, + ) -> Result<()> { + self.conn.execute( + "UPDATE scheduled_tasks + SET last_run_at = ?2, next_run_at = ?3, updated_at = ?4 + WHERE id = ?1", + rusqlite::params![ + schedule_id, + last_run_at.to_rfc3339(), + next_run_at.to_rfc3339(), + chrono::Utc::now().to_rfc3339(), + ], + )?; + Ok(()) + } + pub fn update_metrics(&self, session_id: &str, metrics: &SessionMetrics) -> Result<()> { self.conn.execute( "UPDATE sessions @@ -3565,6 +3700,31 @@ fn map_conflict_incident(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + let last_run_at = row + .get::<_, Option>(9)? + .map(|value| parse_store_timestamp(value, 9)) + .transpose()?; + let next_run_at = parse_store_timestamp(row.get::<_, String>(10)?, 10)?; + let created_at = parse_store_timestamp(row.get::<_, String>(11)?, 11)?; + let updated_at = parse_store_timestamp(row.get::<_, String>(12)?, 12)?; + Ok(ScheduledTask { + id: row.get(0)?, + cron_expr: row.get(1)?, + task: row.get(2)?, + agent_type: row.get(3)?, + profile_name: normalize_optional_string(row.get(4)?), + working_dir: PathBuf::from(row.get::<_, String>(5)?), + project: row.get(6)?, + task_group: row.get(7)?, + use_worktree: row.get::<_, i64>(8)? != 0, + last_run_at, + next_run_at, + created_at, + updated_at, + }) +} + fn parse_timestamp_column( value: String, index: usize, @@ -5096,6 +5256,49 @@ mod tests { Ok(()) } + #[test] + fn scheduled_tasks_round_trip_and_advance_runs() -> Result<()> { + let tempdir = TestDir::new("store-scheduled-tasks")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + let due_next_run = now - ChronoDuration::minutes(1); + + let inserted = db.insert_scheduled_task( + "*/15 * * * *", + "Check backlog health", + "claude", + Some("planner"), + tempdir.path(), + "ecc-core", + "scheduled maintenance", + true, + due_next_run, + )?; + + let listed = db.list_scheduled_tasks()?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, inserted.id); + assert_eq!(listed[0].profile_name.as_deref(), Some("planner")); + + let due = db.list_due_scheduled_tasks(now, 10)?; + assert_eq!(due.len(), 1); + assert_eq!(due[0].id, inserted.id); + + let advanced_next_run = now + ChronoDuration::minutes(15); + db.record_scheduled_task_run(inserted.id, now, advanced_next_run)?; + + let refreshed = db + .get_scheduled_task(inserted.id)? + .context("scheduled task should still exist")?; + assert_eq!(refreshed.last_run_at, Some(now)); + assert_eq!(refreshed.next_run_at, advanced_next_run); + + assert_eq!(db.delete_scheduled_task(inserted.id)?, 1); + assert!(db.get_scheduled_task(inserted.id)?.is_none()); + + Ok(()) + } + #[test] fn context_graph_detail_includes_incoming_and_outgoing_relations() -> Result<()> { let tempdir = TestDir::new("store-context-relations")?; From bcd869d520ac5eba7c9ec78e6ff69db3955fb0a2 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 08:45:47 -0700 Subject: [PATCH 149/459] feat: add ecc2 configurable harness runners --- ecc2/src/config/mod.rs | 87 ++++++++++++ ecc2/src/session/manager.rs | 268 ++++++++++++++++++++++++++++++++++-- ecc2/src/tui/dashboard.rs | 1 + 3 files changed, 343 insertions(+), 13 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 2f2309d0..159d78f0 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -79,6 +79,26 @@ pub struct ResolvedAgentProfile { pub append_system_prompt: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HarnessRunnerConfig { + pub program: String, + pub base_args: Vec, + pub cwd_flag: Option, + pub session_name_flag: Option, + pub task_flag: Option, + pub model_flag: Option, + pub add_dir_flag: Option, + pub include_directories_flag: Option, + pub allowed_tools_flag: Option, + pub disallowed_tools_flag: Option, + pub permission_mode_flag: Option, + pub max_budget_usd_flag: Option, + pub append_system_prompt_flag: Option, + pub inline_system_prompt_for_task: bool, + pub env: BTreeMap, +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct OrchestrationTemplateConfig { @@ -198,6 +218,7 @@ pub struct Config { pub auto_terminate_stale_sessions: bool, pub default_agent: String, pub default_agent_profile: Option, + pub harness_runners: BTreeMap, pub agent_profiles: BTreeMap, pub orchestration_templates: BTreeMap, pub memory_connectors: BTreeMap, @@ -263,6 +284,7 @@ impl Default for Config { auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), default_agent_profile: None, + harness_runners: BTreeMap::new(), agent_profiles: BTreeMap::new(), orchestration_templates: BTreeMap::new(), memory_connectors: BTreeMap::new(), @@ -329,6 +351,11 @@ impl Config { self.resolve_agent_profile_inner(name, &mut chain) } + pub fn harness_runner(&self, harness: &str) -> Option<&HarnessRunnerConfig> { + let key = harness.trim().to_ascii_lowercase(); + self.harness_runners.get(&key) + } + pub fn resolve_orchestration_template( &self, name: &str, @@ -720,6 +747,28 @@ impl ResolvedAgentProfile { } } +impl Default for HarnessRunnerConfig { + fn default() -> Self { + Self { + program: String::new(), + base_args: Vec::new(), + cwd_flag: None, + session_name_flag: None, + task_flag: None, + model_flag: None, + add_dir_flag: None, + include_directories_flag: None, + allowed_tools_flag: None, + disallowed_tools_flag: None, + permission_mode_flag: None, + max_budget_usd_flag: None, + append_system_prompt_flag: None, + inline_system_prompt_for_task: true, + env: BTreeMap::new(), + } + } +} + fn merge_unique(base: &mut Vec, additions: &[T]) where T: Clone + PartialEq, @@ -1210,6 +1259,44 @@ inherits = "a" .contains("agent profile inheritance cycle")); } + #[test] + fn harness_runners_deserialize_from_toml() { + let config: Config = toml::from_str( + r#" +[harness_runners.cursor] +program = "cursor-agent" +base_args = ["run"] +cwd_flag = "--cwd" +session_name_flag = "--name" +task_flag = "--task" +model_flag = "--model" +permission_mode_flag = "--permission-mode" +inline_system_prompt_for_task = true + +[harness_runners.cursor.env] +ECC_HARNESS = "cursor" +"#, + ) + .unwrap(); + + let runner = config.harness_runner("cursor").expect("cursor runner"); + assert_eq!(runner.program, "cursor-agent"); + assert_eq!(runner.base_args, vec!["run"]); + assert_eq!(runner.cwd_flag.as_deref(), Some("--cwd")); + assert_eq!(runner.session_name_flag.as_deref(), Some("--name")); + assert_eq!(runner.task_flag.as_deref(), Some("--task")); + assert_eq!(runner.model_flag.as_deref(), Some("--model")); + assert_eq!( + runner.permission_mode_flag.as_deref(), + Some("--permission-mode") + ); + assert!(runner.inline_system_prompt_for_task); + assert_eq!( + runner.env.get("ECC_HARNESS").map(String::as_str), + Some("cursor") + ); + } + #[test] fn orchestration_templates_resolve_steps_and_interpolate_variables() { let config: Config = toml::from_str( diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 150b4ef0..a8ed0f91 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -2002,8 +2002,17 @@ pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { Ok(()) } -fn agent_program(agent_type: &str) -> Result { - match HarnessKind::from_agent_type(agent_type) { +fn agent_program(cfg: &Config, agent_type: &str) -> Result { + let harness = HarnessKind::from_agent_type(agent_type); + if let Some(runner) = cfg.harness_runner(harness.as_str()) { + let program = runner.program.trim(); + if program.is_empty() { + anyhow::bail!("Configured harness runner for {harness} is missing a program"); + } + return Ok(PathBuf::from(program)); + } + + match harness { HarnessKind::Claude => Ok(PathBuf::from("claude")), HarnessKind::Codex => Ok(PathBuf::from("codex")), HarnessKind::OpenCode => Ok(PathBuf::from("opencode")), @@ -2067,9 +2076,10 @@ pub async fn run_session( return Ok(()); } - let agent_program = agent_program(agent_type)?; + let agent_program = agent_program(cfg, agent_type)?; let profile = db.get_session_profile(session_id)?; let command = build_agent_command( + cfg, agent_type, &agent_program, task, @@ -2666,6 +2676,7 @@ async fn spawn_session_runner_for_program( } fn build_agent_command( + cfg: &Config, agent_type: &str, agent_program: &Path, task: &str, @@ -2674,6 +2685,17 @@ fn build_agent_command( profile: Option<&SessionAgentProfile>, ) -> Command { let harness = HarnessKind::from_agent_type(agent_type); + if let Some(runner) = cfg.harness_runner(harness.as_str()) { + return build_configured_harness_command( + runner, + agent_program, + task, + session_id, + working_dir, + profile, + ); + } + let task = normalize_task_for_harness(harness, task, profile); let mut command = Command::new(agent_program); command.env("ECC_SESSION_ID", session_id); @@ -2771,23 +2793,122 @@ fn build_agent_command( command } +fn build_configured_harness_command( + runner: &crate::config::HarnessRunnerConfig, + agent_program: &Path, + task: &str, + session_id: &str, + working_dir: &Path, + profile: Option<&SessionAgentProfile>, +) -> Command { + let mut command = Command::new(agent_program); + command.env("ECC_SESSION_ID", session_id); + for (key, value) in &runner.env { + if !value.trim().is_empty() { + command.env(key, value); + } + } + for arg in &runner.base_args { + if !arg.trim().is_empty() { + command.arg(arg); + } + } + if let Some(flag) = runner.cwd_flag.as_deref() { + command.arg(flag).arg(working_dir); + } + if let Some(flag) = runner.session_name_flag.as_deref() { + command.arg(flag).arg(format!("ecc-{session_id}")); + } + if let Some(profile) = profile { + if let (Some(flag), Some(model)) = (runner.model_flag.as_deref(), profile.model.as_ref()) { + command.arg(flag).arg(model); + } + if let Some(flag) = runner.add_dir_flag.as_deref() { + for dir in &profile.add_dirs { + command.arg(flag).arg(dir); + } + } + if let Some(flag) = runner.include_directories_flag.as_deref() { + if !profile.add_dirs.is_empty() { + let include_dirs = profile + .add_dirs + .iter() + .map(|dir| dir.to_string_lossy().to_string()) + .collect::>() + .join(","); + command.arg(flag).arg(include_dirs); + } + } + if let Some(flag) = runner.allowed_tools_flag.as_deref() { + if !profile.allowed_tools.is_empty() { + command.arg(flag).arg(profile.allowed_tools.join(",")); + } + } + if let Some(flag) = runner.disallowed_tools_flag.as_deref() { + if !profile.disallowed_tools.is_empty() { + command.arg(flag).arg(profile.disallowed_tools.join(",")); + } + } + if let (Some(flag), Some(permission_mode)) = ( + runner.permission_mode_flag.as_deref(), + profile.permission_mode.as_ref(), + ) { + command.arg(flag).arg(permission_mode); + } + if let (Some(flag), Some(max_budget_usd)) = ( + runner.max_budget_usd_flag.as_deref(), + profile.max_budget_usd, + ) { + command.arg(flag).arg(max_budget_usd.to_string()); + } + if let (Some(flag), Some(prompt)) = ( + runner.append_system_prompt_flag.as_deref(), + profile.append_system_prompt.as_ref(), + ) { + command.arg(flag).arg(prompt); + } + } + + let task = if runner.inline_system_prompt_for_task && runner.append_system_prompt_flag.is_none() + { + normalize_task_with_inline_system_prompt(task, profile) + } else { + task.to_string() + }; + + if let Some(flag) = runner.task_flag.as_deref() { + command.arg(flag); + } + command + .arg(task) + .current_dir(working_dir) + .stdin(Stdio::null()); + command +} + fn normalize_task_for_harness( harness: HarnessKind, task: &str, profile: Option<&SessionAgentProfile>, +) -> String { + let rendered = normalize_task_with_inline_system_prompt(task, profile); + + match harness { + HarnessKind::Claude => task.to_string(), + HarnessKind::Codex | HarnessKind::OpenCode | HarnessKind::Gemini => rendered, + _ => task.to_string(), + } +} + +fn normalize_task_with_inline_system_prompt( + task: &str, + profile: Option<&SessionAgentProfile>, ) -> String { let Some(system_prompt) = profile.and_then(|profile| profile.append_system_prompt.as_ref()) else { return task.to_string(); }; - - match harness { - HarnessKind::Claude => task.to_string(), - HarnessKind::Codex | HarnessKind::OpenCode | HarnessKind::Gemini => { - format!("System instructions:\n{system_prompt}\n\nTask:\n{task}") - } - _ => task.to_string(), - } + format!("System instructions:\n{system_prompt}\n\nTask:\n{task}") } async fn spawn_claude_code( @@ -2796,8 +2917,15 @@ async fn spawn_claude_code( session_id: &str, working_dir: &Path, ) -> Result { - let mut command = - build_agent_command("claude", agent_program, task, session_id, working_dir, None); + let mut command = build_agent_command( + &Config::default(), + "claude", + agent_program, + task, + session_id, + working_dir, + None, + ); let child = command .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -3490,6 +3618,7 @@ mod tests { auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), default_agent_profile: None, + harness_runners: Default::default(), agent_profiles: Default::default(), orchestration_templates: Default::default(), memory_connectors: Default::default(), @@ -3534,6 +3663,7 @@ mod tests { #[test] fn build_agent_command_applies_profile_runner_flags_for_claude() { + let cfg = Config::default(); let profile = SessionAgentProfile { profile_name: "reviewer".to_string(), agent: None, @@ -3548,6 +3678,7 @@ mod tests { }; let command = build_agent_command( + &cfg, "claude", Path::new("claude"), "review this change", @@ -3590,6 +3721,7 @@ mod tests { #[test] fn build_agent_command_normalizes_runner_flags_for_codex() { + let cfg = Config::default(); let profile = SessionAgentProfile { profile_name: "reviewer".to_string(), agent: None, @@ -3604,6 +3736,7 @@ mod tests { }; let command = build_agent_command( + &cfg, "codex", Path::new("codex"), "review this change", @@ -3641,6 +3774,7 @@ mod tests { #[test] fn build_agent_command_normalizes_runner_flags_for_opencode() { + let cfg = Config::default(); let profile = SessionAgentProfile { profile_name: "builder".to_string(), agent: None, @@ -3655,6 +3789,7 @@ mod tests { }; let command = build_agent_command( + &cfg, "opencode", Path::new("opencode"), "stabilize callback flow", @@ -3685,6 +3820,7 @@ mod tests { #[test] fn build_agent_command_normalizes_runner_flags_for_gemini() { + let cfg = Config::default(); let profile = SessionAgentProfile { profile_name: "investigator".to_string(), agent: None, @@ -3699,6 +3835,7 @@ mod tests { }; let command = build_agent_command( + &cfg, "gemini", Path::new("gemini"), "investigate auth regression", @@ -3725,6 +3862,111 @@ mod tests { ); } + #[test] + fn agent_program_uses_configured_runner_for_cursor() -> Result<()> { + let mut cfg = Config::default(); + cfg.harness_runners.insert( + "cursor".to_string(), + crate::config::HarnessRunnerConfig { + program: "cursor-agent".to_string(), + ..Default::default() + }, + ); + + assert_eq!( + agent_program(&cfg, "cursor")?, + PathBuf::from("cursor-agent") + ); + Ok(()) + } + + #[test] + fn build_agent_command_uses_configured_runner_for_cursor() { + let mut cfg = Config::default(); + cfg.harness_runners.insert( + "cursor".to_string(), + crate::config::HarnessRunnerConfig { + program: "cursor-agent".to_string(), + base_args: vec!["run".to_string()], + cwd_flag: Some("--cwd".to_string()), + session_name_flag: Some("--name".to_string()), + task_flag: Some("--task".to_string()), + model_flag: Some("--model".to_string()), + permission_mode_flag: Some("--permission-mode".to_string()), + add_dir_flag: Some("--context-dir".to_string()), + inline_system_prompt_for_task: true, + env: BTreeMap::from([("ECC_HARNESS".to_string(), "cursor".to_string())]), + ..Default::default() + }, + ); + let profile = SessionAgentProfile { + profile_name: "worker".to_string(), + agent: None, + model: Some("gpt-5.4".to_string()), + allowed_tools: Vec::new(), + disallowed_tools: Vec::new(), + permission_mode: Some("plan".to_string()), + add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")], + max_budget_usd: None, + token_budget: None, + append_system_prompt: Some("Use repo context carefully.".to_string()), + }; + + let command = build_agent_command( + &cfg, + "cursor", + Path::new("cursor-agent"), + "fix callback regression", + "sess-cur1", + Path::new("/tmp/repo"), + Some(&profile), + ); + let args = command + .as_std() + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!( + args, + vec![ + "run", + "--cwd", + "/tmp/repo", + "--name", + "ecc-sess-cur1", + "--model", + "gpt-5.4", + "--context-dir", + "docs", + "--context-dir", + "specs", + "--permission-mode", + "plan", + "--task", + "System instructions:\nUse repo context carefully.\n\nTask:\nfix callback regression", + ] + ); + let mut envs = command + .as_std() + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().to_string(), + value.map(|value| value.to_string_lossy().to_string()), + ) + }) + .collect::>(); + envs.sort(); + assert_eq!( + envs, + vec![ + ("ECC_HARNESS".to_string(), Some("cursor".to_string())), + ("ECC_SESSION_ID".to_string(), Some("sess-cur1".to_string())), + ] + ); + } + #[test] fn build_session_record_canonicalizes_known_agent_aliases() -> Result<()> { let tempdir = TestDir::new("manager-canonical-agent-type")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index dab25e72..6345872e 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -14590,6 +14590,7 @@ diff --git a/src/lib.rs b/src/lib.rs auto_terminate_stale_sessions: false, default_agent: "claude".to_string(), default_agent_profile: None, + harness_runners: Default::default(), agent_profiles: Default::default(), orchestration_templates: Default::default(), memory_connectors: Default::default(), From 4a1f3cbd3fb9dbcd19b54b7b1e48d9e7489953e1 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 08:57:59 -0700 Subject: [PATCH 150/459] feat: preserve custom ecc2 harness labels --- ecc2/src/main.rs | 4 +- ecc2/src/session/manager.rs | 27 ++++++++-- ecc2/src/session/mod.rs | 104 ++++++++++++++++++++++++++++++++++-- ecc2/src/session/store.rs | 61 ++++++++++++++++----- ecc2/src/tui/dashboard.rs | 2 +- 5 files changed, 172 insertions(+), 26 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index d5668649..decbe825 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1230,8 +1230,8 @@ async fn main() -> Result<()> { for s in sessions { let harness = harnesses .get(&s.id) - .map(|info| info.primary.to_string()) - .unwrap_or_else(|| "unknown".to_string()); + .map(|info| info.primary_label.clone()) + .unwrap_or_else(|| session::SessionHarnessInfo::runner_key(&s.agent_type)); println!("{} [{}] [{}] {}", s.id, s.state, harness, s.task); } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index a8ed0f91..807605a3 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -2004,10 +2004,11 @@ pub async fn delete_session(db: &StateStore, id: &str) -> Result<()> { fn agent_program(cfg: &Config, agent_type: &str) -> Result { let harness = HarnessKind::from_agent_type(agent_type); - if let Some(runner) = cfg.harness_runner(harness.as_str()) { + let runner_key = SessionHarnessInfo::runner_key(agent_type); + if let Some(runner) = cfg.harness_runner(&runner_key) { let program = runner.program.trim(); if program.is_empty() { - anyhow::bail!("Configured harness runner for {harness} is missing a program"); + anyhow::bail!("Configured harness runner for {runner_key} is missing a program"); } return Ok(PathBuf::from(program)); } @@ -2685,7 +2686,7 @@ fn build_agent_command( profile: Option<&SessionAgentProfile>, ) -> Command { let harness = HarnessKind::from_agent_type(agent_type); - if let Some(runner) = cfg.harness_runner(harness.as_str()) { + if let Some(runner) = cfg.harness_runner(&SessionHarnessInfo::runner_key(agent_type)) { return build_configured_harness_command( runner, agent_program, @@ -3327,7 +3328,7 @@ impl fmt::Display for SessionStatus { writeln!(f, "Session: {}", s.id)?; writeln!(f, "Task: {}", s.task)?; writeln!(f, "Agent: {}", s.agent_type)?; - writeln!(f, "Harness: {}", self.harness.primary)?; + writeln!(f, "Harness: {}", self.harness.primary_label)?; writeln!(f, "Detected: {}", self.harness.detected_summary())?; writeln!(f, "State: {}", s.state)?; if let Some(profile) = self.profile.as_ref() { @@ -3880,6 +3881,24 @@ mod tests { Ok(()) } + #[test] + fn agent_program_uses_configured_runner_for_unknown_custom_harness() -> Result<()> { + let mut cfg = Config::default(); + cfg.harness_runners.insert( + "acme-runner".to_string(), + crate::config::HarnessRunnerConfig { + program: "acme-agent".to_string(), + ..Default::default() + }, + ); + + assert_eq!( + agent_program(&cfg, "acme-runner")?, + PathBuf::from("acme-agent") + ); + Ok(()) + } + #[test] fn build_agent_command_uses_configured_runner_for_cursor() { let mut cfg = Config::default(); diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index ffff01e7..2c1ba242 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -109,11 +109,38 @@ impl fmt::Display for HarnessKind { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct SessionHarnessInfo { pub primary: HarnessKind, + pub primary_label: String, pub detected: Vec, } impl SessionHarnessInfo { + pub fn runner_key(agent_type: &str) -> String { + let canonical = HarnessKind::canonical_agent_type(agent_type); + match HarnessKind::from_agent_type(&canonical) { + HarnessKind::Unknown if canonical.is_empty() => { + HarnessKind::Unknown.as_str().to_string() + } + HarnessKind::Unknown => canonical, + harness => harness.as_str().to_string(), + } + } + + fn primary_label_for(agent_type: &str, primary: HarnessKind) -> String { + match primary { + HarnessKind::Unknown => { + let label = Self::runner_key(agent_type); + if label.is_empty() { + HarnessKind::Unknown.as_str().to_string() + } else { + label + } + } + harness => harness.as_str().to_string(), + } + } + pub fn detect(agent_type: &str, working_dir: &Path) -> Self { + let runner_key = Self::runner_key(agent_type); let detected = [ HarnessKind::Claude, HarnessKind::Codex, @@ -132,12 +159,43 @@ impl SessionHarnessInfo { }) .collect::>(); - let primary = match HarnessKind::from_agent_type(agent_type) { - HarnessKind::Unknown => detected.first().copied().unwrap_or(HarnessKind::Unknown), + let primary = match HarnessKind::from_agent_type(&runner_key) { + HarnessKind::Unknown if runner_key == HarnessKind::Unknown.as_str() => { + detected.first().copied().unwrap_or(HarnessKind::Unknown) + } + HarnessKind::Unknown => HarnessKind::Unknown, harness => harness, }; - Self { primary, detected } + Self { + primary, + primary_label: Self::primary_label_for(agent_type, primary), + detected, + } + } + + pub fn from_persisted( + harness_label: &str, + agent_type: &str, + working_dir: &Path, + detected: Vec, + ) -> Self { + let primary = HarnessKind::from_db_value(harness_label); + if primary == HarnessKind::Unknown && detected.is_empty() && harness_label.trim().is_empty() + { + return Self::detect(agent_type, working_dir); + } + + let normalized_label = harness_label.trim().to_ascii_lowercase(); + Self { + primary, + primary_label: if normalized_label.is_empty() { + Self::primary_label_for(agent_type, primary) + } else { + normalized_label + }, + detected, + } } pub fn detected_summary(&self) -> String { @@ -510,6 +568,7 @@ mod tests { let harness = SessionHarnessInfo::detect("claude", repo.path()); assert_eq!(harness.primary, HarnessKind::Claude); + assert_eq!(harness.primary_label, "claude"); assert_eq!( harness.detected, vec![HarnessKind::Claude, HarnessKind::Codex] @@ -519,13 +578,14 @@ mod tests { } #[test] - fn detect_session_harness_falls_back_to_project_markers_for_unknown_agent( + fn detect_session_harness_falls_back_to_project_markers_when_agent_unspecified( ) -> Result<(), Box> { let repo = TestDir::new("session-harness-markers")?; fs::create_dir_all(repo.path().join(".gemini"))?; - let harness = SessionHarnessInfo::detect("custom-runner", repo.path()); + let harness = SessionHarnessInfo::detect("", repo.path()); assert_eq!(harness.primary, HarnessKind::Gemini); + assert_eq!(harness.primary_label, "gemini"); assert_eq!(harness.detected, vec![HarnessKind::Gemini]); Ok(()) } @@ -543,4 +603,38 @@ mod tests { "custom-runner" ); } + + #[test] + fn detect_session_harness_preserves_custom_agent_label_without_markers() { + let harness = SessionHarnessInfo::detect(" custom-runner ", Path::new(".")); + assert_eq!(harness.primary, HarnessKind::Unknown); + assert_eq!(harness.primary_label, "custom-runner"); + assert!(harness.detected.is_empty()); + } + + #[test] + fn detect_session_harness_preserves_custom_agent_label_with_project_markers( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-custom-markers")?; + fs::create_dir_all(repo.path().join(".claude"))?; + fs::create_dir_all(repo.path().join(".codex"))?; + + let harness = SessionHarnessInfo::detect("custom-runner", repo.path()); + assert_eq!(harness.primary, HarnessKind::Unknown); + assert_eq!(harness.primary_label, "custom-runner"); + assert_eq!( + harness.detected, + vec![HarnessKind::Claude, HarnessKind::Codex] + ); + Ok(()) + } + + #[test] + fn runner_key_uses_canonical_label_for_unknown_harnesses() { + assert_eq!( + SessionHarnessInfo::runner_key(" custom-runner "), + "custom-runner" + ); + assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude"); + } } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 31196d3c..7414f99a 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -706,7 +706,7 @@ impl StateStore { rusqlite::params![ session_id, canonical_agent_type, - harness.primary.to_string(), + harness.primary_label, detected_json ], )?; @@ -728,7 +728,7 @@ impl StateStore { session.project, session.task_group, session.agent_type, - harness.primary.to_string(), + harness.primary_label, detected_json, session.working_dir.to_string_lossy().to_string(), session.state.to_string(), @@ -1763,16 +1763,17 @@ impl StateStore { let harnesses = stmt .query_map([], |row| { let session_id: String = row.get(0)?; - let primary = HarnessKind::from_db_value(&row.get::<_, String>(1)?); + let harness_label: String = row.get(1)?; let detected = serde_json::from_str::>(&row.get::<_, String>(2)?) .unwrap_or_default(); let agent_type: String = row.get(3)?; let working_dir = PathBuf::from(row.get::<_, String>(4)?); - let info = if primary == HarnessKind::Unknown && detected.is_empty() { - SessionHarnessInfo::detect(&agent_type, &working_dir) - } else { - SessionHarnessInfo { primary, detected } - }; + let info = SessionHarnessInfo::from_persisted( + &harness_label, + &agent_type, + &working_dir, + detected, + ); Ok((session_id, info)) })? .collect::, _>>()?; @@ -1788,16 +1789,17 @@ impl StateStore { )?; stmt.query_row([session_id], |row| { - let primary = HarnessKind::from_db_value(&row.get::<_, String>(0)?); + let harness_label: String = row.get(0)?; let detected = serde_json::from_str::>(&row.get::<_, String>(1)?) .unwrap_or_default(); let agent_type: String = row.get(2)?; let working_dir = PathBuf::from(row.get::<_, String>(3)?); - let info = if primary == HarnessKind::Unknown && detected.is_empty() { - SessionHarnessInfo::detect(&agent_type, &working_dir) - } else { - SessionHarnessInfo { primary, detected } - }; + let info = SessionHarnessInfo::from_persisted( + &harness_label, + &agent_type, + &working_dir, + detected, + ); Ok(info) }) .optional() @@ -4191,10 +4193,41 @@ mod tests { .get_session_harness_info("sess-legacy")? .expect("legacy row should be backfilled"); assert_eq!(harness.primary, HarnessKind::Gemini); + assert_eq!(harness.primary_label, "gemini"); assert_eq!(harness.detected, vec![HarnessKind::Codex]); Ok(()) } + #[test] + fn insert_session_preserves_custom_harness_label_for_unknown_agent_types() -> Result<()> { + let tempdir = TestDir::new("store-custom-harness-label")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "sess-custom".to_string(), + task: "Run custom harness".to_string(), + project: "ecc".to_string(), + task_group: "compat".to_string(), + agent_type: "acme-runner".to_string(), + working_dir: PathBuf::from(tempdir.path()), + state: SessionState::Pending, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let harness = db + .get_session_harness_info("sess-custom")? + .expect("custom session should have harness info"); + assert_eq!(harness.primary, HarnessKind::Unknown); + assert_eq!(harness.primary_label, "acme-runner"); + Ok(()) + } + #[test] fn session_profile_round_trips_with_launch_settings() -> Result<()> { let tempdir = TestDir::new("store-session-profile")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index 6345872e..a73be631 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -6347,7 +6347,7 @@ impl Dashboard { if let Some(harness) = self.session_harnesses.get(&session.id) { lines.push(format!( "Harness {} | Detected {}", - harness.primary, + harness.primary_label, harness.detected_summary() )); } From bbed46d3eb2ba16bd2c79fb74de954d6cef23d79 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 09:08:06 -0700 Subject: [PATCH 151/459] feat: detect custom ecc2 harness markers --- ecc2/src/config/mod.rs | 7 +++ ecc2/src/main.rs | 10 +++- ecc2/src/session/manager.rs | 37 ++++++++++-- ecc2/src/session/mod.rs | 109 ++++++++++++++++++++++++++++++++++-- ecc2/src/tui/dashboard.rs | 36 +++++++++--- 5 files changed, 176 insertions(+), 23 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 159d78f0..0f083e39 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -84,6 +84,7 @@ pub struct ResolvedAgentProfile { pub struct HarnessRunnerConfig { pub program: String, pub base_args: Vec, + pub project_markers: Vec, pub cwd_flag: Option, pub session_name_flag: Option, pub task_flag: Option, @@ -752,6 +753,7 @@ impl Default for HarnessRunnerConfig { Self { program: String::new(), base_args: Vec::new(), + project_markers: Vec::new(), cwd_flag: None, session_name_flag: None, task_flag: None, @@ -1266,6 +1268,7 @@ inherits = "a" [harness_runners.cursor] program = "cursor-agent" base_args = ["run"] +project_markers = [".cursor", ".cursor/rules"] cwd_flag = "--cwd" session_name_flag = "--name" task_flag = "--task" @@ -1282,6 +1285,10 @@ ECC_HARNESS = "cursor" let runner = config.harness_runner("cursor").expect("cursor runner"); assert_eq!(runner.program, "cursor-agent"); assert_eq!(runner.base_args, vec!["run"]); + assert_eq!( + runner.project_markers, + vec![PathBuf::from(".cursor"), PathBuf::from(".cursor/rules")] + ); assert_eq!(runner.cwd_flag.as_deref(), Some("--cwd")); assert_eq!(runner.session_name_flag.as_deref(), Some("--name")); assert_eq!(runner.task_flag.as_deref(), Some("--task")); diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index decbe825..355a279c 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -1230,15 +1230,19 @@ async fn main() -> Result<()> { for s in sessions { let harness = harnesses .get(&s.id) - .map(|info| info.primary_label.clone()) - .unwrap_or_else(|| session::SessionHarnessInfo::runner_key(&s.agent_type)); + .cloned() + .unwrap_or_else(|| { + session::SessionHarnessInfo::detect(&s.agent_type, &s.working_dir) + }) + .with_config_detection(&cfg, &s.working_dir) + .primary_label; println!("{} [{}] [{}] {}", s.id, s.state, harness, s.task); } } Some(Commands::Status { session_id }) => { sync_runtime_session_metrics(&db, &cfg)?; let id = session_id.unwrap_or_else(|| "latest".to_string()); - let status = session::manager::get_status(&db, &id)?; + let status = session::manager::get_status(&db, &cfg, &id)?; println!("{status}"); } Some(Commands::Team { session_id, depth }) => { diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 807605a3..d5996dc7 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -158,7 +158,7 @@ pub fn list_sessions(db: &StateStore) -> Result> { db.list_sessions() } -pub fn get_status(db: &StateStore, id: &str) -> Result { +pub fn get_status(db: &StateStore, cfg: &Config, id: &str) -> Result { let session = resolve_session(db, id)?; let session_id = session.id.clone(); Ok(SessionStatus { @@ -166,7 +166,8 @@ pub fn get_status(db: &StateStore, id: &str) -> Result { .get_session_harness_info(&session_id)? .unwrap_or_else(|| { SessionHarnessInfo::detect(&session.agent_type, &session.working_dir) - }), + }) + .with_config_detection(cfg, &session.working_dir), profile: db.get_session_profile(&session_id)?, session, parent_session: db.latest_task_handoff_source(&session_id)?, @@ -5500,12 +5501,38 @@ mod tests { db.insert_session(&build_session("older", SessionState::Running, older))?; db.insert_session(&build_session("newer", SessionState::Idle, newer))?; - let status = get_status(&db, "latest")?; + let status = get_status(&db, &cfg, "latest")?; assert_eq!(status.session.id, "newer"); Ok(()) } + #[test] + fn get_status_uses_configured_custom_harness_markers() -> Result<()> { + let tempdir = TestDir::new("manager-custom-harness-status")?; + fs::create_dir_all(tempdir.path().join(".acme"))?; + let mut cfg = build_config(tempdir.path()); + cfg.harness_runners.insert( + "acme-runner".to_string(), + crate::config::HarnessRunnerConfig { + project_markers: vec![PathBuf::from(".acme")], + ..Default::default() + }, + ); + let db = StateStore::open(&cfg.db_path)?; + let mut session = build_session("custom", SessionState::Pending, Utc::now()); + session.agent_type = "".to_string(); + session.working_dir = tempdir.path().to_path_buf(); + db.insert_session(&session)?; + + let status = get_status(&db, &cfg, "custom")?; + assert_eq!(status.harness.primary, HarnessKind::Unknown); + assert_eq!(status.harness.primary_label, "acme-runner"); + assert_eq!(status.harness.detected_summary(), "acme-runner"); + + Ok(()) + } + #[test] fn get_status_surfaces_handoff_lineage() -> Result<()> { let tempdir = TestDir::new("manager-status-lineage")?; @@ -5538,14 +5565,14 @@ mod tests { "task_handoff", )?; - let status = get_status(&db, "parent")?; + let status = get_status(&db, &cfg, "parent")?; let rendered = status.to_string(); assert!(rendered.contains("Children:")); assert!(rendered.contains("child")); assert!(rendered.contains("sibling")); - let child_status = get_status(&db, "child")?; + let child_status = get_status(&db, &cfg, "child")?; assert_eq!(child_status.parent_session.as_deref(), Some("parent")); Ok(()) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 2c1ba242..3b774a70 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -111,9 +111,34 @@ pub struct SessionHarnessInfo { pub primary: HarnessKind, pub primary_label: String, pub detected: Vec, + pub detected_labels: Vec, } impl SessionHarnessInfo { + fn detected_labels_for(detected: &[HarnessKind]) -> Vec { + detected.iter().map(|harness| harness.to_string()).collect() + } + + fn configured_detected_labels(cfg: &crate::config::Config, working_dir: &Path) -> Vec { + let mut labels = Vec::new(); + for (name, runner) in &cfg.harness_runners { + if runner.project_markers.is_empty() { + continue; + } + if runner + .project_markers + .iter() + .any(|marker| working_dir.join(marker).exists()) + { + let label = Self::runner_key(name); + if !label.is_empty() && !labels.contains(&label) { + labels.push(label); + } + } + } + labels + } + pub fn runner_key(agent_type: &str) -> String { let canonical = HarnessKind::canonical_agent_type(agent_type); match HarnessKind::from_agent_type(&canonical) { @@ -167,10 +192,12 @@ impl SessionHarnessInfo { harness => harness, }; + let detected_labels = Self::detected_labels_for(&detected); Self { primary, primary_label: Self::primary_label_for(agent_type, primary), detected, + detected_labels, } } @@ -187,6 +214,7 @@ impl SessionHarnessInfo { } let normalized_label = harness_label.trim().to_ascii_lowercase(); + let detected_labels = Self::detected_labels_for(&detected); Self { primary, primary_label: if normalized_label.is_empty() { @@ -195,18 +223,36 @@ impl SessionHarnessInfo { normalized_label }, detected, + detected_labels, } } + pub fn with_config_detection( + mut self, + cfg: &crate::config::Config, + working_dir: &Path, + ) -> Self { + for label in Self::configured_detected_labels(cfg, working_dir) { + if !self.detected_labels.contains(&label) { + self.detected_labels.push(label); + } + } + + if self.primary == HarnessKind::Unknown + && self.primary_label == HarnessKind::Unknown.as_str() + && !self.detected_labels.is_empty() + { + self.primary_label = self.detected_labels[0].clone(); + } + + self + } + pub fn detected_summary(&self) -> String { - if self.detected.is_empty() { + if self.detected_labels.is_empty() { "none detected".to_string() } else { - self.detected - .iter() - .map(|harness| harness.to_string()) - .collect::>() - .join(", ") + self.detected_labels.join(", ") } } } @@ -573,6 +619,7 @@ mod tests { harness.detected, vec![HarnessKind::Claude, HarnessKind::Codex] ); + assert_eq!(harness.detected_labels, vec!["claude", "codex"]); assert_eq!(harness.detected_summary(), "claude, codex"); Ok(()) } @@ -587,6 +634,7 @@ mod tests { assert_eq!(harness.primary, HarnessKind::Gemini); assert_eq!(harness.primary_label, "gemini"); assert_eq!(harness.detected, vec![HarnessKind::Gemini]); + assert_eq!(harness.detected_labels, vec!["gemini"]); Ok(()) } @@ -610,6 +658,7 @@ mod tests { assert_eq!(harness.primary, HarnessKind::Unknown); assert_eq!(harness.primary_label, "custom-runner"); assert!(harness.detected.is_empty()); + assert!(harness.detected_labels.is_empty()); } #[test] @@ -626,6 +675,54 @@ mod tests { harness.detected, vec![HarnessKind::Claude, HarnessKind::Codex] ); + assert_eq!(harness.detected_labels, vec!["claude", "codex"]); + Ok(()) + } + + #[test] + fn config_detection_adds_custom_markers_to_detected_summary( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-custom-config")?; + fs::create_dir_all(repo.path().join(".acme"))?; + let mut cfg = crate::config::Config::default(); + cfg.harness_runners.insert( + "acme-runner".to_string(), + crate::config::HarnessRunnerConfig { + project_markers: vec![PathBuf::from(".acme")], + ..Default::default() + }, + ); + + let harness = + SessionHarnessInfo::detect("", repo.path()).with_config_detection(&cfg, repo.path()); + assert_eq!(harness.primary, HarnessKind::Unknown); + assert_eq!(harness.primary_label, "acme-runner"); + assert_eq!(harness.detected_labels, vec!["acme-runner"]); + assert_eq!(harness.detected_summary(), "acme-runner"); + Ok(()) + } + + #[test] + fn config_detection_preserves_custom_primary_label_and_appends_marker_matches( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-config-append")?; + fs::create_dir_all(repo.path().join(".acme"))?; + fs::create_dir_all(repo.path().join(".codex"))?; + let mut cfg = crate::config::Config::default(); + cfg.harness_runners.insert( + "acme-runner".to_string(), + crate::config::HarnessRunnerConfig { + project_markers: vec![PathBuf::from(".acme")], + ..Default::default() + }, + ); + + let harness = SessionHarnessInfo::detect("acme-runner", repo.path()) + .with_config_detection(&cfg, repo.path()); + assert_eq!(harness.primary, HarnessKind::Unknown); + assert_eq!(harness.primary_label, "acme-runner"); + assert_eq!(harness.detected_labels, vec!["codex", "acme-runner"]); + assert_eq!(harness.detected_summary(), "codex, acme-runner"); Ok(()) } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a73be631..c2f94056 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -476,6 +476,29 @@ impl SessionCompletionSummary { } } +fn load_session_harnesses( + db: &StateStore, + cfg: &Config, + sessions: &[Session], +) -> HashMap { + let working_dirs = sessions + .iter() + .map(|session| (session.id.as_str(), session.working_dir.as_path())) + .collect::>(); + db.list_session_harnesses() + .unwrap_or_default() + .into_iter() + .map(|(session_id, info)| { + let info = if let Some(working_dir) = working_dirs.get(session_id.as_str()) { + info.with_config_detection(cfg, working_dir) + } else { + info + }; + (session_id, info) + }) + .collect() +} + impl Dashboard { pub fn new(db: StateStore, cfg: Config) -> Self { Self::with_output_store(db, cfg, SessionOutputStore::default()) @@ -498,7 +521,7 @@ impl Dashboard { let _ = db.sync_tool_activity_metrics(&cfg.tool_activity_metrics_path()); } let sessions = db.list_sessions().unwrap_or_default(); - let session_harnesses = db.list_session_harnesses().unwrap_or_default(); + let session_harnesses = load_session_harnesses(&db, &cfg, &sessions); let initial_session_states = sessions .iter() .map(|session| (session.id.clone(), session.state.clone())) @@ -4040,13 +4063,7 @@ impl Dashboard { Vec::new() } }; - self.session_harnesses = match self.db.list_session_harnesses() { - Ok(harnesses) => harnesses, - Err(error) => { - tracing::warn!("Failed to refresh session harnesses: {error}"); - HashMap::new() - } - }; + self.session_harnesses = load_session_harnesses(&self.db, &self.cfg, &self.sessions); self.unread_message_counts = match self.db.unread_message_counts() { Ok(counts) => counts, Err(error) => { @@ -14488,7 +14505,8 @@ diff --git a/src/lib.rs b/src/lib.rs .map(|session| { ( session.id.clone(), - SessionHarnessInfo::detect(&session.agent_type, &session.working_dir), + SessionHarnessInfo::detect(&session.agent_type, &session.working_dir) + .with_config_detection(&cfg, &session.working_dir), ) }) .collect(); From 780951861270057cb3a17dcae76bbae3b6f929c0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 09:21:30 -0700 Subject: [PATCH 152/459] feat: add ecc2 remote dispatch intake --- ecc2/src/comms/mod.rs | 13 ++ ecc2/src/main.rs | 427 +++++++++++++++++++++++++++++++++++- ecc2/src/session/daemon.rs | 23 ++ ecc2/src/session/manager.rs | 366 ++++++++++++++++++++++++++++++- ecc2/src/session/mod.rs | 51 +++++ ecc2/src/session/store.rs | 224 ++++++++++++++++++- 6 files changed, 1098 insertions(+), 6 deletions(-) diff --git a/ecc2/src/comms/mod.rs b/ecc2/src/comms/mod.rs index 376dfd57..f7838f29 100644 --- a/ecc2/src/comms/mod.rs +++ b/ecc2/src/comms/mod.rs @@ -1,5 +1,6 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; +use std::fmt; use crate::session::store::StateStore; @@ -13,6 +14,18 @@ pub enum TaskPriority { Critical, } +impl fmt::Display for TaskPriority { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Low => "low", + Self::Normal => "normal", + Self::High => "high", + Self::Critical => "critical", + }; + write!(f, "{label}") + } +} + /// Message types for inter-agent communication. #[derive(Debug, Clone, Serialize, Deserialize)] pub enum MessageType { diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 355a279c..33580245 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -11,7 +11,8 @@ use clap::Parser; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs::File; -use std::io::{BufRead, BufReader}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use tracing_subscriber::EnvFilter; @@ -327,6 +328,11 @@ enum Commands { #[command(subcommand)] command: ScheduleCommands, }, + /// Manage remote task intake and dispatch + Remote { + #[command(subcommand)] + command: RemoteCommands, + }, /// Export sessions, tool spans, and metrics in OTLP-compatible JSON ExportOtel { /// Session ID or alias. Omit to export all sessions. @@ -442,6 +448,69 @@ enum ScheduleCommands { }, } +#[derive(clap::Subcommand, Debug)] +enum RemoteCommands { + /// Queue a remote task request + Add { + /// Task description to dispatch + #[arg(short, long)] + task: String, + /// Optional lead session ID or alias to route through + #[arg(long)] + to_session: Option, + /// Task priority + #[arg(long, value_enum, default_value_t = TaskPriorityArg::Normal)] + priority: TaskPriorityArg, + /// Agent type (defaults to ECC default agent) + #[arg(short, long)] + agent: Option, + /// Agent profile defined in ecc2.toml + #[arg(long)] + profile: Option, + #[command(flatten)] + worktree: WorktreePolicyArgs, + /// Optional project grouping override + #[arg(long)] + project: Option, + /// Optional task-group grouping override + #[arg(long)] + task_group: Option, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// List queued remote task requests + List { + /// Include already dispatched or failed requests + #[arg(long)] + all: bool, + /// Maximum requests to return + #[arg(long, default_value_t = 20)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Dispatch queued remote task requests now + Run { + /// Maximum queued requests to process + #[arg(long, default_value_t = 20)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, + /// Serve a token-authenticated remote dispatch intake endpoint + Serve { + /// Address to bind, for example 127.0.0.1:8787 + #[arg(long, default_value = "127.0.0.1:8787")] + bind: String, + /// Bearer token required for POST /dispatch + #[arg(long)] + token: String, + }, +} + #[derive(clap::Subcommand, Debug)] enum GraphCommands { /// Create or update a graph entity @@ -656,7 +725,8 @@ enum MessageKindArg { Conflict, } -#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] enum TaskPriorityArg { Low, Normal, @@ -734,6 +804,18 @@ struct GraphConnectorStatusReport { connectors: Vec, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct RemoteDispatchHttpRequest { + task: String, + to_session: Option, + priority: Option, + agent: Option, + profile: Option, + use_worktree: Option, + project: Option, + task_group: Option, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct JsonlMemoryConnectorRecord { @@ -1870,6 +1952,106 @@ async fn main() -> Result<()> { } } }, + Some(Commands::Remote { command }) => match command { + RemoteCommands::Add { + task, + to_session, + priority, + agent, + profile, + worktree, + project, + task_group, + json, + } => { + let target_session_id = to_session + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let request = session::manager::create_remote_dispatch_request( + &db, + &cfg, + &task, + target_session_id.as_deref(), + priority.into(), + agent.as_deref().unwrap_or(&cfg.default_agent), + profile.as_deref(), + worktree.resolve(&cfg), + session::SessionGrouping { + project, + task_group, + }, + "cli", + None, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&request)?); + } else { + println!( + "Queued remote request #{} [{}] {}", + request.id, request.priority, request.task + ); + if let Some(target_session_id) = request.target_session_id.as_deref() { + println!("- target {}", short_session(target_session_id)); + } + } + } + RemoteCommands::List { all, limit, json } => { + let requests = session::manager::list_remote_dispatch_requests(&db, all, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&requests)?); + } else if requests.is_empty() { + println!("No remote dispatch requests"); + } else { + println!("Remote dispatch requests"); + for request in requests { + let target = request + .target_session_id + .as_deref() + .map(short_session) + .unwrap_or_else(|| "new-session".to_string()); + println!( + "#{} [{}] {} -> {} | {}", + request.id, request.priority, request.status, target, request.task + ); + } + } + } + RemoteCommands::Run { limit, json } => { + let outcomes = + session::manager::run_remote_dispatch_requests(&db, &cfg, limit).await?; + if json { + println!("{}", serde_json::to_string_pretty(&outcomes)?); + } else if outcomes.is_empty() { + println!("No pending remote dispatch requests"); + } else { + println!("Processed {} remote request(s)", outcomes.len()); + for outcome in outcomes { + let target = outcome + .target_session_id + .as_deref() + .map(short_session) + .unwrap_or_else(|| "new-session".to_string()); + let result = outcome + .session_id + .as_deref() + .map(short_session) + .unwrap_or_else(|| "-".to_string()); + println!( + "#{} [{}] {} -> {} | {}", + outcome.request_id, + outcome.priority, + target, + result, + format_remote_dispatch_action(&outcome.action) + ); + } + } + } + RemoteCommands::Serve { bind, token } => { + run_remote_dispatch_server(&db, &cfg, &bind, &token)?; + } + }, Some(Commands::Daemon) => { println!("Starting ECC daemon..."); session::daemon::run(db, cfg).await?; @@ -2894,10 +3076,251 @@ fn build_message( }) } +fn format_remote_dispatch_action(action: &session::manager::RemoteDispatchAction) -> String { + match action { + session::manager::RemoteDispatchAction::SpawnedTopLevel => "spawned top-level".to_string(), + session::manager::RemoteDispatchAction::Assigned(action) => match action { + session::manager::AssignmentAction::Spawned => "spawned delegate".to_string(), + session::manager::AssignmentAction::ReusedIdle => "reused idle delegate".to_string(), + session::manager::AssignmentAction::ReusedActive => { + "reused active delegate".to_string() + } + session::manager::AssignmentAction::DeferredSaturated => { + "deferred (saturated)".to_string() + } + }, + session::manager::RemoteDispatchAction::DeferredSaturated => { + "deferred (saturated)".to_string() + } + session::manager::RemoteDispatchAction::Failed(error) => format!("failed: {error}"), + } +} + fn short_session(session_id: &str) -> String { session_id.chars().take(8).collect() } +fn run_remote_dispatch_server( + db: &session::store::StateStore, + cfg: &config::Config, + bind_addr: &str, + bearer_token: &str, +) -> Result<()> { + let listener = TcpListener::bind(bind_addr) + .with_context(|| format!("Failed to bind remote dispatch server on {bind_addr}"))?; + println!("Remote dispatch server listening on http://{bind_addr}"); + + for stream in listener.incoming() { + match stream { + Ok(mut stream) => { + if let Err(error) = + handle_remote_dispatch_connection(&mut stream, db, cfg, bearer_token) + { + let _ = write_http_response( + &mut stream, + 500, + "application/json", + &serde_json::json!({ + "error": error.to_string(), + }) + .to_string(), + ); + } + } + Err(error) => tracing::warn!("Remote dispatch accept failed: {error}"), + } + } + + Ok(()) +} + +fn handle_remote_dispatch_connection( + stream: &mut TcpStream, + db: &session::store::StateStore, + cfg: &config::Config, + bearer_token: &str, +) -> Result<()> { + let (method, path, headers, body) = read_http_request(stream)?; + match (method.as_str(), path.as_str()) { + ("GET", "/health") => write_http_response( + stream, + 200, + "application/json", + &serde_json::json!({"ok": true}).to_string(), + ), + ("POST", "/dispatch") => { + let auth = headers + .get("authorization") + .map(String::as_str) + .unwrap_or_default(); + let expected = format!("Bearer {bearer_token}"); + if auth != expected { + return write_http_response( + stream, + 401, + "application/json", + &serde_json::json!({"error": "unauthorized"}).to_string(), + ); + } + + let payload: RemoteDispatchHttpRequest = + serde_json::from_slice(&body).context("Invalid remote dispatch JSON body")?; + if payload.task.trim().is_empty() { + return write_http_response( + stream, + 400, + "application/json", + &serde_json::json!({"error": "task is required"}).to_string(), + ); + } + + let target_session_id = match payload + .to_session + .as_deref() + .map(|value| resolve_session_id(db, value)) + .transpose() + { + Ok(value) => value, + Err(error) => { + return write_http_response( + stream, + 400, + "application/json", + &serde_json::json!({"error": error.to_string()}).to_string(), + ); + } + }; + let requester = stream.peer_addr().ok().map(|addr| addr.ip().to_string()); + let request = match session::manager::create_remote_dispatch_request( + db, + cfg, + &payload.task, + target_session_id.as_deref(), + payload.priority.unwrap_or(TaskPriorityArg::Normal).into(), + payload.agent.as_deref().unwrap_or(&cfg.default_agent), + payload.profile.as_deref(), + payload.use_worktree.unwrap_or(cfg.auto_create_worktrees), + session::SessionGrouping { + project: payload.project, + task_group: payload.task_group, + }, + "http", + requester.as_deref(), + ) { + Ok(request) => request, + Err(error) => { + return write_http_response( + stream, + 400, + "application/json", + &serde_json::json!({"error": error.to_string()}).to_string(), + ); + } + }; + + write_http_response( + stream, + 202, + "application/json", + &serde_json::to_string(&request)?, + ) + } + _ => write_http_response( + stream, + 404, + "application/json", + &serde_json::json!({"error": "not found"}).to_string(), + ), + } +} + +fn read_http_request( + stream: &mut TcpStream, +) -> Result<(String, String, BTreeMap, Vec)> { + let mut buffer = Vec::new(); + let mut temp = [0_u8; 1024]; + let header_end = loop { + let read = stream.read(&mut temp)?; + if read == 0 { + anyhow::bail!("Unexpected EOF while reading HTTP request"); + } + buffer.extend_from_slice(&temp[..read]); + if let Some(index) = buffer.windows(4).position(|window| window == b"\r\n\r\n") { + break index + 4; + } + if buffer.len() > 64 * 1024 { + anyhow::bail!("HTTP request headers too large"); + } + }; + + let header_text = String::from_utf8(buffer[..header_end].to_vec()) + .context("HTTP request headers were not valid UTF-8")?; + let mut lines = header_text.split("\r\n"); + let request_line = lines + .next() + .filter(|line| !line.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("Missing HTTP request line"))?; + let mut request_parts = request_line.split_whitespace(); + let method = request_parts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing HTTP method"))? + .to_string(); + let path = request_parts + .next() + .ok_or_else(|| anyhow::anyhow!("Missing HTTP path"))? + .to_string(); + + let mut headers = BTreeMap::new(); + for line in lines { + if line.is_empty() { + break; + } + if let Some((key, value)) = line.split_once(':') { + headers.insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + } + } + + let content_length = headers + .get("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let mut body = buffer[header_end..].to_vec(); + while body.len() < content_length { + let read = stream.read(&mut temp)?; + if read == 0 { + anyhow::bail!("Unexpected EOF while reading HTTP request body"); + } + body.extend_from_slice(&temp[..read]); + } + body.truncate(content_length); + + Ok((method, path, headers, body)) +} + +fn write_http_response( + stream: &mut TcpStream, + status: u16, + content_type: &str, + body: &str, +) -> Result<()> { + let status_text = match status { + 200 => "OK", + 202 => "Accepted", + 400 => "Bad Request", + 401 => "Unauthorized", + 404 => "Not Found", + _ => "Internal Server Error", + }; + write!( + stream, + "HTTP/1.1 {status} {status_text}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + )?; + stream.flush()?; + Ok(()) +} + fn format_coordination_status( status: &session::manager::CoordinationStatus, json: bool, diff --git a/ecc2/src/session/daemon.rs b/ecc2/src/session/daemon.rs index 9f55df04..e62ea0ae 100644 --- a/ecc2/src/session/daemon.rs +++ b/ecc2/src/session/daemon.rs @@ -31,6 +31,10 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> { tracing::error!("Scheduled task dispatch pass failed: {e}"); } + if let Err(e) = maybe_run_remote_dispatch(&db, &cfg).await { + tracing::error!("Remote dispatch pass failed: {e}"); + } + if let Err(e) = coordinate_backlog_cycle(&db, &cfg).await { tracing::error!("Backlog coordination pass failed: {e}"); } @@ -101,6 +105,25 @@ async fn maybe_run_due_schedules(db: &StateStore, cfg: &Config) -> Result Ok(outcomes.len()) } +async fn maybe_run_remote_dispatch(db: &StateStore, cfg: &Config) -> Result { + let outcomes = + manager::run_remote_dispatch_requests(db, cfg, cfg.max_parallel_sessions).await?; + let routed = outcomes + .iter() + .filter(|outcome| { + matches!( + outcome.action, + manager::RemoteDispatchAction::SpawnedTopLevel + | manager::RemoteDispatchAction::Assigned(_) + ) + }) + .count(); + if routed > 0 { + tracing::info!("Dispatched {} remote request(s)", routed); + } + Ok(routed) +} + async fn maybe_auto_dispatch(db: &StateStore, cfg: &Config) -> Result { let summary = maybe_auto_dispatch_with_recorder( cfg, diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index d5996dc7..e2dccfe5 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -17,7 +17,7 @@ use super::{ ScheduledTask, Session, SessionAgentProfile, SessionGrouping, SessionHarnessInfo, SessionMetrics, SessionState, }; -use crate::comms::{self, MessageType}; +use crate::comms::{self, MessageType, TaskPriority}; use crate::config::Config; use crate::observability::{log_tool_call, ToolCallEvent, ToolLogEntry, ToolLogPage, ToolLogger}; use crate::worktree; @@ -252,6 +252,64 @@ pub fn delete_scheduled_task(db: &StateStore, schedule_id: i64) -> Result Ok(db.delete_scheduled_task(schedule_id)? > 0) } +#[allow(clippy::too_many_arguments)] +pub fn create_remote_dispatch_request( + db: &StateStore, + cfg: &Config, + task: &str, + target_session_id: Option<&str>, + priority: TaskPriority, + agent_type: &str, + profile_name: Option<&str>, + use_worktree: bool, + grouping: SessionGrouping, + source: &str, + requester: Option<&str>, +) -> Result { + let working_dir = + std::env::current_dir().context("Failed to resolve current working directory")?; + let project = grouping + .project + .as_deref() + .and_then(normalize_group_label) + .unwrap_or_else(|| default_project_label(&working_dir)); + let task_group = grouping + .task_group + .as_deref() + .and_then(normalize_group_label) + .unwrap_or_else(|| default_task_group_label(task)); + let agent_type = HarnessKind::canonical_agent_type(agent_type); + + if let Some(profile_name) = profile_name { + cfg.resolve_agent_profile(profile_name)?; + } + if let Some(target_session_id) = target_session_id { + let _ = resolve_session(db, target_session_id)?; + } + + db.insert_remote_dispatch_request( + target_session_id, + task, + priority, + &agent_type, + profile_name, + &working_dir, + &project, + &task_group, + use_worktree, + source, + requester, + ) +} + +pub fn list_remote_dispatch_requests( + db: &StateStore, + include_processed: bool, + limit: usize, +) -> Result> { + db.list_remote_dispatch_requests(include_processed, limit) +} + pub async fn run_due_schedules( db: &StateStore, cfg: &Config, @@ -262,6 +320,133 @@ pub async fn run_due_schedules( run_due_schedules_with_runner_program(db, cfg, limit, &runner_program).await } +pub async fn run_remote_dispatch_requests( + db: &StateStore, + cfg: &Config, + limit: usize, +) -> Result> { + let requests = db.list_pending_remote_dispatch_requests(limit)?; + let runner_program = + std::env::current_exe().context("Failed to resolve ECC executable path")?; + run_remote_dispatch_requests_with_runner_program(db, cfg, requests, &runner_program).await +} + +async fn run_remote_dispatch_requests_with_runner_program( + db: &StateStore, + cfg: &Config, + requests: Vec, + runner_program: &Path, +) -> Result> { + let mut outcomes = Vec::new(); + + for request in requests { + let grouping = SessionGrouping { + project: normalize_group_label(&request.project), + task_group: normalize_group_label(&request.task_group), + }; + + let outcome = if let Some(target_session_id) = request.target_session_id.as_deref() { + match assign_session_in_dir_with_runner_program( + db, + cfg, + target_session_id, + &request.task, + &request.agent_type, + request.use_worktree, + &request.working_dir, + &runner_program, + request.profile_name.as_deref(), + grouping, + ) + .await + { + Ok(assignment) if assignment.action == AssignmentAction::DeferredSaturated => { + RemoteDispatchOutcome { + request_id: request.id, + task: request.task.clone(), + priority: request.priority, + target_session_id: request.target_session_id.clone(), + session_id: None, + action: RemoteDispatchAction::DeferredSaturated, + } + } + Ok(assignment) => { + db.record_remote_dispatch_success( + request.id, + Some(&assignment.session_id), + Some(assignment.action.label()), + )?; + RemoteDispatchOutcome { + request_id: request.id, + task: request.task.clone(), + priority: request.priority, + target_session_id: request.target_session_id.clone(), + session_id: Some(assignment.session_id), + action: RemoteDispatchAction::Assigned(assignment.action), + } + } + Err(error) => { + db.record_remote_dispatch_failure(request.id, &error.to_string())?; + RemoteDispatchOutcome { + request_id: request.id, + task: request.task.clone(), + priority: request.priority, + target_session_id: request.target_session_id.clone(), + session_id: None, + action: RemoteDispatchAction::Failed(error.to_string()), + } + } + } + } else { + match queue_session_in_dir_with_runner_program( + db, + cfg, + &request.task, + &request.agent_type, + request.use_worktree, + &request.working_dir, + &runner_program, + request.profile_name.as_deref(), + None, + grouping, + ) + .await + { + Ok(session_id) => { + db.record_remote_dispatch_success( + request.id, + Some(&session_id), + Some("spawned_top_level"), + )?; + RemoteDispatchOutcome { + request_id: request.id, + task: request.task.clone(), + priority: request.priority, + target_session_id: None, + session_id: Some(session_id), + action: RemoteDispatchAction::SpawnedTopLevel, + } + } + Err(error) => { + db.record_remote_dispatch_failure(request.id, &error.to_string())?; + RemoteDispatchOutcome { + request_id: request.id, + task: request.task.clone(), + priority: request.priority, + target_session_id: None, + session_id: None, + action: RemoteDispatchAction::Failed(error.to_string()), + } + } + } + }; + + outcomes.push(outcome); + } + + Ok(outcomes) +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct TemplateLaunchStepOutcome { pub step_name: String, @@ -3076,6 +3261,25 @@ pub struct ScheduledRunOutcome { pub next_run_at: chrono::DateTime, } +#[derive(Debug, Clone, Serialize)] +pub struct RemoteDispatchOutcome { + pub request_id: i64, + pub task: String, + pub priority: TaskPriority, + pub target_session_id: Option, + pub session_id: Option, + pub action: RemoteDispatchAction, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case", tag = "type", content = "details")] +pub enum RemoteDispatchAction { + SpawnedTopLevel, + Assigned(AssignmentAction), + DeferredSaturated, + Failed(String), +} + pub struct RebalanceOutcome { pub from_session_id: String, pub message_id: i64, @@ -3130,7 +3334,8 @@ pub enum CoordinationHealth { EscalationRequired, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] pub enum AssignmentAction { Spawned, ReusedIdle, @@ -3138,6 +3343,17 @@ pub enum AssignmentAction { DeferredSaturated, } +impl AssignmentAction { + fn label(self) -> &'static str { + match self { + Self::Spawned => "spawned", + Self::ReusedIdle => "reused_idle", + Self::ReusedActive => "reused_active", + Self::DeferredSaturated => "deferred_saturated", + } + } +} + pub fn preview_assignment_for_task( db: &StateStore, cfg: &Config, @@ -4341,6 +4557,152 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn run_remote_dispatch_requests_prioritizes_critical_targeted_work() -> Result<()> { + let tempdir = TestDir::new("manager-run-remote-dispatch-priority")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let (fake_runner, _log_path) = write_fake_claude(tempdir.path())?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "Lead orchestration".to_string(), + project: "repo".to_string(), + task_group: "Lead orchestration".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let low = create_remote_dispatch_request( + &db, + &cfg, + "Low priority cleanup", + Some("lead"), + TaskPriority::Low, + "claude", + None, + true, + SessionGrouping::default(), + "cli", + None, + )?; + let critical = create_remote_dispatch_request( + &db, + &cfg, + "Critical production incident", + Some("lead"), + TaskPriority::Critical, + "claude", + None, + true, + SessionGrouping::default(), + "cli", + None, + )?; + + let outcomes = run_remote_dispatch_requests_with_runner_program( + &db, + &cfg, + db.list_pending_remote_dispatch_requests(1)?, + &fake_runner, + ) + .await?; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].request_id, critical.id); + assert!(matches!( + outcomes[0].action, + RemoteDispatchAction::Assigned(AssignmentAction::Spawned) + )); + + let low_request = db + .get_remote_dispatch_request(low.id)? + .context("low priority request should still exist")?; + assert_eq!( + low_request.status, + crate::session::RemoteDispatchStatus::Pending + ); + + let critical_request = db + .get_remote_dispatch_request(critical.id)? + .context("critical request should still exist")?; + assert_eq!( + critical_request.status, + crate::session::RemoteDispatchStatus::Dispatched + ); + assert!(critical_request.result_session_id.is_some()); + + Ok(()) + } + + #[tokio::test(flavor = "current_thread")] + async fn run_remote_dispatch_requests_spawns_top_level_session_when_untargeted() -> Result<()> { + let tempdir = TestDir::new("manager-run-remote-dispatch-top-level")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let (fake_runner, _log_path) = write_fake_claude(tempdir.path())?; + + let request = db.insert_remote_dispatch_request( + None, + "Remote phone triage", + TaskPriority::High, + "claude", + None, + &repo_root, + "ecc-core", + "phone dispatch", + true, + "http", + Some("127.0.0.1"), + )?; + + let outcomes = run_remote_dispatch_requests_with_runner_program( + &db, + &cfg, + db.list_pending_remote_dispatch_requests(10)?, + &fake_runner, + ) + .await?; + assert_eq!(outcomes.len(), 1); + assert_eq!(outcomes[0].request_id, request.id); + assert!(matches!( + outcomes[0].action, + RemoteDispatchAction::SpawnedTopLevel + )); + + let request = db + .get_remote_dispatch_request(request.id)? + .context("remote request should still exist")?; + assert_eq!( + request.status, + crate::session::RemoteDispatchStatus::Dispatched + ); + let session_id = request + .result_session_id + .clone() + .context("spawned top-level request should record a session id")?; + let session = db + .get_session(&session_id)? + .context("spawned session should exist")?; + assert_eq!(session.project, "ecc-core"); + assert_eq!(session.task_group, "phone dispatch"); + + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn stop_session_kills_process_and_optionally_cleans_worktree() -> Result<()> { let tempdir = TestDir::new("manager-stop-session")?; diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 3b774a70..5175d9d9 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -395,6 +395,57 @@ pub struct ScheduledTask { pub updated_at: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoteDispatchRequest { + pub id: i64, + pub target_session_id: Option, + pub task: String, + pub priority: crate::comms::TaskPriority, + pub agent_type: String, + pub profile_name: Option, + pub working_dir: PathBuf, + pub project: String, + pub task_group: String, + pub use_worktree: bool, + pub source: String, + pub requester: Option, + pub status: RemoteDispatchStatus, + pub result_session_id: Option, + pub result_action: Option, + pub error: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub dispatched_at: Option>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RemoteDispatchStatus { + Pending, + Dispatched, + Failed, +} + +impl fmt::Display for RemoteDispatchStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pending => write!(f, "pending"), + Self::Dispatched => write!(f, "dispatched"), + Self::Failed => write!(f, "failed"), + } + } +} + +impl RemoteDispatchStatus { + pub fn from_db_value(value: &str) -> Self { + match value { + "dispatched" => Self::Dispatched, + "failed" => Self::Failed, + _ => Self::Pending, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct FileActivityEntry { pub session_id: String, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 7414f99a..d23187c9 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -18,8 +18,9 @@ use super::{ ContextGraphCompactionStats, ContextGraphEntity, ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry, - HarnessKind, ScheduledTask, Session, SessionAgentProfile, SessionHarnessInfo, SessionMessage, - SessionMetrics, SessionState, WorktreeInfo, + HarnessKind, RemoteDispatchRequest, RemoteDispatchStatus, ScheduledTask, Session, + SessionAgentProfile, SessionHarnessInfo, SessionMessage, SessionMetrics, SessionState, + WorktreeInfo, }; pub struct StateStore { @@ -315,6 +316,28 @@ impl StateStore { updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS remote_dispatch_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + task TEXT NOT NULL, + priority INTEGER NOT NULL DEFAULT 1, + agent_type TEXT NOT NULL, + profile_name TEXT, + working_dir TEXT NOT NULL, + project TEXT NOT NULL DEFAULT '', + task_group TEXT NOT NULL DEFAULT '', + use_worktree INTEGER NOT NULL DEFAULT 1, + source TEXT NOT NULL DEFAULT '', + requester TEXT, + status TEXT NOT NULL DEFAULT 'pending', + result_session_id TEXT, + result_action TEXT, + error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + dispatched_at TEXT + ); + CREATE TABLE IF NOT EXISTS conflict_incidents ( id INTEGER PRIMARY KEY AUTOINCREMENT, conflict_key TEXT NOT NULL UNIQUE, @@ -377,6 +400,8 @@ impl StateStore { ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at); CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at ON pending_worktree_queue(requested_at, session_id); + CREATE INDEX IF NOT EXISTS idx_remote_dispatch_requests_status_priority + ON remote_dispatch_requests(status, priority DESC, created_at, id); INSERT OR IGNORE INTO daemon_activity (id) VALUES (1); ", @@ -1164,6 +1189,153 @@ impl StateStore { Ok(()) } + #[allow(clippy::too_many_arguments)] + pub fn insert_remote_dispatch_request( + &self, + target_session_id: Option<&str>, + task: &str, + priority: crate::comms::TaskPriority, + agent_type: &str, + profile_name: Option<&str>, + working_dir: &Path, + project: &str, + task_group: &str, + use_worktree: bool, + source: &str, + requester: Option<&str>, + ) -> Result { + let now = chrono::Utc::now(); + self.conn.execute( + "INSERT INTO remote_dispatch_requests ( + target_session_id, + task, + priority, + agent_type, + profile_name, + working_dir, + project, + task_group, + use_worktree, + source, + requester, + status, + created_at, + updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'pending', ?12, ?13)", + rusqlite::params![ + target_session_id, + task, + task_priority_db_value(priority), + agent_type, + profile_name, + working_dir.display().to_string(), + project, + task_group, + if use_worktree { 1_i64 } else { 0_i64 }, + source, + requester, + now.to_rfc3339(), + now.to_rfc3339(), + ], + )?; + let id = self.conn.last_insert_rowid(); + self.get_remote_dispatch_request(id)?.ok_or_else(|| { + anyhow::anyhow!("Remote dispatch request {id} was not found after insert") + }) + } + + pub fn list_remote_dispatch_requests( + &self, + include_processed: bool, + limit: usize, + ) -> Result> { + let sql = if include_processed { + "SELECT id, target_session_id, task, priority, agent_type, profile_name, working_dir, + project, task_group, use_worktree, source, requester, status, + result_session_id, result_action, error, created_at, updated_at, dispatched_at + FROM remote_dispatch_requests + ORDER BY CASE status WHEN 'pending' THEN 0 WHEN 'failed' THEN 1 ELSE 2 END ASC, + priority DESC, created_at ASC, id ASC + LIMIT ?1" + } else { + "SELECT id, target_session_id, task, priority, agent_type, profile_name, working_dir, + project, task_group, use_worktree, source, requester, status, + result_session_id, result_action, error, created_at, updated_at, dispatched_at + FROM remote_dispatch_requests + WHERE status = 'pending' + ORDER BY priority DESC, created_at ASC, id ASC + LIMIT ?1" + }; + + let mut stmt = self.conn.prepare(sql)?; + let rows = stmt.query_map([limit as i64], map_remote_dispatch_request)?; + rows.collect::, _>>().map_err(Into::into) + } + + pub fn list_pending_remote_dispatch_requests( + &self, + limit: usize, + ) -> Result> { + self.list_remote_dispatch_requests(false, limit) + } + + pub fn get_remote_dispatch_request( + &self, + request_id: i64, + ) -> Result> { + self.conn + .query_row( + "SELECT id, target_session_id, task, priority, agent_type, profile_name, working_dir, + project, task_group, use_worktree, source, requester, status, + result_session_id, result_action, error, created_at, updated_at, dispatched_at + FROM remote_dispatch_requests + WHERE id = ?1", + [request_id], + map_remote_dispatch_request, + ) + .optional() + .map_err(Into::into) + } + + pub fn record_remote_dispatch_success( + &self, + request_id: i64, + result_session_id: Option<&str>, + result_action: Option<&str>, + ) -> Result<()> { + let now = chrono::Utc::now(); + self.conn.execute( + "UPDATE remote_dispatch_requests + SET status = 'dispatched', + result_session_id = ?2, + result_action = ?3, + error = NULL, + dispatched_at = ?4, + updated_at = ?4 + WHERE id = ?1", + rusqlite::params![ + request_id, + result_session_id, + result_action, + now.to_rfc3339() + ], + )?; + Ok(()) + } + + pub fn record_remote_dispatch_failure(&self, request_id: i64, error: &str) -> Result<()> { + let now = chrono::Utc::now(); + self.conn.execute( + "UPDATE remote_dispatch_requests + SET status = 'failed', + error = ?2, + updated_at = ?3 + WHERE id = ?1", + rusqlite::params![request_id, error, now.to_rfc3339()], + )?; + Ok(()) + } + pub fn update_metrics(&self, session_id: &str, metrics: &SessionMetrics) -> Result<()> { self.conn.execute( "UPDATE sessions @@ -3727,6 +3899,36 @@ fn map_scheduled_task(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { + let created_at = parse_store_timestamp(row.get::<_, String>(16)?, 16)?; + let updated_at = parse_store_timestamp(row.get::<_, String>(17)?, 17)?; + let dispatched_at = row + .get::<_, Option>(18)? + .map(|value| parse_store_timestamp(value, 18)) + .transpose()?; + Ok(RemoteDispatchRequest { + id: row.get(0)?, + target_session_id: normalize_optional_string(row.get(1)?), + task: row.get(2)?, + priority: task_priority_from_db_value(row.get::<_, i64>(3)?), + agent_type: row.get(4)?, + profile_name: normalize_optional_string(row.get(5)?), + working_dir: PathBuf::from(row.get::<_, String>(6)?), + project: row.get(7)?, + task_group: row.get(8)?, + use_worktree: row.get::<_, i64>(9)? != 0, + source: row.get(10)?, + requester: normalize_optional_string(row.get(11)?), + status: RemoteDispatchStatus::from_db_value(&row.get::<_, String>(12)?), + result_session_id: normalize_optional_string(row.get(13)?), + result_action: normalize_optional_string(row.get(14)?), + error: normalize_optional_string(row.get(15)?), + created_at, + updated_at, + dispatched_at, + }) +} + fn parse_timestamp_column( value: String, index: usize, @@ -3769,6 +3971,24 @@ fn default_input_params_json() -> String { "{}".to_string() } +fn task_priority_db_value(priority: crate::comms::TaskPriority) -> i64 { + match priority { + crate::comms::TaskPriority::Low => 0, + crate::comms::TaskPriority::Normal => 1, + crate::comms::TaskPriority::High => 2, + crate::comms::TaskPriority::Critical => 3, + } +} + +fn task_priority_from_db_value(value: i64) -> crate::comms::TaskPriority { + match value { + 0 => crate::comms::TaskPriority::Low, + 2 => crate::comms::TaskPriority::High, + 3 => crate::comms::TaskPriority::Critical, + _ => crate::comms::TaskPriority::Normal, + } +} + fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { let tool_name = tool_name.trim().to_ascii_lowercase(); if tool_name.contains("read") { From 30913b2cc4a0df7a5685b570f2f5d33f19a543af Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 09:40:01 -0700 Subject: [PATCH 153/459] feat: add ecc2 computer use remote dispatch --- ecc2/src/config/mod.rs | 93 ++++++++++++- ecc2/src/main.rs | 268 +++++++++++++++++++++++++++++++++++- ecc2/src/session/manager.rs | 199 +++++++++++++++++++++++++- ecc2/src/session/mod.rs | 27 ++++ ecc2/src/session/store.rs | 78 +++++++---- ecc2/src/tui/dashboard.rs | 1 + 6 files changed, 635 insertions(+), 31 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 0f083e39..d48bd9a6 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -50,6 +50,16 @@ pub struct ConflictResolutionConfig { pub notify_lead: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ComputerUseDispatchConfig { + pub agent: Option, + pub profile: Option, + pub use_worktree: bool, + pub project: Option, + pub task_group: Option, +} + #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct AgentProfileConfig { @@ -223,6 +233,7 @@ pub struct Config { pub agent_profiles: BTreeMap, pub orchestration_templates: BTreeMap, pub memory_connectors: BTreeMap, + pub computer_use_dispatch: ComputerUseDispatchConfig, pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_limit_per_session: usize, pub auto_create_worktrees: bool, @@ -289,6 +300,7 @@ impl Default for Config { agent_profiles: BTreeMap::new(), orchestration_templates: BTreeMap::new(), memory_connectors: BTreeMap::new(), + computer_use_dispatch: ComputerUseDispatchConfig::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -347,6 +359,26 @@ impl Config { self.budget_alert_thresholds.sanitized() } + pub fn computer_use_dispatch_defaults(&self) -> ResolvedComputerUseDispatchConfig { + let agent = self + .computer_use_dispatch + .agent + .clone() + .unwrap_or_else(|| self.default_agent.clone()); + let profile = self + .computer_use_dispatch + .profile + .clone() + .or_else(|| self.default_agent_profile.clone()); + ResolvedComputerUseDispatchConfig { + agent, + profile, + use_worktree: self.computer_use_dispatch.use_worktree, + project: self.computer_use_dispatch.project.clone(), + task_group: self.computer_use_dispatch.task_group.clone(), + } + } + pub fn resolve_agent_profile(&self, name: &str) -> Result { let mut chain = Vec::new(); self.resolve_agent_profile_inner(name, &mut chain) @@ -771,6 +803,27 @@ impl Default for HarnessRunnerConfig { } } +impl Default for ComputerUseDispatchConfig { + fn default() -> Self { + Self { + agent: None, + profile: None, + use_worktree: false, + project: None, + task_group: None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ResolvedComputerUseDispatchConfig { + pub agent: String, + pub profile: Option, + pub use_worktree: bool, + pub project: Option, + pub task_group: Option, +} + fn merge_unique(base: &mut Vec, additions: &[T]) where T: Clone + PartialEq, @@ -851,8 +904,8 @@ impl BudgetAlertThresholds { #[cfg(test)] mod tests { use super::{ - BudgetAlertThresholds, Config, ConflictResolutionConfig, ConflictResolutionStrategy, - PaneLayout, + BudgetAlertThresholds, ComputerUseDispatchConfig, Config, ConflictResolutionConfig, + ConflictResolutionStrategy, PaneLayout, ResolvedComputerUseDispatchConfig, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::collections::BTreeMap; @@ -1202,6 +1255,42 @@ notify_lead = false ); } + #[test] + fn computer_use_dispatch_deserializes_from_toml() { + let config: Config = toml::from_str( + r#" +[computer_use_dispatch] +agent = "codex" +profile = "browser" +use_worktree = true +project = "ops" +task_group = "remote browser" +"#, + ) + .unwrap(); + + assert_eq!( + config.computer_use_dispatch, + ComputerUseDispatchConfig { + agent: Some("codex".to_string()), + profile: Some("browser".to_string()), + use_worktree: true, + project: Some("ops".to_string()), + task_group: Some("remote browser".to_string()), + } + ); + assert_eq!( + config.computer_use_dispatch_defaults(), + ResolvedComputerUseDispatchConfig { + agent: "codex".to_string(), + profile: Some("browser".to_string()), + use_worktree: true, + project: Some("ops".to_string()), + task_group: Some("remote browser".to_string()), + } + ); + } + #[test] fn agent_profiles_resolve_inheritance_and_defaults() { let config: Config = toml::from_str( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 33580245..78e4bf46 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -45,6 +45,28 @@ impl WorktreePolicyArgs { } } +#[derive(clap::Args, Debug, Clone, Default)] +struct OptionalWorktreePolicyArgs { + /// Create a dedicated worktree + #[arg(short = 'w', long = "worktree", action = clap::ArgAction::SetTrue, overrides_with = "no_worktree")] + worktree: bool, + /// Skip dedicated worktree creation + #[arg(long = "no-worktree", action = clap::ArgAction::SetTrue, overrides_with = "worktree")] + no_worktree: bool, +} + +impl OptionalWorktreePolicyArgs { + fn resolve(&self, default_value: bool) -> bool { + if self.worktree { + true + } else if self.no_worktree { + false + } else { + default_value + } + } +} + #[derive(clap::Subcommand, Debug)] enum Commands { /// Launch the TUI dashboard @@ -479,6 +501,41 @@ enum RemoteCommands { #[arg(long)] json: bool, }, + /// Queue a remote computer-use task request + ComputerUse { + /// Goal to complete with computer-use/browser tools + #[arg(long)] + goal: String, + /// Optional target URL to open first + #[arg(long)] + target_url: Option, + /// Extra context for the operator + #[arg(long)] + context: Option, + /// Optional lead session ID or alias to route through + #[arg(long)] + to_session: Option, + /// Task priority + #[arg(long, value_enum, default_value_t = TaskPriorityArg::Normal)] + priority: TaskPriorityArg, + /// Agent type override (defaults to [computer_use_dispatch] or ECC default agent) + #[arg(short, long)] + agent: Option, + /// Agent profile override (defaults to [computer_use_dispatch] or ECC default profile) + #[arg(long)] + profile: Option, + #[command(flatten)] + worktree: OptionalWorktreePolicyArgs, + /// Optional project grouping override + #[arg(long)] + project: Option, + /// Optional task-group grouping override + #[arg(long)] + task_group: Option, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// List queued remote task requests List { /// Include already dispatched or failed requests @@ -816,6 +873,20 @@ struct RemoteDispatchHttpRequest { task_group: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +struct RemoteComputerUseHttpRequest { + goal: String, + target_url: Option, + context: Option, + to_session: Option, + priority: Option, + agent: Option, + profile: Option, + use_worktree: Option, + project: Option, + task_group: Option, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct JsonlMemoryConnectorRecord { @@ -1996,6 +2067,57 @@ async fn main() -> Result<()> { } } } + RemoteCommands::ComputerUse { + goal, + target_url, + context, + to_session, + priority, + agent, + profile, + worktree, + project, + task_group, + json, + } => { + let target_session_id = to_session + .as_deref() + .map(|value| resolve_session_id(&db, value)) + .transpose()?; + let defaults = cfg.computer_use_dispatch_defaults(); + let request = session::manager::create_computer_use_remote_dispatch_request( + &db, + &cfg, + &goal, + target_url.as_deref(), + context.as_deref(), + target_session_id.as_deref(), + priority.into(), + agent.as_deref(), + profile.as_deref(), + Some(worktree.resolve(defaults.use_worktree)), + session::SessionGrouping { + project, + task_group, + }, + "cli_computer_use", + None, + )?; + if json { + println!("{}", serde_json::to_string_pretty(&request)?); + } else { + println!( + "Queued remote {} request #{} [{}] {}", + request.request_kind, request.id, request.priority, goal + ); + if let Some(target_url) = request.target_url.as_deref() { + println!("- target url {target_url}"); + } + if let Some(target_session_id) = request.target_session_id.as_deref() { + println!("- target {}", short_session(target_session_id)); + } + } + } RemoteCommands::List { all, limit, json } => { let requests = session::manager::list_remote_dispatch_requests(&db, all, limit)?; if json { @@ -2010,9 +2132,15 @@ async fn main() -> Result<()> { .as_deref() .map(short_session) .unwrap_or_else(|| "new-session".to_string()); + let label = format_remote_dispatch_kind(request.request_kind); println!( - "#{} [{}] {} -> {} | {}", - request.id, request.priority, request.status, target, request.task + "#{} [{}] {} {} -> {} | {}", + request.id, + request.priority, + label, + request.status, + target, + request.task.lines().next().unwrap_or(&request.task) ); } } @@ -3096,6 +3224,13 @@ fn format_remote_dispatch_action(action: &session::manager::RemoteDispatchAction } } +fn format_remote_dispatch_kind(kind: session::RemoteDispatchKind) -> &'static str { + match kind { + session::RemoteDispatchKind::Standard => "standard", + session::RemoteDispatchKind::ComputerUse => "computer_use", + } +} + fn short_session(session_id: &str) -> String { session_id.chars().take(8).collect() } @@ -3225,6 +3360,86 @@ fn handle_remote_dispatch_connection( &serde_json::to_string(&request)?, ) } + ("POST", "/computer-use") => { + let auth = headers + .get("authorization") + .map(String::as_str) + .unwrap_or_default(); + let expected = format!("Bearer {bearer_token}"); + if auth != expected { + return write_http_response( + stream, + 401, + "application/json", + &serde_json::json!({"error": "unauthorized"}).to_string(), + ); + } + + let payload: RemoteComputerUseHttpRequest = + serde_json::from_slice(&body).context("Invalid remote computer-use JSON body")?; + if payload.goal.trim().is_empty() { + return write_http_response( + stream, + 400, + "application/json", + &serde_json::json!({"error": "goal is required"}).to_string(), + ); + } + + let target_session_id = match payload + .to_session + .as_deref() + .map(|value| resolve_session_id(db, value)) + .transpose() + { + Ok(value) => value, + Err(error) => { + return write_http_response( + stream, + 400, + "application/json", + &serde_json::json!({"error": error.to_string()}).to_string(), + ); + } + }; + let requester = stream.peer_addr().ok().map(|addr| addr.ip().to_string()); + let defaults = cfg.computer_use_dispatch_defaults(); + let request = match session::manager::create_computer_use_remote_dispatch_request( + db, + cfg, + &payload.goal, + payload.target_url.as_deref(), + payload.context.as_deref(), + target_session_id.as_deref(), + payload.priority.unwrap_or(TaskPriorityArg::Normal).into(), + payload.agent.as_deref(), + payload.profile.as_deref(), + Some(payload.use_worktree.unwrap_or(defaults.use_worktree)), + session::SessionGrouping { + project: payload.project, + task_group: payload.task_group, + }, + "http_computer_use", + requester.as_deref(), + ) { + Ok(request) => request, + Err(error) => { + return write_http_response( + stream, + 400, + "application/json", + &serde_json::json!({"error": error.to_string()}).to_string(), + ); + } + }; + + write_http_response( + stream, + 202, + "application/json", + &serde_json::to_string(&request)?, + ) + } _ => write_http_response( stream, 404, @@ -4995,6 +5210,55 @@ mod tests { } } + #[test] + fn cli_parses_remote_computer_use_command() { + let cli = Cli::try_parse_from([ + "ecc", + "remote", + "computer-use", + "--goal", + "Confirm the recovery banner", + "--target-url", + "https://ecc.tools/account", + "--context", + "Use the production flow", + "--priority", + "critical", + "--agent", + "codex", + "--profile", + "browser", + "--no-worktree", + ]) + .expect("remote computer-use should parse"); + + match cli.command { + Some(Commands::Remote { + command: + RemoteCommands::ComputerUse { + goal, + target_url, + context, + priority, + agent, + profile, + worktree, + .. + }, + }) => { + assert_eq!(goal, "Confirm the recovery banner"); + assert_eq!(target_url.as_deref(), Some("https://ecc.tools/account")); + assert_eq!(context.as_deref(), Some("Use the production flow")); + assert_eq!(priority, TaskPriorityArg::Critical); + assert_eq!(agent.as_deref(), Some("codex")); + assert_eq!(profile.as_deref(), Some("browser")); + assert!(worktree.no_worktree); + assert!(!worktree.worktree); + } + _ => panic!("expected remote computer-use subcommand"), + } + } + #[test] fn cli_parses_start_with_handoff_source() { let cli = Cli::try_parse_from([ diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index e2dccfe5..5c6d4e30 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -14,8 +14,8 @@ use super::runtime::capture_command_output; use super::store::StateStore; use super::{ default_project_label, default_task_group_label, normalize_group_label, HarnessKind, - ScheduledTask, Session, SessionAgentProfile, SessionGrouping, SessionHarnessInfo, - SessionMetrics, SessionState, + RemoteDispatchKind, ScheduledTask, Session, SessionAgentProfile, SessionGrouping, + SessionHarnessInfo, SessionMetrics, SessionState, }; use crate::comms::{self, MessageType, TaskPriority}; use crate::config::Config; @@ -268,6 +268,125 @@ pub fn create_remote_dispatch_request( ) -> Result { let working_dir = std::env::current_dir().context("Failed to resolve current working directory")?; + create_remote_dispatch_request_inner( + db, + cfg, + RemoteDispatchKind::Standard, + &working_dir, + task, + None, + target_session_id, + priority, + agent_type, + profile_name, + use_worktree, + grouping, + source, + requester, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn create_computer_use_remote_dispatch_request( + db: &StateStore, + cfg: &Config, + goal: &str, + target_url: Option<&str>, + context: Option<&str>, + target_session_id: Option<&str>, + priority: TaskPriority, + agent_type_override: Option<&str>, + profile_name_override: Option<&str>, + use_worktree_override: Option, + grouping: SessionGrouping, + source: &str, + requester: Option<&str>, +) -> Result { + let working_dir = + std::env::current_dir().context("Failed to resolve current working directory")?; + create_computer_use_remote_dispatch_request_in_dir( + db, + cfg, + &working_dir, + goal, + target_url, + context, + target_session_id, + priority, + agent_type_override, + profile_name_override, + use_worktree_override, + grouping, + source, + requester, + ) +} + +#[allow(clippy::too_many_arguments)] +fn create_computer_use_remote_dispatch_request_in_dir( + db: &StateStore, + cfg: &Config, + working_dir: &Path, + goal: &str, + target_url: Option<&str>, + context: Option<&str>, + target_session_id: Option<&str>, + priority: TaskPriority, + agent_type_override: Option<&str>, + profile_name_override: Option<&str>, + use_worktree_override: Option, + grouping: SessionGrouping, + source: &str, + requester: Option<&str>, +) -> Result { + let defaults = cfg.computer_use_dispatch_defaults(); + let task = render_computer_use_task(goal, target_url, context); + let agent_type = agent_type_override.unwrap_or(&defaults.agent); + let profile_name = profile_name_override.or(defaults.profile.as_deref()); + let use_worktree = use_worktree_override.unwrap_or(defaults.use_worktree); + let grouping = SessionGrouping { + project: grouping.project.or(defaults.project), + task_group: grouping + .task_group + .or(defaults.task_group) + .or_else(|| Some(default_task_group_label(goal))), + }; + + create_remote_dispatch_request_inner( + db, + cfg, + RemoteDispatchKind::ComputerUse, + working_dir, + &task, + target_url, + target_session_id, + priority, + agent_type, + profile_name, + use_worktree, + grouping, + source, + requester, + ) +} + +#[allow(clippy::too_many_arguments)] +fn create_remote_dispatch_request_inner( + db: &StateStore, + cfg: &Config, + request_kind: RemoteDispatchKind, + working_dir: &Path, + task: &str, + target_url: Option<&str>, + target_session_id: Option<&str>, + priority: TaskPriority, + agent_type: &str, + profile_name: Option<&str>, + use_worktree: bool, + grouping: SessionGrouping, + source: &str, + requester: Option<&str>, +) -> Result { let project = grouping .project .as_deref() @@ -288,8 +407,10 @@ pub fn create_remote_dispatch_request( } db.insert_remote_dispatch_request( + request_kind, target_session_id, task, + target_url, priority, &agent_type, profile_name, @@ -302,6 +423,24 @@ pub fn create_remote_dispatch_request( ) } +fn render_computer_use_task(goal: &str, target_url: Option<&str>, context: Option<&str>) -> String { + let mut lines = vec![ + "Computer-use task.".to_string(), + format!("Goal: {}", goal.trim()), + ]; + if let Some(target_url) = target_url.map(str::trim).filter(|value| !value.is_empty()) { + lines.push(format!("Target URL: {target_url}")); + } + if let Some(context) = context.map(str::trim).filter(|value| !value.is_empty()) { + lines.push(format!("Context: {context}")); + } + lines.push( + "Use browser or computer-use tools directly when available, and report blockers clearly if auth, approvals, or local-device access prevent completion." + .to_string(), + ); + lines.join("\n") +} + pub fn list_remote_dispatch_requests( db: &StateStore, include_processed: bool, @@ -3840,6 +3979,7 @@ mod tests { agent_profiles: Default::default(), orchestration_templates: Default::default(), memory_connectors: Default::default(), + computer_use_dispatch: crate::config::ComputerUseDispatchConfig::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -4656,8 +4796,10 @@ mod tests { let (fake_runner, _log_path) = write_fake_claude(tempdir.path())?; let request = db.insert_remote_dispatch_request( + RemoteDispatchKind::Standard, None, "Remote phone triage", + None, TaskPriority::High, "claude", None, @@ -4703,6 +4845,59 @@ mod tests { Ok(()) } + #[test] + fn create_computer_use_remote_dispatch_request_uses_config_defaults() -> Result<()> { + let tempdir = TestDir::new("manager-create-computer-use-remote-defaults")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + + let mut cfg = build_config(tempdir.path()); + cfg.computer_use_dispatch = crate::config::ComputerUseDispatchConfig { + agent: Some("codex".to_string()), + profile: None, + use_worktree: false, + project: Some("ops".to_string()), + task_group: Some("remote browser".to_string()), + }; + let db = StateStore::open(&cfg.db_path)?; + + let request = create_computer_use_remote_dispatch_request_in_dir( + &db, + &cfg, + &repo_root, + "Open the billing portal and confirm the refund banner", + Some("https://ecc.tools/account"), + Some("Use the production account flow"), + None, + TaskPriority::Critical, + None, + None, + None, + SessionGrouping::default(), + "http_computer_use", + Some("127.0.0.1"), + )?; + + assert_eq!(request.request_kind, RemoteDispatchKind::ComputerUse); + assert_eq!( + request.target_url.as_deref(), + Some("https://ecc.tools/account") + ); + assert_eq!(request.agent_type, "codex"); + assert_eq!(request.project, "ops"); + assert_eq!(request.task_group, "remote browser"); + assert!(!request.use_worktree); + assert!(request.task.contains("Computer-use task.")); + assert!(request.task.contains("Goal: Open the billing portal")); + assert!(request + .task + .contains("Target URL: https://ecc.tools/account")); + assert!(request + .task + .contains("Context: Use the production account flow")); + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn stop_session_kills_process_and_optionally_cleans_worktree() -> Result<()> { let tempdir = TestDir::new("manager-stop-session")?; diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 5175d9d9..1f53a6fb 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -398,8 +398,10 @@ pub struct ScheduledTask { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RemoteDispatchRequest { pub id: i64, + pub request_kind: RemoteDispatchKind, pub target_session_id: Option, pub task: String, + pub target_url: Option, pub priority: crate::comms::TaskPriority, pub agent_type: String, pub profile_name: Option, @@ -418,6 +420,31 @@ pub struct RemoteDispatchRequest { pub dispatched_at: Option>, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RemoteDispatchKind { + Standard, + ComputerUse, +} + +impl fmt::Display for RemoteDispatchKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Standard => write!(f, "standard"), + Self::ComputerUse => write!(f, "computer_use"), + } + } +} + +impl RemoteDispatchKind { + pub fn from_db_value(value: &str) -> Self { + match value { + "computer_use" => Self::ComputerUse, + _ => Self::Standard, + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum RemoteDispatchStatus { diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index d23187c9..6d808784 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -18,8 +18,8 @@ use super::{ ContextGraphCompactionStats, ContextGraphEntity, ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry, - HarnessKind, RemoteDispatchRequest, RemoteDispatchStatus, ScheduledTask, Session, - SessionAgentProfile, SessionHarnessInfo, SessionMessage, SessionMetrics, SessionState, + HarnessKind, RemoteDispatchKind, RemoteDispatchRequest, RemoteDispatchStatus, ScheduledTask, + Session, SessionAgentProfile, SessionHarnessInfo, SessionMessage, SessionMetrics, SessionState, WorktreeInfo, }; @@ -318,8 +318,10 @@ impl StateStore { CREATE TABLE IF NOT EXISTS remote_dispatch_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, + request_kind TEXT NOT NULL DEFAULT 'standard', target_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, task TEXT NOT NULL, + target_url TEXT, priority INTEGER NOT NULL DEFAULT 1, agent_type TEXT NOT NULL, profile_name TEXT, @@ -681,6 +683,24 @@ impl StateStore { .context("Failed to add last_auto_prune_active_skipped column to daemon_activity table")?; } + if !self.has_column("remote_dispatch_requests", "request_kind")? { + self.conn + .execute( + "ALTER TABLE remote_dispatch_requests ADD COLUMN request_kind TEXT NOT NULL DEFAULT 'standard'", + [], + ) + .context("Failed to add request_kind column to remote_dispatch_requests table")?; + } + + if !self.has_column("remote_dispatch_requests", "target_url")? { + self.conn + .execute( + "ALTER TABLE remote_dispatch_requests ADD COLUMN target_url TEXT", + [], + ) + .context("Failed to add target_url column to remote_dispatch_requests table")?; + } + self.conn.execute_batch( "CREATE UNIQUE INDEX IF NOT EXISTS idx_tool_log_hook_event ON tool_log(hook_event_id) @@ -1192,8 +1212,10 @@ impl StateStore { #[allow(clippy::too_many_arguments)] pub fn insert_remote_dispatch_request( &self, + request_kind: RemoteDispatchKind, target_session_id: Option<&str>, task: &str, + target_url: Option<&str>, priority: crate::comms::TaskPriority, agent_type: &str, profile_name: Option<&str>, @@ -1207,8 +1229,10 @@ impl StateStore { let now = chrono::Utc::now(); self.conn.execute( "INSERT INTO remote_dispatch_requests ( + request_kind, target_session_id, task, + target_url, priority, agent_type, profile_name, @@ -1221,10 +1245,12 @@ impl StateStore { status, created_at, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'pending', ?12, ?13)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 'pending', ?14, ?15)", rusqlite::params![ + request_kind.to_string(), target_session_id, task, + target_url, task_priority_db_value(priority), agent_type, profile_name, @@ -1250,7 +1276,7 @@ impl StateStore { limit: usize, ) -> Result> { let sql = if include_processed { - "SELECT id, target_session_id, task, priority, agent_type, profile_name, working_dir, + "SELECT id, request_kind, target_session_id, task, target_url, priority, agent_type, profile_name, working_dir, project, task_group, use_worktree, source, requester, status, result_session_id, result_action, error, created_at, updated_at, dispatched_at FROM remote_dispatch_requests @@ -1258,7 +1284,7 @@ impl StateStore { priority DESC, created_at ASC, id ASC LIMIT ?1" } else { - "SELECT id, target_session_id, task, priority, agent_type, profile_name, working_dir, + "SELECT id, request_kind, target_session_id, task, target_url, priority, agent_type, profile_name, working_dir, project, task_group, use_worktree, source, requester, status, result_session_id, result_action, error, created_at, updated_at, dispatched_at FROM remote_dispatch_requests @@ -1285,7 +1311,7 @@ impl StateStore { ) -> Result> { self.conn .query_row( - "SELECT id, target_session_id, task, priority, agent_type, profile_name, working_dir, + "SELECT id, request_kind, target_session_id, task, target_url, priority, agent_type, profile_name, working_dir, project, task_group, use_worktree, source, requester, status, result_session_id, result_action, error, created_at, updated_at, dispatched_at FROM remote_dispatch_requests @@ -3900,29 +3926,31 @@ fn map_scheduled_task(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result { - let created_at = parse_store_timestamp(row.get::<_, String>(16)?, 16)?; - let updated_at = parse_store_timestamp(row.get::<_, String>(17)?, 17)?; + let created_at = parse_store_timestamp(row.get::<_, String>(18)?, 18)?; + let updated_at = parse_store_timestamp(row.get::<_, String>(19)?, 19)?; let dispatched_at = row - .get::<_, Option>(18)? - .map(|value| parse_store_timestamp(value, 18)) + .get::<_, Option>(20)? + .map(|value| parse_store_timestamp(value, 20)) .transpose()?; Ok(RemoteDispatchRequest { id: row.get(0)?, - target_session_id: normalize_optional_string(row.get(1)?), - task: row.get(2)?, - priority: task_priority_from_db_value(row.get::<_, i64>(3)?), - agent_type: row.get(4)?, - profile_name: normalize_optional_string(row.get(5)?), - working_dir: PathBuf::from(row.get::<_, String>(6)?), - project: row.get(7)?, - task_group: row.get(8)?, - use_worktree: row.get::<_, i64>(9)? != 0, - source: row.get(10)?, - requester: normalize_optional_string(row.get(11)?), - status: RemoteDispatchStatus::from_db_value(&row.get::<_, String>(12)?), - result_session_id: normalize_optional_string(row.get(13)?), - result_action: normalize_optional_string(row.get(14)?), - error: normalize_optional_string(row.get(15)?), + request_kind: RemoteDispatchKind::from_db_value(&row.get::<_, String>(1)?), + target_session_id: normalize_optional_string(row.get(2)?), + task: row.get(3)?, + target_url: normalize_optional_string(row.get(4)?), + priority: task_priority_from_db_value(row.get::<_, i64>(5)?), + agent_type: row.get(6)?, + profile_name: normalize_optional_string(row.get(7)?), + working_dir: PathBuf::from(row.get::<_, String>(8)?), + project: row.get(9)?, + task_group: row.get(10)?, + use_worktree: row.get::<_, i64>(11)? != 0, + source: row.get(12)?, + requester: normalize_optional_string(row.get(13)?), + status: RemoteDispatchStatus::from_db_value(&row.get::<_, String>(14)?), + result_session_id: normalize_optional_string(row.get(15)?), + result_action: normalize_optional_string(row.get(16)?), + error: normalize_optional_string(row.get(17)?), created_at, updated_at, dispatched_at, diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c2f94056..ebdf2e53 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -14612,6 +14612,7 @@ diff --git a/src/lib.rs b/src/lib.rs agent_profiles: Default::default(), orchestration_templates: Default::default(), memory_connectors: Default::default(), + computer_use_dispatch: crate::config::ComputerUseDispatchConfig::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, From 03e52f49e86d5ffbb34625aa53c2eb4e9063cbc7 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 09:49:05 -0700 Subject: [PATCH 154/459] feat: normalize ecc2 profiles across harnesses --- ecc2/src/session/manager.rs | 199 +++++++++++++++++++++++++++++++++--- 1 file changed, 183 insertions(+), 16 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 5c6d4e30..d63fdf19 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -3195,12 +3195,7 @@ fn build_configured_harness_command( } } - let task = if runner.inline_system_prompt_for_task && runner.append_system_prompt_flag.is_none() - { - normalize_task_with_inline_system_prompt(task, profile) - } else { - task.to_string() - }; + let task = normalize_task_for_configured_runner(runner, task, profile); if let Some(flag) = runner.task_flag.as_deref() { command.arg(flag); @@ -3217,24 +3212,143 @@ fn normalize_task_for_harness( task: &str, profile: Option<&SessionAgentProfile>, ) -> String { - let rendered = normalize_task_with_inline_system_prompt(task, profile); - match harness { HarnessKind::Claude => task.to_string(), - HarnessKind::Codex | HarnessKind::OpenCode | HarnessKind::Gemini => rendered, + HarnessKind::Codex => render_task_with_profile_projection( + task, + profile, + TaskProjectionSupport { + supports_model: true, + supports_add_dirs: true, + ..TaskProjectionSupport::default() + }, + ), + HarnessKind::OpenCode => render_task_with_profile_projection( + task, + profile, + TaskProjectionSupport { + supports_model: true, + ..TaskProjectionSupport::default() + }, + ), + HarnessKind::Gemini => render_task_with_profile_projection( + task, + profile, + TaskProjectionSupport { + supports_model: true, + supports_add_dirs: true, + ..TaskProjectionSupport::default() + }, + ), _ => task.to_string(), } } -fn normalize_task_with_inline_system_prompt( +#[derive(Debug, Default, Clone, Copy)] +struct TaskProjectionSupport { + supports_model: bool, + supports_add_dirs: bool, + supports_allowed_tools: bool, + supports_disallowed_tools: bool, + supports_permission_mode: bool, + supports_max_budget_usd: bool, + supports_append_system_prompt: bool, +} + +fn normalize_task_for_configured_runner( + runner: &crate::config::HarnessRunnerConfig, task: &str, profile: Option<&SessionAgentProfile>, ) -> String { - let Some(system_prompt) = profile.and_then(|profile| profile.append_system_prompt.as_ref()) - else { + render_task_with_profile_projection( + task, + profile, + TaskProjectionSupport { + supports_model: runner.model_flag.is_some(), + supports_add_dirs: runner.add_dir_flag.is_some() + || runner.include_directories_flag.is_some(), + supports_allowed_tools: runner.allowed_tools_flag.is_some(), + supports_disallowed_tools: runner.disallowed_tools_flag.is_some(), + supports_permission_mode: runner.permission_mode_flag.is_some(), + supports_max_budget_usd: runner.max_budget_usd_flag.is_some(), + supports_append_system_prompt: runner.append_system_prompt_flag.is_some() + && !runner.inline_system_prompt_for_task, + }, + ) +} + +fn render_task_with_profile_projection( + task: &str, + profile: Option<&SessionAgentProfile>, + support: TaskProjectionSupport, +) -> String { + let Some(profile) = profile else { return task.to_string(); }; - format!("System instructions:\n{system_prompt}\n\nTask:\n{task}") + + let mut sections = Vec::new(); + if !support.supports_append_system_prompt { + if let Some(system_prompt) = profile.append_system_prompt.as_ref() { + sections.push(format!("System instructions:\n{system_prompt}")); + } + } + + let mut directives = Vec::new(); + if !support.supports_model { + if let Some(model) = profile.model.as_ref() { + directives.push(format!("Preferred model: {model}")); + } + } + if !support.supports_add_dirs && !profile.add_dirs.is_empty() { + directives.push(format!( + "Additional context dirs: {}", + profile + .add_dirs + .iter() + .map(|dir| dir.to_string_lossy().to_string()) + .collect::>() + .join(", ") + )); + } + if !support.supports_allowed_tools && !profile.allowed_tools.is_empty() { + directives.push(format!( + "Allowed tools: {}", + profile.allowed_tools.join(", ") + )); + } + if !support.supports_disallowed_tools && !profile.disallowed_tools.is_empty() { + directives.push(format!( + "Disallowed tools: {}", + profile.disallowed_tools.join(", ") + )); + } + if !support.supports_permission_mode { + if let Some(permission_mode) = profile.permission_mode.as_ref() { + directives.push(format!("Permission mode: {permission_mode}")); + } + } + if !support.supports_max_budget_usd { + if let Some(max_budget_usd) = profile.max_budget_usd { + directives.push(format!("Max budget USD: {max_budget_usd}")); + } + } + if let Some(token_budget) = profile.token_budget { + directives.push(format!("Token budget: {token_budget}")); + } + + if !directives.is_empty() { + sections.push(format!( + "ECC execution profile:\n- {}", + directives.join("\n- ") + )); + } + + if sections.is_empty() { + return task.to_string(); + } + + sections.push(format!("Task:\n{task}")); + sections.join("\n\n") } async fn spawn_claude_code( @@ -4125,7 +4239,7 @@ mod tests { "docs", "--add-dir", "specs", - "System instructions:\nReview thoroughly.\n\nTask:\nreview this change", + "System instructions:\nReview thoroughly.\n\nECC execution profile:\n- Allowed tools: Read\n- Disallowed tools: Bash\n- Permission mode: plan\n- Max budget USD: 1.25\n- Token budget: 750\n\nTask:\nreview this change", ] ); } @@ -4171,7 +4285,7 @@ mod tests { "ecc-sess-9999", "--model", "anthropic/claude-sonnet-4", - "System instructions:\nBuild carefully.\n\nTask:\nstabilize callback flow", + "System instructions:\nBuild carefully.\n\nECC execution profile:\n- Additional context dirs: docs\n\nTask:\nstabilize callback flow", ] ); } @@ -4215,7 +4329,7 @@ mod tests { "gemini-2.5-pro", "--include-directories", "docs,../shared", - "System instructions:\nUse repo context carefully.\n\nTask:\ninvestigate auth regression", + "System instructions:\nUse repo context carefully.\n\nECC execution profile:\n- Allowed tools: Read\n- Disallowed tools: Bash\n- Permission mode: plan\n- Max budget USD: 1\n- Token budget: 500\n\nTask:\ninvestigate auth regression", ] ); } @@ -4343,6 +4457,59 @@ mod tests { ); } + #[test] + fn build_agent_command_projects_unsupported_profile_fields_for_configured_runner() { + let mut cfg = Config::default(); + cfg.harness_runners.insert( + "cursor".to_string(), + crate::config::HarnessRunnerConfig { + program: "cursor-agent".to_string(), + base_args: vec!["run".to_string()], + task_flag: Some("--task".to_string()), + model_flag: Some("--model".to_string()), + ..Default::default() + }, + ); + let profile = SessionAgentProfile { + profile_name: "worker".to_string(), + agent: None, + model: Some("gpt-5.4".to_string()), + allowed_tools: vec!["Read".to_string()], + disallowed_tools: vec!["Bash".to_string()], + permission_mode: Some("plan".to_string()), + add_dirs: vec![PathBuf::from("docs"), PathBuf::from("specs")], + max_budget_usd: Some(2.5), + token_budget: Some(900), + append_system_prompt: Some("Use repo context carefully.".to_string()), + }; + + let command = build_agent_command( + &cfg, + "cursor", + Path::new("cursor-agent"), + "fix callback regression", + "sess-cur2", + Path::new("/tmp/repo"), + Some(&profile), + ); + let args = command + .as_std() + .get_args() + .map(|value| value.to_string_lossy().to_string()) + .collect::>(); + + assert_eq!( + args, + vec![ + "run", + "--model", + "gpt-5.4", + "--task", + "System instructions:\nUse repo context carefully.\n\nECC execution profile:\n- Additional context dirs: docs, specs\n- Allowed tools: Read\n- Disallowed tools: Bash\n- Permission mode: plan\n- Max budget USD: 2.5\n- Token budget: 900\n\nTask:\nfix callback regression", + ] + ); + } + #[test] fn build_session_record_canonicalizes_known_agent_aliases() -> Result<()> { let tempdir = TestDir::new("manager-canonical-agent-type")?; From 050d9a97079cfa41dce681e9cb41074f6ba66aff Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 09:55:06 -0700 Subject: [PATCH 155/459] fix: honor ecc2 default agent in cli commands --- ecc2/src/main.rs | 125 ++++++++++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 50 deletions(-) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 78e4bf46..8a9c8b9f 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -76,9 +76,9 @@ enum Commands { /// Task description for the agent #[arg(short, long)] task: String, - /// Agent type (claude, codex, custom) - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, /// Agent profile defined in ecc2.toml #[arg(long)] profile: Option, @@ -95,9 +95,9 @@ enum Commands { /// Task description for the delegated session #[arg(short, long)] task: Option, - /// Agent type (claude, codex, custom) - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, /// Agent profile defined in ecc2.toml #[arg(long)] profile: Option, @@ -125,9 +125,9 @@ enum Commands { /// Task description for the assignment #[arg(short, long)] task: String, - /// Agent type (claude, codex, custom) - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, /// Agent profile defined in ecc2.toml #[arg(long)] profile: Option, @@ -138,9 +138,9 @@ enum Commands { DrainInbox { /// Lead session ID or alias session_id: String, - /// Agent type for routed delegates - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum unread task handoffs to route @@ -149,9 +149,9 @@ enum Commands { }, /// Sweep unread task handoffs across lead sessions and route them through the assignment policy AutoDispatch { - /// Agent type for routed delegates - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass @@ -160,9 +160,9 @@ enum Commands { }, /// Dispatch unread handoffs, then rebalance delegate backlog across lead teams CoordinateBacklog { - /// Agent type for routed delegates - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass @@ -192,9 +192,9 @@ enum Commands { }, /// Coordinate only when backlog pressure actually needs work MaintainCoordination { - /// Agent type for routed delegates - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass @@ -212,9 +212,9 @@ enum Commands { }, /// Rebalance unread handoffs across lead teams with backed-up delegates RebalanceAll { - /// Agent type for routed delegates - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum lead sessions to sweep in one pass @@ -225,9 +225,9 @@ enum Commands { RebalanceTeam { /// Lead session ID or alias session_id: String, - /// Agent type for routed delegates - #[arg(short, long, default_value = "claude")] - agent: String, + /// Agent type for routed delegates (defaults to `default_agent` from ecc2.toml) + #[arg(short, long)] + agent: Option, #[command(flatten)] worktree: WorktreePolicyArgs, /// Maximum handoffs to reroute in one pass @@ -963,7 +963,7 @@ async fn main() -> Result<()> { &db, &cfg, &task, - &agent, + agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, profile.as_deref(), &source.id, @@ -975,7 +975,7 @@ async fn main() -> Result<()> { &db, &cfg, &task, - &agent, + agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, profile.as_deref(), grouping, @@ -1013,7 +1013,7 @@ async fn main() -> Result<()> { &db, &cfg, &task, - &agent, + agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, profile.as_deref(), &source.id, @@ -1081,7 +1081,7 @@ async fn main() -> Result<()> { &cfg, &lead_id, &task, - &agent, + agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, profile.as_deref(), session::SessionGrouping::default(), @@ -1115,9 +1115,15 @@ async fn main() -> Result<()> { }) => { let use_worktree = worktree.resolve(&cfg); let lead_id = resolve_session_id(&db, &session_id)?; - let outcomes = - session::manager::drain_inbox(&db, &cfg, &lead_id, &agent, use_worktree, limit) - .await?; + let outcomes = session::manager::drain_inbox( + &db, + &cfg, + &lead_id, + agent.as_deref().unwrap_or(&cfg.default_agent), + use_worktree, + limit, + ) + .await?; if outcomes.is_empty() { println!("No unread task handoffs for {}", short_session(&lead_id)); } else { @@ -1162,7 +1168,7 @@ async fn main() -> Result<()> { let outcomes = session::manager::auto_dispatch_backlog( &db, &cfg, - &agent, + agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, lead_limit, ) @@ -1223,7 +1229,7 @@ async fn main() -> Result<()> { let run = run_coordination_loop( &db, &cfg, - &agent, + agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, lead_limit, pass_budget, @@ -1271,7 +1277,7 @@ async fn main() -> Result<()> { run_coordination_loop( &db, &cfg, - &agent, + agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, lead_limit, max_passes.max(1), @@ -1307,9 +1313,14 @@ async fn main() -> Result<()> { lead_limit, }) => { let use_worktree = worktree.resolve(&cfg); - let outcomes = - session::manager::rebalance_all_teams(&db, &cfg, &agent, use_worktree, lead_limit) - .await?; + let outcomes = session::manager::rebalance_all_teams( + &db, + &cfg, + agent.as_deref().unwrap_or(&cfg.default_agent), + use_worktree, + lead_limit, + ) + .await?; if outcomes.is_empty() { println!("No delegate backlog needed global rebalancing"); } else { @@ -1341,7 +1352,7 @@ async fn main() -> Result<()> { &db, &cfg, &lead_id, - &agent, + agent.as_deref().unwrap_or(&cfg.default_agent), use_worktree, limit, ) @@ -5281,13 +5292,27 @@ mod tests { .. }) => { assert_eq!(task, "Follow up"); - assert_eq!(agent, "claude"); + assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(from_session.as_deref(), Some("planner")); } _ => panic!("expected start subcommand"), } } + #[test] + fn cli_parses_start_without_agent_override() { + let cli = Cli::try_parse_from(["ecc", "start", "--task", "Follow up"]) + .expect("start without --agent should parse"); + + match cli.command { + Some(Commands::Start { task, agent, .. }) => { + assert_eq!(task, "Follow up"); + assert!(agent.is_none()); + } + _ => panic!("expected start subcommand"), + } + } + #[test] fn cli_parses_start_no_worktree_override() { let cli = Cli::try_parse_from(["ecc", "start", "--task", "Follow up", "--no-worktree"]) @@ -5324,7 +5349,7 @@ mod tests { }) => { assert_eq!(from_session, "planner"); assert_eq!(task.as_deref(), Some("Review auth changes")); - assert_eq!(agent, "codex"); + assert_eq!(agent.as_deref(), Some("codex")); } _ => panic!("expected delegate subcommand"), } @@ -6226,7 +6251,7 @@ mod tests { }) => { assert_eq!(from_session, "lead"); assert_eq!(task, "Review auth changes"); - assert_eq!(agent, "claude"); + assert_eq!(agent.as_deref(), Some("claude")); } _ => panic!("expected assign subcommand"), } @@ -6253,7 +6278,7 @@ mod tests { .. }) => { assert_eq!(session_id, "lead"); - assert_eq!(agent, "claude"); + assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(limit, 3); } _ => panic!("expected drain-inbox subcommand"), @@ -6276,7 +6301,7 @@ mod tests { Some(Commands::AutoDispatch { agent, lead_limit, .. }) => { - assert_eq!(agent, "claude"); + assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(lead_limit, 4); } _ => panic!("expected auto-dispatch subcommand"), @@ -6304,7 +6329,7 @@ mod tests { max_passes, .. }) => { - assert_eq!(agent, "claude"); + assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(lead_limit, 7); assert!(!check); assert!(!until_healthy); @@ -6400,7 +6425,7 @@ mod tests { Some(Commands::RebalanceAll { agent, lead_limit, .. }) => { - assert_eq!(agent, "claude"); + assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(lead_limit, 6); } _ => panic!("expected rebalance-all subcommand"), @@ -7794,7 +7819,7 @@ Guide users to repair before reinstall. max_passes, .. }) => { - assert_eq!(agent, "claude"); + assert!(agent.is_none()); assert!(!json); assert!(!check); assert_eq!(max_passes, 5); @@ -7988,7 +8013,7 @@ Guide users to repair before reinstall. .. }) => { assert_eq!(session_id, "lead"); - assert_eq!(agent, "claude"); + assert_eq!(agent.as_deref(), Some("claude")); assert_eq!(limit, 2); } _ => panic!("expected rebalance-team subcommand"), From b51792fe0e7b5a2090f97bf5466a5b03a2be5241 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 10:12:35 -0700 Subject: [PATCH 156/459] feat: auto-resolve ecc2 harnesses from repo markers --- ecc2/src/session/manager.rs | 161 +++++++++++++++++++++++++++++++----- ecc2/src/session/mod.rs | 62 ++++++++++++++ 2 files changed, 202 insertions(+), 21 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index d63fdf19..6f293a27 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1067,7 +1067,7 @@ pub async fn rebalance_team_backlog( return Ok(outcomes); } - let delegates = direct_delegate_sessions(db, &lead.id, agent_type)?; + let delegates = direct_delegate_sessions(db, cfg, &lead, agent_type)?; let unread_counts = db.unread_message_counts()?; let team_has_capacity = delegates.len() < cfg.max_parallel_sessions; @@ -1099,7 +1099,7 @@ pub async fn rebalance_team_backlog( break; } - let current_delegates = direct_delegate_sessions(db, &lead.id, agent_type)?; + let current_delegates = direct_delegate_sessions(db, cfg, &lead, agent_type)?; let current_unread_counts = db.unread_message_counts()?; let current_team_has_capacity = current_delegates.len() < cfg.max_parallel_sessions; let current_has_clear_idle_elsewhere = current_delegates.iter().any(|candidate| { @@ -1567,7 +1567,7 @@ async fn assign_session_in_dir_with_runner_program( .task_group .or_else(|| normalize_group_label(&lead.task_group)), }; - let delegates = direct_delegate_sessions(db, &lead.id, agent_type)?; + let delegates = direct_delegate_sessions(db, cfg, &lead, agent_type)?; let delegate_handoff_backlog = delegates .iter() .map(|session| { @@ -2601,7 +2601,6 @@ async fn queue_session_with_resolved_profile_and_runner_program( .as_ref() .and_then(|profile| profile.agent.as_deref()) .unwrap_or(agent_type); - let effective_agent_type = HarnessKind::canonical_agent_type(effective_agent_type); let session = build_session_record( db, task, @@ -2658,7 +2657,8 @@ fn build_session_record( repo_root: &Path, grouping: SessionGrouping, ) -> Result { - let canonical_agent_type = HarnessKind::canonical_agent_type(agent_type); + let canonical_agent_type = + SessionHarnessInfo::resolve_requested_agent_type(cfg, agent_type, repo_root); let id = uuid::Uuid::new_v4().to_string()[..8].to_string(); let now = chrono::Utc::now(); @@ -2809,12 +2809,15 @@ async fn spawn_session_runner( fn direct_delegate_sessions( db: &StateStore, - lead_id: &str, + cfg: &Config, + lead: &Session, agent_type: &str, ) -> Result> { - let target_harness = HarnessKind::from_agent_type(agent_type); + let resolved_agent_type = + SessionHarnessInfo::resolve_requested_agent_type(cfg, agent_type, &lead.working_dir); + let target_harness = HarnessKind::from_agent_type(&resolved_agent_type); let mut sessions = Vec::new(); - for child_id in db.delegated_children(lead_id, 50)? { + for child_id in db.delegated_children(&lead.id, 50)? { let Some(session) = db.get_session(&child_id)? else { continue; }; @@ -2823,7 +2826,7 @@ fn direct_delegate_sessions( if HarnessKind::from_agent_type(&session.agent_type) != target_harness { continue; } - } else if session.agent_type != HarnessKind::canonical_agent_type(agent_type) { + } else if session.agent_type != resolved_agent_type { continue; } @@ -2904,7 +2907,8 @@ fn summarize_backlog_pressure( let mut summary = BacklogPressureSummary::default(); for (session_id, _) in targets { - let delegates = direct_delegate_sessions(db, session_id, agent_type)?; + let lead = resolve_session(db, session_id)?; + let delegates = direct_delegate_sessions(db, cfg, &lead, agent_type)?; let has_clear_idle_delegate = delegates.iter().any(|delegate| { delegate.state == SessionState::Idle && db.unread_task_handoff_count(&delegate.id).unwrap_or(0) == 0 @@ -3615,7 +3619,7 @@ pub fn preview_assignment_for_task( agent_type: &str, ) -> Result { let lead = resolve_session(db, lead_id)?; - let delegates = direct_delegate_sessions(db, &lead.id, agent_type)?; + let delegates = direct_delegate_sessions(db, cfg, &lead, agent_type)?; let delegate_handoff_backlog = delegates .iter() .map(|session| { @@ -4579,12 +4583,96 @@ mod tests { "task_handoff", )?; - let delegates = direct_delegate_sessions(&db, "lead", "claude")?; + let lead = resolve_session(&db, "lead")?; + let delegates = direct_delegate_sessions(&db, &cfg, &lead, "claude")?; assert_eq!(delegates.len(), 1); assert_eq!(delegates[0].id, "child"); Ok(()) } + #[test] + fn direct_delegate_sessions_resolves_auto_to_configured_harness() -> Result<()> { + let tempdir = TestDir::new("manager-delegate-auto-custom-harness")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + fs::create_dir_all(repo_root.join(".acme"))?; + + let mut cfg = build_config(tempdir.path()); + cfg.harness_runners.insert( + "acme-runner".to_string(), + crate::config::HarnessRunnerConfig { + project_markers: vec![PathBuf::from(".acme")], + ..Default::default() + }, + ); + let db = StateStore::open(&cfg.db_path)?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "lead".to_string(), + task: "Lead task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "acme-runner".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Running, + pid: Some(42), + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "custom-child".to_string(), + task: "Delegate task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "acme-runner".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Idle, + pid: Some(7), + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "claude-child".to_string(), + task: "Other delegate task".to_string(), + project: "workspace".to_string(), + task_group: "general".to_string(), + agent_type: "claude".to_string(), + working_dir: repo_root.clone(), + state: SessionState::Idle, + pid: Some(8), + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.send_message( + "lead", + "custom-child", + "{\"task\":\"Delegate task\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + db.send_message( + "lead", + "claude-child", + "{\"task\":\"Other delegate task\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + + let lead = resolve_session(&db, "lead")?; + let delegates = direct_delegate_sessions(&db, &cfg, &lead, "auto")?; + assert_eq!(delegates.len(), 1); + assert_eq!(delegates[0].id, "custom-child"); + Ok(()) + } + #[test] fn enforce_session_heartbeats_marks_overdue_running_sessions_stale() -> Result<()> { let tempdir = TestDir::new("manager-heartbeat-stale")?; @@ -4786,6 +4874,37 @@ mod tests { Ok(()) } + #[tokio::test(flavor = "current_thread")] + async fn create_session_resolves_auto_agent_from_repo_markers() -> Result<()> { + let tempdir = TestDir::new("manager-create-session-auto-agent")?; + let repo_root = tempdir.path().join("repo"); + init_git_repo(&repo_root)?; + fs::create_dir_all(repo_root.join(".codex"))?; + + let cfg = build_config(tempdir.path()); + let db = StateStore::open(&cfg.db_path)?; + let (fake_runner, _log_path) = write_fake_claude(tempdir.path())?; + + let session_id = create_session_in_dir( + &db, + &cfg, + "implement lifecycle", + "auto", + false, + &repo_root, + &fake_runner, + ) + .await?; + + let session = db + .get_session(&session_id)? + .context("session should exist")?; + assert_eq!(session.agent_type, "codex"); + + stop_session_with_options(&db, &session_id, false).await?; + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn create_session_derives_project_and_task_group_defaults() -> Result<()> { let tempdir = TestDir::new("manager-create-session-grouping-defaults")?; @@ -7229,7 +7348,7 @@ mod tests { let now = Utc::now(); db.insert_session(&Session { - id: "worker".to_string(), + id: "lead".to_string(), task: "worker task".to_string(), project: "workspace".to_string(), task_group: "general".to_string(), @@ -7245,7 +7364,7 @@ mod tests { })?; db.insert_session(&Session { - id: "worker-child".to_string(), + id: "delegate".to_string(), task: "delegate task".to_string(), project: "workspace".to_string(), task_group: "general".to_string(), @@ -7261,31 +7380,31 @@ mod tests { })?; db.send_message( - "worker", - "worker-child", + "lead", + "delegate", "{\"task\":\"seed delegate\",\"context\":\"Delegated from worker\"}", "task_handoff", )?; - let _ = db.mark_messages_read("worker-child")?; + let _ = db.mark_messages_read("delegate")?; db.send_message( "planner", - "worker", + "lead", "{\"task\":\"task-a\",\"context\":\"Inbound\"}", "task_handoff", )?; db.send_message( "planner", - "worker", + "lead", "{\"task\":\"task-b\",\"context\":\"Inbound\"}", "task_handoff", )?; let outcome = coordinate_backlog(&db, &cfg, "claude", true, 10).await?; - assert_eq!(outcome.remaining_backlog_sessions, 1); + assert_eq!(outcome.remaining_backlog_sessions, 2); assert_eq!(outcome.remaining_backlog_messages, 2); - assert_eq!(outcome.remaining_absorbable_sessions, 0); + assert_eq!(outcome.remaining_absorbable_sessions, 1); assert_eq!(outcome.remaining_saturated_sessions, 1); Ok(()) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 1f53a6fb..1b1d8b8b 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -248,6 +248,24 @@ impl SessionHarnessInfo { self } + pub fn resolve_requested_agent_type( + cfg: &crate::config::Config, + requested_agent_type: &str, + working_dir: &Path, + ) -> String { + let canonical = HarnessKind::canonical_agent_type(requested_agent_type); + if !canonical.is_empty() && canonical != "auto" { + return canonical; + } + + let detected = Self::detect("", working_dir).with_config_detection(cfg, working_dir); + if detected.primary_label != HarnessKind::Unknown.as_str() { + return Self::runner_key(&detected.primary_label); + } + + HarnessKind::Claude.as_str().to_string() + } + pub fn detected_summary(&self) -> String { if self.detected_labels.is_empty() { "none detected".to_string() @@ -812,4 +830,48 @@ mod tests { ); assert_eq!(SessionHarnessInfo::runner_key("claude-code"), "claude"); } + + #[test] + fn resolve_requested_agent_type_uses_detected_builtin_marker_for_auto( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-resolve-auto-built-in")?; + fs::create_dir_all(repo.path().join(".codex"))?; + + let resolved = SessionHarnessInfo::resolve_requested_agent_type( + &crate::config::Config::default(), + "auto", + repo.path(), + ); + assert_eq!(resolved, "codex"); + Ok(()) + } + + #[test] + fn resolve_requested_agent_type_uses_configured_marker_for_auto( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-resolve-auto-custom")?; + fs::create_dir_all(repo.path().join(".acme"))?; + let mut cfg = crate::config::Config::default(); + cfg.harness_runners.insert( + "acme-runner".to_string(), + crate::config::HarnessRunnerConfig { + project_markers: vec![PathBuf::from(".acme")], + ..Default::default() + }, + ); + + let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path()); + assert_eq!(resolved, "acme-runner"); + Ok(()) + } + + #[test] + fn resolve_requested_agent_type_falls_back_to_claude_without_markers() { + let resolved = SessionHarnessInfo::resolve_requested_agent_type( + &crate::config::Config::default(), + "auto", + Path::new("."), + ); + assert_eq!(resolved, "claude"); + } } From 176efb7623adb5a0f28fd5b9f648b92af7f3347a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 10:24:33 -0700 Subject: [PATCH 157/459] feat: add ecc2 harness compatibility env --- ecc2/src/session/manager.rs | 122 ++++++++++++++++++++++++++++++------ 1 file changed, 103 insertions(+), 19 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 6f293a27..9f1bc164 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -3018,6 +3018,7 @@ fn build_agent_command( if let Some(runner) = cfg.harness_runner(&SessionHarnessInfo::runner_key(agent_type)) { return build_configured_harness_command( runner, + agent_type, agent_program, task, session_id, @@ -3028,7 +3029,7 @@ fn build_agent_command( let task = normalize_task_for_harness(harness, task, profile); let mut command = Command::new(agent_program); - command.env("ECC_SESSION_ID", session_id); + apply_shared_harness_runtime_env(&mut command, agent_type, session_id, working_dir, profile); match harness { HarnessKind::Claude => { command @@ -3125,6 +3126,7 @@ fn build_agent_command( fn build_configured_harness_command( runner: &crate::config::HarnessRunnerConfig, + agent_type: &str, agent_program: &Path, task: &str, session_id: &str, @@ -3132,7 +3134,7 @@ fn build_configured_harness_command( profile: Option<&SessionAgentProfile>, ) -> Command { let mut command = Command::new(agent_program); - command.env("ECC_SESSION_ID", session_id); + apply_shared_harness_runtime_env(&mut command, agent_type, session_id, working_dir, profile); for (key, value) in &runner.env { if !value.trim().is_empty() { command.env(key, value); @@ -3211,6 +3213,52 @@ fn build_configured_harness_command( command } +fn apply_shared_harness_runtime_env( + command: &mut Command, + agent_type: &str, + session_id: &str, + working_dir: &Path, + profile: Option<&SessionAgentProfile>, +) { + let harness_label = SessionHarnessInfo::runner_key(agent_type); + command.env("ECC_SESSION_ID", session_id); + command.env("ECC_HARNESS", &harness_label); + command.env("ECC_WORKING_DIR", working_dir); + command.env("ECC_PROJECT_DIR", working_dir); + command.env("CLAUDE_SESSION_ID", session_id); + command.env("CLAUDE_PROJECT_DIR", working_dir); + command.env("CLAUDE_CODE_ENTRYPOINT", "cli"); + if let Some(model) = profile.and_then(|profile| profile.model.as_ref()) { + command.env("CLAUDE_MODEL", model); + } + if let Some(plugin_root) = resolve_ecc_plugin_root() { + command.env("ECC_PLUGIN_ROOT", &plugin_root); + command.env("CLAUDE_PLUGIN_ROOT", &plugin_root); + } +} + +fn resolve_ecc_plugin_root() -> Option { + let mut seeds = Vec::new(); + if let Ok(current_exe) = std::env::current_exe() { + seeds.push(current_exe); + } + seeds.push(PathBuf::from(env!("CARGO_MANIFEST_DIR"))); + + for seed in seeds { + for candidate in seed.ancestors() { + if is_ecc_plugin_root(candidate) { + return Some(candidate.to_path_buf()); + } + } + } + + None +} + +fn is_ecc_plugin_root(candidate: &Path) -> bool { + candidate.join("scripts/lib/utils.js").is_file() && candidate.join("hooks/hooks.json").is_file() +} + fn normalize_task_for_harness( harness: HarnessKind, task: &str, @@ -4246,6 +4294,24 @@ mod tests { "System instructions:\nReview thoroughly.\n\nECC execution profile:\n- Allowed tools: Read\n- Disallowed tools: Bash\n- Permission mode: plan\n- Max budget USD: 1.25\n- Token budget: 750\n\nTask:\nreview this change", ] ); + + let envs = command_env_map(&command); + assert_eq!(envs.get("ECC_SESSION_ID"), Some(&"sess-1234".to_string())); + assert_eq!( + envs.get("CLAUDE_SESSION_ID"), + Some(&"sess-1234".to_string()) + ); + assert_eq!( + envs.get("CLAUDE_PROJECT_DIR"), + Some(&"/tmp/repo".to_string()) + ); + assert_eq!(envs.get("CLAUDE_CODE_ENTRYPOINT"), Some(&"cli".to_string())); + assert_eq!(envs.get("ECC_HARNESS"), Some(&"codex".to_string())); + assert_eq!(envs.get("CLAUDE_MODEL"), Some(&"gpt-5.4".to_string())); + assert!( + envs.contains_key("CLAUDE_PLUGIN_ROOT"), + "shared compatibility env should expose the ECC plugin root" + ); } #[test] @@ -4441,24 +4507,20 @@ mod tests { "System instructions:\nUse repo context carefully.\n\nTask:\nfix callback regression", ] ); - let mut envs = command - .as_std() - .get_envs() - .map(|(key, value)| { - ( - key.to_string_lossy().to_string(), - value.map(|value| value.to_string_lossy().to_string()), - ) - }) - .collect::>(); - envs.sort(); + let envs = command_env_map(&command); + assert_eq!(envs.get("ECC_SESSION_ID"), Some(&"sess-cur1".to_string())); assert_eq!( - envs, - vec![ - ("ECC_HARNESS".to_string(), Some("cursor".to_string())), - ("ECC_SESSION_ID".to_string(), Some("sess-cur1".to_string())), - ] + envs.get("CLAUDE_SESSION_ID"), + Some(&"sess-cur1".to_string()) ); + assert_eq!( + envs.get("CLAUDE_PROJECT_DIR"), + Some(&"/tmp/repo".to_string()) + ); + assert_eq!(envs.get("CLAUDE_CODE_ENTRYPOINT"), Some(&"cli".to_string())); + assert_eq!(envs.get("ECC_HARNESS"), Some(&"cursor".to_string())); + assert_eq!(envs.get("CLAUDE_MODEL"), Some(&"gpt-5.4".to_string())); + assert_eq!(envs.get("ECC_PLUGIN_ROOT"), envs.get("CLAUDE_PLUGIN_ROOT")); } #[test] @@ -4806,7 +4868,7 @@ mod tests { let script_path = root.join("fake-claude.sh"); let log_path = root.join("fake-claude.log"); let script = format!( - "#!/usr/bin/env python3\nimport os\nimport pathlib\nimport signal\nimport sys\nimport time\n\nlog_path = pathlib.Path(r\"{}\")\nlog_path.write_text(os.getcwd() + \"\\n\", encoding=\"utf-8\")\nwith log_path.open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(\" \".join(sys.argv[1:]) + \"\\n\")\n handle.write(\"ECC_SESSION_ID=\" + os.environ.get(\"ECC_SESSION_ID\", \"\") + \"\\n\")\n\ndef handle_term(signum, frame):\n raise SystemExit(0)\n\nsignal.signal(signal.SIGTERM, handle_term)\nwhile True:\n time.sleep(0.1)\n", + "#!/usr/bin/env python3\nimport os\nimport pathlib\nimport signal\nimport sys\nimport time\n\nlog_path = pathlib.Path(r\"{}\")\nlog_path.write_text(os.getcwd() + \"\\n\", encoding=\"utf-8\")\nwith log_path.open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(\" \".join(sys.argv[1:]) + \"\\n\")\n handle.write(\"ECC_SESSION_ID=\" + os.environ.get(\"ECC_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_SESSION_ID=\" + os.environ.get(\"CLAUDE_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PROJECT_DIR=\" + os.environ.get(\"CLAUDE_PROJECT_DIR\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_CODE_ENTRYPOINT=\" + os.environ.get(\"CLAUDE_CODE_ENTRYPOINT\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PLUGIN_ROOT=\" + os.environ.get(\"CLAUDE_PLUGIN_ROOT\", \"\") + \"\\n\")\n handle.write(\"ECC_HARNESS=\" + os.environ.get(\"ECC_HARNESS\", \"\") + \"\\n\")\n\ndef handle_term(signum, frame):\n raise SystemExit(0)\n\nsignal.signal(signal.SIGTERM, handle_term)\nwhile True:\n time.sleep(0.1)\n", log_path.display() ); @@ -4834,6 +4896,21 @@ mod tests { anyhow::bail!("timed out waiting for {}", path.display()); } + fn command_env_map(command: &Command) -> BTreeMap { + command + .as_std() + .get_envs() + .filter_map(|(key, value)| { + value.map(|value| { + ( + key.to_string_lossy().to_string(), + value.to_string_lossy().to_string(), + ) + }) + }) + .collect() + } + #[tokio::test(flavor = "current_thread")] async fn create_session_spawns_process_and_marks_session_running() -> Result<()> { let tempdir = TestDir::new("manager-create-session")?; @@ -4869,6 +4946,13 @@ mod tests { assert!(log.contains("--print")); assert!(log.contains("implement lifecycle")); assert!(log.contains(&format!("ECC_SESSION_ID={session_id}"))); + assert!(log.contains(&format!("CLAUDE_SESSION_ID={session_id}"))); + assert!(log.contains(&format!( + "CLAUDE_PROJECT_DIR={}", + repo_root.to_string_lossy() + ))); + assert!(log.contains("CLAUDE_CODE_ENTRYPOINT=cli")); + assert!(log.contains("ECC_HARNESS=claude")); stop_session_with_options(&db, &session_id, false).await?; Ok(()) From 7b7ec434dfc88081728d3eacbd786e4821defbee Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 10:33:07 -0700 Subject: [PATCH 158/459] feat: add ecc2 package manager harness env --- ecc2/src/session/manager.rs | 168 +++++++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 9f1bc164..d8fd1e55 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -3228,6 +3228,10 @@ fn apply_shared_harness_runtime_env( command.env("CLAUDE_SESSION_ID", session_id); command.env("CLAUDE_PROJECT_DIR", working_dir); command.env("CLAUDE_CODE_ENTRYPOINT", "cli"); + if let Some(package_manager) = resolve_project_package_manager(working_dir) { + command.env("CLAUDE_PACKAGE_MANAGER", package_manager); + command.env("CLAUDE_CODE_PACKAGE_MANAGER", package_manager); + } if let Some(model) = profile.and_then(|profile| profile.model.as_ref()) { command.env("CLAUDE_MODEL", model); } @@ -3259,6 +3263,75 @@ fn is_ecc_plugin_root(candidate: &Path) -> bool { candidate.join("scripts/lib/utils.js").is_file() && candidate.join("hooks/hooks.json").is_file() } +fn resolve_project_package_manager(working_dir: &Path) -> Option<&'static str> { + if let Ok(package_manager) = std::env::var("CLAUDE_PACKAGE_MANAGER") { + if let Some(package_manager) = normalize_package_manager_name(&package_manager) { + return Some(package_manager); + } + } + + read_package_manager_from_json( + &working_dir.join(".claude").join("package-manager.json"), + "packageManager", + ) + .or_else(|| read_package_manager_from_package_json(&working_dir.join("package.json"))) + .or_else(|| detect_package_manager_from_lockfile(working_dir)) + .or_else(|| { + dirs::home_dir().and_then(|home_dir| { + read_package_manager_from_json( + &home_dir.join(".claude").join("package-manager.json"), + "packageManager", + ) + }) + }) + .or(Some("npm")) +} + +fn read_package_manager_from_json(path: &Path, field_name: &str) -> Option<&'static str> { + let content = std::fs::read_to_string(path).ok()?; + let value: serde_json::Value = serde_json::from_str(&content).ok()?; + value + .get(field_name) + .and_then(|value| value.as_str()) + .and_then(normalize_package_manager_name) +} + +fn read_package_manager_from_package_json(path: &Path) -> Option<&'static str> { + let package_manager = read_package_manager_from_json(path, "packageManager")?; + Some(package_manager) +} + +fn detect_package_manager_from_lockfile(working_dir: &Path) -> Option<&'static str> { + [ + ("pnpm", "pnpm-lock.yaml"), + ("bun", "bun.lockb"), + ("yarn", "yarn.lock"), + ("npm", "package-lock.json"), + ] + .into_iter() + .find_map(|(package_manager, lockfile)| { + working_dir + .join(lockfile) + .is_file() + .then_some(package_manager) + }) +} + +fn normalize_package_manager_name(package_manager: &str) -> Option<&'static str> { + let canonical = package_manager + .split('@') + .next() + .unwrap_or(package_manager) + .trim(); + match canonical { + "npm" => Some("npm"), + "pnpm" => Some("pnpm"), + "yarn" => Some("yarn"), + "bun" => Some("bun"), + _ => None, + } +} + fn normalize_task_for_harness( harness: HarnessKind, task: &str, @@ -4576,6 +4649,69 @@ mod tests { ); } + #[test] + fn build_agent_command_exports_detected_package_manager_env_from_lockfile() -> Result<()> { + let tempdir = TestDir::new("manager-package-manager-lockfile")?; + let repo_root = tempdir.path().join("repo"); + fs::create_dir_all(&repo_root)?; + write_package_manager_project_files(&repo_root, None, Some("pnpm-lock.yaml"), None)?; + + let cfg = Config::default(); + let command = build_agent_command( + &cfg, + "codex", + Path::new("codex"), + "inspect dependency graph", + "sess-pnpm", + &repo_root, + None, + ); + let envs = command_env_map(&command); + assert_eq!( + envs.get("CLAUDE_PACKAGE_MANAGER"), + Some(&"pnpm".to_string()) + ); + assert_eq!( + envs.get("CLAUDE_CODE_PACKAGE_MANAGER"), + Some(&"pnpm".to_string()) + ); + Ok(()) + } + + #[test] + fn build_agent_command_prefers_project_package_manager_config_over_lockfile() -> Result<()> { + let tempdir = TestDir::new("manager-package-manager-config")?; + let repo_root = tempdir.path().join("repo"); + fs::create_dir_all(&repo_root)?; + write_package_manager_project_files( + &repo_root, + Some("pnpm@9.0.0"), + Some("package-lock.json"), + Some("yarn"), + )?; + + let cfg = Config::default(); + let command = build_agent_command( + &cfg, + "codex", + Path::new("codex"), + "inspect dependency graph", + "sess-yarn", + &repo_root, + None, + ); + let envs = command_env_map(&command); + assert_eq!( + envs.get("CLAUDE_PACKAGE_MANAGER"), + Some(&"yarn".to_string()) + ); + assert_eq!( + envs.get("CLAUDE_CODE_PACKAGE_MANAGER"), + Some(&"yarn".to_string()) + ); + Ok(()) + } + #[test] fn build_session_record_canonicalizes_known_agent_aliases() -> Result<()> { let tempdir = TestDir::new("manager-canonical-agent-type")?; @@ -4868,7 +5004,7 @@ mod tests { let script_path = root.join("fake-claude.sh"); let log_path = root.join("fake-claude.log"); let script = format!( - "#!/usr/bin/env python3\nimport os\nimport pathlib\nimport signal\nimport sys\nimport time\n\nlog_path = pathlib.Path(r\"{}\")\nlog_path.write_text(os.getcwd() + \"\\n\", encoding=\"utf-8\")\nwith log_path.open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(\" \".join(sys.argv[1:]) + \"\\n\")\n handle.write(\"ECC_SESSION_ID=\" + os.environ.get(\"ECC_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_SESSION_ID=\" + os.environ.get(\"CLAUDE_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PROJECT_DIR=\" + os.environ.get(\"CLAUDE_PROJECT_DIR\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_CODE_ENTRYPOINT=\" + os.environ.get(\"CLAUDE_CODE_ENTRYPOINT\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PLUGIN_ROOT=\" + os.environ.get(\"CLAUDE_PLUGIN_ROOT\", \"\") + \"\\n\")\n handle.write(\"ECC_HARNESS=\" + os.environ.get(\"ECC_HARNESS\", \"\") + \"\\n\")\n\ndef handle_term(signum, frame):\n raise SystemExit(0)\n\nsignal.signal(signal.SIGTERM, handle_term)\nwhile True:\n time.sleep(0.1)\n", + "#!/usr/bin/env python3\nimport os\nimport pathlib\nimport signal\nimport sys\nimport time\n\nlog_path = pathlib.Path(r\"{}\")\nlog_path.write_text(os.getcwd() + \"\\n\", encoding=\"utf-8\")\nwith log_path.open(\"a\", encoding=\"utf-8\") as handle:\n handle.write(\" \".join(sys.argv[1:]) + \"\\n\")\n handle.write(\"ECC_SESSION_ID=\" + os.environ.get(\"ECC_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_SESSION_ID=\" + os.environ.get(\"CLAUDE_SESSION_ID\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PROJECT_DIR=\" + os.environ.get(\"CLAUDE_PROJECT_DIR\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_CODE_ENTRYPOINT=\" + os.environ.get(\"CLAUDE_CODE_ENTRYPOINT\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PACKAGE_MANAGER=\" + os.environ.get(\"CLAUDE_PACKAGE_MANAGER\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_CODE_PACKAGE_MANAGER=\" + os.environ.get(\"CLAUDE_CODE_PACKAGE_MANAGER\", \"\") + \"\\n\")\n handle.write(\"CLAUDE_PLUGIN_ROOT=\" + os.environ.get(\"CLAUDE_PLUGIN_ROOT\", \"\") + \"\\n\")\n handle.write(\"ECC_HARNESS=\" + os.environ.get(\"ECC_HARNESS\", \"\") + \"\\n\")\n\ndef handle_term(signum, frame):\n raise SystemExit(0)\n\nsignal.signal(signal.SIGTERM, handle_term)\nwhile True:\n time.sleep(0.1)\n", log_path.display() ); @@ -4911,11 +5047,39 @@ mod tests { .collect() } + fn write_package_manager_project_files( + repo_root: &Path, + package_manager_field: Option<&str>, + lockfile_name: Option<&str>, + project_config_package_manager: Option<&str>, + ) -> Result<()> { + let package_json = match package_manager_field { + Some(package_manager_field) => format!( + "{{\"name\":\"ecc-smoke\",\"packageManager\":\"{package_manager_field}\"}}\n" + ), + None => "{\"name\":\"ecc-smoke\"}\n".to_string(), + }; + fs::write(repo_root.join("package.json"), package_json)?; + if let Some(lockfile_name) = lockfile_name { + fs::write(repo_root.join(lockfile_name), "lockfile\n")?; + } + if let Some(project_config_package_manager) = project_config_package_manager { + let claude_dir = repo_root.join(".claude"); + fs::create_dir_all(&claude_dir)?; + fs::write( + claude_dir.join("package-manager.json"), + format!("{{\"packageManager\":\"{project_config_package_manager}\"}}\n"), + )?; + } + Ok(()) + } + #[tokio::test(flavor = "current_thread")] async fn create_session_spawns_process_and_marks_session_running() -> Result<()> { let tempdir = TestDir::new("manager-create-session")?; let repo_root = tempdir.path().join("repo"); init_git_repo(&repo_root)?; + write_package_manager_project_files(&repo_root, None, Some("pnpm-lock.yaml"), None)?; let cfg = build_config(tempdir.path()); let db = StateStore::open(&cfg.db_path)?; @@ -4952,6 +5116,8 @@ mod tests { repo_root.to_string_lossy() ))); assert!(log.contains("CLAUDE_CODE_ENTRYPOINT=cli")); + assert!(log.contains("CLAUDE_PACKAGE_MANAGER=pnpm")); + assert!(log.contains("CLAUDE_CODE_PACKAGE_MANAGER=pnpm")); assert!(log.contains("ECC_HARNESS=claude")); stop_session_with_options(&db, &session_id, false).await?; From feee17ad02aeeddf26cb1fdd1e41da962eecd458 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 10:39:21 -0700 Subject: [PATCH 159/459] feat: extend ecc2 harness marker coverage --- ecc2/src/session/mod.rs | 90 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 1b1d8b8b..3531b6ea 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -86,6 +86,13 @@ impl HarnessKind { } } + fn supports_direct_execution(self) -> bool { + matches!( + self, + Self::Claude | Self::Codex | Self::OpenCode | Self::Gemini + ) + } + fn project_markers(self) -> &'static [&'static str] { match self { Self::Claude => &[".claude"], @@ -95,7 +102,10 @@ impl HarnessKind { Self::Cursor => &[".cursor"], Self::Kiro => &[".kiro"], Self::Trae => &[".trae"], - Self::Unknown | Self::Zed | Self::FactoryDroid | Self::Windsurf => &[], + Self::Zed => &[".zed"], + Self::FactoryDroid => &[".factory-droid", ".factory_droid"], + Self::Windsurf => &[".windsurf"], + Self::Unknown => &[], } } } @@ -174,6 +184,9 @@ impl SessionHarnessInfo { HarnessKind::Cursor, HarnessKind::Kiro, HarnessKind::Trae, + HarnessKind::Zed, + HarnessKind::FactoryDroid, + HarnessKind::Windsurf, ] .into_iter() .filter(|harness| { @@ -259,13 +272,26 @@ impl SessionHarnessInfo { } let detected = Self::detect("", working_dir).with_config_detection(cfg, working_dir); - if detected.primary_label != HarnessKind::Unknown.as_str() { + if detected.primary_label != HarnessKind::Unknown.as_str() + && Self::can_launch_detected_label(cfg, &detected.primary_label) + { return Self::runner_key(&detected.primary_label); } + for label in &detected.detected_labels { + if Self::can_launch_detected_label(cfg, label) { + return Self::runner_key(label); + } + } + HarnessKind::Claude.as_str().to_string() } + fn can_launch_detected_label(cfg: &crate::config::Config, label: &str) -> bool { + cfg.harness_runner(label).is_some() + || HarnessKind::from_agent_type(label).supports_direct_execution() + } + pub fn detected_summary(&self) -> String { if self.detected_labels.is_empty() { "none detected".to_string() @@ -734,6 +760,32 @@ mod tests { Ok(()) } + #[test] + fn detect_session_harness_collects_extended_builtin_markers( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-extended-markers")?; + fs::create_dir_all(repo.path().join(".zed"))?; + fs::create_dir_all(repo.path().join(".factory-droid"))?; + fs::create_dir_all(repo.path().join(".windsurf"))?; + + let harness = SessionHarnessInfo::detect("", repo.path()); + assert_eq!(harness.primary, HarnessKind::Zed); + assert_eq!(harness.primary_label, "zed"); + assert_eq!( + harness.detected, + vec![ + HarnessKind::Zed, + HarnessKind::FactoryDroid, + HarnessKind::Windsurf + ] + ); + assert_eq!( + harness.detected_labels, + vec!["zed", "factory_droid", "windsurf"] + ); + Ok(()) + } + #[test] fn canonical_agent_type_normalizes_known_aliases() { assert_eq!(HarnessKind::canonical_agent_type("claude-code"), "claude"); @@ -865,6 +917,40 @@ mod tests { Ok(()) } + #[test] + fn resolve_requested_agent_type_skips_nonlaunchable_builtin_markers_without_runner( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-resolve-auto-nonlaunchable")?; + fs::create_dir_all(repo.path().join(".zed"))?; + + let resolved = SessionHarnessInfo::resolve_requested_agent_type( + &crate::config::Config::default(), + "auto", + repo.path(), + ); + assert_eq!(resolved, "claude"); + Ok(()) + } + + #[test] + fn resolve_requested_agent_type_uses_configured_runner_for_extended_builtin_markers( + ) -> Result<(), Box> { + let repo = TestDir::new("session-harness-resolve-auto-extended-runner")?; + fs::create_dir_all(repo.path().join(".windsurf"))?; + let mut cfg = crate::config::Config::default(); + cfg.harness_runners.insert( + "windsurf".to_string(), + crate::config::HarnessRunnerConfig { + program: "windsurf".to_string(), + ..Default::default() + }, + ); + + let resolved = SessionHarnessInfo::resolve_requested_agent_type(&cfg, "auto", repo.path()); + assert_eq!(resolved, "windsurf"); + Ok(()) + } + #[test] fn resolve_requested_agent_type_falls_back_to_claude_without_markers() { let resolved = SessionHarnessInfo::resolve_requested_agent_type( From 0f028f38f60053fc706669b027da18bfa7feb004 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 10:50:17 -0700 Subject: [PATCH 160/459] feat: add ecc2 legacy migration audit --- docs/HERMES-OPENCLAW-MIGRATION.md | 6 + docs/HERMES-SETUP.md | 1 + ecc2/src/main.rs | 552 +++++++++++++++++++++++++++++- 3 files changed, 557 insertions(+), 2 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index e35cc27c..8173419e 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -183,6 +183,12 @@ It is mostly: - clarifying public docs - continuing the ECC 2.0 operator/control-plane buildout +ECC 2.0 now ships a bounded migration audit entrypoint: + +- `ecc migrate audit --source ~/.hermes` + +Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes. + ## What Still Belongs In Backlog The remaining large migration themes are already tracked: diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index aaefb1ba..533abc58 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -82,6 +82,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order +0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 8a9c8b9f..94f0ff45 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -9,8 +9,8 @@ mod worktree; use anyhow::{Context, Result}; use clap::Parser; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fs::File; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; @@ -345,6 +345,11 @@ enum Commands { #[command(subcommand)] command: GraphCommands, }, + /// Audit Hermes/OpenClaw-style workspaces and map them onto ECC2 + Migrate { + #[command(subcommand)] + command: MigrationCommands, + }, /// Manage persistent scheduled task dispatch Schedule { #[command(subcommand)] @@ -568,6 +573,19 @@ enum RemoteCommands { }, } +#[derive(clap::Subcommand, Debug)] +enum MigrationCommands { + /// Audit a Hermes/OpenClaw-style workspace and map it onto ECC2 features + Audit { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, +} + #[derive(clap::Subcommand, Debug)] enum GraphCommands { /// Create or update a graph entity @@ -861,6 +879,41 @@ struct GraphConnectorStatusReport { connectors: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum LegacyMigrationReadiness { + ReadyNow, + ManualTranslation, + LocalAuthRequired, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyMigrationArtifact { + category: String, + readiness: LegacyMigrationReadiness, + source_paths: Vec, + detected_items: usize, + mapping: Vec, + notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyMigrationAuditSummary { + artifact_categories_detected: usize, + ready_now_categories: usize, + manual_translation_categories: usize, + local_auth_required_categories: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyMigrationAuditReport { + source: String, + detected_systems: Vec, + summary: LegacyMigrationAuditSummary, + recommended_next_steps: Vec, + artifacts: Vec, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct RemoteDispatchHttpRequest { task: String, @@ -1588,6 +1641,16 @@ async fn main() -> Result<()> { println!("{}", format_decisions_human(&entries, all)); } } + Some(Commands::Migrate { command }) => match command { + MigrationCommands::Audit { source, json } => { + let report = build_legacy_migration_audit_report(&source)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_migration_audit_human(&report)); + } + } + }, Some(Commands::Graph { command }) => match command { GraphCommands::AddEntity { session_id, @@ -4397,6 +4460,401 @@ fn format_graph_observations_human(observations: &[session::ContextGraphObservat lines.join("\n") } +fn build_legacy_migration_audit_report(source: &Path) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let mut artifacts = Vec::new(); + + let scheduler_paths = collect_existing_relative_paths( + &source, + &["cron/scheduler.py", "jobs.py", "cron/jobs.json"], + ); + if !scheduler_paths.is_empty() { + artifacts.push(LegacyMigrationArtifact { + category: "scheduler".to_string(), + readiness: LegacyMigrationReadiness::ReadyNow, + detected_items: scheduler_paths.len(), + source_paths: scheduler_paths, + mapping: vec![ + "ecc schedule add".to_string(), + "ecc schedule list".to_string(), + "ecc schedule run-due".to_string(), + "ecc daemon".to_string(), + ], + notes: vec![ + "Recurring jobs can be recreated directly in ECC2's persistent scheduler." + .to_string(), + "Translate each legacy cron prompt into an explicit ECC task body before enabling it." + .to_string(), + ], + }); + } + + let gateway_dir = source.join("gateway"); + if gateway_dir.is_dir() { + artifacts.push(LegacyMigrationArtifact { + category: "gateway_dispatch".to_string(), + readiness: LegacyMigrationReadiness::ReadyNow, + detected_items: count_files_recursive(&gateway_dir)?, + source_paths: vec!["gateway".to_string()], + mapping: vec![ + "ecc remote serve".to_string(), + "ecc remote add".to_string(), + "ecc remote computer-use".to_string(), + "ecc remote run".to_string(), + ], + notes: vec![ + "ECC2 already ships a token-authenticated remote dispatch queue and HTTP intake." + .to_string(), + "Remote handlers should be translated to ECC task bodies instead of copied verbatim." + .to_string(), + ], + }); + } + + let memory_paths = collect_existing_relative_paths(&source, &["memory_tool.py"]); + if !memory_paths.is_empty() { + artifacts.push(LegacyMigrationArtifact { + category: "memory_tool".to_string(), + readiness: LegacyMigrationReadiness::ReadyNow, + detected_items: memory_paths.len(), + source_paths: memory_paths, + mapping: vec![ + "ecc graph add-observation".to_string(), + "ecc graph connector-sync".to_string(), + "ecc graph recall".to_string(), + "ecc graph connectors".to_string(), + ], + notes: vec![ + "ECC2 deep memory now supports persistent observations, recall, compaction, and external connectors." + .to_string(), + ], + }); + } + + let workspace_dir = source.join("workspace"); + if workspace_dir.is_dir() { + artifacts.push(LegacyMigrationArtifact { + category: "workspace_memory".to_string(), + readiness: LegacyMigrationReadiness::ReadyNow, + detected_items: count_files_recursive(&workspace_dir)?, + source_paths: vec!["workspace".to_string()], + mapping: vec![ + "ecc graph connector-sync".to_string(), + "ecc graph recall".to_string(), + "WORKING-CONTEXT.md".to_string(), + ], + notes: vec![ + "Import only sanitized operator memory into the shared context graph." + .to_string(), + "Private business data, secrets, and personal archives should stay outside the public repo." + .to_string(), + ], + }); + } + + let skills_paths = collect_existing_relative_paths(&source, &["skills", "skills/ecc-imports"]); + if !skills_paths.is_empty() { + artifacts.push(LegacyMigrationArtifact { + category: "skills".to_string(), + readiness: LegacyMigrationReadiness::ManualTranslation, + detected_items: count_files_recursive(&source.join("skills"))?, + source_paths: skills_paths, + mapping: vec![ + "skills/".to_string(), + "ecc template".to_string(), + "configure-ecc".to_string(), + ], + notes: vec![ + "Reusable skills should be ported one by one into ECC-native skills or orchestration templates." + .to_string(), + "Do not bulk-copy legacy private skills without auditing for secrets and operator-only assumptions." + .to_string(), + ], + }); + } + + let tools_dir = source.join("tools"); + if tools_dir.is_dir() { + artifacts.push(LegacyMigrationArtifact { + category: "tools".to_string(), + readiness: LegacyMigrationReadiness::ManualTranslation, + detected_items: count_files_recursive(&tools_dir)?, + source_paths: vec!["tools".to_string()], + mapping: vec![ + "agents/".to_string(), + "commands/".to_string(), + "hooks/".to_string(), + "harness_runners.".to_string(), + ], + notes: vec![ + "Legacy tool wrappers should be rebuilt as ECC agents, commands, hooks, or configured harness runners." + .to_string(), + "Only the reusable workflow surface should move across; opaque runtime glue should be reimplemented minimally." + .to_string(), + ], + }); + } + + let plugins_dir = source.join("plugins"); + if plugins_dir.is_dir() { + artifacts.push(LegacyMigrationArtifact { + category: "plugins".to_string(), + readiness: LegacyMigrationReadiness::ManualTranslation, + detected_items: count_files_recursive(&plugins_dir)?, + source_paths: vec!["plugins".to_string()], + mapping: vec![ + "hooks/".to_string(), + "commands/".to_string(), + "skills/".to_string(), + ], + notes: vec![ + "Bridge plugins normally translate into ECC hooks, commands, or skills instead of one-for-one plugin copies." + .to_string(), + ], + }); + } + + let env_service_paths = collect_env_service_paths(&source)?; + if !env_service_paths.is_empty() { + artifacts.push(LegacyMigrationArtifact { + category: "env_services".to_string(), + readiness: LegacyMigrationReadiness::LocalAuthRequired, + detected_items: env_service_paths.len(), + source_paths: env_service_paths, + mapping: vec![ + "Claude connectors / OAuth".to_string(), + "MCP config".to_string(), + "local API key setup".to_string(), + ], + notes: vec![ + "Secret material should not be imported into ECC2." + .to_string(), + "Re-enter credentials locally through connectors, OAuth, MCP servers, or local env configuration." + .to_string(), + ], + }); + } + + let summary = LegacyMigrationAuditSummary { + artifact_categories_detected: artifacts.len(), + ready_now_categories: artifacts + .iter() + .filter(|artifact| artifact.readiness == LegacyMigrationReadiness::ReadyNow) + .count(), + manual_translation_categories: artifacts + .iter() + .filter(|artifact| artifact.readiness == LegacyMigrationReadiness::ManualTranslation) + .count(), + local_auth_required_categories: artifacts + .iter() + .filter(|artifact| artifact.readiness == LegacyMigrationReadiness::LocalAuthRequired) + .count(), + }; + + Ok(LegacyMigrationAuditReport { + source: source.display().to_string(), + detected_systems: detect_legacy_workspace_systems(&source, &artifacts), + summary, + recommended_next_steps: build_legacy_migration_next_steps(&artifacts), + artifacts, + }) +} + +fn collect_existing_relative_paths(source: &Path, relative_paths: &[&str]) -> Vec { + let mut matches = Vec::new(); + for relative_path in relative_paths { + if source.join(relative_path).exists() { + matches.push((*relative_path).to_string()); + } + } + matches +} + +fn collect_env_service_paths(source: &Path) -> Result> { + let mut matches = Vec::new(); + for file_name in [ + "config.yaml", + ".env", + ".env.local", + ".env.production", + ".envrc", + ] { + if source.join(file_name).is_file() { + matches.push(file_name.to_string()); + } + } + + let services_dir = source.join("services"); + if services_dir.is_dir() { + let service_file_count = count_files_recursive(&services_dir)?; + if service_file_count > 0 { + matches.push("services".to_string()); + } + } + + Ok(matches) +} + +fn count_files_recursive(path: &Path) -> Result { + if !path.exists() { + return Ok(0); + } + if path.is_file() { + return Ok(1); + } + + let mut total = 0usize; + for entry in fs::read_dir(path)? { + let entry = entry?; + let entry_path = entry.path(); + total += count_files_recursive(&entry_path)?; + } + Ok(total) +} + +fn detect_legacy_workspace_systems( + source: &Path, + artifacts: &[LegacyMigrationArtifact], +) -> Vec { + let mut detected = BTreeSet::new(); + let display = source.display().to_string().to_lowercase(); + if display.contains("hermes") + || source.join("config.yaml").is_file() + || source.join("cron").exists() + || source.join("workspace").exists() + { + detected.insert("hermes".to_string()); + } + if display.contains("openclaw") || source.join(".openclaw").exists() { + detected.insert("openclaw".to_string()); + } + if detected.is_empty() && !artifacts.is_empty() { + detected.insert("legacy_workspace".to_string()); + } + detected.into_iter().collect() +} + +fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> Vec { + let mut steps = Vec::new(); + let categories: BTreeSet<&str> = artifacts + .iter() + .map(|artifact| artifact.category.as_str()) + .collect(); + + if categories.contains("scheduler") { + steps.push( + "Recreate recurring jobs with `ecc schedule add`, verify them with `ecc schedule list`, then enable processing through `ecc daemon`." + .to_string(), + ); + } + if categories.contains("gateway_dispatch") { + steps.push( + "Replace gateway/dispatch entrypoints with `ecc remote serve`, `ecc remote add`, and `ecc remote computer-use`." + .to_string(), + ); + } + if categories.contains("memory_tool") || categories.contains("workspace_memory") { + steps.push( + "Import sanitized operator memory through `ecc graph connector-sync`, then use `ecc graph recall` and pinned observations for durable context." + .to_string(), + ); + } + if categories.contains("skills") { + steps.push( + "Translate reusable Hermes/OpenClaw skills into ECC skills or orchestration templates one lane at a time instead of bulk-copying them." + .to_string(), + ); + } + if categories.contains("tools") || categories.contains("plugins") { + steps.push( + "Rebuild valuable tool/plugin wrappers as ECC agents, commands, hooks, or harness runners, keeping only reusable workflow behavior." + .to_string(), + ); + } + if categories.contains("env_services") { + steps.push( + "Reconfigure credentials locally through Claude connectors, MCP config, OAuth, or local API key setup; do not import raw secret material." + .to_string(), + ); + } + + if steps.is_empty() { + steps.push( + "No recognizable Hermes/OpenClaw migration surfaces were detected; inspect the workspace manually before attempting migration." + .to_string(), + ); + } + + steps +} + +fn format_legacy_migration_audit_human(report: &LegacyMigrationAuditReport) -> String { + let mut lines = vec![ + format!("Legacy migration audit: {}", report.source), + format!( + "Detected systems: {}", + if report.detected_systems.is_empty() { + "none".to_string() + } else { + report.detected_systems.join(", ") + } + ), + format!( + "Artifact categories: {} | ready now {} | manual translation {} | local auth {}", + report.summary.artifact_categories_detected, + report.summary.ready_now_categories, + report.summary.manual_translation_categories, + report.summary.local_auth_required_categories + ), + ]; + + if report.artifacts.is_empty() { + lines.push("No recognizable Hermes/OpenClaw migration surfaces found.".to_string()); + return lines.join("\n"); + } + + lines.push(String::new()); + lines.push("Artifacts".to_string()); + for artifact in &report.artifacts { + lines.push(format!( + "- {} [{}] | items {}", + artifact.category, + format_legacy_migration_readiness(artifact.readiness), + artifact.detected_items + )); + lines.push(format!(" sources {}", artifact.source_paths.join(", "))); + lines.push(format!(" map to {}", artifact.mapping.join(", "))); + for note in &artifact.notes { + lines.push(format!(" note {note}")); + } + } + + lines.push(String::new()); + lines.push("Recommended next steps".to_string()); + for step in &report.recommended_next_steps { + lines.push(format!("- {step}")); + } + + lines.join("\n") +} + +fn format_legacy_migration_readiness(readiness: LegacyMigrationReadiness) -> &'static str { + match readiness { + LegacyMigrationReadiness::ReadyNow => "ready_now", + LegacyMigrationReadiness::ManualTranslation => "manual_translation", + LegacyMigrationReadiness::LocalAuthRequired => "local_auth_required", + } +} + fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, @@ -6820,6 +7278,96 @@ mod tests { } } + #[test] + fn cli_parses_migrate_audit_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "audit", + "--source", + "/tmp/hermes", + "--json", + ]) + .expect("migrate audit should parse"); + + match cli.command { + Some(Commands::Migrate { + command: MigrationCommands::Audit { source, json }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert!(json); + } + _ => panic!("expected migrate audit subcommand"), + } + } + + #[test] + fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { + let tempdir = TestDir::new("legacy-migration-audit")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("cron"))?; + fs::create_dir_all(root.join("gateway"))?; + fs::create_dir_all(root.join("workspace/notes"))?; + fs::create_dir_all(root.join("skills/ecc-imports"))?; + fs::create_dir_all(root.join("tools"))?; + fs::create_dir_all(root.join("plugins"))?; + fs::write(root.join("config.yaml"), "model: claude\n")?; + fs::write(root.join("cron/scheduler.py"), "print('tick')\n")?; + fs::write(root.join("jobs.py"), "JOBS = []\n")?; + fs::write(root.join("gateway/router.py"), "route = True\n")?; + fs::write(root.join("memory_tool.py"), "class MemoryTool: pass\n")?; + fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; + fs::write(root.join("skills/ecc-imports/research.md"), "# skill\n")?; + fs::write(root.join("tools/browser.py"), "print('browser')\n")?; + fs::write(root.join("plugins/reminders.py"), "print('reminders')\n")?; + fs::write( + root.join(".env.local"), + "STRIPE_SECRET_KEY=sk_test_secret\n", + )?; + + let report = build_legacy_migration_audit_report(root)?; + + assert_eq!(report.detected_systems, vec!["hermes"]); + assert_eq!(report.summary.artifact_categories_detected, 8); + assert_eq!(report.summary.ready_now_categories, 4); + assert_eq!(report.summary.manual_translation_categories, 3); + assert_eq!(report.summary.local_auth_required_categories, 1); + assert!(report + .recommended_next_steps + .iter() + .any(|step| step.contains("ecc schedule add"))); + assert!(report + .recommended_next_steps + .iter() + .any(|step| step.contains("ecc remote serve"))); + + let scheduler = report + .artifacts + .iter() + .find(|artifact| artifact.category == "scheduler") + .expect("scheduler artifact"); + assert_eq!(scheduler.readiness, LegacyMigrationReadiness::ReadyNow); + assert_eq!(scheduler.detected_items, 2); + + let env_services = report + .artifacts + .iter() + .find(|artifact| artifact.category == "env_services") + .expect("env services artifact"); + assert_eq!( + env_services.readiness, + LegacyMigrationReadiness::LocalAuthRequired + ); + assert!(env_services + .source_paths + .contains(&"config.yaml".to_string())); + assert!(env_services + .source_paths + .contains(&".env.local".to_string())); + + Ok(()) + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( From d36e9c48a477a095fc58cc0196e3a2121ecb0c54 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 10:54:49 -0700 Subject: [PATCH 161/459] feat: add ecc2 legacy migration plan --- ecc2/src/main.rs | 299 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 94f0ff45..7be7a41e 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -584,6 +584,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Generate an actionable ECC2 migration plan from a legacy workspace audit + Plan { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Write the plan to a file instead of stdout + #[arg(long)] + output: Option, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, } #[derive(clap::Subcommand, Debug)] @@ -914,6 +926,26 @@ struct LegacyMigrationAuditReport { artifacts: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyMigrationPlanStep { + category: String, + readiness: LegacyMigrationReadiness, + title: String, + target_surface: String, + source_paths: Vec, + command_snippets: Vec, + config_snippets: Vec, + notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyMigrationPlanReport { + source: String, + generated_at: String, + audit_summary: LegacyMigrationAuditSummary, + steps: Vec, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct RemoteDispatchHttpRequest { task: String, @@ -1650,6 +1682,25 @@ async fn main() -> Result<()> { println!("{}", format_legacy_migration_audit_human(&report)); } } + MigrationCommands::Plan { + source, + output, + json, + } => { + let audit = build_legacy_migration_audit_report(&source)?; + let plan = build_legacy_migration_plan_report(&audit); + let rendered = if json { + serde_json::to_string_pretty(&plan)? + } else { + format_legacy_migration_plan_human(&plan) + }; + if let Some(path) = output { + std::fs::write(&path, &rendered)?; + println!("Migration plan written to {}", path.display()); + } else { + println!("{rendered}"); + } + } }, Some(Commands::Graph { command }) => match command { GraphCommands::AddEntity { @@ -4797,6 +4848,145 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V steps } +fn build_legacy_migration_plan_report( + audit: &LegacyMigrationAuditReport, +) -> LegacyMigrationPlanReport { + let mut steps = Vec::new(); + + for artifact in &audit.artifacts { + let step = match artifact.category.as_str() { + "scheduler" => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: "Recreate Hermes/OpenClaw recurring jobs in ECC2 scheduler".to_string(), + target_surface: "ECC2 scheduler".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: vec![ + "ecc schedule add --cron \"\" --task \"Translate legacy recurring job from cron/scheduler.py\"".to_string(), + "ecc schedule list".to_string(), + "ecc daemon".to_string(), + ], + config_snippets: Vec::new(), + notes: artifact.notes.clone(), + }, + "gateway_dispatch" => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: "Replace legacy gateway intake with ECC2 remote dispatch".to_string(), + target_surface: "ECC2 remote dispatch".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: vec![ + "ecc remote serve --bind 127.0.0.1:8787 --token ".to_string(), + "ecc remote add --task \"Translate legacy dispatch workflow\"".to_string(), + "ecc remote computer-use --goal \"Translate legacy browser/operator flow\"".to_string(), + ], + config_snippets: Vec::new(), + notes: artifact.notes.clone(), + }, + "memory_tool" => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: "Port legacy memory tool usage to ECC2 deep memory".to_string(), + target_surface: "ECC2 context graph".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: vec![ + "ecc graph add-observation --entity-id --type migration_note --summary \"Imported legacy memory pattern\"".to_string(), + "ecc graph recall \"\"".to_string(), + "ecc graph connectors".to_string(), + ], + config_snippets: Vec::new(), + notes: artifact.notes.clone(), + }, + "workspace_memory" => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: "Import sanitized workspace memory through ECC2 connectors".to_string(), + target_surface: "ECC2 memory connectors".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: vec![ + "ecc graph connector-sync hermes_workspace".to_string(), + "ecc graph recall \"\"".to_string(), + ], + config_snippets: vec![format!( + "[memory_connectors.hermes_workspace]\nkind = \"markdown_directory\"\npath = \"{}\"\nrecurse = true\ndefault_entity_type = \"legacy_workspace_note\"\ndefault_observation_type = \"legacy_workspace_memory\"", + Path::new(&audit.source).join("workspace").display() + )], + notes: artifact.notes.clone(), + }, + "skills" => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: "Translate reusable legacy skills into ECC-native surfaces".to_string(), + target_surface: "ECC skills / orchestration templates".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: vec![ + "ecc template --task \"\"".to_string(), + ], + config_snippets: vec![ + "[orchestration_templates.legacy_workflow]\nproject = \"legacy-migration\"\ntask_group = \"legacy workflow\"\nagent = \"claude\"\nworktree = false\n\n[[orchestration_templates.legacy_workflow.steps]]\nname = \"operator\"\ntask = \"Translate and run the legacy workflow for {{task}}\"".to_string(), + ], + notes: artifact.notes.clone(), + }, + "tools" => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: "Rebuild valuable legacy tools as ECC agents, hooks, commands, or harness runners".to_string(), + target_surface: "ECC agents / hooks / commands / harness runners".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: vec![ + "ecc start --task \"Rebuild one legacy tool as an ECC-native command or hook\"".to_string(), + ], + config_snippets: vec![ + "[harness_runners.legacy-runner]\nprogram = \"\"\nbase_args = []\nproject_markers = [\".legacy-runner\"]".to_string(), + ], + notes: artifact.notes.clone(), + }, + "plugins" => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: "Translate legacy bridge plugins into ECC-native automation".to_string(), + target_surface: "ECC hooks / commands / skills".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: vec![ + "ecc start --task \"Port one bridge plugin behavior into an ECC hook or command\"".to_string(), + ], + config_snippets: Vec::new(), + notes: artifact.notes.clone(), + }, + "env_services" => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: "Reconfigure local auth and connectors without importing secrets".to_string(), + target_surface: "Claude connectors / MCP / local API key setup".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: Vec::new(), + config_snippets: vec![ + "# Re-enter connector auth locally; do not copy legacy secrets into ECC2.\n# Typical targets: Google Drive OAuth, GitHub, Stripe, Linear, browser creds.".to_string(), + ], + notes: artifact.notes.clone(), + }, + _ => LegacyMigrationPlanStep { + category: artifact.category.clone(), + readiness: artifact.readiness, + title: format!("Review legacy {} surface", artifact.category), + target_surface: "Manual ECC2 translation".to_string(), + source_paths: artifact.source_paths.clone(), + command_snippets: Vec::new(), + config_snippets: Vec::new(), + notes: artifact.notes.clone(), + }, + }; + steps.push(step); + } + + LegacyMigrationPlanReport { + source: audit.source.clone(), + generated_at: chrono::Utc::now().to_rfc3339(), + audit_summary: audit.summary.clone(), + steps, + } +} + fn format_legacy_migration_audit_human(report: &LegacyMigrationAuditReport) -> String { let mut lines = vec![ format!("Legacy migration audit: {}", report.source), @@ -4855,6 +5045,53 @@ fn format_legacy_migration_readiness(readiness: LegacyMigrationReadiness) -> &'s } } +fn format_legacy_migration_plan_human(report: &LegacyMigrationPlanReport) -> String { + let mut lines = vec![ + format!("Legacy migration plan: {}", report.source), + format!("Generated at: {}", report.generated_at), + format!( + "Audit summary: {} categories | ready now {} | manual translation {} | local auth {}", + report.audit_summary.artifact_categories_detected, + report.audit_summary.ready_now_categories, + report.audit_summary.manual_translation_categories, + report.audit_summary.local_auth_required_categories + ), + ]; + + if report.steps.is_empty() { + lines.push("No migration steps generated.".to_string()); + return lines.join("\n"); + } + + lines.push(String::new()); + lines.push("Plan".to_string()); + for step in &report.steps { + lines.push(format!( + "- {} [{}] -> {}", + step.title, + format_legacy_migration_readiness(step.readiness), + step.target_surface + )); + if !step.source_paths.is_empty() { + lines.push(format!(" sources {}", step.source_paths.join(", "))); + } + for command in &step.command_snippets { + lines.push(format!(" command {}", command)); + } + for snippet in &step.config_snippets { + lines.push(" config".to_string()); + for line in snippet.lines() { + lines.push(format!(" {}", line)); + } + } + for note in &step.notes { + lines.push(format!(" note {}", note)); + } + } + + lines.join("\n") +} + fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, @@ -7301,6 +7538,36 @@ mod tests { } } + #[test] + fn cli_parses_migrate_plan_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "plan", + "--source", + "/tmp/hermes", + "--output", + "/tmp/plan.md", + ]) + .expect("migrate plan should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::Plan { + source, + output, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert_eq!(output, Some(PathBuf::from("/tmp/plan.md"))); + assert!(!json); + } + _ => panic!("expected migrate plan subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -7368,6 +7635,38 @@ mod tests { Ok(()) } + #[test] + fn legacy_migration_plan_report_generates_workspace_connector_step() -> Result<()> { + let tempdir = TestDir::new("legacy-migration-plan")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("workspace/notes"))?; + fs::write(root.join("config.yaml"), "model: claude\n")?; + fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; + + let audit = build_legacy_migration_audit_report(root)?; + let plan = build_legacy_migration_plan_report(&audit); + + let workspace_step = plan + .steps + .iter() + .find(|step| step.category == "workspace_memory") + .expect("workspace memory step"); + assert_eq!(workspace_step.readiness, LegacyMigrationReadiness::ReadyNow); + assert!(workspace_step + .config_snippets + .iter() + .any(|snippet| snippet.contains("[memory_connectors.hermes_workspace]"))); + assert!(workspace_step + .command_snippets + .contains(&"ecc graph connector-sync hermes_workspace".to_string())); + + let rendered = format_legacy_migration_plan_human(&plan); + assert!(rendered.contains("Legacy migration plan")); + assert!(rendered.contains("Import sanitized workspace memory through ECC2 connectors")); + + Ok(()) + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( From 046af44065e0377c20d8e1f1714b38cae85882c3 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 10:57:13 -0700 Subject: [PATCH 162/459] feat: add ecc2 legacy migration scaffold --- ecc2/src/main.rs | 161 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 7be7a41e..710ce961 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -596,6 +596,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Scaffold migration artifacts on disk from a legacy workspace audit + Scaffold { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Directory where scaffolded migration artifacts should be written + #[arg(long)] + output_dir: PathBuf, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, } #[derive(clap::Subcommand, Debug)] @@ -946,6 +958,14 @@ struct LegacyMigrationPlanReport { steps: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyMigrationScaffoldReport { + source: String, + output_dir: String, + files_written: Vec, + steps_scaffolded: usize, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct RemoteDispatchHttpRequest { task: String, @@ -1701,6 +1721,20 @@ async fn main() -> Result<()> { println!("{rendered}"); } } + MigrationCommands::Scaffold { + source, + output_dir, + json, + } => { + let audit = build_legacy_migration_audit_report(&source)?; + let plan = build_legacy_migration_plan_report(&audit); + let report = write_legacy_migration_scaffold(&plan, &output_dir)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_migration_scaffold_human(&report)); + } + } }, Some(Commands::Graph { command }) => match command { GraphCommands::AddEntity { @@ -4987,6 +5021,62 @@ fn build_legacy_migration_plan_report( } } +fn write_legacy_migration_scaffold( + plan: &LegacyMigrationPlanReport, + output_dir: &Path, +) -> Result { + fs::create_dir_all(output_dir).with_context(|| { + format!( + "create migration scaffold output directory: {}", + output_dir.display() + ) + })?; + + let plan_path = output_dir.join("migration-plan.md"); + let config_path = output_dir.join("ecc2.migration.toml"); + + fs::write(&plan_path, format_legacy_migration_plan_human(plan)) + .with_context(|| format!("write migration plan: {}", plan_path.display()))?; + fs::write(&config_path, render_legacy_migration_config_scaffold(plan)) + .with_context(|| format!("write migration config scaffold: {}", config_path.display()))?; + + Ok(LegacyMigrationScaffoldReport { + source: plan.source.clone(), + output_dir: output_dir.display().to_string(), + files_written: vec![ + plan_path.display().to_string(), + config_path.display().to_string(), + ], + steps_scaffolded: plan.steps.len(), + }) +} + +fn render_legacy_migration_config_scaffold(plan: &LegacyMigrationPlanReport) -> String { + let mut sections = vec![ + format!( + "# ECC2 migration scaffold generated from {}\n# Review every section before merging it into a real ecc2.toml.", + plan.source + ), + ]; + + for step in &plan.steps { + if step.config_snippets.is_empty() { + continue; + } + sections.push(format!( + "\n# {} [{} -> {}]", + step.title, + format_legacy_migration_readiness(step.readiness), + step.target_surface + )); + for snippet in &step.config_snippets { + sections.push(snippet.clone()); + } + } + + sections.join("\n\n") +} + fn format_legacy_migration_audit_human(report: &LegacyMigrationAuditReport) -> String { let mut lines = vec![ format!("Legacy migration audit: {}", report.source), @@ -5092,6 +5182,19 @@ fn format_legacy_migration_plan_human(report: &LegacyMigrationPlanReport) -> Str lines.join("\n") } +fn format_legacy_migration_scaffold_human(report: &LegacyMigrationScaffoldReport) -> String { + let mut lines = vec![ + format!("Legacy migration scaffold written for {}", report.source), + format!("- output dir {}", report.output_dir), + format!("- steps scaffolded {}", report.steps_scaffolded), + "- files".to_string(), + ]; + for path in &report.files_written { + lines.push(format!(" {}", path)); + } + lines.join("\n") +} + fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, @@ -7568,6 +7671,37 @@ mod tests { } } + #[test] + fn cli_parses_migrate_scaffold_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "scaffold", + "--source", + "/tmp/hermes", + "--output-dir", + "/tmp/migration-scaffold", + "--json", + ]) + .expect("migrate scaffold should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::Scaffold { + source, + output_dir, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert_eq!(output_dir, PathBuf::from("/tmp/migration-scaffold")); + assert!(json); + } + _ => panic!("expected migrate scaffold subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -7667,6 +7801,33 @@ mod tests { Ok(()) } + #[test] + fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { + let tempdir = TestDir::new("legacy-migration-scaffold")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("workspace/notes"))?; + fs::create_dir_all(root.join("skills/ecc-imports"))?; + fs::write(root.join("config.yaml"), "model: claude\n")?; + fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; + fs::write(root.join("skills/ecc-imports/triage.md"), "# triage\n")?; + + let audit = build_legacy_migration_audit_report(root)?; + let plan = build_legacy_migration_plan_report(&audit); + let output_dir = root.join("out"); + let report = write_legacy_migration_scaffold(&plan, &output_dir)?; + + assert_eq!(report.steps_scaffolded, plan.steps.len()); + assert_eq!(report.files_written.len(), 2); + + let plan_text = fs::read_to_string(output_dir.join("migration-plan.md"))?; + let config_text = fs::read_to_string(output_dir.join("ecc2.migration.toml"))?; + assert!(plan_text.contains("Legacy migration plan")); + assert!(config_text.contains("[memory_connectors.hermes_workspace]")); + assert!(config_text.contains("[orchestration_templates.legacy_workflow]")); + + Ok(()) + } + #[test] fn format_decisions_human_renders_details() { let text = format_decisions_human( From 790cb0205c997461c7f9ade8b4bda6274f1d9728 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:06:14 -0700 Subject: [PATCH 163/459] feat: add ecc2 legacy schedule migration import --- docs/HERMES-OPENCLAW-MIGRATION.md | 3 + docs/HERMES-SETUP.md | 1 + ecc2/src/main.rs | 701 +++++++++++++++++++++++++++++- 3 files changed, 699 insertions(+), 6 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index 8173419e..5ebd59b7 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -186,6 +186,9 @@ It is mostly: ECC 2.0 now ships a bounded migration audit entrypoint: - `ecc migrate audit --source ~/.hermes` +- `ecc migrate plan --source ~/.hermes --output migration-plan.md` +- `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` +- `ecc migrate import-schedules --source ~/.hermes --dry-run` Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes. diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index 533abc58..7a9568b9 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,6 +83,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. +0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, then preview recurring jobs with `ecc migrate import-schedules --dry-run`. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 710ce961..c9b83e5a 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -608,6 +608,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Import recurring jobs from a legacy cron/jobs.json into ECC2 schedules + ImportSchedules { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Preview detected jobs without creating ECC2 schedules + #[arg(long)] + dry_run: bool, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, } #[derive(clap::Subcommand, Debug)] @@ -966,6 +978,47 @@ struct LegacyMigrationScaffoldReport { steps_scaffolded: usize, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum LegacyScheduleImportJobStatus { + Ready, + Imported, + Disabled, + Invalid, + Skipped, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyScheduleImportJobReport { + source_path: String, + job_name: String, + cron_expr: Option, + task: Option, + agent: Option, + profile: Option, + project: Option, + task_group: Option, + use_worktree: Option, + status: LegacyScheduleImportJobStatus, + reason: Option, + command_snippet: Option, + imported_schedule_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyScheduleImportReport { + source: String, + source_path: String, + dry_run: bool, + jobs_detected: usize, + ready_jobs: usize, + imported_jobs: usize, + disabled_jobs: usize, + invalid_jobs: usize, + skipped_jobs: usize, + jobs: Vec, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct RemoteDispatchHttpRequest { task: String, @@ -1735,6 +1788,18 @@ async fn main() -> Result<()> { println!("{}", format_legacy_migration_scaffold_human(&report)); } } + MigrationCommands::ImportSchedules { + source, + dry_run, + json, + } => { + let report = import_legacy_schedules(&db, &cfg, &source, dry_run)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_schedule_import_human(&report)); + } + } }, Some(Commands::Graph { command }) => match command { GraphCommands::AddEntity { @@ -4882,10 +4947,362 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V steps } +#[derive(Debug, Clone, PartialEq, Eq)] +struct LegacyScheduleDraft { + source_path: String, + job_name: String, + cron_expr: Option, + task: Option, + agent: Option, + profile: Option, + project: Option, + task_group: Option, + use_worktree: Option, + enabled: bool, +} + +fn load_legacy_schedule_drafts(source: &Path) -> Result> { + let jobs_path = source.join("cron/jobs.json"); + if !jobs_path.is_file() { + return Ok(Vec::new()); + } + + let text = fs::read_to_string(&jobs_path) + .with_context(|| format!("read legacy scheduler jobs: {}", jobs_path.display()))?; + let value: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("parse legacy scheduler jobs JSON: {}", jobs_path.display()))?; + let source_path = jobs_path + .strip_prefix(source) + .unwrap_or(&jobs_path) + .display() + .to_string(); + + let entries: Vec<&serde_json::Value> = match &value { + serde_json::Value::Array(items) => items.iter().collect(), + serde_json::Value::Object(map) => { + if let Some(items) = ["jobs", "schedules", "tasks"] + .iter() + .find_map(|key| map.get(*key).and_then(serde_json::Value::as_array)) + { + items.iter().collect() + } else { + vec![&value] + } + } + _ => anyhow::bail!( + "legacy scheduler jobs file must be a JSON object or array: {}", + jobs_path.display() + ), + }; + + Ok(entries + .into_iter() + .enumerate() + .map(|(index, value)| build_legacy_schedule_draft(value, index, &source_path)) + .collect()) +} + +fn build_legacy_schedule_draft( + value: &serde_json::Value, + index: usize, + source_path: &str, +) -> LegacyScheduleDraft { + let job_name = json_string_candidates( + value, + &[ + &["name"], + &["id"], + &["title"], + &["job_name"], + &["task_name"], + ], + ) + .unwrap_or_else(|| format!("legacy-job-{}", index + 1)); + let cron_expr = json_string_candidates( + value, + &[ + &["cron"], + &["schedule"], + &["cron_expr"], + &["trigger", "cron"], + &["timing", "cron"], + ], + ); + let task = json_string_candidates( + value, + &[ + &["task"], + &["prompt"], + &["goal"], + &["description"], + &["command"], + &["task", "prompt"], + &["task", "description"], + ], + ); + let enabled = !json_bool_candidates(value, &[&["disabled"]]).unwrap_or(false) + && json_bool_candidates(value, &[&["enabled"], &["active"]]).unwrap_or(true); + + LegacyScheduleDraft { + source_path: source_path.to_string(), + job_name, + cron_expr, + task, + agent: json_string_candidates(value, &[&["agent"], &["runner"]]), + profile: json_string_candidates(value, &[&["profile"], &["agent_profile"]]), + project: json_string_candidates(value, &[&["project"]]), + task_group: json_string_candidates(value, &[&["task_group"], &["group"]]), + use_worktree: json_bool_candidates(value, &[&["use_worktree"], &["worktree"]]), + enabled, + } +} + +fn json_string_candidates(value: &serde_json::Value, paths: &[&[&str]]) -> Option { + paths + .iter() + .find_map(|path| json_lookup(value, path)) + .and_then(json_to_string) +} + +fn json_bool_candidates(value: &serde_json::Value, paths: &[&[&str]]) -> Option { + paths.iter().find_map(|path| { + json_lookup(value, path).and_then(|value| match value { + serde_json::Value::Bool(boolean) => Some(*boolean), + serde_json::Value::String(text) => match text.trim().to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" => Some(true), + "false" | "0" | "no" | "off" => Some(false), + _ => None, + }, + _ => None, + }) + }) +} + +fn json_lookup<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + Some(current) +} + +fn json_to_string(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + serde_json::Value::Number(number) => Some(number.to_string()), + _ => None, + } +} + +fn shell_quote_double(value: &str) -> String { + format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + ) +} + +fn validate_schedule_cron_expr(expr: &str) -> Result<()> { + let trimmed = expr.trim(); + let normalized = match trimmed.split_whitespace().count() { + 5 => format!("0 {trimmed}"), + 6 | 7 => trimmed.to_string(), + fields => { + anyhow::bail!( + "invalid cron expression `{trimmed}`: expected 5, 6, or 7 fields but found {fields}" + ) + } + }; + ::from_str(&normalized) + .with_context(|| format!("invalid cron expression `{trimmed}`"))?; + Ok(()) +} + +fn build_legacy_schedule_add_command(draft: &LegacyScheduleDraft) -> Option { + let cron_expr = draft.cron_expr.as_deref()?; + let task = draft.task.as_deref()?; + let mut parts = vec![ + "ecc schedule add".to_string(), + format!("--cron {}", shell_quote_double(cron_expr)), + format!("--task {}", shell_quote_double(task)), + ]; + if let Some(agent) = draft.agent.as_deref() { + parts.push(format!("--agent {}", shell_quote_double(agent))); + } + if let Some(profile) = draft.profile.as_deref() { + parts.push(format!("--profile {}", shell_quote_double(profile))); + } + match draft.use_worktree { + Some(true) => parts.push("--worktree".to_string()), + Some(false) => parts.push("--no-worktree".to_string()), + None => {} + } + if let Some(project) = draft.project.as_deref() { + parts.push(format!("--project {}", shell_quote_double(project))); + } + if let Some(task_group) = draft.task_group.as_deref() { + parts.push(format!("--task-group {}", shell_quote_double(task_group))); + } + Some(parts.join(" ")) +} + +fn import_legacy_schedules( + db: &session::store::StateStore, + cfg: &config::Config, + source: &Path, + dry_run: bool, +) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let drafts = load_legacy_schedule_drafts(&source)?; + let source_path = source.join("cron/jobs.json"); + let source_path = source_path + .strip_prefix(&source) + .unwrap_or(&source_path) + .display() + .to_string(); + + let mut report = LegacyScheduleImportReport { + source: source.display().to_string(), + source_path, + dry_run, + jobs_detected: drafts.len(), + ready_jobs: 0, + imported_jobs: 0, + disabled_jobs: 0, + invalid_jobs: 0, + skipped_jobs: 0, + jobs: Vec::new(), + }; + + for draft in drafts { + let mut item = LegacyScheduleImportJobReport { + source_path: draft.source_path.clone(), + job_name: draft.job_name.clone(), + cron_expr: draft.cron_expr.clone(), + task: draft.task.clone(), + agent: draft.agent.clone(), + profile: draft.profile.clone(), + project: draft.project.clone(), + task_group: draft.task_group.clone(), + use_worktree: draft.use_worktree, + status: LegacyScheduleImportJobStatus::Ready, + reason: None, + command_snippet: build_legacy_schedule_add_command(&draft), + imported_schedule_id: None, + }; + + if !draft.enabled { + item.status = LegacyScheduleImportJobStatus::Disabled; + item.reason = Some("disabled in legacy workspace".to_string()); + report.disabled_jobs += 1; + report.jobs.push(item); + continue; + } + + let cron_expr = match draft.cron_expr.as_deref() { + Some(value) => value, + None => { + item.status = LegacyScheduleImportJobStatus::Invalid; + item.reason = Some("missing cron expression".to_string()); + report.invalid_jobs += 1; + report.jobs.push(item); + continue; + } + }; + let task = match draft.task.as_deref() { + Some(value) => value, + None => { + item.status = LegacyScheduleImportJobStatus::Invalid; + item.reason = Some("missing task/prompt".to_string()); + report.invalid_jobs += 1; + report.jobs.push(item); + continue; + } + }; + + if let Err(error) = validate_schedule_cron_expr(cron_expr) { + item.status = LegacyScheduleImportJobStatus::Invalid; + item.reason = Some(error.to_string()); + report.invalid_jobs += 1; + report.jobs.push(item); + continue; + } + + if let Some(profile) = draft.profile.as_deref() { + if let Err(error) = cfg.resolve_agent_profile(profile) { + item.status = LegacyScheduleImportJobStatus::Skipped; + item.reason = Some(format!("profile `{profile}` is not usable here: {error}")); + report.skipped_jobs += 1; + report.jobs.push(item); + continue; + } + } + + report.ready_jobs += 1; + if dry_run { + report.jobs.push(item); + continue; + } + + let schedule = session::manager::create_scheduled_task( + db, + cfg, + cron_expr, + task, + draft.agent.as_deref().unwrap_or(&cfg.default_agent), + draft.profile.as_deref(), + draft.use_worktree.unwrap_or(cfg.auto_create_worktrees), + session::SessionGrouping { + project: draft.project.clone(), + task_group: draft.task_group.clone(), + }, + )?; + item.status = LegacyScheduleImportJobStatus::Imported; + item.imported_schedule_id = Some(schedule.id); + report.imported_jobs += 1; + report.jobs.push(item); + } + + Ok(report) +} + fn build_legacy_migration_plan_report( audit: &LegacyMigrationAuditReport, ) -> LegacyMigrationPlanReport { let mut steps = Vec::new(); + let legacy_schedule_drafts = + load_legacy_schedule_drafts(Path::new(&audit.source)).unwrap_or_default(); + let schedule_commands = legacy_schedule_drafts + .iter() + .filter(|draft| draft.enabled) + .filter_map(build_legacy_schedule_add_command) + .collect::>(); + let disabled_schedule_jobs = legacy_schedule_drafts + .iter() + .filter(|draft| !draft.enabled) + .count(); + let invalid_schedule_jobs = legacy_schedule_drafts + .iter() + .filter(|draft| draft.enabled && (draft.cron_expr.is_none() || draft.task.is_none())) + .count(); for artifact in &audit.artifacts { let step = match artifact.category.as_str() { @@ -4895,13 +5312,39 @@ fn build_legacy_migration_plan_report( title: "Recreate Hermes/OpenClaw recurring jobs in ECC2 scheduler".to_string(), target_surface: "ECC2 scheduler".to_string(), source_paths: artifact.source_paths.clone(), - command_snippets: vec![ - "ecc schedule add --cron \"\" --task \"Translate legacy recurring job from cron/scheduler.py\"".to_string(), - "ecc schedule list".to_string(), - "ecc daemon".to_string(), - ], + command_snippets: if schedule_commands.is_empty() { + vec![ + "ecc schedule add --cron \"\" --task \"Translate legacy recurring job from cron/scheduler.py\"".to_string(), + "ecc schedule list".to_string(), + "ecc daemon".to_string(), + ] + } else { + let mut commands = schedule_commands.clone(); + commands.push("ecc schedule list".to_string()); + commands.push("ecc daemon".to_string()); + commands + }, config_snippets: Vec::new(), - notes: artifact.notes.clone(), + notes: { + let mut notes = artifact.notes.clone(); + if !schedule_commands.is_empty() { + notes.push(format!( + "Recovered {} concrete recurring job(s) from cron/jobs.json.", + schedule_commands.len() + )); + } + if disabled_schedule_jobs > 0 { + notes.push(format!( + "{disabled_schedule_jobs} legacy recurring job(s) are disabled and were left out of generated ECC2 commands." + )); + } + if invalid_schedule_jobs > 0 { + notes.push(format!( + "{invalid_schedule_jobs} legacy recurring job(s) were missing cron/task fields and still need manual translation." + )); + } + notes + }, }, "gateway_dispatch" => LegacyMigrationPlanStep { category: artifact.category.clone(), @@ -5195,6 +5638,64 @@ fn format_legacy_migration_scaffold_human(report: &LegacyMigrationScaffoldReport lines.join("\n") } +fn format_legacy_schedule_import_human(report: &LegacyScheduleImportReport) -> String { + let mut lines = vec![ + format!( + "Legacy schedule import {} for {}", + if report.dry_run { + "preview" + } else { + "complete" + }, + report.source + ), + format!("- source path {}", report.source_path), + format!("- jobs detected {}", report.jobs_detected), + format!("- ready jobs {}", report.ready_jobs), + format!("- imported jobs {}", report.imported_jobs), + format!("- disabled jobs {}", report.disabled_jobs), + format!("- invalid jobs {}", report.invalid_jobs), + format!("- skipped jobs {}", report.skipped_jobs), + ]; + + if report.jobs.is_empty() { + lines.push("- no importable cron/jobs.json entries were found".to_string()); + return lines.join("\n"); + } + + lines.push("Jobs".to_string()); + for job in &report.jobs { + lines.push(format!( + "- {} [{}]", + job.job_name, + match job.status { + LegacyScheduleImportJobStatus::Ready => "ready", + LegacyScheduleImportJobStatus::Imported => "imported", + LegacyScheduleImportJobStatus::Disabled => "disabled", + LegacyScheduleImportJobStatus::Invalid => "invalid", + LegacyScheduleImportJobStatus::Skipped => "skipped", + } + )); + if let Some(cron_expr) = job.cron_expr.as_deref() { + lines.push(format!(" cron {}", cron_expr)); + } + if let Some(task) = job.task.as_deref() { + lines.push(format!(" task {}", task)); + } + if let Some(command) = job.command_snippet.as_deref() { + lines.push(format!(" command {}", command)); + } + if let Some(schedule_id) = job.imported_schedule_id { + lines.push(format!(" schedule {}", schedule_id)); + } + if let Some(reason) = job.reason.as_deref() { + lines.push(format!(" note {}", reason)); + } + } + + lines.join("\n") +} + fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, @@ -7702,6 +8203,36 @@ mod tests { } } + #[test] + fn cli_parses_migrate_import_schedules_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "import-schedules", + "--source", + "/tmp/hermes", + "--dry-run", + "--json", + ]) + .expect("migrate import-schedules should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::ImportSchedules { + source, + dry_run, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert!(dry_run); + assert!(json); + } + _ => panic!("expected migrate import-schedules subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -7773,8 +8304,32 @@ mod tests { fn legacy_migration_plan_report_generates_workspace_connector_step() -> Result<()> { let tempdir = TestDir::new("legacy-migration-plan")?; let root = tempdir.path(); + fs::create_dir_all(root.join("cron"))?; fs::create_dir_all(root.join("workspace/notes"))?; fs::write(root.join("config.yaml"), "model: claude\n")?; + fs::write( + root.join("cron/jobs.json"), + serde_json::json!({ + "jobs": [ + { + "name": "portal-recovery", + "cron": "*/15 * * * *", + "prompt": "Check portal-first recovery flow", + "agent": "codex", + "project": "billing-web", + "task_group": "recovery", + "use_worktree": false + }, + { + "name": "paused-job", + "cron": "0 12 * * *", + "prompt": "This one stays paused", + "disabled": true + } + ] + }) + .to_string(), + )?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; let audit = build_legacy_migration_audit_report(root)?; @@ -7794,6 +8349,24 @@ mod tests { .command_snippets .contains(&"ecc graph connector-sync hermes_workspace".to_string())); + let scheduler_step = plan + .steps + .iter() + .find(|step| step.category == "scheduler") + .expect("scheduler step"); + assert!(scheduler_step + .command_snippets + .iter() + .any(|command| command.contains("ecc schedule add --cron \"*/15 * * * *\""))); + assert!(!scheduler_step + .command_snippets + .iter() + .any(|command| command.contains(""))); + assert!(scheduler_step + .notes + .iter() + .any(|note| note.contains("disabled"))); + let rendered = format_legacy_migration_plan_human(&plan); assert!(rendered.contains("Legacy migration plan")); assert!(rendered.contains("Import sanitized workspace memory through ECC2 connectors")); @@ -7801,6 +8374,122 @@ mod tests { Ok(()) } + #[test] + fn import_legacy_schedules_dry_run_reports_ready_disabled_and_invalid_jobs() -> Result<()> { + let tempdir = TestDir::new("legacy-schedule-import-dry-run")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("cron"))?; + fs::write( + root.join("cron/jobs.json"), + serde_json::json!({ + "jobs": [ + { + "name": "portal-recovery", + "cron": "*/15 * * * *", + "prompt": "Check portal-first recovery flow", + "agent": "codex", + "project": "billing-web", + "task_group": "recovery", + "use_worktree": false + }, + { + "name": "paused-job", + "cron": "0 12 * * *", + "prompt": "This one stays paused", + "disabled": true + }, + { + "name": "broken-job", + "prompt": "Missing cron" + } + ] + }) + .to_string(), + )?; + + let tempdb = TestDir::new("legacy-schedule-import-dry-run-db")?; + let db = StateStore::open(&tempdb.path().join("state.db"))?; + let report = import_legacy_schedules(&db, &config::Config::default(), root, true)?; + + assert!(report.dry_run); + assert_eq!(report.jobs_detected, 3); + assert_eq!(report.ready_jobs, 1); + assert_eq!(report.imported_jobs, 0); + assert_eq!(report.disabled_jobs, 1); + assert_eq!(report.invalid_jobs, 1); + assert_eq!(report.skipped_jobs, 0); + assert_eq!(report.jobs.len(), 3); + assert!(report + .jobs + .iter() + .any(|job| job.command_snippet.as_deref() == Some("ecc schedule add --cron \"*/15 * * * *\" --task \"Check portal-first recovery flow\" --agent \"codex\" --no-worktree --project \"billing-web\" --task-group \"recovery\""))); + + Ok(()) + } + + #[test] + fn import_legacy_schedules_creates_real_ecc2_schedules() -> Result<()> { + let tempdir = TestDir::new("legacy-schedule-import-live")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("cron"))?; + fs::write( + root.join("cron/jobs.json"), + serde_json::json!({ + "jobs": [ + { + "name": "portal-recovery", + "cron": "*/15 * * * *", + "prompt": "Check portal-first recovery flow", + "agent": "codex", + "project": "billing-web", + "task_group": "recovery", + "use_worktree": false + } + ] + }) + .to_string(), + )?; + + let target_repo = tempdir.path().join("target"); + fs::create_dir_all(&target_repo)?; + fs::write(target_repo.join(".gitignore"), "target\n")?; + + let tempdb = TestDir::new("legacy-schedule-import-live-db")?; + let db = StateStore::open(&tempdb.path().join("state.db"))?; + struct CurrentDirGuard(PathBuf); + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + let _cwd_guard = CurrentDirGuard(std::env::current_dir()?); + std::env::set_current_dir(&target_repo)?; + let report = import_legacy_schedules(&db, &config::Config::default(), root, false)?; + + assert!(!report.dry_run); + assert_eq!(report.ready_jobs, 1); + assert_eq!(report.imported_jobs, 1); + assert_eq!( + report.jobs[0].status, + LegacyScheduleImportJobStatus::Imported + ); + assert!(report.jobs[0].imported_schedule_id.is_some()); + + let schedules = db.list_scheduled_tasks()?; + assert_eq!(schedules.len(), 1); + assert_eq!(schedules[0].task, "Check portal-first recovery flow"); + assert_eq!(schedules[0].agent_type, "codex"); + assert_eq!(schedules[0].project, "billing-web"); + assert_eq!(schedules[0].task_group, "recovery"); + assert!(!schedules[0].use_worktree); + assert_eq!( + schedules[0].working_dir.canonicalize()?, + target_repo.canonicalize()? + ); + + Ok(()) + } + #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?; From b6426ade321e561a54a91fe3a9d4c3de2abcb8c5 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:10:40 -0700 Subject: [PATCH 164/459] feat: add ecc2 legacy workspace memory import --- docs/HERMES-OPENCLAW-MIGRATION.md | 1 + docs/HERMES-SETUP.md | 2 +- ecc2/src/main.rs | 231 ++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 1 deletion(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index 5ebd59b7..5520c939 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -189,6 +189,7 @@ ECC 2.0 now ships a bounded migration audit entrypoint: - `ecc migrate plan --source ~/.hermes --output migration-plan.md` - `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` - `ecc migrate import-schedules --source ~/.hermes --dry-run` +- `ecc migrate import-memory --source ~/.hermes` Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes. diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index 7a9568b9..a25da46a 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,7 +83,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. -0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, then preview recurring jobs with `ecc migrate import-schedules --dry-run`. +0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index c9b83e5a..10d1c1b9 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -620,6 +620,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Import legacy workspace memory into the ECC2 context graph + ImportMemory { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Maximum imported records across all synthesized connectors + #[arg(long, default_value_t = 100)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, } #[derive(clap::Subcommand, Debug)] @@ -1019,6 +1031,13 @@ struct LegacyScheduleImportReport { jobs: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyMemoryImportReport { + source: String, + connectors_detected: usize, + report: GraphConnectorSyncReport, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct RemoteDispatchHttpRequest { task: String, @@ -1800,6 +1819,18 @@ async fn main() -> Result<()> { println!("{}", format_legacy_schedule_import_human(&report)); } } + MigrationCommands::ImportMemory { + source, + limit, + json, + } => { + let report = import_legacy_memory(&db, &cfg, &source, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_memory_import_human(&report)); + } + } }, Some(Commands::Graph { command }) => match command { GraphCommands::AddEntity { @@ -5284,6 +5315,65 @@ fn import_legacy_schedules( Ok(report) } +fn import_legacy_memory( + db: &session::store::StateStore, + cfg: &config::Config, + source: &Path, + limit: usize, +) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let mut import_cfg = cfg.clone(); + import_cfg.memory_connectors.clear(); + + let workspace_dir = source.join("workspace"); + if workspace_dir.is_dir() { + if !collect_markdown_paths(&workspace_dir, true)?.is_empty() { + import_cfg.memory_connectors.insert( + "legacy_workspace_markdown".to_string(), + config::MemoryConnectorConfig::MarkdownDirectory( + config::MemoryConnectorMarkdownDirectoryConfig { + path: workspace_dir.clone(), + recurse: true, + session_id: None, + default_entity_type: Some("legacy_workspace_note".to_string()), + default_observation_type: Some("legacy_workspace_memory".to_string()), + }, + ), + ); + } + if !collect_jsonl_paths(&workspace_dir, true)?.is_empty() { + import_cfg.memory_connectors.insert( + "legacy_workspace_jsonl".to_string(), + config::MemoryConnectorConfig::JsonlDirectory( + config::MemoryConnectorJsonlDirectoryConfig { + path: workspace_dir, + recurse: true, + session_id: None, + default_entity_type: Some("legacy_workspace_record".to_string()), + default_observation_type: Some("legacy_workspace_memory".to_string()), + }, + ), + ); + } + } + + let report = sync_all_memory_connectors(db, &import_cfg, limit)?; + Ok(LegacyMemoryImportReport { + source: source.display().to_string(), + connectors_detected: import_cfg.memory_connectors.len(), + report, + }) +} + fn build_legacy_migration_plan_report( audit: &LegacyMigrationAuditReport, ) -> LegacyMigrationPlanReport { @@ -5696,6 +5786,41 @@ fn format_legacy_schedule_import_human(report: &LegacyScheduleImportReport) -> S lines.join("\n") } +fn format_legacy_memory_import_human(report: &LegacyMemoryImportReport) -> String { + let mut lines = vec![ + format!( + "Legacy workspace memory import complete for {}", + report.source + ), + format!("- connectors detected {}", report.connectors_detected), + format!("- connectors synced {}", report.report.connectors_synced), + format!("- records read {}", report.report.records_read), + format!("- entities upserted {}", report.report.entities_upserted), + format!("- observations added {}", report.report.observations_added), + format!("- skipped records {}", report.report.skipped_records), + format!( + "- skipped unchanged sources {}", + report.report.skipped_unchanged_sources + ), + ]; + + if !report.report.connectors.is_empty() { + lines.push("Connectors".to_string()); + for connector in &report.report.connectors { + lines.push(format!( + "- {} | records {} | entities {} | observations {} | skipped unchanged {}", + connector.connector_name, + connector.records_read, + connector.entities_upserted, + connector.observations_added, + connector.skipped_unchanged_sources + )); + } + } + + lines.join("\n") +} + fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, @@ -8233,6 +8358,37 @@ mod tests { } } + #[test] + fn cli_parses_migrate_import_memory_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "import-memory", + "--source", + "/tmp/hermes", + "--limit", + "24", + "--json", + ]) + .expect("migrate import-memory should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::ImportMemory { + source, + limit, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert_eq!(limit, 24); + assert!(json); + } + _ => panic!("expected migrate import-memory subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -8490,6 +8646,81 @@ mod tests { Ok(()) } + #[test] + fn import_legacy_memory_imports_workspace_markdown_and_jsonl() -> Result<()> { + let tempdir = TestDir::new("legacy-memory-import")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("workspace/notes"))?; + fs::create_dir_all(root.join("workspace/memory"))?; + fs::write( + root.join("workspace/notes/recovery.md"), + r#"# Billing incident +Customer wiped setup and got charged twice after reinstalling. + +## Portal routing +Route existing installs to portal first before checkout. +"#, + )?; + fs::write( + root.join("workspace/memory/hermes.jsonl"), + [ + serde_json::json!({ + "entity_name": "Billing recovery checklist", + "summary": "Use portal-first routing before offering checkout again" + }) + .to_string(), + serde_json::json!({ + "entity_name": "Repair before reinstall", + "summary": "Recommend ecc repair before purchase flows" + }) + .to_string(), + ] + .join("\n"), + )?; + + let tempdb = TestDir::new("legacy-memory-import-db")?; + let db = StateStore::open(&tempdb.path().join("state.db"))?; + let report = import_legacy_memory(&db, &config::Config::default(), root, 10)?; + + assert_eq!(report.connectors_detected, 2); + assert_eq!(report.report.connectors_synced, 2); + assert_eq!(report.report.records_read, 4); + assert_eq!(report.report.entities_upserted, 4); + assert_eq!(report.report.observations_added, 4); + + let recalled = db.recall_context_entities(None, "charged twice portal reinstall", 10)?; + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Billing incident")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Billing recovery checklist")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "Repair before reinstall")); + + Ok(()) + } + + #[test] + fn import_legacy_memory_reports_no_workspace_connectors_when_absent() -> Result<()> { + let tempdir = TestDir::new("legacy-memory-import-empty")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("skills"))?; + + let tempdb = TestDir::new("legacy-memory-import-empty-db")?; + let db = StateStore::open(&tempdb.path().join("state.db"))?; + let report = import_legacy_memory(&db, &config::Config::default(), root, 10)?; + + assert_eq!(report.connectors_detected, 0); + assert_eq!(report.report.connectors_synced, 0); + assert_eq!(report.report.records_read, 0); + assert_eq!(report.report.entities_upserted, 0); + assert_eq!(report.report.observations_added, 0); + + Ok(()) + } + #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?; From e7dd7047b5625c5664b63411242aba7f30184271 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:23:10 -0700 Subject: [PATCH 165/459] feat: add ecc2 legacy remote migration import --- docs/HERMES-OPENCLAW-MIGRATION.md | 1 + docs/HERMES-SETUP.md | 2 +- ecc2/src/main.rs | 1015 ++++++++++++++++++++++++++++- 3 files changed, 1010 insertions(+), 8 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index 5520c939..e83bc91e 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -189,6 +189,7 @@ ECC 2.0 now ships a bounded migration audit entrypoint: - `ecc migrate plan --source ~/.hermes --output migration-plan.md` - `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` - `ecc migrate import-schedules --source ~/.hermes --dry-run` +- `ecc migrate import-remote --source ~/.hermes --dry-run` - `ecc migrate import-memory --source ~/.hermes` Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes. diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index a25da46a..0f431b57 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,7 +83,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. -0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. +0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 10d1c1b9..d6812f53 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -632,6 +632,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Import legacy gateway/dispatch tasks into the ECC2 remote queue + ImportRemote { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Preview detected requests without creating ECC2 remote queue entries + #[arg(long)] + dry_run: bool, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, } #[derive(clap::Subcommand, Debug)] @@ -1038,6 +1050,51 @@ struct LegacyMemoryImportReport { report: GraphConnectorSyncReport, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum LegacyRemoteImportRequestStatus { + Ready, + Imported, + Disabled, + Invalid, + Skipped, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyRemoteImportRequestReport { + source_path: String, + request_name: String, + request_kind: session::RemoteDispatchKind, + task: Option, + goal: Option, + target_url: Option, + context: Option, + target_session: Option, + priority: Option, + agent: Option, + profile: Option, + project: Option, + task_group: Option, + use_worktree: Option, + status: LegacyRemoteImportRequestStatus, + reason: Option, + command_snippet: Option, + imported_request_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyRemoteImportReport { + source: String, + dry_run: bool, + requests_detected: usize, + ready_requests: usize, + imported_requests: usize, + disabled_requests: usize, + invalid_requests: usize, + skipped_requests: usize, + requests: Vec, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] struct RemoteDispatchHttpRequest { task: String, @@ -1831,6 +1888,18 @@ async fn main() -> Result<()> { println!("{}", format_legacy_memory_import_human(&report)); } } + MigrationCommands::ImportRemote { + source, + dry_run, + json, + } => { + let report = import_legacy_remote_dispatch(&db, &cfg, &source, dry_run)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_remote_import_human(&report)); + } + } }, Some(Commands::Graph { command }) => match command { GraphCommands::AddEntity { @@ -3072,6 +3141,13 @@ fn collect_jsonl_paths(root: &Path, recurse: bool) -> Result> { Ok(paths) } +fn collect_json_paths(root: &Path, recurse: bool) -> Result> { + let mut paths = Vec::new(); + collect_json_paths_inner(root, recurse, &mut paths)?; + paths.sort(); + Ok(paths) +} + fn collect_markdown_paths(root: &Path, recurse: bool) -> Result> { let mut paths = Vec::new(); collect_markdown_paths_inner(root, recurse, &mut paths)?; @@ -3114,6 +3190,29 @@ fn collect_jsonl_paths_inner(root: &Path, recurse: bool, paths: &mut Vec) -> Result<()> { + for entry in std::fs::read_dir(root) + .with_context(|| format!("read memory connector directory {}", root.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + if recurse { + collect_json_paths_inner(&path, recurse, paths)?; + } + continue; + } + if path + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case("json")) + { + paths.push(path); + } + } + Ok(()) +} + fn collect_markdown_paths_inner( root: &Path, recurse: bool, @@ -4939,7 +5038,7 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V } if categories.contains("gateway_dispatch") { steps.push( - "Replace gateway/dispatch entrypoints with `ecc remote serve`, `ecc remote add`, and `ecc remote computer-use`." + "Replace gateway/dispatch entrypoints with `ecc remote serve`, preview/import legacy requests with `ecc migrate import-remote`, then verify them with `ecc remote list` / `ecc remote run`." .to_string(), ); } @@ -4992,6 +5091,25 @@ struct LegacyScheduleDraft { enabled: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct LegacyRemoteDispatchDraft { + source_path: String, + request_name: String, + request_kind: session::RemoteDispatchKind, + task: Option, + goal: Option, + target_url: Option, + context: Option, + target_session: Option, + priority: Option, + agent: Option, + profile: Option, + project: Option, + task_group: Option, + use_worktree: Option, + enabled: bool, +} + fn load_legacy_schedule_drafts(source: &Path) -> Result> { let jobs_path = source.join("cron/jobs.json"); if !jobs_path.is_file() { @@ -5033,6 +5151,284 @@ fn load_legacy_schedule_drafts(source: &Path) -> Result .collect()) } +fn load_legacy_remote_dispatch_drafts(source: &Path) -> Result> { + let gateway_dir = source.join("gateway"); + if !gateway_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut drafts = Vec::new(); + for path in collect_json_paths(&gateway_dir, true)? { + drafts.extend(load_legacy_remote_dispatch_json_file(source, &path)?); + } + for path in collect_jsonl_paths(&gateway_dir, true)? { + drafts.extend(load_legacy_remote_dispatch_jsonl_file(source, &path)?); + } + Ok(drafts) +} + +fn load_legacy_remote_dispatch_json_file( + source: &Path, + path: &Path, +) -> Result> { + let text = fs::read_to_string(path) + .with_context(|| format!("read legacy remote dispatch JSON: {}", path.display()))?; + let value: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("parse legacy remote dispatch JSON: {}", path.display()))?; + let source_path = path + .strip_prefix(source) + .unwrap_or(path) + .display() + .to_string(); + + let entries = extract_legacy_remote_dispatch_entries(&value); + Ok(entries + .into_iter() + .enumerate() + .map(|(index, entry)| build_legacy_remote_dispatch_draft(entry, index, &source_path)) + .collect()) +} + +fn load_legacy_remote_dispatch_jsonl_file( + source: &Path, + path: &Path, +) -> Result> { + let file = File::open(path) + .with_context(|| format!("open legacy remote dispatch JSONL: {}", path.display()))?; + let reader = BufReader::new(file); + let source_path = path + .strip_prefix(source) + .unwrap_or(path) + .display() + .to_string(); + + let mut drafts = Vec::new(); + for (index, line) in reader.lines().enumerate() { + let line = line?; + if line.trim().is_empty() { + continue; + } + let value: serde_json::Value = serde_json::from_str(&line).with_context(|| { + format!( + "parse legacy remote dispatch JSONL: {} line {}", + path.display(), + index + 1 + ) + })?; + if !legacy_remote_dispatch_entry_is_relevant(&value) { + continue; + } + drafts.push(build_legacy_remote_dispatch_draft( + &value, + drafts.len(), + &source_path, + )); + } + Ok(drafts) +} + +fn extract_legacy_remote_dispatch_entries<'a>( + value: &'a serde_json::Value, +) -> Vec<&'a serde_json::Value> { + match value { + serde_json::Value::Array(items) => items + .iter() + .filter(|item| legacy_remote_dispatch_entry_is_relevant(item)) + .collect(), + serde_json::Value::Object(map) => { + if let Some(items) = [ + "dispatches", + "requests", + "remote_requests", + "tasks", + "queue", + "items", + ] + .iter() + .find_map(|key| map.get(*key).and_then(serde_json::Value::as_array)) + { + return items + .iter() + .filter(|item| legacy_remote_dispatch_entry_is_relevant(item)) + .collect(); + } + if legacy_remote_dispatch_entry_is_relevant(value) { + vec![value] + } else { + Vec::new() + } + } + _ => Vec::new(), + } +} + +fn legacy_remote_dispatch_entry_is_relevant(value: &serde_json::Value) -> bool { + if json_string_candidates( + value, + &[ + &["task"], + &["prompt"], + &["description"], + &["goal"], + &["message"], + &["target_url"], + &["url"], + &["to_session"], + &["target_session"], + &["lead"], + ], + ) + .is_some() + { + return true; + } + if json_bool_candidates(value, &[&["computer_use"], &["browser"], &["use_browser"]]) + .unwrap_or(false) + { + return true; + } + json_string_candidates( + value, + &[&["kind"], &["type"], &["mode"], &["dispatch_type"]], + ) + .map(|kind| { + matches!( + kind.trim().to_ascii_lowercase().as_str(), + "dispatch" + | "remote_dispatch" + | "remote-dispatch" + | "task" + | "computer_use" + | "computer-use" + | "computer use" + | "browser" + | "browser_task" + | "operator_browser" + ) + }) + .unwrap_or(false) +} + +fn build_legacy_remote_dispatch_draft( + value: &serde_json::Value, + index: usize, + source_path: &str, +) -> LegacyRemoteDispatchDraft { + let request_name = json_string_candidates( + value, + &[ + &["name"], + &["id"], + &["title"], + &["label"], + &["request_name"], + ], + ) + .unwrap_or_else(|| format!("legacy-remote-request-{}", index + 1)); + let request_kind = detect_legacy_remote_dispatch_kind(value); + let body_text = json_string_candidates( + value, + &[ + &["task"], + &["prompt"], + &["description"], + &["goal"], + &["message"], + &["instructions"], + ], + ); + let enabled = !json_bool_candidates(value, &[&["disabled"]]).unwrap_or(false) + && json_bool_candidates(value, &[&["enabled"], &["active"]]).unwrap_or(true); + + LegacyRemoteDispatchDraft { + source_path: source_path.to_string(), + request_name, + request_kind, + task: (request_kind == session::RemoteDispatchKind::Standard) + .then(|| body_text.clone()) + .flatten(), + goal: (request_kind == session::RemoteDispatchKind::ComputerUse) + .then_some(body_text) + .flatten(), + target_url: json_string_candidates( + value, + &[ + &["target_url"], + &["url"], + &["start_url"], + &["browser", "url"], + ], + ), + context: json_string_candidates( + value, + &[ + &["context"], + &["notes"], + &["details"], + &["browser_context"], + &["extra_context"], + ], + ), + target_session: json_string_candidates( + value, + &[ + &["to_session"], + &["target_session"], + &["target_session_id"], + &["session"], + &["lead"], + &["to"], + ], + ), + priority: json_task_priority_candidates(value, &[&["priority"], &["task", "priority"]]), + agent: json_string_candidates(value, &[&["agent"], &["runner"]]), + profile: json_string_candidates(value, &[&["profile"], &["agent_profile"]]), + project: json_string_candidates(value, &[&["project"]]), + task_group: json_string_candidates(value, &[&["task_group"], &["group"]]), + use_worktree: json_bool_candidates(value, &[&["use_worktree"], &["worktree"]]), + enabled, + } +} + +fn detect_legacy_remote_dispatch_kind(value: &serde_json::Value) -> session::RemoteDispatchKind { + if json_bool_candidates(value, &[&["computer_use"], &["browser"], &["use_browser"]]) + .unwrap_or(false) + { + return session::RemoteDispatchKind::ComputerUse; + } + if json_string_candidates( + value, + &[ + &["target_url"], + &["url"], + &["start_url"], + &["browser", "url"], + ], + ) + .is_some() + { + return session::RemoteDispatchKind::ComputerUse; + } + if let Some(kind) = json_string_candidates( + value, + &[&["kind"], &["type"], &["mode"], &["dispatch_type"]], + ) { + let normalized = kind.trim().to_ascii_lowercase(); + if matches!( + normalized.as_str(), + "computer_use" + | "computer-use" + | "computer use" + | "browser" + | "browser_task" + | "operator_browser" + ) { + return session::RemoteDispatchKind::ComputerUse; + } + } + session::RemoteDispatchKind::Standard +} + fn build_legacy_schedule_draft( value: &serde_json::Value, index: usize, @@ -5109,6 +5505,40 @@ fn json_bool_candidates(value: &serde_json::Value, paths: &[&[&str]]) -> Option< }) } +fn json_task_priority_candidates( + value: &serde_json::Value, + paths: &[&[&str]], +) -> Option { + paths.iter().find_map(|path| { + json_lookup(value, path).and_then(|value| match value { + serde_json::Value::String(text) => match text.trim().to_ascii_lowercase().as_str() { + "low" | "p3" => Some(TaskPriorityArg::Low), + "normal" | "medium" | "default" => Some(TaskPriorityArg::Normal), + "high" | "urgent" | "p2" | "p1" => Some(TaskPriorityArg::High), + "critical" | "crit" | "p0" => Some(TaskPriorityArg::Critical), + _ => None, + }, + serde_json::Value::Number(number) => number.as_i64().and_then(|value| match value { + 0 => Some(TaskPriorityArg::Low), + 1 => Some(TaskPriorityArg::Normal), + 2 => Some(TaskPriorityArg::High), + 3 => Some(TaskPriorityArg::Critical), + _ => None, + }), + _ => None, + }) + }) +} + +fn format_task_priority_arg(priority: TaskPriorityArg) -> &'static str { + match priority { + TaskPriorityArg::Low => "low", + TaskPriorityArg::Normal => "normal", + TaskPriorityArg::High => "high", + TaskPriorityArg::Critical => "critical", + } +} + fn json_lookup<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a serde_json::Value> { let mut current = value; for segment in path { @@ -5374,6 +5804,250 @@ fn import_legacy_memory( }) } +fn build_legacy_remote_add_command(draft: &LegacyRemoteDispatchDraft) -> Option { + match draft.request_kind { + session::RemoteDispatchKind::Standard => { + let task = draft.task.as_deref()?; + let mut parts = vec![ + "ecc remote add".to_string(), + format!("--task {}", shell_quote_double(task)), + ]; + if let Some(target_session) = draft.target_session.as_deref() { + parts.push(format!( + "--to-session {}", + shell_quote_double(target_session) + )); + } + if let Some(priority) = draft + .priority + .filter(|value| *value != TaskPriorityArg::Normal) + { + parts.push(format!("--priority {}", format_task_priority_arg(priority))); + } + if let Some(agent) = draft.agent.as_deref() { + parts.push(format!("--agent {}", shell_quote_double(agent))); + } + if let Some(profile) = draft.profile.as_deref() { + parts.push(format!("--profile {}", shell_quote_double(profile))); + } + match draft.use_worktree { + Some(true) => parts.push("--worktree".to_string()), + Some(false) => parts.push("--no-worktree".to_string()), + None => {} + } + if let Some(project) = draft.project.as_deref() { + parts.push(format!("--project {}", shell_quote_double(project))); + } + if let Some(task_group) = draft.task_group.as_deref() { + parts.push(format!("--task-group {}", shell_quote_double(task_group))); + } + Some(parts.join(" ")) + } + session::RemoteDispatchKind::ComputerUse => { + let goal = draft.goal.as_deref()?; + let mut parts = vec![ + "ecc remote computer-use".to_string(), + format!("--goal {}", shell_quote_double(goal)), + ]; + if let Some(target_url) = draft.target_url.as_deref() { + parts.push(format!("--target-url {}", shell_quote_double(target_url))); + } + if let Some(context) = draft.context.as_deref() { + parts.push(format!("--context {}", shell_quote_double(context))); + } + if let Some(target_session) = draft.target_session.as_deref() { + parts.push(format!( + "--to-session {}", + shell_quote_double(target_session) + )); + } + if let Some(priority) = draft + .priority + .filter(|value| *value != TaskPriorityArg::Normal) + { + parts.push(format!("--priority {}", format_task_priority_arg(priority))); + } + if let Some(agent) = draft.agent.as_deref() { + parts.push(format!("--agent {}", shell_quote_double(agent))); + } + if let Some(profile) = draft.profile.as_deref() { + parts.push(format!("--profile {}", shell_quote_double(profile))); + } + match draft.use_worktree { + Some(true) => parts.push("--worktree".to_string()), + Some(false) => parts.push("--no-worktree".to_string()), + None => {} + } + if let Some(project) = draft.project.as_deref() { + parts.push(format!("--project {}", shell_quote_double(project))); + } + if let Some(task_group) = draft.task_group.as_deref() { + parts.push(format!("--task-group {}", shell_quote_double(task_group))); + } + Some(parts.join(" ")) + } + } +} + +fn import_legacy_remote_dispatch( + db: &session::store::StateStore, + cfg: &config::Config, + source: &Path, + dry_run: bool, +) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let drafts = load_legacy_remote_dispatch_drafts(&source)?; + let mut report = LegacyRemoteImportReport { + source: source.display().to_string(), + dry_run, + requests_detected: drafts.len(), + ready_requests: 0, + imported_requests: 0, + disabled_requests: 0, + invalid_requests: 0, + skipped_requests: 0, + requests: Vec::new(), + }; + + for draft in drafts { + let mut item = LegacyRemoteImportRequestReport { + source_path: draft.source_path.clone(), + request_name: draft.request_name.clone(), + request_kind: draft.request_kind, + task: draft.task.clone(), + goal: draft.goal.clone(), + target_url: draft.target_url.clone(), + context: draft.context.clone(), + target_session: draft.target_session.clone(), + priority: draft.priority, + agent: draft.agent.clone(), + profile: draft.profile.clone(), + project: draft.project.clone(), + task_group: draft.task_group.clone(), + use_worktree: draft.use_worktree, + status: LegacyRemoteImportRequestStatus::Ready, + reason: None, + command_snippet: build_legacy_remote_add_command(&draft), + imported_request_id: None, + }; + + if !draft.enabled { + item.status = LegacyRemoteImportRequestStatus::Disabled; + item.reason = Some("disabled in legacy workspace".to_string()); + report.disabled_requests += 1; + report.requests.push(item); + continue; + } + + let body_text = match draft.request_kind { + session::RemoteDispatchKind::Standard => draft.task.as_deref(), + session::RemoteDispatchKind::ComputerUse => draft.goal.as_deref(), + }; + if body_text.is_none() { + item.status = LegacyRemoteImportRequestStatus::Invalid; + item.reason = Some(match draft.request_kind { + session::RemoteDispatchKind::Standard => "missing task/prompt".to_string(), + session::RemoteDispatchKind::ComputerUse => { + "missing computer-use goal/prompt".to_string() + } + }); + report.invalid_requests += 1; + report.requests.push(item); + continue; + } + + if let Some(profile) = draft.profile.as_deref() { + if let Err(error) = cfg.resolve_agent_profile(profile) { + item.status = LegacyRemoteImportRequestStatus::Skipped; + item.reason = Some(format!("profile `{profile}` is not usable here: {error}")); + report.skipped_requests += 1; + report.requests.push(item); + continue; + } + } + + let target_session_id = match draft.target_session.as_deref() { + Some(value) => match resolve_session_id(db, value) { + Ok(resolved) => Some(resolved), + Err(error) => { + item.status = LegacyRemoteImportRequestStatus::Skipped; + item.reason = Some(format!( + "target session `{value}` is not usable here: {error}" + )); + report.skipped_requests += 1; + report.requests.push(item); + continue; + } + }, + None => None, + }; + + report.ready_requests += 1; + if dry_run { + report.requests.push(item); + continue; + } + + let request = match draft.request_kind { + session::RemoteDispatchKind::Standard => { + session::manager::create_remote_dispatch_request( + db, + cfg, + body_text.expect("checked task text"), + target_session_id.as_deref(), + draft.priority.unwrap_or(TaskPriorityArg::Normal).into(), + draft.agent.as_deref().unwrap_or(&cfg.default_agent), + draft.profile.as_deref(), + draft.use_worktree.unwrap_or(cfg.auto_create_worktrees), + session::SessionGrouping { + project: draft.project.clone(), + task_group: draft.task_group.clone(), + }, + "migrate_remote", + None, + )? + } + session::RemoteDispatchKind::ComputerUse => { + let defaults = cfg.computer_use_dispatch_defaults(); + session::manager::create_computer_use_remote_dispatch_request( + db, + cfg, + body_text.expect("checked goal text"), + draft.target_url.as_deref(), + draft.context.as_deref(), + target_session_id.as_deref(), + draft.priority.unwrap_or(TaskPriorityArg::Normal).into(), + draft.agent.as_deref(), + draft.profile.as_deref(), + Some(draft.use_worktree.unwrap_or(defaults.use_worktree)), + session::SessionGrouping { + project: draft.project.clone(), + task_group: draft.task_group.clone(), + }, + "migrate_remote_computer_use", + None, + )? + } + }; + + item.status = LegacyRemoteImportRequestStatus::Imported; + item.imported_request_id = Some(request.id); + report.imported_requests += 1; + report.requests.push(item); + } + + Ok(report) +} + fn build_legacy_migration_plan_report( audit: &LegacyMigrationAuditReport, ) -> LegacyMigrationPlanReport { @@ -5393,6 +6067,27 @@ fn build_legacy_migration_plan_report( .iter() .filter(|draft| draft.enabled && (draft.cron_expr.is_none() || draft.task.is_none())) .count(); + let legacy_remote_drafts = + load_legacy_remote_dispatch_drafts(Path::new(&audit.source)).unwrap_or_default(); + let remote_commands = legacy_remote_drafts + .iter() + .filter(|draft| draft.enabled) + .filter_map(build_legacy_remote_add_command) + .collect::>(); + let disabled_remote_requests = legacy_remote_drafts + .iter() + .filter(|draft| !draft.enabled) + .count(); + let invalid_remote_requests = legacy_remote_drafts + .iter() + .filter(|draft| { + draft.enabled + && match draft.request_kind { + session::RemoteDispatchKind::Standard => draft.task.is_none(), + session::RemoteDispatchKind::ComputerUse => draft.goal.is_none(), + } + }) + .count(); for artifact in &audit.artifacts { let step = match artifact.category.as_str() { @@ -5442,13 +6137,42 @@ fn build_legacy_migration_plan_report( title: "Replace legacy gateway intake with ECC2 remote dispatch".to_string(), target_surface: "ECC2 remote dispatch".to_string(), source_paths: artifact.source_paths.clone(), - command_snippets: vec![ - "ecc remote serve --bind 127.0.0.1:8787 --token ".to_string(), - "ecc remote add --task \"Translate legacy dispatch workflow\"".to_string(), - "ecc remote computer-use --goal \"Translate legacy browser/operator flow\"".to_string(), - ], + command_snippets: if remote_commands.is_empty() { + vec![ + "ecc remote serve --bind 127.0.0.1:8787 --token ".to_string(), + "ecc remote add --task \"Translate legacy dispatch workflow\"".to_string(), + "ecc remote computer-use --goal \"Translate legacy browser/operator flow\"".to_string(), + ] + } else { + let mut commands = vec![ + "ecc remote serve --bind 127.0.0.1:8787 --token ".to_string(), + ]; + commands.extend(remote_commands.clone()); + commands.push("ecc remote list".to_string()); + commands.push("ecc remote run".to_string()); + commands + }, config_snippets: Vec::new(), - notes: artifact.notes.clone(), + notes: { + let mut notes = artifact.notes.clone(); + if !remote_commands.is_empty() { + notes.push(format!( + "Recovered {} concrete remote dispatch request(s) from gateway JSON/JSONL files.", + remote_commands.len() + )); + } + if disabled_remote_requests > 0 { + notes.push(format!( + "{disabled_remote_requests} legacy remote dispatch request(s) are disabled and were left out of generated ECC2 commands." + )); + } + if invalid_remote_requests > 0 { + notes.push(format!( + "{invalid_remote_requests} legacy remote dispatch request(s) were missing task/goal fields and still need manual translation." + )); + } + notes + }, }, "memory_tool" => LegacyMigrationPlanStep { category: artifact.category.clone(), @@ -5821,6 +6545,70 @@ fn format_legacy_memory_import_human(report: &LegacyMemoryImportReport) -> Strin lines.join("\n") } +fn format_legacy_remote_import_human(report: &LegacyRemoteImportReport) -> String { + let mut lines = vec![ + format!( + "Legacy remote dispatch import {} for {}", + if report.dry_run { + "preview" + } else { + "complete" + }, + report.source + ), + format!("- requests detected {}", report.requests_detected), + format!("- ready requests {}", report.ready_requests), + format!("- imported requests {}", report.imported_requests), + format!("- disabled requests {}", report.disabled_requests), + format!("- invalid requests {}", report.invalid_requests), + format!("- skipped requests {}", report.skipped_requests), + ]; + + if report.requests.is_empty() { + lines.push("- no importable gateway JSON/JSONL request entries were found".to_string()); + return lines.join("\n"); + } + + lines.push("Requests".to_string()); + for request in &report.requests { + let status = match request.status { + LegacyRemoteImportRequestStatus::Ready => "ready", + LegacyRemoteImportRequestStatus::Imported => "imported", + LegacyRemoteImportRequestStatus::Disabled => "disabled", + LegacyRemoteImportRequestStatus::Invalid => "invalid", + LegacyRemoteImportRequestStatus::Skipped => "skipped", + }; + lines.push(format!( + "- {} [{} / {}]", + request.request_name, status, request.request_kind + )); + lines.push(format!(" source {}", request.source_path)); + if let Some(task) = request.task.as_deref() { + lines.push(format!(" task {}", task)); + } + if let Some(goal) = request.goal.as_deref() { + lines.push(format!(" goal {}", goal)); + } + if let Some(target_url) = request.target_url.as_deref() { + lines.push(format!(" target url {}", target_url)); + } + if let Some(target_session) = request.target_session.as_deref() { + lines.push(format!(" target {}", target_session)); + } + if let Some(command) = request.command_snippet.as_deref() { + lines.push(format!(" command {}", command)); + } + if let Some(request_id) = request.imported_request_id { + lines.push(format!(" request {}", request_id)); + } + if let Some(reason) = request.reason.as_deref() { + lines.push(format!(" note {}", reason)); + } + } + + lines.join("\n") +} + fn format_graph_recall_human( entries: &[session::ContextGraphRecallEntry], session_id: Option<&str>, @@ -8461,6 +9249,7 @@ mod tests { let tempdir = TestDir::new("legacy-migration-plan")?; let root = tempdir.path(); fs::create_dir_all(root.join("cron"))?; + fs::create_dir_all(root.join("gateway"))?; fs::create_dir_all(root.join("workspace/notes"))?; fs::write(root.join("config.yaml"), "model: claude\n")?; fs::write( @@ -8486,6 +9275,37 @@ mod tests { }) .to_string(), )?; + fs::write( + root.join("gateway/dispatch.jsonl"), + [ + serde_json::json!({ + "name": "route-account-recovery", + "task": "Handle account recovery triage", + "priority": "high", + "agent": "codex", + "project": "ecc-tools", + "task_group": "recovery" + }) + .to_string(), + serde_json::json!({ + "name": "browser-billing-check", + "kind": "computer_use", + "goal": "Verify the billing portal warning banner", + "target_url": "https://ecc.tools/account", + "context": "Use the production account flow", + "priority": "critical", + "use_worktree": false + }) + .to_string(), + serde_json::json!({ + "name": "paused-remote", + "task": "Do not migrate this now", + "disabled": true + }) + .to_string(), + ] + .join("\n"), + )?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; let audit = build_legacy_migration_audit_report(root)?; @@ -8523,6 +9343,31 @@ mod tests { .iter() .any(|note| note.contains("disabled"))); + let gateway_step = plan + .steps + .iter() + .find(|step| step.category == "gateway_dispatch") + .expect("gateway step"); + assert!(gateway_step + .command_snippets + .iter() + .any(|command| command + .contains("ecc remote add --task \"Handle account recovery triage\""))); + assert!(gateway_step + .command_snippets + .iter() + .any(|command| command.contains( + "ecc remote computer-use --goal \"Verify the billing portal warning banner\"" + ))); + assert!(!gateway_step + .command_snippets + .iter() + .any(|command| command.contains("Translate legacy dispatch workflow"))); + assert!(gateway_step + .notes + .iter() + .any(|note| note.contains("disabled"))); + let rendered = format_legacy_migration_plan_human(&plan); assert!(rendered.contains("Legacy migration plan")); assert!(rendered.contains("Import sanitized workspace memory through ECC2 connectors")); @@ -8721,6 +9566,162 @@ Route existing installs to portal first before checkout. Ok(()) } + #[test] + fn import_legacy_remote_dispatch_dry_run_reports_ready_disabled_and_invalid_requests( + ) -> Result<()> { + let tempdir = TestDir::new("legacy-remote-import-dry-run")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("gateway"))?; + fs::write( + root.join("gateway/dispatch.json"), + serde_json::json!({ + "requests": [ + { + "name": "route-account-recovery", + "task": "Handle account recovery triage", + "priority": "high", + "agent": "codex", + "project": "ecc-tools", + "task_group": "recovery", + "use_worktree": false + }, + { + "name": "browser-billing-check", + "kind": "computer_use", + "goal": "Verify the billing portal warning banner", + "target_url": "https://ecc.tools/account", + "context": "Use the production account flow", + "priority": "critical" + }, + { + "name": "paused-remote", + "task": "Do not migrate this now", + "disabled": true + }, + { + "name": "broken-remote", + "kind": "computer_use", + "context": "Missing goal" + } + ] + }) + .to_string(), + )?; + + let tempdb = TestDir::new("legacy-remote-import-dry-run-db")?; + let db = StateStore::open(&tempdb.path().join("state.db"))?; + let report = import_legacy_remote_dispatch(&db, &Config::default(), root, true)?; + + assert!(report.dry_run); + assert_eq!(report.requests_detected, 4); + assert_eq!(report.ready_requests, 2); + assert_eq!(report.imported_requests, 0); + assert_eq!(report.disabled_requests, 1); + assert_eq!(report.invalid_requests, 1); + assert_eq!(report.skipped_requests, 0); + assert_eq!(report.requests.len(), 4); + assert!(report.requests.iter().any(|request| request.command_snippet.as_deref() + == Some("ecc remote add --task \"Handle account recovery triage\" --priority high --agent \"codex\" --no-worktree --project \"ecc-tools\" --task-group \"recovery\""))); + assert!(report.requests.iter().any(|request| request.command_snippet.as_deref() + == Some("ecc remote computer-use --goal \"Verify the billing portal warning banner\" --target-url \"https://ecc.tools/account\" --context \"Use the production account flow\" --priority critical"))); + + Ok(()) + } + + #[test] + fn import_legacy_remote_dispatch_creates_real_pending_requests() -> Result<()> { + let tempdir = TestDir::new("legacy-remote-import-live")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("gateway"))?; + fs::write( + root.join("gateway/dispatch.jsonl"), + [ + serde_json::json!({ + "name": "route-account-recovery", + "task": "Handle account recovery triage", + "priority": "high", + "agent": "codex", + "project": "ecc-tools", + "task_group": "recovery", + "use_worktree": false + }) + .to_string(), + serde_json::json!({ + "name": "browser-billing-check", + "kind": "computer_use", + "goal": "Verify the billing portal warning banner", + "target_url": "https://ecc.tools/account", + "context": "Use the production account flow", + "priority": "critical", + "project": "remote-ops", + "task_group": "browser" + }) + .to_string(), + ] + .join("\n"), + )?; + + let target_repo = tempdir.path().join("target"); + fs::create_dir_all(&target_repo)?; + fs::write(target_repo.join(".gitignore"), "target\n")?; + + let tempdb = TestDir::new("legacy-remote-import-live-db")?; + let db = StateStore::open(&tempdb.path().join("state.db"))?; + struct CurrentDirGuard(PathBuf); + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + let _cwd_guard = CurrentDirGuard(std::env::current_dir()?); + std::env::set_current_dir(&target_repo)?; + + let report = import_legacy_remote_dispatch(&db, &Config::default(), root, false)?; + + assert!(!report.dry_run); + assert_eq!(report.ready_requests, 2); + assert_eq!(report.imported_requests, 2); + assert_eq!( + report.requests[0].status, + LegacyRemoteImportRequestStatus::Imported + ); + assert!(report + .requests + .iter() + .all(|request| request.imported_request_id.is_some())); + + let requests = db.list_pending_remote_dispatch_requests(10)?; + assert_eq!(requests.len(), 2); + assert_eq!( + requests[0].request_kind, + session::RemoteDispatchKind::ComputerUse + ); + assert_eq!(requests[0].priority, comms::TaskPriority::Critical); + assert_eq!(requests[0].project, "remote-ops"); + assert_eq!(requests[0].task_group, "browser"); + assert_eq!( + requests[0].target_url.as_deref(), + Some("https://ecc.tools/account") + ); + assert!(requests[0].task.contains("Computer-use task.")); + assert_eq!( + requests[1].request_kind, + session::RemoteDispatchKind::Standard + ); + assert_eq!(requests[1].priority, comms::TaskPriority::High); + assert_eq!(requests[1].agent_type, "codex"); + assert_eq!(requests[1].project, "ecc-tools"); + assert_eq!(requests[1].task_group, "recovery"); + assert!(!requests[1].use_worktree); + assert_eq!(requests[1].task, "Handle account recovery triage"); + assert_eq!( + requests[1].working_dir.canonicalize()?, + target_repo.canonicalize()? + ); + + Ok(()) + } + #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?; From f4b1b11e10cfa8eeab0da685792d3c443ae01262 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:33:18 -0700 Subject: [PATCH 166/459] feat: add ecc2 legacy env migration import --- docs/HERMES-OPENCLAW-MIGRATION.md | 1 + docs/HERMES-SETUP.md | 2 +- ecc2/src/main.rs | 373 +++++++++++++++++++++++++++++- 3 files changed, 373 insertions(+), 3 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index e83bc91e..42b9bcb6 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -190,6 +190,7 @@ ECC 2.0 now ships a bounded migration audit entrypoint: - `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` - `ecc migrate import-schedules --source ~/.hermes --dry-run` - `ecc migrate import-remote --source ~/.hermes --dry-run` +- `ecc migrate import-env --source ~/.hermes --dry-run` - `ecc migrate import-memory --source ~/.hermes` Use that first to inventory the legacy workspace and map detected surfaces onto the current ECC2 scheduler, remote dispatch, memory graph, templates, and manual-translation lanes. diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index 0f431b57..a0905e5e 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,7 +83,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. -0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. +0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index d6812f53..59252f19 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -632,6 +632,21 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Import safe legacy env/service config context into the ECC2 context graph + ImportEnv { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Preview detected importable sources without writing to the ECC2 graph + #[arg(long)] + dry_run: bool, + /// Maximum imported records across all synthesized connectors + #[arg(long, default_value_t = 100)] + limit: usize, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Import legacy gateway/dispatch tasks into the ECC2 remote queue ImportRemote { /// Path to the legacy Hermes/OpenClaw workspace root @@ -1050,6 +1065,34 @@ struct LegacyMemoryImportReport { report: GraphConnectorSyncReport, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum LegacyEnvImportSourceStatus { + Ready, + Imported, + ManualOnly, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyEnvImportSourceReport { + source_path: String, + connector_name: Option, + status: LegacyEnvImportSourceStatus, + reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyEnvImportReport { + source: String, + dry_run: bool, + importable_sources: usize, + imported_sources: usize, + manual_reentry_sources: usize, + connectors_detected: usize, + report: GraphConnectorSyncReport, + sources: Vec, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum LegacyRemoteImportRequestStatus { @@ -1888,6 +1931,19 @@ async fn main() -> Result<()> { println!("{}", format_legacy_memory_import_human(&report)); } } + MigrationCommands::ImportEnv { + source, + dry_run, + limit, + json, + } => { + let report = import_legacy_env_services(&db, &source, dry_run, limit)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_env_import_human(&report)); + } + } MigrationCommands::ImportRemote { source, dry_run, @@ -5062,7 +5118,7 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V } if categories.contains("env_services") { steps.push( - "Reconfigure credentials locally through Claude connectors, MCP config, OAuth, or local API key setup; do not import raw secret material." + "Preview safe env/service context with `ecc migrate import-env --source --dry-run`, then reconfigure credentials locally through Claude connectors, MCP config, OAuth, or local API key setup without importing raw secret material." .to_string(), ); } @@ -5804,6 +5860,112 @@ fn import_legacy_memory( }) } +fn import_legacy_env_services( + db: &session::store::StateStore, + source: &Path, + dry_run: bool, + limit: usize, +) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let env_service_paths = collect_env_service_paths(&source)?; + let mut report = LegacyEnvImportReport { + source: source.display().to_string(), + dry_run, + importable_sources: 0, + imported_sources: 0, + manual_reentry_sources: 0, + connectors_detected: 0, + report: GraphConnectorSyncReport::default(), + sources: Vec::new(), + }; + + let mut import_cfg = config::Config::default(); + for relative_path in env_service_paths { + if let Some(connector) = build_legacy_env_connector(&source, &relative_path) { + report.importable_sources += 1; + report.connectors_detected += 1; + report.sources.push(LegacyEnvImportSourceReport { + source_path: relative_path.clone(), + connector_name: Some(connector.0.clone()), + status: if dry_run { + LegacyEnvImportSourceStatus::Ready + } else { + LegacyEnvImportSourceStatus::Imported + }, + reason: Some("safe dotenv-style import available".to_string()), + }); + import_cfg.memory_connectors.insert( + connector.0, + config::MemoryConnectorConfig::DotenvFile(connector.1), + ); + } else { + report.manual_reentry_sources += 1; + report.sources.push(LegacyEnvImportSourceReport { + source_path: relative_path, + connector_name: None, + status: LegacyEnvImportSourceStatus::ManualOnly, + reason: Some( + "manual auth/config translation still required; raw secret-bearing config is not imported" + .to_string(), + ), + }); + } + } + + if dry_run || import_cfg.memory_connectors.is_empty() { + return Ok(report); + } + + let sync_report = sync_all_memory_connectors(db, &import_cfg, limit)?; + report.imported_sources = sync_report.connectors_synced; + report.report = sync_report; + Ok(report) +} + +fn build_legacy_env_connector( + source: &Path, + relative_path: &str, +) -> Option<(String, config::MemoryConnectorDotenvFileConfig)> { + let is_importable = matches!( + relative_path, + ".env" | ".env.local" | ".env.production" | ".envrc" + ); + if !is_importable { + return None; + } + + let connector_name = format!( + "legacy_env_{}", + relative_path + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::() + .trim_matches('_') + ); + Some(( + connector_name, + config::MemoryConnectorDotenvFileConfig { + path: source.join(relative_path), + session_id: None, + default_entity_type: Some("legacy_service_config".to_string()), + default_observation_type: Some("legacy_env_context".to_string()), + key_prefixes: Vec::new(), + include_keys: Vec::new(), + exclude_keys: Vec::new(), + include_safe_values: true, + }, + )) +} + fn build_legacy_remote_add_command(draft: &LegacyRemoteDispatchDraft) -> Option { match draft.request_kind { session::RemoteDispatchKind::Standard => { @@ -6250,7 +6412,17 @@ fn build_legacy_migration_plan_report( title: "Reconfigure local auth and connectors without importing secrets".to_string(), target_surface: "Claude connectors / MCP / local API key setup".to_string(), source_paths: artifact.source_paths.clone(), - command_snippets: Vec::new(), + command_snippets: vec![ + format!( + "ecc migrate import-env --source {} --dry-run", + shell_quote_double(&audit.source) + ), + format!( + "ecc migrate import-env --source {}", + shell_quote_double(&audit.source) + ), + "ecc graph recall \"\"".to_string(), + ], config_snippets: vec![ "# Re-enter connector auth locally; do not copy legacy secrets into ECC2.\n# Typical targets: Google Drive OAuth, GitHub, Stripe, Linear, browser creds.".to_string(), ], @@ -6545,6 +6717,56 @@ fn format_legacy_memory_import_human(report: &LegacyMemoryImportReport) -> Strin lines.join("\n") } +fn format_legacy_env_import_human(report: &LegacyEnvImportReport) -> String { + let mut lines = vec![ + format!( + "Legacy env/service import {} for {}", + if report.dry_run { + "preview" + } else { + "complete" + }, + report.source + ), + format!("- importable sources {}", report.importable_sources), + format!("- imported sources {}", report.imported_sources), + format!("- manual reentry sources {}", report.manual_reentry_sources), + format!("- connectors detected {}", report.connectors_detected), + format!("- connectors synced {}", report.report.connectors_synced), + format!("- records read {}", report.report.records_read), + format!("- entities upserted {}", report.report.entities_upserted), + format!("- observations added {}", report.report.observations_added), + format!("- skipped records {}", report.report.skipped_records), + format!( + "- skipped unchanged sources {}", + report.report.skipped_unchanged_sources + ), + ]; + + if report.sources.is_empty() { + lines.push("- no recognized env/service migration sources were found".to_string()); + return lines.join("\n"); + } + + lines.push("Sources".to_string()); + for source in &report.sources { + let status = match source.status { + LegacyEnvImportSourceStatus::Ready => "ready", + LegacyEnvImportSourceStatus::Imported => "imported", + LegacyEnvImportSourceStatus::ManualOnly => "manual", + }; + lines.push(format!("- {} [{}]", source.source_path, status)); + if let Some(connector_name) = source.connector_name.as_deref() { + lines.push(format!(" connector {}", connector_name)); + } + if let Some(reason) = source.reason.as_deref() { + lines.push(format!(" note {}", reason)); + } + } + + lines.join("\n") +} + fn format_legacy_remote_import_human(report: &LegacyRemoteImportReport) -> String { let mut lines = vec![ format!( @@ -9177,6 +9399,40 @@ mod tests { } } + #[test] + fn cli_parses_migrate_import_env_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "import-env", + "--source", + "/tmp/hermes", + "--dry-run", + "--limit", + "42", + "--json", + ]) + .expect("migrate import-env should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::ImportEnv { + source, + dry_run, + limit, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert!(dry_run); + assert_eq!(limit, 42); + assert!(json); + } + _ => panic!("expected migrate import-env subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -9371,6 +9627,15 @@ mod tests { let rendered = format_legacy_migration_plan_human(&plan); assert!(rendered.contains("Legacy migration plan")); assert!(rendered.contains("Import sanitized workspace memory through ECC2 connectors")); + let env_step = plan + .steps + .iter() + .find(|step| step.category == "env_services") + .expect("env services step"); + assert!(env_step + .command_snippets + .iter() + .any(|command| command.contains("ecc migrate import-env --source"))); Ok(()) } @@ -9722,6 +9987,110 @@ Route existing installs to portal first before checkout. Ok(()) } + #[test] + fn import_legacy_env_dry_run_reports_importable_and_manual_sources() -> Result<()> { + let tempdir = TestDir::new("legacy-env-import-dry-run")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("services"))?; + fs::write( + root.join(".env.local"), + "STRIPE_SECRET_KEY=sk_test_secret\nPUBLIC_BASE_URL=https://ecc.tools\n", + )?; + fs::write( + root.join(".envrc"), + "export OPENAI_API_KEY=sk-openai-secret\nexport PUBLIC_DOCS_URL=https://docs.ecc.tools\n", + )?; + fs::write(root.join("config.yaml"), "model: claude\n")?; + fs::write( + root.join("services").join("billing.json"), + "{\"port\": 3000}\n", + )?; + + let tempdb = TestDir::new("legacy-env-import-dry-run-db")?; + let db = StateStore::open(&tempdb.path().join("state.db"))?; + let report = import_legacy_env_services(&db, root, true, 10)?; + + assert!(report.dry_run); + assert_eq!(report.importable_sources, 2); + assert_eq!(report.imported_sources, 0); + assert_eq!(report.manual_reentry_sources, 2); + assert_eq!(report.connectors_detected, 2); + assert_eq!(report.report.connectors_synced, 0); + assert_eq!( + report + .sources + .iter() + .filter(|item| item.status == LegacyEnvImportSourceStatus::Ready) + .count(), + 2 + ); + assert!(report.sources.iter().any(|item| { + item.source_path == "config.yaml" + && item.status == LegacyEnvImportSourceStatus::ManualOnly + })); + assert!(report.sources.iter().any(|item| { + item.source_path == "services" && item.status == LegacyEnvImportSourceStatus::ManualOnly + })); + + Ok(()) + } + + #[test] + fn import_legacy_env_imports_safe_context_into_graph() -> Result<()> { + let tempdir = TestDir::new("legacy-env-import-live")?; + let root = tempdir.path(); + fs::write( + root.join(".env.local"), + "STRIPE_SECRET_KEY=sk_test_secret\nPUBLIC_BASE_URL=https://ecc.tools\n", + )?; + fs::write( + root.join(".env.production"), + "export OPENAI_API_KEY=sk-openai-secret\nexport PUBLIC_DOCS_URL=https://docs.ecc.tools\n", + )?; + + let tempdb = TestDir::new("legacy-env-import-live-db")?; + let db = StateStore::open(&tempdb.path().join("state.db"))?; + let report = import_legacy_env_services(&db, root, false, 10)?; + + assert!(!report.dry_run); + assert_eq!(report.importable_sources, 2); + assert_eq!(report.imported_sources, 2); + assert_eq!(report.manual_reentry_sources, 0); + assert_eq!(report.report.connectors_synced, 2); + assert_eq!(report.report.records_read, 4); + assert!(report.sources.iter().all(|item| { + item.status == LegacyEnvImportSourceStatus::Imported + || item.status == LegacyEnvImportSourceStatus::Ready + })); + + let recalled = db.recall_context_entities(None, "stripe docs ecc.tools", 10)?; + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "STRIPE_SECRET_KEY")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "PUBLIC_BASE_URL")); + assert!(recalled + .iter() + .any(|entry| entry.entity.name == "PUBLIC_DOCS_URL")); + + let secret = recalled + .iter() + .find(|entry| entry.entity.name == "STRIPE_SECRET_KEY") + .expect("secret entry should exist"); + let observations = db.list_context_observations(Some(secret.entity.id), 5)?; + assert_eq!( + observations[0] + .details + .get("secret_redacted") + .map(String::as_str), + Some("true") + ); + assert!(!observations[0].details.contains_key("value")); + + Ok(()) + } + #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?; From cee82417db49bbd733be0302c2c1f70929d03067 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:41:36 -0700 Subject: [PATCH 167/459] feat: add ecc2 legacy skill migration import --- docs/HERMES-OPENCLAW-MIGRATION.md | 1 + docs/HERMES-SETUP.md | 2 +- ecc2/src/main.rs | 400 +++++++++++++++++++++++++++++- 3 files changed, 401 insertions(+), 2 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index 42b9bcb6..a386f951 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -188,6 +188,7 @@ ECC 2.0 now ships a bounded migration audit entrypoint: - `ecc migrate audit --source ~/.hermes` - `ecc migrate plan --source ~/.hermes --output migration-plan.md` - `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` +- `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills` - `ecc migrate import-schedules --source ~/.hermes --dry-run` - `ecc migrate import-remote --source ~/.hermes --dry-run` - `ecc migrate import-env --source ~/.hermes --dry-run` diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index a0905e5e..83af8abc 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,7 +83,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. -0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. +0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 59252f19..63b5c87f 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -647,6 +647,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Scaffold ECC-native orchestration templates from legacy skill markdown + ImportSkills { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Directory where imported ECC2 skill artifacts should be written + #[arg(long)] + output_dir: PathBuf, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Import legacy gateway/dispatch tasks into the ECC2 remote queue ImportRemote { /// Path to the legacy Hermes/OpenClaw workspace root @@ -1093,6 +1105,29 @@ struct LegacyEnvImportReport { sources: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacySkillImportEntry { + source_path: String, + template_name: String, + title: String, + summary: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacySkillImportReport { + source: String, + output_dir: String, + skills_detected: usize, + templates_generated: usize, + files_written: Vec, + skills: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct LegacySkillTemplateFile { + orchestration_templates: BTreeMap, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum LegacyRemoteImportRequestStatus { @@ -1944,6 +1979,18 @@ async fn main() -> Result<()> { println!("{}", format_legacy_env_import_human(&report)); } } + MigrationCommands::ImportSkills { + source, + output_dir, + json, + } => { + let report = import_legacy_skills(&source, &output_dir)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_skill_import_human(&report)); + } + } MigrationCommands::ImportRemote { source, dry_run, @@ -5106,7 +5153,7 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V } if categories.contains("skills") { steps.push( - "Translate reusable Hermes/OpenClaw skills into ECC skills or orchestration templates one lane at a time instead of bulk-copying them." + "Scaffold translated legacy skills with `ecc migrate import-skills --source --output-dir `, then promote the reusable ones into ECC skills or orchestration templates one lane at a time instead of bulk-copying them." .to_string(), ); } @@ -5966,6 +6013,239 @@ fn build_legacy_env_connector( )) } +fn import_legacy_skills(source: &Path, output_dir: &Path) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let skills_dir = source.join("skills"); + let mut report = LegacySkillImportReport { + source: source.display().to_string(), + output_dir: output_dir.display().to_string(), + skills_detected: 0, + templates_generated: 0, + files_written: Vec::new(), + skills: Vec::new(), + }; + if !skills_dir.is_dir() { + return Ok(report); + } + + let skill_paths = collect_markdown_paths(&skills_dir, true)?; + if skill_paths.is_empty() { + return Ok(report); + } + + fs::create_dir_all(output_dir) + .with_context(|| format!("create legacy skill output dir {}", output_dir.display()))?; + + let mut templates = BTreeMap::new(); + for path in skill_paths { + let draft = build_legacy_skill_draft(&source, &skills_dir, &path)?; + report.skills_detected += 1; + report.templates_generated += 1; + report.skills.push(LegacySkillImportEntry { + source_path: draft.source_path.clone(), + template_name: draft.template_name.clone(), + title: draft.title.clone(), + summary: draft.summary.clone(), + }); + templates.insert( + draft.template_name.clone(), + config::OrchestrationTemplateConfig { + description: Some(format!( + "Migrated legacy skill scaffold from {}", + draft.source_path + )), + project: Some("legacy-migration".to_string()), + task_group: Some("legacy skill".to_string()), + agent: Some("claude".to_string()), + profile: None, + worktree: Some(false), + steps: vec![config::OrchestrationTemplateStepConfig { + name: Some("operator".to_string()), + task: format!( + "Use the migrated legacy skill context from {}.\nLegacy skill title: {}\nLegacy summary: {}\nLegacy excerpt:\n{}\nTranslate and run that workflow for {{{{task}}}}.", + draft.source_path, draft.title, draft.summary, draft.excerpt + ), + agent: None, + profile: None, + worktree: Some(false), + project: Some("legacy-migration".to_string()), + task_group: Some("legacy skill".to_string()), + }], + }, + ); + } + + let templates_path = output_dir.join("ecc2.imported-skills.toml"); + fs::write( + &templates_path, + toml::to_string_pretty(&LegacySkillTemplateFile { + orchestration_templates: templates, + })?, + ) + .with_context(|| { + format!( + "write imported skill templates {}", + templates_path.display() + ) + })?; + report + .files_written + .push(templates_path.display().to_string()); + + let summary_path = output_dir.join("imported-skills.md"); + fs::write( + &summary_path, + format_legacy_skill_import_summary_markdown(&report), + ) + .with_context(|| format!("write imported skill summary {}", summary_path.display()))?; + report + .files_written + .push(summary_path.display().to_string()); + + Ok(report) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LegacySkillDraft { + source_path: String, + template_name: String, + title: String, + summary: String, + excerpt: String, +} + +fn build_legacy_skill_draft( + source: &Path, + skills_dir: &Path, + path: &Path, +) -> Result { + let body = fs::read_to_string(path) + .with_context(|| format!("read legacy skill file {}", path.display()))?; + let source_path = path + .strip_prefix(source) + .unwrap_or(path) + .display() + .to_string(); + let relative_to_skills = path.strip_prefix(skills_dir).unwrap_or(path); + let title = extract_legacy_skill_title(relative_to_skills, &body); + let summary = extract_legacy_skill_summary(&body).unwrap_or_else(|| title.clone()); + let excerpt = extract_legacy_skill_excerpt(&body, 8, 600).unwrap_or_else(|| summary.clone()); + let template_name = slugify_legacy_skill_template_name(relative_to_skills); + + Ok(LegacySkillDraft { + source_path, + template_name, + title, + summary, + excerpt, + }) +} + +fn extract_legacy_skill_title(relative_path: &Path, body: &str) -> String { + for line in body.lines() { + let trimmed = line.trim(); + if let Some(title) = trimmed.strip_prefix("#") { + let title = title.trim(); + if !title.is_empty() { + return title.to_string(); + } + } + } + relative_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.replace(['-', '_'], " ")) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "legacy skill".to_string()) +} + +fn extract_legacy_skill_summary(body: &str) -> Option { + body.lines() + .map(str::trim) + .find(|line| !line.is_empty() && !line.starts_with('#')) + .map(ToString::to_string) +} + +fn extract_legacy_skill_excerpt(body: &str, max_lines: usize, max_chars: usize) -> Option { + let mut lines = Vec::new(); + let mut chars = 0usize; + for line in body.lines().map(str::trim).filter(|line| !line.is_empty()) { + if chars >= max_chars || lines.len() >= max_lines { + break; + } + let remaining = max_chars.saturating_sub(chars); + if remaining == 0 { + break; + } + let truncated = truncate_connector_text(line, remaining); + chars += truncated.len(); + lines.push(truncated); + } + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +fn slugify_legacy_skill_template_name(relative_path: &Path) -> String { + relative_path + .to_string_lossy() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .split('_') + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("_") +} + +fn format_legacy_skill_import_summary_markdown(report: &LegacySkillImportReport) -> String { + let mut lines = vec![ + "# Imported legacy skills".to_string(), + String::new(), + format!("- Source: `{}`", report.source), + format!("- Output dir: `{}`", report.output_dir), + format!("- Skills detected: {}", report.skills_detected), + format!("- Templates generated: {}", report.templates_generated), + String::new(), + ]; + + if report.skills.is_empty() { + lines.push("No legacy skill markdown files were detected.".to_string()); + return lines.join("\n"); + } + + lines.push("## Skills".to_string()); + lines.push(String::new()); + for skill in &report.skills { + lines.push(format!( + "- `{}` -> `{}`", + skill.source_path, skill.template_name + )); + lines.push(format!(" - Title: {}", skill.title)); + lines.push(format!(" - Summary: {}", skill.summary)); + } + + lines.join("\n") +} + fn build_legacy_remote_add_command(draft: &LegacyRemoteDispatchDraft) -> Option { match draft.request_kind { session::RemoteDispatchKind::Standard => { @@ -6373,6 +6653,10 @@ fn build_legacy_migration_plan_report( target_surface: "ECC skills / orchestration templates".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ + format!( + "ecc migrate import-skills --source {} --output-dir migration-artifacts/skills", + shell_quote_double(&audit.source) + ), "ecc template --task \"\"".to_string(), ], config_snippets: vec![ @@ -6767,6 +7051,36 @@ fn format_legacy_env_import_human(report: &LegacyEnvImportReport) -> String { lines.join("\n") } +fn format_legacy_skill_import_human(report: &LegacySkillImportReport) -> String { + let mut lines = vec![ + format!("Legacy skill import complete for {}", report.source), + format!("- output dir {}", report.output_dir), + format!("- skills detected {}", report.skills_detected), + format!("- templates generated {}", report.templates_generated), + ]; + + if !report.files_written.is_empty() { + lines.push("Files".to_string()); + for path in &report.files_written { + lines.push(format!("- {}", path)); + } + } + + if !report.skills.is_empty() { + lines.push("Skills".to_string()); + for skill in &report.skills { + lines.push(format!( + "- {} -> {}", + skill.source_path, skill.template_name + )); + lines.push(format!(" title {}", skill.title)); + lines.push(format!(" summary {}", skill.summary)); + } + } + + lines.join("\n") +} + fn format_legacy_remote_import_human(report: &LegacyRemoteImportReport) -> String { let mut lines = vec![ format!( @@ -9433,6 +9747,37 @@ mod tests { } } + #[test] + fn cli_parses_migrate_import_skills_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "import-skills", + "--source", + "/tmp/hermes", + "--output-dir", + "/tmp/out", + "--json", + ]) + .expect("migrate import-skills should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::ImportSkills { + source, + output_dir, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert_eq!(output_dir, PathBuf::from("/tmp/out")); + assert!(json); + } + _ => panic!("expected migrate import-skills subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -9507,6 +9852,7 @@ mod tests { fs::create_dir_all(root.join("cron"))?; fs::create_dir_all(root.join("gateway"))?; fs::create_dir_all(root.join("workspace/notes"))?; + fs::create_dir_all(root.join("skills/ecc-imports"))?; fs::write(root.join("config.yaml"), "model: claude\n")?; fs::write( root.join("cron/jobs.json"), @@ -9563,6 +9909,7 @@ mod tests { .join("\n"), )?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; + fs::write(root.join("skills/ecc-imports/research.md"), "# research\n")?; let audit = build_legacy_migration_audit_report(root)?; let plan = build_legacy_migration_plan_report(&audit); @@ -9636,6 +9983,15 @@ mod tests { .command_snippets .iter() .any(|command| command.contains("ecc migrate import-env --source"))); + let skills_step = plan + .steps + .iter() + .find(|step| step.category == "skills") + .expect("skills step"); + assert!(skills_step + .command_snippets + .iter() + .any(|command| command.contains("ecc migrate import-skills --source"))); Ok(()) } @@ -10091,6 +10447,48 @@ Route existing installs to portal first before checkout. Ok(()) } + #[test] + fn import_legacy_skills_writes_template_artifacts() -> Result<()> { + let tempdir = TestDir::new("legacy-skill-import")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("skills/ecc-imports"))?; + fs::create_dir_all(root.join("skills/ops"))?; + fs::write( + root.join("skills/ecc-imports/research.md"), + "# Recovery research\nGather billing/account context before touching checkout logic.\n", + )?; + fs::write( + root.join("skills/ops/recovery.markdown"), + "# Portal repair\nRoute wiped installs toward repair before presenting new checkout.\n", + )?; + + let output_dir = root.join("out"); + let report = import_legacy_skills(root, &output_dir)?; + + assert_eq!(report.skills_detected, 2); + assert_eq!(report.templates_generated, 2); + assert_eq!(report.files_written.len(), 2); + assert!(report + .skills + .iter() + .any(|skill| skill.template_name == "ecc_imports_research_md")); + assert!(report + .skills + .iter() + .any(|skill| skill.template_name == "ops_recovery_markdown")); + + let config_text = fs::read_to_string(output_dir.join("ecc2.imported-skills.toml"))?; + assert!(config_text.contains("[orchestration_templates.ecc_imports_research_md]")); + assert!(config_text.contains("[orchestration_templates.ops_recovery_markdown]")); + assert!(config_text.contains("Translate and run that workflow for {{task}}.")); + + let summary_text = fs::read_to_string(output_dir.join("imported-skills.md"))?; + assert!(summary_text.contains("skills/ecc-imports/research.md")); + assert!(summary_text.contains("skills/ops/recovery.markdown")); + + Ok(()) + } + #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?; From 4ff5a7169fb7d9ad4b29b8c6e78cc681f1b2fbe4 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:49:38 -0700 Subject: [PATCH 168/459] feat: add ecc2 legacy tool migration import --- docs/HERMES-OPENCLAW-MIGRATION.md | 1 + docs/HERMES-SETUP.md | 2 +- ecc2/src/main.rs | 474 +++++++++++++++++++++++++++++- 3 files changed, 473 insertions(+), 4 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index a386f951..1b83d428 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -189,6 +189,7 @@ ECC 2.0 now ships a bounded migration audit entrypoint: - `ecc migrate plan --source ~/.hermes --output migration-plan.md` - `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` - `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills` +- `ecc migrate import-tools --source ~/.hermes --output-dir migration-artifacts/tools` - `ecc migrate import-schedules --source ~/.hermes --dry-run` - `ecc migrate import-remote --source ~/.hermes --dry-run` - `ecc migrate import-env --source ~/.hermes --dry-run` diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index 83af8abc..111c81c7 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,7 +83,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. -0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. +0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 63b5c87f..aa901663 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -659,6 +659,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Scaffold ECC-native templates from legacy tool scripts + ImportTools { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Directory where imported ECC2 tool artifacts should be written + #[arg(long)] + output_dir: PathBuf, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Import legacy gateway/dispatch tasks into the ECC2 remote queue ImportRemote { /// Path to the legacy Hermes/OpenClaw workspace root @@ -1128,6 +1140,30 @@ struct LegacySkillTemplateFile { orchestration_templates: BTreeMap, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyToolImportEntry { + source_path: String, + template_name: String, + title: String, + summary: String, + suggested_surface: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyToolImportReport { + source: String, + output_dir: String, + tools_detected: usize, + templates_generated: usize, + files_written: Vec, + tools: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct LegacyToolTemplateFile { + orchestration_templates: BTreeMap, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum LegacyRemoteImportRequestStatus { @@ -1991,6 +2027,18 @@ async fn main() -> Result<()> { println!("{}", format_legacy_skill_import_human(&report)); } } + MigrationCommands::ImportTools { + source, + output_dir, + json, + } => { + let report = import_legacy_tools(&source, &output_dir)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_tool_import_human(&report)); + } + } MigrationCommands::ImportRemote { source, dry_run, @@ -5157,9 +5205,15 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V .to_string(), ); } - if categories.contains("tools") || categories.contains("plugins") { + if categories.contains("tools") { steps.push( - "Rebuild valuable tool/plugin wrappers as ECC agents, commands, hooks, or harness runners, keeping only reusable workflow behavior." + "Scaffold translated legacy tools with `ecc migrate import-tools --source --output-dir `, then rebuild the valuable ones as ECC-native commands, hooks, or harness runners instead of shelling back out to the old stack." + .to_string(), + ); + } + if categories.contains("plugins") { + steps.push( + "Rebuild valuable bridge plugins as ECC-native hooks, commands, or skills, keeping only reusable workflow behavior." .to_string(), ); } @@ -6246,6 +6300,292 @@ fn format_legacy_skill_import_summary_markdown(report: &LegacySkillImportReport) lines.join("\n") } +fn import_legacy_tools(source: &Path, output_dir: &Path) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let tools_dir = source.join("tools"); + let mut report = LegacyToolImportReport { + source: source.display().to_string(), + output_dir: output_dir.display().to_string(), + tools_detected: 0, + templates_generated: 0, + files_written: Vec::new(), + tools: Vec::new(), + }; + if !tools_dir.is_dir() { + return Ok(report); + } + + let tool_paths = collect_legacy_tool_paths(&tools_dir)?; + if tool_paths.is_empty() { + return Ok(report); + } + + fs::create_dir_all(output_dir) + .with_context(|| format!("create legacy tool output dir {}", output_dir.display()))?; + + let mut templates = BTreeMap::new(); + for path in tool_paths { + let draft = build_legacy_tool_draft(&source, &tools_dir, &path)?; + report.tools_detected += 1; + report.templates_generated += 1; + report.tools.push(LegacyToolImportEntry { + source_path: draft.source_path.clone(), + template_name: draft.template_name.clone(), + title: draft.title.clone(), + summary: draft.summary.clone(), + suggested_surface: draft.suggested_surface.clone(), + }); + templates.insert( + draft.template_name.clone(), + config::OrchestrationTemplateConfig { + description: Some(format!( + "Migrated legacy tool scaffold from {}", + draft.source_path + )), + project: Some("legacy-migration".to_string()), + task_group: Some("legacy tool".to_string()), + agent: Some("claude".to_string()), + profile: None, + worktree: Some(false), + steps: vec![config::OrchestrationTemplateStepConfig { + name: Some("operator".to_string()), + task: format!( + "Use the migrated legacy tool context from {}.\nSuggested ECC target surface: {}\nLegacy tool title: {}\nLegacy summary: {}\nLegacy excerpt:\n{}\nRebuild or wrap that behavior as an ECC-native {} for {{{{task}}}}.", + draft.source_path, + draft.suggested_surface, + draft.title, + draft.summary, + draft.excerpt, + draft.suggested_surface + ), + agent: None, + profile: None, + worktree: Some(false), + project: Some("legacy-migration".to_string()), + task_group: Some("legacy tool".to_string()), + }], + }, + ); + } + + let templates_path = output_dir.join("ecc2.imported-tools.toml"); + fs::write( + &templates_path, + toml::to_string_pretty(&LegacyToolTemplateFile { + orchestration_templates: templates, + })?, + ) + .with_context(|| format!("write imported tool templates {}", templates_path.display()))?; + report + .files_written + .push(templates_path.display().to_string()); + + let summary_path = output_dir.join("imported-tools.md"); + fs::write( + &summary_path, + format_legacy_tool_import_summary_markdown(&report), + ) + .with_context(|| format!("write imported tool summary {}", summary_path.display()))?; + report + .files_written + .push(summary_path.display().to_string()); + + Ok(report) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LegacyToolDraft { + source_path: String, + template_name: String, + title: String, + summary: String, + excerpt: String, + suggested_surface: String, +} + +fn collect_legacy_tool_paths(root: &Path) -> Result> { + let mut paths = Vec::new(); + collect_legacy_tool_paths_inner(root, &mut paths)?; + paths.sort(); + Ok(paths) +} + +fn collect_legacy_tool_paths_inner(root: &Path, paths: &mut Vec) -> Result<()> { + let mut entries = fs::read_dir(root) + .with_context(|| format!("read legacy tools dir {}", root.display()))? + .collect::>>() + .with_context(|| format!("read entries under {}", root.display()))?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = entry + .file_type() + .with_context(|| format!("read file type for {}", path.display()))?; + if file_type.is_dir() { + collect_legacy_tool_paths_inner(&path, paths)?; + continue; + } + if file_type.is_file() && is_legacy_tool_candidate(&path) { + paths.push(path); + } + } + Ok(()) +} + +fn is_legacy_tool_candidate(path: &Path) -> bool { + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("py" | "js" | "ts" | "mjs" | "cjs" | "sh" | "bash" | "zsh" | "rb" | "pl" | "php") + ) || path.extension().is_none() +} + +fn build_legacy_tool_draft( + source: &Path, + tools_dir: &Path, + path: &Path, +) -> Result { + let body = + fs::read(path).with_context(|| format!("read legacy tool file {}", path.display()))?; + let body = String::from_utf8_lossy(&body).into_owned(); + let source_path = path + .strip_prefix(source) + .unwrap_or(path) + .display() + .to_string(); + let relative_to_tools = path.strip_prefix(tools_dir).unwrap_or(path); + let title = extract_legacy_tool_title(relative_to_tools); + let summary = extract_legacy_tool_summary(&body).unwrap_or_else(|| title.clone()); + let excerpt = extract_legacy_tool_excerpt(&body, 10, 700).unwrap_or_else(|| summary.clone()); + let template_name = format!( + "tool_{}", + slugify_legacy_skill_template_name(relative_to_tools) + ); + let suggested_surface = classify_legacy_tool_surface(&source_path, &body).to_string(); + + Ok(LegacyToolDraft { + source_path, + template_name, + title, + summary, + excerpt, + suggested_surface, + }) +} + +fn extract_legacy_tool_title(relative_path: &Path) -> String { + relative_path + .file_stem() + .and_then(|value| value.to_str()) + .map(|value| value.replace(['-', '_'], " ")) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "legacy tool".to_string()) +} + +fn extract_legacy_tool_summary(body: &str) -> Option { + body.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with("#!")) + .find_map(|line| { + let stripped = line + .trim_start_matches("#") + .trim_start_matches("//") + .trim_start_matches("--") + .trim_start_matches("/*") + .trim_start_matches('*') + .trim(); + if stripped.is_empty() { + None + } else { + Some(truncate_connector_text(stripped, 160)) + } + }) +} + +fn extract_legacy_tool_excerpt(body: &str, max_lines: usize, max_chars: usize) -> Option { + let mut lines = Vec::new(); + let mut chars = 0usize; + for line in body.lines().map(str::trim).filter(|line| !line.is_empty()) { + if line.starts_with("#!") { + continue; + } + if chars >= max_chars || lines.len() >= max_lines { + break; + } + let remaining = max_chars.saturating_sub(chars); + if remaining == 0 { + break; + } + let truncated = truncate_connector_text(line, remaining); + chars += truncated.len(); + lines.push(truncated); + } + if lines.is_empty() { + None + } else { + Some(lines.join("\n")) + } +} + +fn classify_legacy_tool_surface(source_path: &str, body: &str) -> &'static str { + let source_lower = source_path.to_ascii_lowercase(); + let body_lower = body.to_ascii_lowercase(); + if source_lower.contains("hook") + || body_lower.contains("pretooluse") + || body_lower.contains("posttooluse") + || body_lower.contains("notification") + { + "hook" + } else if source_lower.contains("runner") + || source_lower.contains("agent") + || body_lower.contains("session_name_flag") + || body_lower.contains("include-directories") + { + "harness runner" + } else { + "command" + } +} + +fn format_legacy_tool_import_summary_markdown(report: &LegacyToolImportReport) -> String { + let mut lines = vec![ + "# Imported legacy tools".to_string(), + String::new(), + format!("- Source: `{}`", report.source), + format!("- Output dir: `{}`", report.output_dir), + format!("- Tools detected: {}", report.tools_detected), + format!("- Templates generated: {}", report.templates_generated), + String::new(), + ]; + + if report.tools.is_empty() { + lines.push("No legacy tool scripts were detected.".to_string()); + return lines.join("\n"); + } + + lines.push("## Tools".to_string()); + lines.push(String::new()); + for tool in &report.tools { + lines.push(format!( + "- `{}` -> `{}`", + tool.source_path, tool.template_name + )); + lines.push(format!(" - Title: {}", tool.title)); + lines.push(format!(" - Summary: {}", tool.summary)); + lines.push(format!(" - Suggested surface: {}", tool.suggested_surface)); + } + + lines.join("\n") +} + fn build_legacy_remote_add_command(draft: &LegacyRemoteDispatchDraft) -> Option { match draft.request_kind { session::RemoteDispatchKind::Standard => { @@ -6671,7 +7011,11 @@ fn build_legacy_migration_plan_report( target_surface: "ECC agents / hooks / commands / harness runners".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ - "ecc start --task \"Rebuild one legacy tool as an ECC-native command or hook\"".to_string(), + format!( + "ecc migrate import-tools --source {} --output-dir migration-artifacts/tools", + shell_quote_double(&audit.source) + ), + "ecc template --task \"Rebuild one legacy tool as an ECC-native command, hook, or harness runner\"".to_string(), ], config_snippets: vec![ "[harness_runners.legacy-runner]\nprogram = \"\"\nbase_args = []\nproject_markers = [\".legacy-runner\"]".to_string(), @@ -7081,6 +7425,34 @@ fn format_legacy_skill_import_human(report: &LegacySkillImportReport) -> String lines.join("\n") } +fn format_legacy_tool_import_human(report: &LegacyToolImportReport) -> String { + let mut lines = vec![ + format!("Legacy tool import complete for {}", report.source), + format!("- output dir {}", report.output_dir), + format!("- tools detected {}", report.tools_detected), + format!("- templates generated {}", report.templates_generated), + ]; + + if !report.files_written.is_empty() { + lines.push("Files".to_string()); + for path in &report.files_written { + lines.push(format!("- {}", path)); + } + } + + if !report.tools.is_empty() { + lines.push("Tools".to_string()); + for tool in &report.tools { + lines.push(format!("- {} -> {}", tool.source_path, tool.template_name)); + lines.push(format!(" title {}", tool.title)); + lines.push(format!(" summary {}", tool.summary)); + lines.push(format!(" suggested surface {}", tool.suggested_surface)); + } + } + + lines.join("\n") +} + fn format_legacy_remote_import_human(report: &LegacyRemoteImportReport) -> String { let mut lines = vec![ format!( @@ -9778,6 +10150,37 @@ mod tests { } } + #[test] + fn cli_parses_migrate_import_tools_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "import-tools", + "--source", + "/tmp/hermes", + "--output-dir", + "/tmp/out", + "--json", + ]) + .expect("migrate import-tools should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::ImportTools { + source, + output_dir, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert_eq!(output_dir, PathBuf::from("/tmp/out")); + assert!(json); + } + _ => panic!("expected migrate import-tools subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -9910,6 +10313,11 @@ mod tests { )?; fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?; fs::write(root.join("skills/ecc-imports/research.md"), "# research\n")?; + fs::create_dir_all(root.join("tools"))?; + fs::write( + root.join("tools/browser.py"), + "# Verify the billing portal banner\nprint('browser')\n", + )?; let audit = build_legacy_migration_audit_report(root)?; let plan = build_legacy_migration_plan_report(&audit); @@ -9992,6 +10400,15 @@ mod tests { .command_snippets .iter() .any(|command| command.contains("ecc migrate import-skills --source"))); + let tools_step = plan + .steps + .iter() + .find(|step| step.category == "tools") + .expect("tools step"); + assert!(tools_step + .command_snippets + .iter() + .any(|command| command.contains("ecc migrate import-tools --source"))); Ok(()) } @@ -10489,6 +10906,57 @@ Route existing installs to portal first before checkout. Ok(()) } + #[test] + fn import_legacy_tools_writes_template_artifacts() -> Result<()> { + let tempdir = TestDir::new("legacy-tool-import")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("tools/browser"))?; + fs::create_dir_all(root.join("tools/hooks"))?; + fs::write( + root.join("tools/browser/check_portal.py"), + "# Verify the billing portal warning banner\nprint('check banner')\n", + )?; + fs::write( + root.join("tools/hooks/preflight.sh"), + "#!/usr/bin/env bash\n# PretoolUse guard for dangerous commands\nexit 0\n", + )?; + + let output_dir = root.join("out"); + let report = import_legacy_tools(root, &output_dir)?; + + assert_eq!(report.tools_detected, 2); + assert_eq!(report.templates_generated, 2); + assert_eq!(report.files_written.len(), 2); + assert!(report + .tools + .iter() + .any(|tool| tool.template_name == "tool_browser_check_portal_py")); + assert!(report + .tools + .iter() + .any(|tool| tool.template_name == "tool_hooks_preflight_sh")); + assert!(report + .tools + .iter() + .any(|tool| tool.suggested_surface == "command")); + assert!(report + .tools + .iter() + .any(|tool| tool.suggested_surface == "hook")); + + let config_text = fs::read_to_string(output_dir.join("ecc2.imported-tools.toml"))?; + assert!(config_text.contains("[orchestration_templates.tool_browser_check_portal_py]")); + assert!(config_text.contains("[orchestration_templates.tool_hooks_preflight_sh]")); + assert!(config_text.contains("Rebuild or wrap that behavior as an ECC-native")); + + let summary_text = fs::read_to_string(output_dir.join("imported-tools.md"))?; + assert!(summary_text.contains("tools/browser/check_portal.py")); + assert!(summary_text.contains("tools/hooks/preflight.sh")); + assert!(summary_text.contains("Suggested surface: hook")); + + Ok(()) + } + #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?; From 125d5e619905d97b519a887d5bc7332dcc448a52 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 11:53:17 -0700 Subject: [PATCH 169/459] feat: add ecc2 legacy plugin migration import --- docs/HERMES-OPENCLAW-MIGRATION.md | 1 + docs/HERMES-SETUP.md | 2 +- ecc2/src/main.rs | 388 +++++++++++++++++++++++++++++- 3 files changed, 388 insertions(+), 3 deletions(-) diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md index 1b83d428..8391398c 100644 --- a/docs/HERMES-OPENCLAW-MIGRATION.md +++ b/docs/HERMES-OPENCLAW-MIGRATION.md @@ -190,6 +190,7 @@ ECC 2.0 now ships a bounded migration audit entrypoint: - `ecc migrate scaffold --source ~/.hermes --output-dir migration-artifacts` - `ecc migrate import-skills --source ~/.hermes --output-dir migration-artifacts/skills` - `ecc migrate import-tools --source ~/.hermes --output-dir migration-artifacts/tools` +- `ecc migrate import-plugins --source ~/.hermes --output-dir migration-artifacts/plugins` - `ecc migrate import-schedules --source ~/.hermes --dry-run` - `ecc migrate import-remote --source ~/.hermes --dry-run` - `ecc migrate import-env --source ~/.hermes --dry-run` diff --git a/docs/HERMES-SETUP.md b/docs/HERMES-SETUP.md index 111c81c7..9cb414e5 100644 --- a/docs/HERMES-SETUP.md +++ b/docs/HERMES-SETUP.md @@ -83,7 +83,7 @@ These stay local and should be configured per operator: ## Suggested Bring-Up Order 0. Run `ecc migrate audit --source ~/.hermes` first to inventory the legacy workspace and see which parts already map onto ECC2. -0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. +0.5. Generate and review artifacts with `ecc migrate plan` / `ecc migrate scaffold`, scaffold reusable legacy skills with `ecc migrate import-skills --output-dir migration-artifacts/skills`, scaffold legacy tool translation templates with `ecc migrate import-tools --output-dir migration-artifacts/tools`, scaffold legacy bridge plugins with `ecc migrate import-plugins --output-dir migration-artifacts/plugins`, preview recurring jobs with `ecc migrate import-schedules --dry-run`, preview gateway dispatch with `ecc migrate import-remote --dry-run`, preview safe env/service context with `ecc migrate import-env --dry-run`, then import sanitized workspace memory with `ecc migrate import-memory`. 1. Install ECC and verify the baseline harness setup. 2. Install Hermes and point it at ECC-imported skills. 3. Register the MCP servers you actually use every day. diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index aa901663..df844a96 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -671,6 +671,18 @@ enum MigrationCommands { #[arg(long)] json: bool, }, + /// Scaffold ECC-native templates from legacy bridge plugins + ImportPlugins { + /// Path to the legacy Hermes/OpenClaw workspace root + #[arg(long)] + source: PathBuf, + /// Directory where imported ECC2 plugin artifacts should be written + #[arg(long)] + output_dir: PathBuf, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Import legacy gateway/dispatch tasks into the ECC2 remote queue ImportRemote { /// Path to the legacy Hermes/OpenClaw workspace root @@ -1164,6 +1176,30 @@ struct LegacyToolTemplateFile { orchestration_templates: BTreeMap, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyPluginImportEntry { + source_path: String, + template_name: String, + title: String, + summary: String, + suggested_surface: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct LegacyPluginImportReport { + source: String, + output_dir: String, + plugins_detected: usize, + templates_generated: usize, + files_written: Vec, + plugins: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct LegacyPluginTemplateFile { + orchestration_templates: BTreeMap, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] enum LegacyRemoteImportRequestStatus { @@ -2039,6 +2075,18 @@ async fn main() -> Result<()> { println!("{}", format_legacy_tool_import_human(&report)); } } + MigrationCommands::ImportPlugins { + source, + output_dir, + json, + } => { + let report = import_legacy_plugins(&source, &output_dir)?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", format_legacy_plugin_import_human(&report)); + } + } MigrationCommands::ImportRemote { source, dry_run, @@ -5213,7 +5261,7 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V } if categories.contains("plugins") { steps.push( - "Rebuild valuable bridge plugins as ECC-native hooks, commands, or skills, keeping only reusable workflow behavior." + "Scaffold translated bridge plugins with `ecc migrate import-plugins --source --output-dir `, then port the valuable ones into ECC-native hooks, commands, or skills." .to_string(), ); } @@ -6586,6 +6634,210 @@ fn format_legacy_tool_import_summary_markdown(report: &LegacyToolImportReport) - lines.join("\n") } +fn import_legacy_plugins(source: &Path, output_dir: &Path) -> Result { + let source = source + .canonicalize() + .with_context(|| format!("Legacy workspace not found: {}", source.display()))?; + if !source.is_dir() { + anyhow::bail!( + "Legacy workspace source must be a directory: {}", + source.display() + ); + } + + let plugins_dir = source.join("plugins"); + let mut report = LegacyPluginImportReport { + source: source.display().to_string(), + output_dir: output_dir.display().to_string(), + plugins_detected: 0, + templates_generated: 0, + files_written: Vec::new(), + plugins: Vec::new(), + }; + if !plugins_dir.is_dir() { + return Ok(report); + } + + let plugin_paths = collect_legacy_tool_paths(&plugins_dir)?; + if plugin_paths.is_empty() { + return Ok(report); + } + + fs::create_dir_all(output_dir) + .with_context(|| format!("create legacy plugin output dir {}", output_dir.display()))?; + + let mut templates = BTreeMap::new(); + for path in plugin_paths { + let draft = build_legacy_plugin_draft(&source, &plugins_dir, &path)?; + report.plugins_detected += 1; + report.templates_generated += 1; + report.plugins.push(LegacyPluginImportEntry { + source_path: draft.source_path.clone(), + template_name: draft.template_name.clone(), + title: draft.title.clone(), + summary: draft.summary.clone(), + suggested_surface: draft.suggested_surface.clone(), + }); + templates.insert( + draft.template_name.clone(), + config::OrchestrationTemplateConfig { + description: Some(format!( + "Migrated legacy plugin scaffold from {}", + draft.source_path + )), + project: Some("legacy-migration".to_string()), + task_group: Some("legacy plugin".to_string()), + agent: Some("claude".to_string()), + profile: None, + worktree: Some(false), + steps: vec![config::OrchestrationTemplateStepConfig { + name: Some("operator".to_string()), + task: format!( + "Use the migrated legacy plugin context from {}.\nSuggested ECC target surface: {}\nLegacy plugin title: {}\nLegacy summary: {}\nLegacy excerpt:\n{}\nPort that behavior into an ECC-native {} for {{{{task}}}}.", + draft.source_path, + draft.suggested_surface, + draft.title, + draft.summary, + draft.excerpt, + draft.suggested_surface + ), + agent: None, + profile: None, + worktree: Some(false), + project: Some("legacy-migration".to_string()), + task_group: Some("legacy plugin".to_string()), + }], + }, + ); + } + + let templates_path = output_dir.join("ecc2.imported-plugins.toml"); + fs::write( + &templates_path, + toml::to_string_pretty(&LegacyPluginTemplateFile { + orchestration_templates: templates, + })?, + ) + .with_context(|| { + format!( + "write imported plugin templates {}", + templates_path.display() + ) + })?; + report + .files_written + .push(templates_path.display().to_string()); + + let summary_path = output_dir.join("imported-plugins.md"); + fs::write( + &summary_path, + format_legacy_plugin_import_summary_markdown(&report), + ) + .with_context(|| format!("write imported plugin summary {}", summary_path.display()))?; + report + .files_written + .push(summary_path.display().to_string()); + + Ok(report) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LegacyPluginDraft { + source_path: String, + template_name: String, + title: String, + summary: String, + excerpt: String, + suggested_surface: String, +} + +fn build_legacy_plugin_draft( + source: &Path, + plugins_dir: &Path, + path: &Path, +) -> Result { + let body = + fs::read(path).with_context(|| format!("read legacy plugin file {}", path.display()))?; + let body = String::from_utf8_lossy(&body).into_owned(); + let source_path = path + .strip_prefix(source) + .unwrap_or(path) + .display() + .to_string(); + let relative_to_plugins = path.strip_prefix(plugins_dir).unwrap_or(path); + let title = extract_legacy_tool_title(relative_to_plugins); + let summary = extract_legacy_tool_summary(&body).unwrap_or_else(|| title.clone()); + let excerpt = extract_legacy_tool_excerpt(&body, 10, 700).unwrap_or_else(|| summary.clone()); + let template_name = format!( + "plugin_{}", + slugify_legacy_skill_template_name(relative_to_plugins) + ); + let suggested_surface = classify_legacy_plugin_surface(&source_path, &body).to_string(); + + Ok(LegacyPluginDraft { + source_path, + template_name, + title, + summary, + excerpt, + suggested_surface, + }) +} + +fn classify_legacy_plugin_surface(source_path: &str, body: &str) -> &'static str { + let source_lower = source_path.to_ascii_lowercase(); + let body_lower = body.to_ascii_lowercase(); + if source_lower.contains("hook") + || body_lower.contains("pretooluse") + || body_lower.contains("posttooluse") + || body_lower.contains("notification") + { + "hook" + } else if source_lower.contains("skill") + || body_lower.contains("skill") + || body_lower.contains("system prompt") + || body_lower.contains("context") + { + "skill" + } else { + "command" + } +} + +fn format_legacy_plugin_import_summary_markdown(report: &LegacyPluginImportReport) -> String { + let mut lines = vec![ + "# Imported legacy plugins".to_string(), + String::new(), + format!("- Source: `{}`", report.source), + format!("- Output dir: `{}`", report.output_dir), + format!("- Plugins detected: {}", report.plugins_detected), + format!("- Templates generated: {}", report.templates_generated), + String::new(), + ]; + + if report.plugins.is_empty() { + lines.push("No legacy plugin scripts were detected.".to_string()); + return lines.join("\n"); + } + + lines.push("## Plugins".to_string()); + lines.push(String::new()); + for plugin in &report.plugins { + lines.push(format!( + "- `{}` -> `{}`", + plugin.source_path, plugin.template_name + )); + lines.push(format!(" - Title: {}", plugin.title)); + lines.push(format!(" - Summary: {}", plugin.summary)); + lines.push(format!( + " - Suggested surface: {}", + plugin.suggested_surface + )); + } + + lines.join("\n") +} + fn build_legacy_remote_add_command(draft: &LegacyRemoteDispatchDraft) -> Option { match draft.request_kind { session::RemoteDispatchKind::Standard => { @@ -7029,7 +7281,11 @@ fn build_legacy_migration_plan_report( target_surface: "ECC hooks / commands / skills".to_string(), source_paths: artifact.source_paths.clone(), command_snippets: vec![ - "ecc start --task \"Port one bridge plugin behavior into an ECC hook or command\"".to_string(), + format!( + "ecc migrate import-plugins --source {} --output-dir migration-artifacts/plugins", + shell_quote_double(&audit.source) + ), + "ecc template --task \"Port one bridge plugin behavior into an ECC hook, command, or skill\"".to_string(), ], config_snippets: Vec::new(), notes: artifact.notes.clone(), @@ -7453,6 +7709,37 @@ fn format_legacy_tool_import_human(report: &LegacyToolImportReport) -> String { lines.join("\n") } +fn format_legacy_plugin_import_human(report: &LegacyPluginImportReport) -> String { + let mut lines = vec![ + format!("Legacy plugin import complete for {}", report.source), + format!("- output dir {}", report.output_dir), + format!("- plugins detected {}", report.plugins_detected), + format!("- templates generated {}", report.templates_generated), + ]; + + if !report.files_written.is_empty() { + lines.push("Files".to_string()); + for path in &report.files_written { + lines.push(format!("- {}", path)); + } + } + + if !report.plugins.is_empty() { + lines.push("Plugins".to_string()); + for plugin in &report.plugins { + lines.push(format!( + "- {} -> {}", + plugin.source_path, plugin.template_name + )); + lines.push(format!(" title {}", plugin.title)); + lines.push(format!(" summary {}", plugin.summary)); + lines.push(format!(" suggested surface {}", plugin.suggested_surface)); + } + } + + lines.join("\n") +} + fn format_legacy_remote_import_human(report: &LegacyRemoteImportReport) -> String { let mut lines = vec![ format!( @@ -10181,6 +10468,37 @@ mod tests { } } + #[test] + fn cli_parses_migrate_import_plugins_command() { + let cli = Cli::try_parse_from([ + "ecc", + "migrate", + "import-plugins", + "--source", + "/tmp/hermes", + "--output-dir", + "/tmp/out", + "--json", + ]) + .expect("migrate import-plugins should parse"); + + match cli.command { + Some(Commands::Migrate { + command: + MigrationCommands::ImportPlugins { + source, + output_dir, + json, + }, + }) => { + assert_eq!(source, PathBuf::from("/tmp/hermes")); + assert_eq!(output_dir, PathBuf::from("/tmp/out")); + assert!(json); + } + _ => panic!("expected migrate import-plugins subcommand"), + } + } + #[test] fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> { let tempdir = TestDir::new("legacy-migration-audit")?; @@ -10256,6 +10574,8 @@ mod tests { fs::create_dir_all(root.join("gateway"))?; fs::create_dir_all(root.join("workspace/notes"))?; fs::create_dir_all(root.join("skills/ecc-imports"))?; + fs::create_dir_all(root.join("tools"))?; + fs::create_dir_all(root.join("plugins"))?; fs::write(root.join("config.yaml"), "model: claude\n")?; fs::write( root.join("cron/jobs.json"), @@ -10318,6 +10638,10 @@ mod tests { root.join("tools/browser.py"), "# Verify the billing portal banner\nprint('browser')\n", )?; + fs::write( + root.join("plugins/recovery.py"), + "# Account recovery command bridge\nprint('recovery')\n", + )?; let audit = build_legacy_migration_audit_report(root)?; let plan = build_legacy_migration_plan_report(&audit); @@ -10409,6 +10733,15 @@ mod tests { .command_snippets .iter() .any(|command| command.contains("ecc migrate import-tools --source"))); + let plugins_step = plan + .steps + .iter() + .find(|step| step.category == "plugins") + .expect("plugins step"); + assert!(plugins_step + .command_snippets + .iter() + .any(|command| command.contains("ecc migrate import-plugins --source"))); Ok(()) } @@ -10957,6 +11290,57 @@ Route existing installs to portal first before checkout. Ok(()) } + #[test] + fn import_legacy_plugins_writes_template_artifacts() -> Result<()> { + let tempdir = TestDir::new("legacy-plugin-import")?; + let root = tempdir.path(); + fs::create_dir_all(root.join("plugins/hooks"))?; + fs::create_dir_all(root.join("plugins/skills"))?; + fs::write( + root.join("plugins/hooks/review.py"), + "# PostToolUse notifier for risky changes\nprint('review')\n", + )?; + fs::write( + root.join("plugins/skills/recovery.py"), + "# Recovery skill bridge for wiped setups\nprint('recovery')\n", + )?; + + let output_dir = root.join("out"); + let report = import_legacy_plugins(root, &output_dir)?; + + assert_eq!(report.plugins_detected, 2); + assert_eq!(report.templates_generated, 2); + assert_eq!(report.files_written.len(), 2); + assert!(report + .plugins + .iter() + .any(|plugin| plugin.template_name == "plugin_hooks_review_py")); + assert!(report + .plugins + .iter() + .any(|plugin| plugin.template_name == "plugin_skills_recovery_py")); + assert!(report + .plugins + .iter() + .any(|plugin| plugin.suggested_surface == "hook")); + assert!(report + .plugins + .iter() + .any(|plugin| plugin.suggested_surface == "skill")); + + let config_text = fs::read_to_string(output_dir.join("ecc2.imported-plugins.toml"))?; + assert!(config_text.contains("[orchestration_templates.plugin_hooks_review_py]")); + assert!(config_text.contains("[orchestration_templates.plugin_skills_recovery_py]")); + assert!(config_text.contains("Port that behavior into an ECC-native")); + + let summary_text = fs::read_to_string(output_dir.join("imported-plugins.md"))?; + assert!(summary_text.contains("plugins/hooks/review.py")); + assert!(summary_text.contains("plugins/skills/recovery.py")); + assert!(summary_text.contains("Suggested surface: skill")); + + Ok(()) + } + #[test] fn legacy_migration_scaffold_writes_plan_and_config_files() -> Result<()> { let tempdir = TestDir::new("legacy-migration-scaffold")?; From 50dc4b0492bb167a85e1cc9676ee01e8d5dec7e5 Mon Sep 17 00:00:00 2001 From: Balaji Guntur <59932973+gnpthbalaji@users.noreply.github.com> Date: Sat, 11 Apr 2026 01:44:13 -0700 Subject: [PATCH 170/459] feat(a11y):add inclusive-ui architect agent for WCAG 2.2 compliance --- agents/inclusive-ui-agent.md | 139 +++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 agents/inclusive-ui-agent.md diff --git a/agents/inclusive-ui-agent.md b/agents/inclusive-ui-agent.md new file mode 100644 index 00000000..c5793317 --- /dev/null +++ b/agents/inclusive-ui-agent.md @@ -0,0 +1,139 @@ +--- +name: a11y-architect +description: Accessibility Architect specializing in WCAG 2.2 compliance for Web and Native platforms. Use PROACTIVELY when designing UI components, establishing design systems, or auditing code for inclusive user experiences. +tools: ['Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob'] +model: sonnet +--- + +You are a Senior Accessibility Architect. Your goal is to ensure that every digital product is Perceivable, Operable, Understandable, and Robust (POUR) for all users, including those with visual, auditory, motor, or cognitive disabilities. + +## Your Role + +- **Architecting Inclusivity**: Design UI systems that natively support assistive technologies (Screen Readers, Voice Control, Switch Access). +- **WCAG 2.2 Enforcement**: Apply the latest success criteria, focusing on new standards like Focus Appearance, Target Size, and Redundant Entry. +- **Platform Strategy**: Bridge the gap between Web standards (WAI-ARIA) and Native frameworks (SwiftUI/Jetpack Compose). +- **Technical Specifications**: Provide developers with precise attributes (roles, labels, hints, and traits) required for compliance. + +## Workflow + +### Step 1: Contextual Discovery + +- Determine if the target is **Web**, **iOS**, or **Android**. +- Analyze the user interaction (e.g., Is this a simple button or a complex data grid?). +- Identify potential accessibility "blockers" (e.g., color-only indicators, missing keyboard traps). + +### Step 2: Strategic Implementation + +- **Apply the Accessibility Skill**: Invoke specific logic to generate semantic code. +- **Define Focus Flow**: Map out how a keyboard or screen reader user will move through the interface. +- **Optimize Touch/Pointer**: Ensure all interactive elements meet the minimum **24x24 pixel** spacing or **44x44 pixel** target size requirements. + +### Step 3: Validation & Documentation + +- Review the output against the WCAG 2.2 Level AA checklist. +- Provide a brief "Implementation Note" explaining _why_ certain attributes (like `aria-live` or `accessibilityHint`) were used. + +## Output Format + +For every component or page request, provide: + +1. **The Code**: Semantic HTML/ARIA or Native code. +2. **The Accessibility Tree**: A description of what a screen reader will announce. +3. **Compliance Mapping**: A list of specific WCAG 2.2 criteria addressed. + +## Examples + +### Example: Accessible Search Component + +**Input**: "Create a search bar with a submit icon." +**Action**: Ensuring the icon-only button has a visible label and the input is correctly labeled. +**Output**: + +```html +
+ + + +
+``` + +## WCAG 2.2 Core Compliance Checklist + +### 1. Perceivable (Information must be presentable) + +- [ ] **Text Alternatives**: All non-text content has a text alternative (Alt text or labels). +- [ ] **Contrast**: Text meets 4.5:1; UI components/graphics meet 3:1 contrast ratios. +- [ ] **Adaptable**: Content reflows and remains functional when resized up to 400%. + +### 2. Operable (Interface components must be usable) + +- [ ] **Keyboard Accessible**: Every interactive element is reachable via keyboard/switch control. +- [ ] **Navigable**: Focus order is logical, and focus indicators are high-contrast (SC 2.4.11). +- [ ] **Pointer Gestures**: Single-pointer alternatives exist for all dragging or multipoint gestures. +- [ ] **Target Size**: Interactive elements are at least 24x24 CSS pixels (SC 2.5.8). + +### 3. Understandable (Information must be clear) + +- [ ] **Predictable**: Navigation and identification of elements are consistent across the app. +- [ ] **Input Assistance**: Forms provide clear error identification and suggestions for fix. +- [ ] **Redundant Entry**: Avoid asking for the same info twice in a single process (SC 3.3.7). + +### 4. Robust (Content must be compatible) + +- [ ] **Compatibility**: Maximize compatibility with assistive tech using valid Name, Role, and Value. +- [ ] **Status Messages**: Screen readers are notified of dynamic changes via ARIA live regions. + +--- + +## Anti-Patterns + +| Issue | Why it fails | +| :------------------------- | :------------------------------------------------------------------------------------------------- | +| **"Click Here" Links** | Non-descriptive; screen reader users navigating by links won't know the destination. | +| **Fixed-Sized Containers** | Prevents content reflow and breaks the layout at higher zoom levels. | +| **Keyboard Traps** | Prevents users from navigating the rest of the page once they enter a component. | +| **Auto-Playing Media** | Distracting for users with cognitive disabilities; interferes with screen reader audio. | +| **Empty Buttons** | Icon-only buttons without an `aria-label` or `accessibilityLabel` are invisible to screen readers. | + +## Accessibility Decision Record Template + +For major UI decisions, use this format: + +```markdown +# ADR-ACC-[000]: [Title of the Accessibility Decision] + +## Status + +Proposed | **Accepted** | Deprecated | Superseded by [ADR-XXX] + +## Context + +_Describe the UI component or workflow being addressed._ + +- **Platform**: [Web | iOS | Android | Cross-platform] +- **WCAG 2.2 Success Criterion**: [e.g., 2.5.8 Target Size (Minimum)] +- **Problem**: What is the current accessibility barrier? (e.g., "The 'Close' button in the modal is too small for users with motor impairments.") + +## Decision + +_Detail the specific implementation choice._ +"We will implement a touch target of at least 44x44 points for all mobile navigation elements and 24x24 CSS pixels for web, ensuring a minimum 4px spacing between adjacent targets." + +## Implementation Details + +### Code/Spec + +[language] +// Example: SwiftUI +Button(action: close) { +Image(systemName: "xmark") +.frame(width: 44, height: 44) // Standardizing hit area +} +.accessibilityLabel("Close modal") +``` + +## Reference + +- See skill `accessibility` to transform raw UI requirements into platform-specific accessible code (WAI-ARIA, SwiftUI, or Jetpack Compose) based on WCAG 2.2 criteria. From aa8948d5cf80521011b443c0a6c9cedf2ca94ffe Mon Sep 17 00:00:00 2001 From: Balaji Guntur <59932973+gnpthbalaji@users.noreply.github.com> Date: Sat, 11 Apr 2026 12:51:21 -0700 Subject: [PATCH 171/459] Adding accessibility skill to go in with the inclusive-ui-agent --- skills/accessibility/SKILL.md | 137 ++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 skills/accessibility/SKILL.md diff --git a/skills/accessibility/SKILL.md b/skills/accessibility/SKILL.md new file mode 100644 index 00000000..862e71f2 --- /dev/null +++ b/skills/accessibility/SKILL.md @@ -0,0 +1,137 @@ +--- +name: accessibility +description: Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA + standards. Use this skill to generate semantic ARIA for Web and accessibility traits for Web and Native platforms (iOS/Android). +origin: ECC +--- + +# Accessibility (WCAG 2.2) + +This skill ensures that digital interfaces are Perceivable, Operable, Understandable, and Robust (POUR) for all users, including those using screen readers, switch controls, or keyboard navigation. It focuses on the technical implementation of WCAG 2.2 success criteria. + +## When to Activate + +- Defining UI component specifications for Web, iOS, or Android. +- Auditing existing code for accessibility barriers or compliance gaps. +- Implementing new WCAG 2.2 standards like Target Size (Minimum) and Focus Appearance. +- Mapping high-level design requirements to technical attributes (ARIA roles, traits, hints). + +## Core Concepts + +- **POUR Principles**: The foundation of WCAG (Perceivable, Operable, Understandable, Robust). +- **Semantic Mapping**: Using native elements over generic containers to provide built-in accessibility. +- **Accessibility Tree**: The representation of the UI that assistive technologies actually "read." +- **Focus Management**: Controlling the order and visibility of the keyboard/screen reader cursor. +- **Labeling & Hints**: Providing context through `aria-label`, `accessibilityLabel`, and `contentDescription`. + +## How It Works + +### Step 1: Identify the Component Role + +Determine the functional purpose (e.g., Is this a button, a link, or a tab?). Use the most semantic native element available before resorting to custom roles. + +### Step 2: Define Perceivable Attributes + +- Ensure text contrast meets **4.5:1** (normal) or **3:1** (large/UI). +- Add text alternatives for non-text content (images, icons). +- Implement responsive reflow (up to 400% zoom without loss of function). + +### Step 3: Implement Operable Controls + +- Ensure a minimum **24x24 CSS pixel** target size (WCAG 2.2 SC 2.5.8). +- Verify all interactive elements are reachable via keyboard and have a visible focus indicator (SC 2.4.11). +- Provide single-pointer alternatives for dragging movements. + +### Step 4: Ensure Understandable Logic + +- Use consistent navigation patterns. +- Provide descriptive error messages and suggestions for correction (SC 3.3.3). +- Implement "Redundant Entry" (SC 3.3.7) to prevent asking for the same data twice. + +### Step 5: Verify Robust Compatibility + +- Use correct `Name, Role, Value` patterns. +- Implement `aria-live` or live regions for dynamic status updates. + +## Accessibility Architecture Diagram + +```mermaid +flowchart TD + UI["UI Component"] --> Platform{Platform?} + Platform -->|Web| ARIA["WAI-ARIA + HTML5"] + Platform -->|iOS| SwiftUI["Accessibility Traits + Labels"] + Platform -->|Android| Compose["Semantics + ContentDesc"] + + ARIA --> AT["Assistive Technology (Screen Readers, Switches)"] + SwiftUI --> AT + Compose --> AT +``` + +## Cross-Platform Mapping + +| Feature | Web (HTML/ARIA) | iOS (SwiftUI) | Android (Compose) | +| :----------------- | :----------------------- | :----------------------------------- | :---------------------------------------------------------- | +| **Primary Label** | `aria-label` / `