feat: group ecc2 sessions by project and task

This commit is contained in:
Affaan Mustafa
2026-04-09 19:54:28 -07:00
parent 181bc26b29
commit cf8b5473c7
8 changed files with 540 additions and 38 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<String> {
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<String> {
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<Vec<Session>> {
@@ -127,6 +149,27 @@ pub async fn assign_session(
task: &str,
agent_type: &str,
use_worktree: bool,
) -> Result<AssignmentOutcome> {
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<AssignmentOutcome> {
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<AssignmentOutcome> {
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<String> {
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<String> {
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<Session> {
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<String> {
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,

View File

@@ -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<String> {
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<String>,
pub task_group: Option<String>,
}

View File

@@ -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,

View File

@@ -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<Vec<Session>> {
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<String> = 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<String> = 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<u32>>(5)?,
pid: row.get::<_, Option<u32>>(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,

View File

@@ -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<String, worktree::WorktreeHealth>,
suppress_inbox_attention: bool,
) -> Self {
let projects = sessions
.iter()
.map(|session| session.project.as_str())
.collect::<HashSet<_>>()
.len();
let task_groups = sessions
.iter()
.map(|session| (session.project.as_str(), session.task_group.as_str()))
.collect::<HashSet<_>>()
.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<String>,
task_group_label: Option<String>,
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"),