diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index b6de51b7..1fa31298 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension}; use serde::Serialize; +use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use std::fs::File; use std::io::{BufRead, BufReader}; @@ -19,6 +20,16 @@ pub struct StateStore { conn: Connection, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct FileActivityOverlap { + pub path: String, + pub current_action: FileActivityAction, + pub other_action: FileActivityAction, + pub other_session_id: String, + pub other_session_state: SessionState, + pub timestamp: chrono::DateTime, +} + #[derive(Debug, Clone, Default, Serialize)] pub struct DaemonActivity { pub last_dispatch_at: Option>, @@ -1627,6 +1638,67 @@ impl StateStore { Ok(events) } + + pub fn list_file_overlaps( + &self, + session_id: &str, + limit: usize, + ) -> Result> { + if limit == 0 { + return Ok(Vec::new()); + } + + let current_activity = self.list_file_activity(session_id, 64)?; + if current_activity.is_empty() { + return Ok(Vec::new()); + } + + let mut current_by_path = HashMap::new(); + for entry in current_activity { + current_by_path.entry(entry.path.clone()).or_insert(entry); + } + + let mut overlaps = Vec::new(); + let mut seen = HashSet::new(); + + for session in self.list_sessions()? { + if session.id == session_id || !session_state_supports_overlap(&session.state) { + continue; + } + + for entry in self.list_file_activity(&session.id, 32)? { + let Some(current) = current_by_path.get(&entry.path) else { + continue; + }; + if !file_overlap_is_relevant(current, &entry) { + continue; + } + if !seen.insert((session.id.clone(), entry.path.clone())) { + continue; + } + + overlaps.push(FileActivityOverlap { + path: entry.path.clone(), + current_action: current.action.clone(), + other_action: entry.action.clone(), + other_session_id: session.id.clone(), + other_session_state: session.state.clone(), + timestamp: entry.timestamp, + }); + } + } + + overlaps.sort_by_key(|entry| { + ( + overlap_state_priority(&entry.other_session_state), + Reverse(entry.timestamp), + entry.other_session_id.clone(), + entry.path.clone(), + ) + }); + overlaps.truncate(limit); + Ok(overlaps) + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -1702,6 +1774,31 @@ fn infer_file_activity_action(tool_name: &str) -> FileActivityAction { } } +fn session_state_supports_overlap(state: &SessionState) -> bool { + matches!( + state, + SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale + ) +} + +fn file_overlap_is_relevant(current: &FileActivityEntry, other: &FileActivityEntry) -> bool { + current.path == other.path + && !(matches!(current.action, FileActivityAction::Read) + && matches!(other.action, FileActivityAction::Read)) +} + +fn overlap_state_priority(state: &SessionState) -> u8 { + match state { + SessionState::Running => 0, + SessionState::Idle => 1, + SessionState::Pending => 2, + SessionState::Stale => 3, + SessionState::Completed => 4, + SessionState::Failed => 5, + SessionState::Stopped => 6, + } +} + #[cfg(test)] mod tests { use super::*; @@ -2015,6 +2112,77 @@ mod tests { Ok(()) } + #[test] + fn list_file_overlaps_reports_other_active_sessions_sharing_paths() -> Result<()> { + let tempdir = TestDir::new("store-file-overlaps")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let now = Utc::now(); + + db.insert_session(&Session { + id: "session-1".to_string(), + task: "focus".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Running, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "session-2".to_string(), + task: "delegate".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Idle, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + db.insert_session(&Session { + id: "session-3".to_string(), + task: "done".to_string(), + agent_type: "claude".to_string(), + working_dir: PathBuf::from("/tmp"), + state: SessionState::Completed, + pid: None, + worktree: None, + created_at: now, + updated_at: now, + last_heartbeat_at: now, + metrics: SessionMetrics::default(), + })?; + + let metrics_dir = tempdir.path().join("metrics"); + fs::create_dir_all(&metrics_dir)?; + let metrics_path = metrics_dir.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"session-1\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"updated lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:02:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"session-2\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"touched lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:03:00Z\"}\n", + "{\"id\":\"evt-3\",\"session_id\":\"session-3\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"completed overlap\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:04:00Z\"}\n" + ), + )?; + + db.sync_tool_activity_metrics(&metrics_path)?; + + let overlaps = db.list_file_overlaps("session-1", 10)?; + assert_eq!(overlaps.len(), 1); + assert_eq!(overlaps[0].path, "src/lib.rs"); + assert_eq!(overlaps[0].current_action, FileActivityAction::Modify); + assert_eq!(overlaps[0].other_action, FileActivityAction::Modify); + assert_eq!(overlaps[0].other_session_id, "session-2"); + assert_eq!(overlaps[0].other_session_state, SessionState::Idle); + + Ok(()) + } + #[test] fn refresh_session_durations_updates_running_and_terminal_sessions() -> Result<()> { let tempdir = TestDir::new("store-duration-metrics")?; diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index cdb2271b..53d8c23a 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -19,7 +19,7 @@ use crate::session::manager; use crate::session::output::{ OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT, }; -use crate::session::store::{DaemonActivity, StateStore}; +use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore}; use crate::session::{FileActivityEntry, Session, SessionMessage, SessionState}; use crate::worktree; @@ -4169,6 +4169,22 @@ impl Dashboard { } } } + let file_overlaps = self + .db + .list_file_overlaps(&session.id, 3) + .unwrap_or_default(); + if !file_overlaps.is_empty() { + lines.push("Potential overlaps".to_string()); + for overlap in file_overlaps { + lines.push(format!( + "- {}", + file_overlap_summary( + &overlap, + &self.short_timestamp(&overlap.timestamp.to_rfc3339()) + ) + )); + } + } lines.push(format!( "Cost ${:.4} | Duration {}s", metrics.cost_usd, metrics.duration_secs @@ -5447,6 +5463,18 @@ fn file_activity_patch_lines(entry: &FileActivityEntry, max_lines: usize) -> Vec .unwrap_or_default() } +fn file_overlap_summary(entry: &FileActivityOverlap, timestamp: &str) -> String { + format!( + "{} {} | {} {} as {} | {}", + file_activity_verb(entry.current_action.clone()), + truncate_for_dashboard(&entry.path, 48), + entry.other_session_state, + format_session_id(&entry.other_session_id), + file_activity_verb(entry.other_action.clone()), + timestamp + ) +} + fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str { match action { crate::session::FileActivityAction::Read => "read", @@ -6152,6 +6180,58 @@ mod tests { Ok(()) } + #[test] + fn metrics_text_surfaces_file_activity_overlaps() -> Result<()> { + let root = std::env::temp_dir().join(format!("ecc2-file-overlaps-{}", Uuid::new_v4())); + fs::create_dir_all(&root)?; + let now = Utc::now(); + let mut focus = sample_session( + "focus-12345678", + "planner", + SessionState::Running, + Some("ecc/focus"), + 512, + 42, + ); + focus.created_at = now - chrono::Duration::hours(1); + focus.updated_at = now - chrono::Duration::minutes(3); + + let mut delegate = sample_session( + "delegate-87654321", + "coder", + SessionState::Idle, + Some("ecc/delegate"), + 256, + 12, + ); + delegate.created_at = now - chrono::Duration::minutes(50); + delegate.updated_at = now - chrono::Duration::minutes(2); + + let mut dashboard = test_dashboard(vec![focus.clone(), delegate.clone()], 0); + dashboard.db.insert_session(&focus)?; + dashboard.db.insert_session(&delegate)?; + + let metrics_path = root.join("tool-usage.jsonl"); + fs::write( + &metrics_path, + concat!( + "{\"id\":\"evt-1\",\"session_id\":\"focus-12345678\",\"tool_name\":\"Edit\",\"input_summary\":\"Edit src/lib.rs\",\"output_summary\":\"updated lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:00:00Z\"}\n", + "{\"id\":\"evt-2\",\"session_id\":\"delegate-87654321\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/lib.rs\",\"output_summary\":\"touched lib\",\"file_events\":[{\"path\":\"src/lib.rs\",\"action\":\"modify\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n" + ), + )?; + dashboard.db.sync_tool_activity_metrics(&metrics_path)?; + dashboard.sync_from_store(); + + let metrics_text = dashboard.selected_session_metrics_text(); + assert!(metrics_text.contains("Potential overlaps")); + assert!(metrics_text.contains("modify src/lib.rs")); + assert!(metrics_text.contains("idle delegate")); + assert!(metrics_text.contains("as modify")); + + let _ = fs::remove_dir_all(root); + Ok(()) + } + #[test] fn timeline_time_filter_hides_old_events() { let now = Utc::now(); diff --git a/tests/hooks/session-activity-tracker.test.js b/tests/hooks/session-activity-tracker.test.js index 0b15c2fd..da56c677 100644 --- a/tests/hooks/session-activity-tracker.test.js +++ b/tests/hooks/session-activity-tracker.test.js @@ -260,6 +260,54 @@ function runTests() { fs.rmSync(repoDir, { recursive: true, force: true }); }) ? passed++ : failed++); + (test('captures tracked Delete activity using git diff context', () => { + const tmpHome = makeTempDir(); + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-activity-tracker-delete-repo-')); + + spawnSync('git', ['init'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['config', 'user.email', 'ecc@example.com'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['config', 'user.name', 'ECC Tests'], { cwd: repoDir, encoding: 'utf8' }); + + const srcDir = path.join(repoDir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + const trackedFile = path.join(srcDir, 'obsolete.ts'); + fs.writeFileSync(trackedFile, 'export const obsolete = true;\n', 'utf8'); + spawnSync('git', ['add', 'src/obsolete.ts'], { cwd: repoDir, encoding: 'utf8' }); + spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, encoding: 'utf8' }); + + fs.rmSync(trackedFile, { force: true }); + + const input = { + tool_name: 'Delete', + tool_input: { + file_path: 'src/obsolete.ts', + }, + tool_output: { output: 'deleted src/obsolete.ts' }, + }; + const result = runScript(input, { + ...withTempHome(tmpHome), + CLAUDE_HOOK_EVENT_NAME: 'PostToolUse', + ECC_SESSION_ID: 'ecc-session-delete', + }, { + cwd: repoDir, + }); + assert.strictEqual(result.code, 0); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.deepStrictEqual(row.file_events, [ + { + path: 'src/obsolete.ts', + action: 'delete', + diff_preview: 'export const obsolete = true; ->', + patch_preview: '@@ -1 +0,0 @@\n-export const obsolete = true;', + }, + ]); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(repoDir, { recursive: true, force: true }); + }) ? passed++ : failed++); + (test('prefers ECC_SESSION_ID over CLAUDE_SESSION_ID and redacts bash summaries', () => { const tmpHome = makeTempDir(); const input = {