From e83ecfd3f99c55cb60a26af6194e060017598520 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 7 Apr 2026 12:24:54 -0700 Subject: [PATCH] feat: add ecc2 delegated team board --- ecc2/src/main.rs | 27 ++++++ ecc2/src/session/manager.rs | 189 ++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 1f182614..6af0f2f6 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -57,6 +57,14 @@ enum Commands { /// Session ID or alias session_id: Option, }, + /// Show delegated team board for a session + Team { + /// Lead session ID or alias + session_id: Option, + /// Delegation depth to traverse + #[arg(long, default_value_t = 2)] + depth: usize, + }, /// Stop a running session Stop { /// Session ID or alias @@ -188,6 +196,11 @@ async fn main() -> Result<()> { let status = session::manager::get_status(&db, &id)?; println!("{status}"); } + Some(Commands::Team { session_id, depth }) => { + let id = session_id.unwrap_or_else(|| "latest".to_string()); + let team = session::manager::get_team_status(&db, &id, depth)?; + println!("{team}"); + } Some(Commands::Stop { session_id }) => { session::manager::stop_session(&db, &session_id).await?; println!("Session stopped: {session_id}"); @@ -446,4 +459,18 @@ mod tests { _ => panic!("expected delegate subcommand"), } } + + #[test] + fn cli_parses_team_command() { + let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"]) + .expect("team should parse"); + + match cli.command { + Some(Commands::Team { session_id, depth }) => { + assert_eq!(session_id.as_deref(), Some("planner")); + assert_eq!(depth, 3); + } + _ => panic!("expected team subcommand"), + } + } } diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 033bbba8..5f36cbd4 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use std::collections::{BTreeMap, HashSet}; use std::fmt; use std::path::{Path, PathBuf}; use std::process::Stdio; @@ -38,6 +39,30 @@ 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 mut visited = HashSet::new(); + visited.insert(root.id.clone()); + + let mut descendants = Vec::new(); + collect_delegation_descendants( + db, + &root.id, + depth, + 1, + &unread_counts, + &mut visited, + &mut descendants, + )?; + + Ok(TeamStatus { + root, + unread_messages: unread_counts, + descendants, + }) +} + pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> { stop_session_with_options(db, id, true).await } @@ -116,6 +141,48 @@ async fn resume_session_with_program( Ok(session.id) } +fn collect_delegation_descendants( + db: &StateStore, + session_id: &str, + remaining_depth: usize, + current_depth: usize, + unread_counts: &std::collections::HashMap, + visited: &mut HashSet, + descendants: &mut Vec, +) -> Result<()> { + if remaining_depth == 0 { + return Ok(()); + } + + for child_id in db.delegated_children(session_id, 50)? { + if !visited.insert(child_id.clone()) { + continue; + } + + let Some(session) = db.get_session(&child_id)? else { + continue; + }; + + descendants.push(DelegatedSessionSummary { + depth: current_depth, + unread_messages: unread_counts.get(&child_id).copied().unwrap_or(0), + session, + }); + + collect_delegation_descendants( + db, + &child_id, + remaining_depth.saturating_sub(1), + current_depth + 1, + unread_counts, + visited, + descendants, + )?; + } + + Ok(()) +} + pub async fn cleanup_session_worktree(db: &StateStore, id: &str) -> Result<()> { let session = resolve_session(db, id)?; @@ -460,6 +527,18 @@ pub struct SessionStatus { delegated_children: Vec, } +pub struct TeamStatus { + root: Session, + unread_messages: std::collections::HashMap, + descendants: Vec, +} + +struct DelegatedSessionSummary { + depth: usize, + unread_messages: usize, + session: Session, +} + impl fmt::Display for SessionStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let s = &self.session; @@ -489,6 +568,71 @@ impl fmt::Display for SessionStatus { } } +impl fmt::Display for TeamStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Lead: {} [{}]", self.root.id, self.root.state)?; + writeln!(f, "Task: {}", self.root.task)?; + writeln!(f, "Agent: {}", self.root.agent_type)?; + if let Some(worktree) = self.root.worktree.as_ref() { + writeln!(f, "Branch: {}", worktree.branch)?; + } + + let lead_unread = self.unread_messages.get(&self.root.id).copied().unwrap_or(0); + writeln!(f, "Inbox: {}", lead_unread)?; + + if self.descendants.is_empty() { + return write!(f, "Board: no delegated sessions"); + } + + 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)) + .or_default() + .push(summary); + } + + for lane in [ + "Running", + "Idle", + "Pending", + "Failed", + "Stopped", + "Completed", + ] { + let Some(items) = lanes.get(lane) else { + continue; + }; + + writeln!(f, " {lane}:")?; + for item in items { + writeln!( + f, + " - {}{} [{}] | inbox {} | {}", + " ".repeat(item.depth.saturating_sub(1)), + item.session.id, + item.session.agent_type, + item.unread_messages, + item.session.task + )?; + } + } + + Ok(()) + } +} + +fn session_state_label(state: &SessionState) -> &'static str { + match state { + SessionState::Pending => "Pending", + SessionState::Running => "Running", + SessionState::Idle => "Idle", + SessionState::Completed => "Completed", + SessionState::Failed => "Failed", + SessionState::Stopped => "Stopped", + } +} + #[cfg(test)] mod tests { use super::*; @@ -904,4 +1048,49 @@ mod tests { Ok(()) } + + #[test] + fn get_team_status_groups_delegated_children() -> Result<()> { + let tempdir = TestDir::new("manager-team-status")?; + let _cfg = build_config(tempdir.path()); + 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("reviewer", SessionState::Completed, now))?; + + db.send_message( + "lead", + "worker-a", + "{\"task\":\"Implement auth\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + db.send_message( + "lead", + "worker-b", + "{\"task\":\"Check billing\",\"context\":\"Delegated from lead\"}", + "task_handoff", + )?; + db.send_message( + "worker-a", + "reviewer", + "{\"task\":\"Review auth\",\"context\":\"Delegated from worker-a\"}", + "task_handoff", + )?; + + let team = get_team_status(&db, "lead", 2)?; + let rendered = team.to_string(); + + assert!(rendered.contains("Lead: lead [running]")); + assert!(rendered.contains("Running:")); + assert!(rendered.contains("Pending:")); + assert!(rendered.contains("Completed:")); + assert!(rendered.contains("worker-a")); + assert!(rendered.contains("worker-b")); + assert!(rendered.contains("reviewer")); + + Ok(()) + } }