From 0eb31212e99be7ca858565bd381b63917b0ec851 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 7 Apr 2026 12:21:29 -0700 Subject: [PATCH] feat: surface ecc2 session handoff lineage --- ecc2/src/session/manager.rs | 60 ++++++++++++++++++++++++++++++++++--- ecc2/src/session/store.rs | 59 ++++++++++++++++++++++++++++++++++++ ecc2/src/tui/dashboard.rs | 50 +++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index b3ea2175..033bbba8 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -30,7 +30,12 @@ pub fn list_sessions(db: &StateStore) -> Result> { pub fn get_status(db: &StateStore, id: &str) -> Result { let session = resolve_session(db, id)?; - Ok(SessionStatus(session)) + let session_id = session.id.clone(); + Ok(SessionStatus { + session, + parent_session: db.latest_task_handoff_source(&session_id)?, + delegated_children: db.delegated_children(&session_id, 5)?, + }) } pub async fn stop_session(db: &StateStore, id: &str) -> Result<()> { @@ -449,15 +454,22 @@ async fn kill_process(pid: u32) -> Result<()> { } } -pub struct SessionStatus(Session); +pub struct SessionStatus { + session: Session, + parent_session: Option, + delegated_children: Vec, +} impl fmt::Display for SessionStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let s = &self.0; + let s = &self.session; writeln!(f, "Session: {}", s.id)?; writeln!(f, "Task: {}", s.task)?; writeln!(f, "Agent: {}", s.agent_type)?; writeln!(f, "State: {}", s.state)?; + if let Some(parent) = self.parent_session.as_ref() { + writeln!(f, "Parent: {}", parent)?; + } if let Some(pid) = s.pid { writeln!(f, "PID: {}", pid)?; } @@ -469,6 +481,9 @@ 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)?; + if !self.delegated_children.is_empty() { + writeln!(f, "Children: {}", self.delegated_children.join(", "))?; + } writeln!(f, "Created: {}", s.created_at)?; write!(f, "Updated: {}", s.updated_at) } @@ -848,7 +863,44 @@ mod tests { db.insert_session(&build_session("newer", SessionState::Idle, newer))?; let status = get_status(&db, "latest")?; - assert_eq!(status.0.id, "newer"); + assert_eq!(status.session.id, "newer"); + + Ok(()) + } + + #[test] + fn get_status_surfaces_handoff_lineage() -> Result<()> { + let tempdir = TestDir::new("manager-status-lineage")?; + let cfg = build_config(tempdir.path()); + 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("sibling", SessionState::Idle, now))?; + + db.send_message( + "parent", + "child", + "{\"task\":\"Review auth flow\",\"context\":\"Delegated from parent\"}", + "task_handoff", + )?; + db.send_message( + "parent", + "sibling", + "{\"task\":\"Check billing\",\"context\":\"Delegated from parent\"}", + "task_handoff", + )?; + + let status = get_status(&db, "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")?; + assert_eq!(child_status.parent_session.as_deref(), Some("parent")); Ok(()) } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 683726a0..e7688a5b 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -411,6 +411,40 @@ impl StateStore { Ok(updated) } + pub fn latest_task_handoff_source(&self, session_id: &str) -> Result> { + self.conn + .query_row( + "SELECT from_session + FROM messages + WHERE to_session = ?1 AND msg_type = 'task_handoff' + ORDER BY id DESC + LIMIT 1", + rusqlite::params![session_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(Into::into) + } + + pub fn delegated_children(&self, session_id: &str, limit: usize) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT to_session + FROM messages + WHERE from_session = ?1 AND msg_type = 'task_handoff' + GROUP BY to_session + ORDER BY MAX(id) DESC + LIMIT ?2", + )?; + + let children = stmt + .query_map(rusqlite::params![session_id, limit as i64], |row| { + row.get::<_, String>(0) + })? + .collect::, _>>()?; + + Ok(children) + } + pub fn append_output_line( &self, session_id: &str, @@ -725,6 +759,31 @@ mod tests { assert_eq!(unread_after.get("worker"), None); assert_eq!(unread_after.get("planner"), Some(&1)); + db.send_message( + "planner", + "worker-2", + "{\"task\":\"Review auth flow\",\"context\":\"Delegated from planner\"}", + "task_handoff", + )?; + db.send_message( + "planner", + "worker-3", + "{\"task\":\"Check billing\",\"context\":\"Delegated from planner\"}", + "task_handoff", + )?; + + assert_eq!( + db.latest_task_handoff_source("worker-2")?, + Some("planner".to_string()) + ); + assert_eq!( + db.delegated_children("planner", 10)?, + vec![ + "worker-3".to_string(), + "worker-2".to_string(), + ] + ); + Ok(()) } } diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index c7b97e50..f0bdcba9 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -36,6 +36,8 @@ pub struct Dashboard { session_output_cache: HashMap>, unread_message_counts: HashMap, selected_messages: Vec, + selected_parent_session: Option, + selected_child_sessions: Vec, logs: Vec, selected_diff_summary: Option, selected_pane: Pane, @@ -112,6 +114,8 @@ impl Dashboard { session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), selected_messages: Vec::new(), + selected_parent_session: None, + selected_child_sessions: Vec::new(), logs: Vec::new(), selected_diff_summary: None, selected_pane: Pane::Sessions, @@ -127,6 +131,7 @@ impl Dashboard { dashboard.sync_selected_output(); dashboard.sync_selected_diff(); dashboard.sync_selected_messages(); + dashboard.sync_selected_lineage(); dashboard.refresh_logs(); dashboard } @@ -474,6 +479,7 @@ impl Dashboard { self.sync_selected_output(); self.sync_selected_diff(); self.sync_selected_messages(); + self.sync_selected_lineage(); self.refresh_logs(); } Pane::Output => { @@ -507,6 +513,7 @@ impl Dashboard { self.sync_selected_output(); self.sync_selected_diff(); self.sync_selected_messages(); + self.sync_selected_lineage(); self.refresh_logs(); } Pane::Output => { @@ -584,6 +591,7 @@ impl Dashboard { self.sync_selected_output(); self.sync_selected_diff(); self.sync_selected_messages(); + self.sync_selected_lineage(); self.refresh_logs(); } @@ -685,6 +693,7 @@ impl Dashboard { self.sync_selected_output(); self.sync_selected_diff(); self.sync_selected_messages(); + self.sync_selected_lineage(); self.refresh_logs(); } @@ -769,6 +778,30 @@ impl Dashboard { }; } + fn sync_selected_lineage(&mut self) { + let Some(session_id) = self.selected_session_id().map(ToOwned::to_owned) else { + self.selected_parent_session = None; + self.selected_child_sessions.clear(); + return; + }; + + self.selected_parent_session = match self.db.latest_task_handoff_source(&session_id) { + Ok(parent) => parent, + Err(error) => { + tracing::warn!("Failed to load session parent linkage: {error}"); + None + } + }; + + self.selected_child_sessions = match self.db.delegated_children(&session_id, 3) { + Ok(children) => children, + Err(error) => { + tracing::warn!("Failed to load delegated child sessions: {error}"); + Vec::new() + } + }; + } + fn selected_session_id(&self) -> Option<&str> { self.sessions .get(self.selected_session) @@ -854,6 +887,21 @@ impl Dashboard { format!("Task {}", session.task), ]; + if let Some(parent) = self.selected_parent_session.as_ref() { + lines.push(format!("Delegated from {}", format_session_id(parent))); + } + + if !self.selected_child_sessions.is_empty() { + lines.push(format!( + "Delegates {}", + self.selected_child_sessions + .iter() + .map(|session_id| format_session_id(session_id)) + .collect::>() + .join(", ") + )); + } + if let Some(worktree) = session.worktree.as_ref() { lines.push(format!( "Branch {} | Base {}", @@ -1770,6 +1818,8 @@ mod tests { session_output_cache: HashMap::new(), unread_message_counts: HashMap::new(), selected_messages: Vec::new(), + selected_parent_session: None, + selected_child_sessions: Vec::new(), logs: Vec::new(), selected_diff_summary: None, selected_pane: Pane::Sessions,