From cf8b5473c7d9fb80f859dcfb270160c0c9bd5a21 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 9 Apr 2026 19:54:28 -0700 Subject: [PATCH] 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"),