diff --git a/ecc2/src/session/mod.rs b/ecc2/src/session/mod.rs index 3531b6ea..902ea1f9 100644 --- a/ecc2/src/session/mod.rs +++ b/ecc2/src/session/mod.rs @@ -411,6 +411,27 @@ pub struct SessionMetrics { pub cost_usd: f64, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionBoardMeta { + pub lane: String, + pub project: Option, + pub feature: Option, + pub issue: Option, + pub row_label: Option, + pub previous_lane: Option, + pub previous_row_label: Option, + pub column_index: i64, + pub row_index: i64, + pub stack_index: i64, + pub progress_percent: i64, + pub status_detail: Option, + pub movement_note: Option, + pub activity_kind: Option, + pub activity_note: Option, + pub handoff_backlog: i64, + pub conflict_signal: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionMessage { pub id: i64, diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index 6d808784..21c1bd5d 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -19,8 +19,8 @@ use super::{ ContextGraphObservation, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats, ContextObservationPriority, DecisionLogEntry, FileActivityAction, FileActivityEntry, HarnessKind, RemoteDispatchKind, RemoteDispatchRequest, RemoteDispatchStatus, ScheduledTask, - Session, SessionAgentProfile, SessionHarnessInfo, SessionMessage, SessionMetrics, SessionState, - WorktreeInfo, + Session, SessionAgentProfile, SessionBoardMeta, SessionHarnessInfo, SessionMessage, + SessionMetrics, SessionState, WorktreeInfo, }; pub struct StateStore { @@ -241,6 +241,28 @@ impl StateStore { timestamp TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS session_board ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + lane TEXT NOT NULL, + project TEXT, + feature TEXT, + issue TEXT, + row_label TEXT, + previous_lane TEXT, + previous_row_label TEXT, + column_index INTEGER NOT NULL DEFAULT 0, + row_index INTEGER NOT NULL DEFAULT 0, + stack_index INTEGER NOT NULL DEFAULT 0, + progress_percent INTEGER NOT NULL DEFAULT 0, + status_detail TEXT, + movement_note TEXT, + activity_kind TEXT, + activity_note TEXT, + handoff_backlog INTEGER NOT NULL DEFAULT 0, + conflict_signal TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS decision_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, @@ -386,6 +408,9 @@ impl StateStore { CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session, read); CREATE INDEX IF NOT EXISTS idx_session_output_session ON session_output(session_id, id); + CREATE INDEX IF NOT EXISTS idx_session_board_lane ON session_board(lane); + CREATE INDEX IF NOT EXISTS idx_session_board_coords + ON session_board(column_index, row_index, stack_index); CREATE INDEX IF NOT EXISTS idx_decision_log_session ON decision_log(session_id, timestamp, id); CREATE INDEX IF NOT EXISTS idx_context_graph_entities_session @@ -409,6 +434,8 @@ impl StateStore { ", )?; self.ensure_session_columns()?; + self.ensure_session_board_columns()?; + self.refresh_session_board_meta()?; Ok(()) } @@ -482,6 +509,51 @@ impl StateStore { .context("Failed to add output_tokens column to sessions table")?; } + if !self.has_column("sessions", "tokens_used")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN tokens_used INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add tokens_used column to sessions table")?; + } + + if !self.has_column("sessions", "tool_calls")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN tool_calls INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add tool_calls column to sessions table")?; + } + + if !self.has_column("sessions", "files_changed")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN files_changed INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add files_changed column to sessions table")?; + } + + if !self.has_column("sessions", "duration_secs")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN duration_secs INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add duration_secs column to sessions table")?; + } + + if !self.has_column("sessions", "cost_usd")? { + self.conn + .execute( + "ALTER TABLE sessions ADD COLUMN cost_usd REAL NOT NULL DEFAULT 0.0", + [], + ) + .context("Failed to add cost_usd column to sessions table")?; + } + if !self.has_column("sessions", "last_heartbeat_at")? { self.conn .execute("ALTER TABLE sessions ADD COLUMN last_heartbeat_at TEXT", []) @@ -496,6 +568,24 @@ impl StateStore { .context("Failed to backfill last_heartbeat_at column")?; } + if !self.has_column("sessions", "worktree_path")? { + self.conn + .execute("ALTER TABLE sessions ADD COLUMN worktree_path TEXT", []) + .context("Failed to add worktree_path column to sessions table")?; + } + + if !self.has_column("sessions", "worktree_branch")? { + self.conn + .execute("ALTER TABLE sessions ADD COLUMN worktree_branch TEXT", []) + .context("Failed to add worktree_branch column to sessions table")?; + } + + if !self.has_column("sessions", "worktree_base")? { + self.conn + .execute("ALTER TABLE sessions ADD COLUMN worktree_base TEXT", []) + .context("Failed to add worktree_base column to sessions table")?; + } + if !self.has_column("tool_log", "hook_event_id")? { self.conn .execute("ALTER TABLE tool_log ADD COLUMN hook_event_id TEXT", []) @@ -712,6 +802,103 @@ impl StateStore { Ok(()) } + fn ensure_session_board_columns(&self) -> Result<()> { + if !self.has_column("session_board", "row_label")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN row_label TEXT", []) + .context("Failed to add row_label column to session_board table")?; + } + + if !self.has_column("session_board", "previous_lane")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN previous_lane TEXT", []) + .context("Failed to add previous_lane column to session_board table")?; + } + + if !self.has_column("session_board", "previous_row_label")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN previous_row_label TEXT", []) + .context("Failed to add previous_row_label column to session_board table")?; + } + + if !self.has_column("session_board", "column_index")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN column_index INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add column_index column to session_board table")?; + } + + if !self.has_column("session_board", "row_index")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN row_index INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add row_index column to session_board table")?; + } + + if !self.has_column("session_board", "stack_index")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN stack_index INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add stack_index column to session_board table")?; + } + + if !self.has_column("session_board", "progress_percent")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN progress_percent INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add progress_percent column to session_board table")?; + } + + if !self.has_column("session_board", "status_detail")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN status_detail TEXT", []) + .context("Failed to add status_detail column to session_board table")?; + } + + if !self.has_column("session_board", "movement_note")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN movement_note TEXT", []) + .context("Failed to add movement_note column to session_board table")?; + } + + if !self.has_column("session_board", "activity_kind")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN activity_kind TEXT", []) + .context("Failed to add activity_kind column to session_board table")?; + } + + if !self.has_column("session_board", "activity_note")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN activity_note TEXT", []) + .context("Failed to add activity_note column to session_board table")?; + } + + if !self.has_column("session_board", "handoff_backlog")? { + self.conn + .execute( + "ALTER TABLE session_board ADD COLUMN handoff_backlog INTEGER NOT NULL DEFAULT 0", + [], + ) + .context("Failed to add handoff_backlog column to session_board table")?; + } + + if !self.has_column("session_board", "conflict_signal")? { + self.conn + .execute("ALTER TABLE session_board ADD COLUMN conflict_signal TEXT", []) + .context("Failed to add conflict_signal column to session_board table")?; + } + + Ok(()) + } + fn has_column(&self, table: &str, column: &str) -> Result { let pragma = format!("PRAGMA table_info({table})"); let mut stmt = self.conn.prepare(&pragma)?; @@ -789,6 +976,7 @@ impl StateStore { session.last_heartbeat_at.to_rfc3339(), ], )?; + self.refresh_session_board_meta()?; Ok(()) } @@ -909,6 +1097,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -949,6 +1138,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -970,6 +1160,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -1003,6 +1194,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -1030,6 +1222,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -1386,6 +1579,7 @@ impl StateStore { session_id, ], )?; + self.refresh_session_board_meta()?; Ok(()) } @@ -1437,6 +1631,7 @@ impl StateStore { } } + self.refresh_session_board_meta()?; Ok(()) } @@ -1522,6 +1717,7 @@ impl StateStore { )?; } + self.refresh_session_board_meta()?; Ok(()) } @@ -1876,6 +2072,7 @@ impl StateStore { WHERE id = ?2", rusqlite::params![chrono::Utc::now().to_rfc3339(), session_id], )?; + self.refresh_session_board_meta()?; Ok(()) } @@ -1979,6 +2176,46 @@ impl StateStore { Ok(harnesses) } + pub fn list_session_board_meta(&self) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT session_id, lane, project, feature, issue, row_label, + previous_lane, previous_row_label, + column_index, row_index, stack_index, progress_percent, + status_detail, movement_note, activity_kind, activity_note, + handoff_backlog, conflict_signal + FROM session_board", + )?; + + let meta = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + SessionBoardMeta { + lane: row.get(1)?, + project: row.get(2)?, + feature: row.get(3)?, + issue: row.get(4)?, + row_label: row.get(5)?, + previous_lane: row.get(6)?, + previous_row_label: row.get(7)?, + column_index: row.get(8)?, + row_index: row.get(9)?, + stack_index: row.get(10)?, + progress_percent: row.get(11)?, + status_detail: row.get(12)?, + movement_note: row.get(13)?, + activity_kind: row.get(14)?, + activity_note: row.get(15)?, + handoff_backlog: row.get(16)?, + conflict_signal: row.get(17)?, + }, + )) + })? + .collect::, _>>()?; + + Ok(meta) + } + pub fn get_session_harness_info(&self, session_id: &str) -> Result> { let mut stmt = self.conn.prepare( "SELECT harness, detected_harnesses_json, agent_type, working_dir @@ -2008,6 +2245,94 @@ impl StateStore { Ok(self.list_sessions()?.into_iter().next()) } + fn refresh_session_board_meta(&self) -> Result<()> { + self.conn.execute( + "DELETE FROM session_board + WHERE session_id NOT IN (SELECT id FROM sessions)", + [], + )?; + + let existing_meta = self.list_session_board_meta().unwrap_or_default(); + let sessions = self.list_sessions()?; + let board_meta = derive_board_meta_map(&sessions); + let now = chrono::Utc::now().to_rfc3339(); + + for session in sessions { + let mut meta = board_meta + .get(&session.id) + .cloned() + .unwrap_or_else(|| SessionBoardMeta { + lane: board_lane_for_state(&session.state).to_string(), + ..SessionBoardMeta::default() + }); + if let Some(previous) = existing_meta.get(&session.id) { + annotate_board_motion(&mut meta, previous); + } + if let Some((activity_kind, activity_note)) = + self.latest_task_handoff_activity(&session.id)? + { + meta.activity_kind = Some(activity_kind); + meta.activity_note = Some(activity_note); + } else { + meta.activity_kind = None; + meta.activity_note = None; + } + meta.handoff_backlog = self.unread_task_handoff_count(&session.id)? as i64; + + self.conn.execute( + "INSERT INTO session_board ( + session_id, lane, project, feature, issue, row_label, + previous_lane, previous_row_label, + column_index, row_index, stack_index, progress_percent, + status_detail, movement_note, activity_kind, activity_note, + handoff_backlog, conflict_signal, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19) + ON CONFLICT(session_id) DO UPDATE SET + lane = excluded.lane, + project = excluded.project, + feature = excluded.feature, + issue = excluded.issue, + row_label = excluded.row_label, + previous_lane = excluded.previous_lane, + previous_row_label = excluded.previous_row_label, + column_index = excluded.column_index, + row_index = excluded.row_index, + stack_index = excluded.stack_index, + progress_percent = excluded.progress_percent, + status_detail = excluded.status_detail, + movement_note = excluded.movement_note, + activity_kind = excluded.activity_kind, + activity_note = excluded.activity_note, + handoff_backlog = excluded.handoff_backlog, + conflict_signal = excluded.conflict_signal, + updated_at = excluded.updated_at", + rusqlite::params![ + session.id, + meta.lane, + meta.project, + meta.feature, + meta.issue, + meta.row_label, + meta.previous_lane, + meta.previous_row_label, + meta.column_index, + meta.row_index, + meta.stack_index, + meta.progress_percent, + meta.status_detail, + meta.movement_note, + meta.activity_kind, + meta.activity_note, + meta.handoff_backlog, + meta.conflict_signal, + now, + ], + )?; + } + + Ok(()) + } + pub fn get_session(&self, id: &str) -> Result> { let sessions = self.list_sessions()?; Ok(sessions @@ -2038,6 +2363,7 @@ impl StateStore { anyhow::bail!("Session not found: {session_id}"); } + self.refresh_session_board_meta()?; Ok(()) } @@ -2048,6 +2374,7 @@ impl StateStore { rusqlite::params![from, to, content, msg_type, chrono::Utc::now().to_rfc3339()], )?; self.sync_context_graph_message(from, to, content, msg_type)?; + self.refresh_session_board_meta()?; Ok(()) } @@ -2318,6 +2645,7 @@ impl StateStore { rusqlite::params![session_id], )?; + self.refresh_session_board_meta()?; Ok(updated) } @@ -2327,6 +2655,7 @@ impl StateStore { rusqlite::params![message_id], )?; + self.refresh_session_board_meta()?; Ok(updated) } @@ -2345,6 +2674,75 @@ impl StateStore { .map_err(Into::into) } + fn latest_task_handoff_activity( + &self, + session_id: &str, + ) -> Result> { + let latest_handoff = self + .conn + .query_row( + "SELECT from_session, to_session, content + FROM messages + WHERE msg_type = 'task_handoff' + AND (from_session = ?1 OR to_session = ?1) + ORDER BY id DESC + LIMIT 1", + rusqlite::params![session_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()?; + + Ok(latest_handoff.and_then(|(from_session, to_session, content)| { + let context = extract_task_handoff_context(&content)?; + let routing_suffix = routing_activity_suffix(&context); + + if session_id == to_session { + Some(( + "received".to_string(), + format!( + "Received from {}{}", + short_session_ref(&from_session), + routing_suffix + .map(|value| format!(" | {value}")) + .unwrap_or_default() + ), + )) + } else if session_id == from_session { + let (kind, base) = match routing_suffix { + Some("spawned") => { + ("spawned", format!("Spawned {}", short_session_ref(&to_session))) + } + Some("spawned fallback") => ( + "spawned_fallback", + format!("Spawned fallback {}", short_session_ref(&to_session)), + ), + _ => ( + "delegated", + format!("Delegated to {}", short_session_ref(&to_session)), + ), + }; + Some(( + kind.to_string(), + format!( + "{base}{}", + routing_suffix + .filter(|value| !value.starts_with("spawned")) + .map(|value| format!(" | {value}")) + .unwrap_or_default() + ), + )) + } else { + None + } + })) + } + pub fn insert_decision( &self, session_id: &str, @@ -3862,6 +4260,411 @@ fn file_activity_action_value(action: &FileActivityAction) -> &'static str { } } +fn board_lane_for_state(state: &SessionState) -> &'static str { + match state { + SessionState::Pending => "Inbox", + SessionState::Running => "In Progress", + SessionState::Idle => "Review", + SessionState::Stale | SessionState::Failed => "Blocked", + SessionState::Completed => "Done", + SessionState::Stopped => "Stopped", + } +} + +fn derive_board_scope(session: &Session) -> (Option, Option, Option) { + let project = extract_labeled_scope(&session.task, &["project", "roadmap", "epic"]); + let feature = extract_labeled_scope(&session.task, &["feature", "workflow", "flow"]); + let issue = extract_issue_reference(&session.task); + (project, feature, issue) +} + +fn derive_board_meta_map(sessions: &[Session]) -> HashMap { + let conflict_signals = derive_board_conflict_signals(sessions); + let scopes = sessions + .iter() + .map(|session| (session.id.clone(), derive_board_scope(session))) + .collect::>(); + + let mut row_specs = scopes + .iter() + .map(|(session_id, (project, feature, issue))| { + let row_label = issue + .clone() + .or_else(|| feature.clone()) + .or_else(|| project.clone()) + .or_else(|| { + sessions + .iter() + .find(|session| &session.id == session_id) + .and_then(|session| session.worktree.as_ref()) + .map(|worktree| worktree.branch.clone()) + }) + .unwrap_or_else(|| "General".to_string()); + + let row_rank = if issue.is_some() { + 0 + } else if feature.is_some() { + 1 + } else if project.is_some() { + 2 + } else { + 3 + }; + + (session_id.clone(), row_label, row_rank) + }) + .collect::>(); + + row_specs.sort_by(|left, right| { + left.2 + .cmp(&right.2) + .then_with(|| left.1.to_ascii_lowercase().cmp(&right.1.to_ascii_lowercase())) + .then_with(|| left.0.cmp(&right.0)) + }); + + let mut row_indices = HashMap::new(); + let mut next_row_index = 0_i64; + for (_, row_label, row_rank) in &row_specs { + let key = (*row_rank, row_label.clone()); + if let std::collections::hash_map::Entry::Vacant(entry) = row_indices.entry(key) { + entry.insert(next_row_index); + next_row_index += 1; + } + } + + let mut stack_counts: HashMap<(i64, i64), i64> = HashMap::new(); + let mut board_meta = HashMap::new(); + + for session in sessions { + let (project, feature, issue) = scopes + .get(&session.id) + .cloned() + .unwrap_or((None, None, None)); + let (_, row_label, row_rank) = row_specs + .iter() + .find(|(session_id, _, _)| session_id == &session.id) + .cloned() + .unwrap_or_else(|| (session.id.clone(), "General".to_string(), 4)); + let column_index = board_column_index(&session.state); + let row_index = row_indices + .get(&(row_rank, row_label.clone())) + .copied() + .unwrap_or_default(); + let stack_index = { + let entry = stack_counts.entry((column_index, row_index)).or_insert(0); + let current = *entry; + *entry += 1; + current + }; + + board_meta.insert( + session.id.clone(), + SessionBoardMeta { + lane: board_lane_for_state(&session.state).to_string(), + project, + feature, + issue, + row_label: Some(row_label), + previous_lane: None, + previous_row_label: None, + column_index, + row_index, + stack_index, + progress_percent: derive_board_progress_percent(session), + status_detail: derive_board_status_detail(session), + movement_note: None, + activity_kind: None, + activity_note: None, + handoff_backlog: 0, + conflict_signal: conflict_signals.get(&session.id).cloned(), + }, + ); + } + + board_meta +} + +fn board_column_index(state: &SessionState) -> i64 { + match state { + SessionState::Pending => 0, + SessionState::Running => 1, + SessionState::Idle => 2, + SessionState::Stale | SessionState::Failed => 3, + SessionState::Completed => 4, + SessionState::Stopped => 5, + } +} + +fn derive_board_progress_percent(session: &Session) -> i64 { + match session.state { + SessionState::Pending => 10, + SessionState::Running => { + if session.metrics.files_changed > 0 { + 60 + } else if session.worktree.is_some() || session.metrics.tool_calls > 0 { + 45 + } else { + 25 + } + } + SessionState::Idle => 85, + SessionState::Stale => 55, + SessionState::Completed => 100, + SessionState::Failed => 65, + SessionState::Stopped => 0, + } +} + +fn derive_board_status_detail(session: &Session) -> Option { + let detail = match session.state { + SessionState::Pending => "Queued", + SessionState::Running => { + if session.metrics.files_changed > 0 { + "Actively editing" + } else if session.worktree.is_some() { + "Scoping" + } else { + "Booting" + } + } + SessionState::Idle => "Awaiting review", + SessionState::Stale => "Needs heartbeat", + SessionState::Completed => "Task complete", + SessionState::Failed => "Blocked by failure", + SessionState::Stopped => "Stopped", + }; + + Some(detail.to_string()) +} + +fn annotate_board_motion(current: &mut SessionBoardMeta, previous: &SessionBoardMeta) { + if previous.lane != current.lane { + current.previous_lane = Some(previous.lane.clone()); + current.previous_row_label = previous.row_label.clone(); + current.movement_note = Some(match current.lane.as_str() { + "Blocked" => "Blocked".to_string(), + "Done" => "Completed".to_string(), + _ => format!("Moved {} -> {}", previous.lane, current.lane), + }); + return; + } + + if previous.row_label != current.row_label { + let from = previous + .row_label + .clone() + .unwrap_or_else(|| "General".to_string()); + let to = current + .row_label + .clone() + .unwrap_or_else(|| "General".to_string()); + current.previous_lane = Some(previous.lane.clone()); + current.previous_row_label = previous.row_label.clone(); + current.movement_note = Some(format!("Retargeted {from} -> {to}")); + } +} + +fn extract_labeled_scope(task: &str, labels: &[&str]) -> Option { + let lowered = task.to_ascii_lowercase(); + + for label in labels { + if let Some(index) = lowered.find(label) { + let mut tail = task.get(index + label.len()..)?.trim_start_matches([' ', ':', '-', '#']); + if tail.is_empty() { + continue; + } + + if let Some((candidate, _)) = tail + .split_once('|') + .or_else(|| tail.split_once(';')) + .or_else(|| tail.split_once(',')) + .or_else(|| tail.split_once('\n')) + { + tail = candidate; + } + + let words = tail + .split_whitespace() + .take(4) + .collect::>() + .join(" ") + .trim() + .trim_matches(|ch: char| matches!(ch, '.' | ',' | ';' | ':' | '|')) + .to_string(); + + if !words.is_empty() { + return Some(words); + } + } + } + + None +} + +fn extract_issue_reference(task: &str) -> Option { + let tokens = task + .split(|ch: char| ch.is_whitespace() || matches!(ch, ',' | ';' | ':' | '(' | ')')) + .filter(|token| !token.is_empty()); + + for token in tokens { + if let Some(stripped) = token.strip_prefix('#') { + if !stripped.is_empty() && stripped.chars().all(|ch| ch.is_ascii_digit()) { + return Some(format!("#{stripped}")); + } + } + + if let Some((prefix, suffix)) = token.split_once('-') { + if !prefix.is_empty() + && !suffix.is_empty() + && prefix.chars().all(|ch| ch.is_ascii_uppercase()) + && suffix.chars().all(|ch| ch.is_ascii_digit()) + { + return Some(token.trim_matches('.').to_string()); + } + } + } + + None +} + +fn derive_board_conflict_signals(sessions: &[Session]) -> HashMap { + let active_sessions = sessions + .iter() + .filter(|session| { + matches!( + session.state, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + ) + }) + .collect::>(); + + let mut sessions_by_branch: HashMap> = HashMap::new(); + let mut sessions_by_task: HashMap> = HashMap::new(); + let mut sessions_by_scope: HashMap> = HashMap::new(); + + for session in active_sessions { + if let Some(worktree) = session.worktree.as_ref() { + sessions_by_branch + .entry(worktree.branch.clone()) + .or_default() + .push(session); + } + + sessions_by_task + .entry(session.task.trim().to_ascii_lowercase()) + .or_default() + .push(session); + + let (project, feature, issue) = derive_board_scope(session); + if let Some(scope) = issue.or(feature).or(project).filter(|scope| !scope.is_empty()) { + sessions_by_scope.entry(scope).or_default().push(session); + } + } + + let mut signals = HashMap::new(); + + for (branch, grouped_sessions) in sessions_by_branch { + if grouped_sessions.len() < 2 { + continue; + } + for session in grouped_sessions { + append_conflict_signal(&mut signals, &session.id, format!("Shared branch {branch}")); + } + } + + for (task, grouped_sessions) in sessions_by_task { + if grouped_sessions.len() < 2 { + continue; + } + for session in grouped_sessions { + append_conflict_signal( + &mut signals, + &session.id, + format!("Shared task {}", truncate_task_for_signal(&task)), + ); + } + } + + for (scope, grouped_sessions) in sessions_by_scope { + if grouped_sessions.len() < 2 { + continue; + } + for session in grouped_sessions { + append_conflict_signal( + &mut signals, + &session.id, + format!("Shared scope {}", truncate_task_for_signal(&scope)), + ); + } + } + + signals +} + +fn append_conflict_signal( + signals: &mut HashMap, + session_id: &str, + next_signal: String, +) { + let entry = signals.entry(session_id.to_string()).or_default(); + if entry.is_empty() { + *entry = next_signal; + return; + } + + if !entry.split("; ").any(|existing| existing == next_signal) { + entry.push_str("; "); + entry.push_str(&next_signal); + } +} + +fn short_session_ref(session_id: &str) -> String { + if session_id.chars().count() <= 12 { + session_id.to_string() + } else { + session_id.chars().take(8).collect() + } +} + +fn routing_activity_suffix(context: &str) -> Option<&'static str> { + let normalized = context.to_ascii_lowercase(); + if normalized.contains("reused idle delegate") { + Some("reused idle") + } else if normalized.contains("reused active delegate") { + Some("reused active") + } else if normalized.contains("spawned fallback delegate") { + Some("spawned fallback") + } else if normalized.contains("spawned new delegate") { + Some("spawned") + } else { + None + } +} + +fn extract_task_handoff_context(content: &str) -> Option { + if let Some(crate::comms::MessageType::TaskHandoff { context, .. }) = crate::comms::parse(content) + { + return Some(context); + } + + let value: serde_json::Value = serde_json::from_str(content).ok()?; + value + .get("context") + .and_then(|context| context.as_str()) + .map(ToOwned::to_owned) +} + +fn truncate_task_for_signal(task: &str) -> String { + const LIMIT: usize = 28; + let trimmed = task.trim(); + let count = trimmed.chars().count(); + if count <= LIMIT { + trimmed.to_string() + } else { + format!("{}...", trimmed.chars().take(LIMIT - 3).collect::()) + } +} + fn map_conflict_incident(row: &rusqlite::Row<'_>) -> rusqlite::Result { let created_at = parse_timestamp_column(row.get::<_, String>(11)?, 11)?; let updated_at = parse_timestamp_column(row.get::<_, String>(12)?, 12)?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index ebdf2e53..831e9e74 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -24,7 +24,7 @@ use crate::session::output::{ use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; use crate::session::{ ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping, - SessionHarnessInfo, SessionMessage, SessionState, + SessionBoardMeta, SessionHarnessInfo, SessionMessage, SessionState, }; use crate::worktree; @@ -93,6 +93,7 @@ pub struct Dashboard { approval_queue_counts: HashMap, approval_queue_preview: Vec, handoff_backlog_counts: HashMap, + board_meta_by_session: HashMap, worktree_health_by_session: HashMap, global_handoff_backlog_leads: usize, global_handoff_backlog_messages: usize, @@ -179,6 +180,7 @@ enum Pane { Sessions, Output, Metrics, + Board, Log, } @@ -333,7 +335,7 @@ impl PaneAreas { match pane { Pane::Sessions => self.sessions = area, Pane::Output => self.output = Some(area), - Pane::Metrics => self.metrics = Some(area), + Pane::Metrics | Pane::Board => self.metrics = Some(area), Pane::Log => self.log = Some(area), } } @@ -553,6 +555,7 @@ impl Dashboard { approval_queue_counts: HashMap::new(), approval_queue_preview: Vec::new(), handoff_backlog_counts: HashMap::new(), + board_meta_by_session: HashMap::new(), worktree_health_by_session: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0, @@ -619,6 +622,7 @@ impl Dashboard { dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default(); dashboard.sync_approval_queue(); dashboard.sync_handoff_backlog_counts(); + dashboard.sync_board_meta(); dashboard.sync_global_handoff_backlog(); dashboard.sync_selected_output(); dashboard.sync_selected_diff(); @@ -1294,10 +1298,18 @@ impl Dashboard { } fn render_metrics(&mut self, frame: &mut Frame, area: Rect) { + let side_pane = if self.selected_pane == Pane::Board { + Pane::Board + } else { + Pane::Metrics + }; let block = Block::default() .borders(Borders::ALL) - .title(" Metrics ") - .border_style(self.pane_border_style(Pane::Metrics)); + .title(match side_pane { + Pane::Board => " Board ", + _ => " Metrics ", + }) + .border_style(self.pane_border_style(side_pane)); let inner = block.inner(area); frame.render_widget(block, area); @@ -1305,6 +1317,17 @@ impl Dashboard { return; } + if side_pane == Pane::Board { + frame.render_widget( + Paragraph::new(self.board_text()) + .scroll((self.metrics_scroll_offset as u16, 0)) + .wrap(Wrap { trim: true }), + inner, + ); + self.sync_metrics_scroll(inner.height as usize); + return; + } + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -1620,7 +1643,7 @@ impl Dashboard { return; }; - if !self.visible_panes().contains(&target) { + if !self.is_pane_visible(target) { self.set_operator_note(format!( "{} pane is not visible", target.title().to_lowercase() @@ -1702,6 +1725,7 @@ impl Dashboard { crossterm::event::KeyCode::Char('2') => self.focus_pane_number(2), crossterm::event::KeyCode::Char('3') => self.focus_pane_number(3), crossterm::event::KeyCode::Char('4') => self.focus_pane_number(4), + crossterm::event::KeyCode::Char('5') => self.focus_pane_number(5), crossterm::event::KeyCode::Char('+') | crossterm::event::KeyCode::Char('=') => { self.increase_pane_size() } @@ -2017,7 +2041,7 @@ impl Dashboard { self.output_scroll_offset = self.output_scroll_offset.saturating_add(1); } } - Pane::Metrics => { + Pane::Metrics | Pane::Board => { let max_scroll = self.max_metrics_scroll(); self.metrics_scroll_offset = self.metrics_scroll_offset.saturating_add(1).min(max_scroll); @@ -2057,7 +2081,7 @@ impl Dashboard { self.output_scroll_offset = self.output_scroll_offset.saturating_sub(1); } - Pane::Metrics => { + Pane::Metrics | Pane::Board => { self.metrics_scroll_offset = self.metrics_scroll_offset.saturating_sub(1); } Pane::Log => { @@ -4073,6 +4097,7 @@ impl Dashboard { }; self.sync_approval_queue(); self.sync_handoff_backlog_counts(); + self.sync_board_meta(); self.sync_worktree_health_by_session(); self.sync_session_state_notifications(); self.sync_approval_notifications(); @@ -4478,7 +4503,7 @@ impl Dashboard { } fn ensure_selected_pane_visible(&mut self) { - if !self.visible_panes().contains(&self.selected_pane) { + if !self.is_pane_visible(self.selected_pane) { self.selected_pane = Pane::Sessions; } } @@ -4581,6 +4606,16 @@ impl Dashboard { } } + fn sync_board_meta(&mut self) { + self.board_meta_by_session = match self.db.list_session_board_meta() { + Ok(meta) => meta, + Err(error) => { + tracing::warn!("Failed to refresh board metadata: {error}"); + HashMap::new() + } + }; + } + fn sync_worktree_health_by_session(&mut self) { self.worktree_health_by_session.clear(); for session in &self.sessions { @@ -6497,6 +6532,268 @@ impl Dashboard { } } + fn board_text(&self) -> String { + if self.sessions.is_empty() { + return "No sessions available.\n\nStart a session to populate the board.".to_string(); + } + + let mut lines = Vec::new(); + lines.push(format!("Board snapshot | {} sessions", self.sessions.len())); + + if let Some(session) = self.sessions.get(self.selected_session) { + let meta = self.board_meta_by_session.get(&session.id); + let branch = session_branch(session); + lines.push(format!( + "Focus {} {} | {} | {}{}", + board_presence_marker(session), + board_codename(session), + meta.map(|meta| meta.lane.as_str()) + .unwrap_or_else(|| board_lane_label(&session.state)), + format_session_id(&session.id), + if branch == "-" { + String::new() + } else { + format!(" | {branch}") + } + )); + lines.push(format!("Task {}", truncate_for_dashboard(&session.task, 48))); + if let Some(meta) = meta { + lines.push(format!( + "Progress {:>3}% {}", + meta.progress_percent, + board_progress_bar(meta.progress_percent) + )); + if let Some(status_detail) = meta.status_detail.as_ref() { + lines.push(format!("Status {status_detail}")); + } + if let Some(movement_note) = meta.movement_note.as_ref() { + lines.push(format!("Event {movement_note}")); + } + if meta.handoff_backlog > 0 { + lines.push(format!("Inbox {} handoff(s)", meta.handoff_backlog)); + } + if let Some(activity_note) = meta.activity_note.as_ref() { + lines.push(format!("Route {activity_note}")); + } + lines.push(format!( + "Coords C{} R{} S{}", + meta.column_index + 1, + meta.row_index + 1, + meta.stack_index + 1 + )); + if let Some(row_label) = meta.row_label.as_ref() { + lines.push(format!("Row {row_label}")); + } + if let Some(project) = meta.project.as_ref() { + lines.push(format!("Project {project}")); + } + if let Some(feature) = meta.feature.as_ref() { + lines.push(format!("Feature {feature}")); + } + if let Some(issue) = meta.issue.as_ref() { + lines.push(format!("Issue {issue}")); + } + } + } + + let overlap_risks = self.board_overlap_risks(); + if overlap_risks.is_empty() { + lines.push("Overlap risk clear".to_string()); + } else { + lines.push("Overlap risk".to_string()); + for risk in overlap_risks { + lines.push(format!("- {risk}")); + } + } + + let lanes = ["Inbox", "In Progress", "Review", "Blocked", "Done", "Stopped"]; + for label in lanes { + let mut lane_sessions = self + .sessions + .iter() + .filter_map(|session| { + let lane = self + .board_meta_by_session + .get(&session.id) + .map(|meta| meta.lane.as_str()) + .unwrap_or_else(|| board_lane_label(&session.state)); + if lane == label { + Some((session, self.board_meta_by_session.get(&session.id))) + } else { + None + } + }) + .collect::>(); + if lane_sessions.is_empty() { + continue; + } + + let mut row_risks: HashMap<(i64, String), Vec> = HashMap::new(); + let mut row_backlogs: HashMap<(i64, String), i64> = HashMap::new(); + for (_, meta) in &lane_sessions { + let Some(meta) = meta else { + continue; + }; + let key = ( + meta.row_index, + meta.row_label + .clone() + .unwrap_or_else(|| "General".to_string()), + ); + if let Some(conflict_signal) = meta.conflict_signal.as_ref() { + let entry = row_risks.entry(key.clone()).or_default(); + for risk in conflict_signal.split("; ") { + if !entry.iter().any(|existing| existing == risk) { + entry.push(risk.to_string()); + } + } + } + if meta.handoff_backlog > 0 { + *row_backlogs.entry(key).or_default() += meta.handoff_backlog; + } + } + + lane_sessions.sort_by(|left, right| { + let left_meta = left.1.cloned().unwrap_or_default(); + let right_meta = right.1.cloned().unwrap_or_default(); + left_meta + .row_index + .cmp(&right_meta.row_index) + .then_with(|| left_meta.stack_index.cmp(&right_meta.stack_index)) + .then_with(|| left.0.id.cmp(&right.0.id)) + }); + + lines.push(String::new()); + lines.push(format!("{label} ({})", lane_sessions.len())); + let mut current_row: Option = None; + for (session, meta) in lane_sessions.into_iter().take(6) { + let meta = meta.cloned().unwrap_or_default(); + let row_label = meta + .row_label + .clone() + .unwrap_or_else(|| "General".to_string()); + if current_row.as_ref() != Some(&row_label) { + current_row = Some(row_label.clone()); + let row_key = (meta.row_index, row_label.clone()); + let row_conflict_summary = row_risks + .get(&row_key) + .filter(|risks| !risks.is_empty()) + .map(|risks| truncate_for_dashboard(&risks.join(" + "), 42)); + let row_backlog = row_backlogs.get(&row_key).copied().unwrap_or(0); + let row_pressure_summary = if row_backlog > 0 { + Some(format!("{} handoff(s)", row_backlog)) + } else { + None + }; + let row_marker = if row_conflict_summary.is_some() { + "!" + } else if row_pressure_summary.is_some() { + "+" + } else { + "-" + }; + lines.push(format!( + " {} Row {} | {}{}{}", + row_marker, + meta.row_index + 1, + row_label, + row_conflict_summary + .map(|summary| format!(" | {summary}")) + .unwrap_or_default(), + row_pressure_summary + .map(|summary| format!(" | {summary}")) + .unwrap_or_default() + )); + } + let branch = session_branch(session); + let branch_suffix = if branch == "-" { + String::new() + } else { + format!(" | {branch}") + }; + let activity_suffix = meta + .activity_note + .as_ref() + .map(|note| format!(" | {}", truncate_for_dashboard(note, 26))) + .unwrap_or_default(); + let backlog_suffix = if meta.handoff_backlog > 0 { + format!(" | inbox {}", meta.handoff_backlog) + } else { + String::new() + }; + let kind_marker = board_activity_marker(&meta); + lines.push(format!( + " {}{} {} {} {} [{}] {:>3}% {} | {}{}{}{}", + board_motion_marker(&meta), + kind_marker, + board_presence_marker(session), + board_codename(session), + format_session_id(&session.id), + session.agent_type, + meta.progress_percent, + board_progress_bar(meta.progress_percent), + truncate_for_dashboard(meta.status_detail.as_deref().unwrap_or(&session.task), 18), + activity_suffix, + backlog_suffix, + branch_suffix + )); + } + } + + lines.join("\n") + } + + fn board_overlap_risks(&self) -> Vec { + let mut risks = self + .board_meta_by_session + .values() + .filter_map(|meta| meta.conflict_signal.clone()) + .collect::>(); + if risks.is_empty() { + let mut duplicate_branches: HashMap> = HashMap::new(); + let mut duplicate_tasks: HashMap> = HashMap::new(); + + for session in self.sessions.iter().filter(|session| { + matches!( + session.state, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale + ) + }) { + if let Some(worktree) = session.worktree.as_ref() { + duplicate_branches + .entry(worktree.branch.clone()) + .or_default() + .push(format_session_id(&session.id)); + } + duplicate_tasks + .entry(session.task.trim().to_ascii_lowercase()) + .or_default() + .push(format_session_id(&session.id)); + } + + for (branch, sessions) in duplicate_branches { + if sessions.len() >= 2 { + risks.push(format!("Shared branch {branch}: {}", sessions.join(", "))); + } + } + for (task, sessions) in duplicate_tasks { + if sessions.len() >= 2 { + risks.push(format!( + "Shared task {}: {}", + truncate_for_dashboard(&task, 32), + sessions.join(", ") + )); + } + } + } + risks.sort(); + risks.dedup(); + risks + } + fn aggregate_cost_summary(&self) -> (String, Style) { let aggregate = self.aggregate_usage(); let thresholds = self.cfg.effective_budget_alert_thresholds(); @@ -6774,7 +7071,9 @@ impl Dashboard { } fn visible_detail_panes(&self) -> Vec { - self.visible_panes() + self.layout_panes() + .into_iter() + .filter(|pane| !self.collapsed_panes.contains(pane)) .into_iter() .filter(|pane| *pane != Pane::Sessions) .collect() @@ -6819,6 +7118,19 @@ impl Dashboard { } } + fn board_pane_visible(&self) -> bool { + self.cfg.pane_layout == PaneLayout::Grid + && !self.collapsed_panes.contains(&Pane::Metrics) + && self.layout_panes().contains(&Pane::Metrics) + } + + fn is_pane_visible(&self, pane: Pane) -> bool { + match pane { + Pane::Board => self.board_pane_visible(), + _ => self.visible_panes().contains(&pane), + } + } + fn theme_palette(&self) -> ThemePalette { match self.cfg.theme { Theme::Dark => ThemePalette { @@ -6886,6 +7198,7 @@ impl Pane { Pane::Sessions => "Sessions", Pane::Output => "Output", Pane::Metrics => "Metrics", + Pane::Board => "Board", Pane::Log => "Log", } } @@ -6896,6 +7209,7 @@ impl Pane { 2 => Some(Self::Output), 3 => Some(Self::Metrics), 4 => Some(Self::Log), + 5 => Some(Self::Board), _ => None, } } @@ -6905,7 +7219,8 @@ impl Pane { Self::Sessions => 1, Self::Output => 2, Self::Metrics => 3, - Self::Log => 4, + Self::Board => 4, + Self::Log => 5, } } } @@ -6915,6 +7230,7 @@ fn pane_rect(pane_areas: &PaneAreas, pane: Pane) -> Option { Pane::Sessions => Some(pane_areas.sessions), Pane::Output => pane_areas.output, Pane::Metrics => pane_areas.metrics, + Pane::Board => pane_areas.metrics, Pane::Log => pane_areas.log, } } @@ -8247,6 +8563,17 @@ fn diff_addition_word_style() -> Style { .add_modifier(Modifier::BOLD) } +fn board_lane_label(state: &SessionState) -> &'static str { + match state { + SessionState::Pending => "Inbox", + SessionState::Running => "In Progress", + SessionState::Idle => "Review", + SessionState::Stale | SessionState::Failed => "Blocked", + SessionState::Completed => "Done", + SessionState::Stopped => "Stopped", + } +} + fn session_state_label(state: &SessionState) -> &'static str { match state { SessionState::Pending => "Pending", @@ -8271,6 +8598,25 @@ fn session_state_color(state: &SessionState) -> Color { } } +fn board_codename(session: &Session) -> String { + const ADJECTIVES: &[&str] = &[ + "Amber", "Cinder", "Moss", "Nova", "Sable", "Slate", "Swift", "Talon", + ]; + const NOUNS: &[&str] = &[ + "Fox", "Kite", "Lynx", "Otter", "Rook", "Sprite", "Wisp", "Wolf", + ]; + + let seed = session + .id + .bytes() + .fold(0usize, |acc, byte| acc.wrapping_mul(33).wrapping_add(byte as usize)); + format!( + "{} {}", + ADJECTIVES[seed % ADJECTIVES.len()], + NOUNS[(seed / ADJECTIVES.len()) % NOUNS.len()] + ) +} + fn file_activity_summary(entry: &FileActivityEntry) -> String { let mut summary = format!( "{} {}", @@ -9048,6 +9394,44 @@ fn session_branch(session: &Session) -> String { .unwrap_or_else(|| "-".to_string()) } +fn board_progress_bar(progress_percent: i64) -> String { + let clamped = progress_percent.clamp(0, 100); + let filled = ((clamped + 9) / 10) as usize; + let empty = 10usize.saturating_sub(filled); + format!("[{}{}]", "#".repeat(filled), ".".repeat(empty)) +} + +fn board_presence_marker(session: &Session) -> String { + let codename = board_codename(session); + let initials = codename + .split_whitespace() + .filter_map(|part| part.chars().next()) + .take(2) + .collect::() + .to_ascii_uppercase(); + format!("@{initials}") +} + +fn board_motion_marker(meta: &SessionBoardMeta) -> &'static str { + match meta.movement_note.as_deref() { + Some("Blocked") => "x", + Some("Completed") => "*", + Some(note) if note.starts_with("Moved ") => ">", + Some(note) if note.starts_with("Retargeted ") => "~", + _ => ".", + } +} + +fn board_activity_marker(meta: &SessionBoardMeta) -> &'static str { + match meta.activity_kind.as_deref() { + Some("received") => "<", + Some("delegated") => ">", + Some("spawned") => "+", + Some("spawned_fallback") => "#", + _ => "", + } +} + fn format_duration(duration_secs: u64) -> String { let hours = duration_secs / 3600; let minutes = (duration_secs % 3600) / 60; @@ -14217,6 +14601,11 @@ diff --git a/src/lib.rs b/src/lib.rs #[test] fn pane_command_mode_sets_layout() { + let tempdir = std::env::temp_dir().join(format!("ecc2-pane-command-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tempdir).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &tempdir); + let mut dashboard = test_dashboard(Vec::new(), 0); dashboard.cfg.pane_layout = PaneLayout::Horizontal; @@ -14231,10 +14620,22 @@ diff --git a/src/lib.rs b/src/lib.rs .operator_note .as_deref() .is_some_and(|note| note.contains("pane layout set to grid | saved to "))); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = std::fs::remove_dir_all(tempdir); } #[test] fn cycle_pane_layout_rotates_and_hides_log_when_leaving_grid() { + let tempdir = std::env::temp_dir().join(format!("ecc2-cycle-pane-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tempdir).unwrap(); + let previous_home = std::env::var_os("HOME"); + std::env::set_var("HOME", &tempdir); + let mut dashboard = test_dashboard(Vec::new(), 0); dashboard.cfg.pane_layout = PaneLayout::Grid; dashboard.cfg.linear_pane_size_percent = 44; @@ -14247,6 +14648,13 @@ diff --git a/src/lib.rs b/src/lib.rs assert_eq!(dashboard.cfg.pane_layout, PaneLayout::Horizontal); assert_eq!(dashboard.pane_size_percent, 44); assert_eq!(dashboard.selected_pane, Pane::Sessions); + + if let Some(home) = previous_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = std::fs::remove_dir_all(tempdir); } #[test] @@ -14532,6 +14940,7 @@ diff --git a/src/lib.rs b/src/lib.rs approval_queue_counts: HashMap::new(), approval_queue_preview: Vec::new(), handoff_backlog_counts: HashMap::new(), + board_meta_by_session: HashMap::new(), worktree_health_by_session: HashMap::new(), global_handoff_backlog_leads: 0, global_handoff_backlog_messages: 0,