Skip to main content

scud/commands/swarm/
session.rs

1//! Swarm session state tracking
2//!
3//! Tracks the state of a swarm execution session, including:
4//! - Waves executed
5//! - Rounds within waves
6//! - Task completion status
7//! - Validation results
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::PathBuf;
13use std::process::Command;
14
15use crate::backpressure::ValidationResult;
16
17/// Get the current git commit SHA
18pub fn get_current_commit() -> Option<String> {
19    Command::new("git")
20        .args(["rev-parse", "HEAD"])
21        .output()
22        .ok()
23        .and_then(|output| {
24            if output.status.success() {
25                Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
26            } else {
27                None
28            }
29        })
30}
31
32/// Brief summary of what was done in a wave
33/// This is NOT accumulated context - just a simple summary for the next wave
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct WaveSummary {
36    /// Wave number
37    pub wave_number: usize,
38    /// Tasks that were completed
39    pub tasks_completed: Vec<String>,
40    /// Files that were changed
41    pub files_changed: Vec<String>,
42}
43
44impl WaveSummary {
45    /// Generate a brief text summary
46    pub fn to_text(&self) -> String {
47        let mut lines = Vec::new();
48
49        lines.push(format!(
50            "Wave {} completed {} task(s):",
51            self.wave_number,
52            self.tasks_completed.len()
53        ));
54
55        for task_id in &self.tasks_completed {
56            lines.push(format!("  - {}", task_id));
57        }
58
59        if !self.files_changed.is_empty() {
60            let file_summary = if self.files_changed.len() <= 5 {
61                self.files_changed.join(", ")
62            } else {
63                format!(
64                    "{} and {} more",
65                    self.files_changed[..5].join(", "),
66                    self.files_changed.len() - 5
67                )
68            };
69            lines.push(format!("Files changed: {}", file_summary));
70        }
71
72        lines.join("\n")
73    }
74}
75
76/// State of a single round within a wave
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct RoundState {
79    /// Round number (0-indexed)
80    pub round_number: usize,
81    /// Task IDs executed in this round
82    pub task_ids: Vec<String>,
83    /// Tags for each task
84    pub tags: Vec<String>,
85    /// Tasks that failed to spawn
86    pub failures: Vec<String>,
87    /// Start time
88    pub started_at: String,
89    /// End time (set when complete)
90    pub completed_at: Option<String>,
91}
92
93impl RoundState {
94    pub fn new(round_number: usize) -> Self {
95        Self {
96            round_number,
97            task_ids: Vec::new(),
98            tags: Vec::new(),
99            failures: Vec::new(),
100            started_at: chrono::Utc::now().to_rfc3339(),
101            completed_at: None,
102        }
103    }
104
105    pub fn mark_complete(&mut self) {
106        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
107    }
108}
109
110/// State of a code review for a wave
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct ReviewState {
113    /// Tasks that were reviewed
114    pub reviewed_tasks: Vec<String>,
115    /// Whether all reviewed tasks passed
116    pub all_passed: bool,
117    /// Tasks flagged for improvement
118    pub tasks_needing_improvement: Vec<String>,
119    /// When the review completed
120    pub completed_at: String,
121}
122
123/// Record of a repair attempt
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RepairAttempt {
126    /// Attempt number (1-indexed)
127    pub attempt_number: usize,
128    /// Tasks attributed as responsible for the failure
129    pub attributed_tasks: Vec<String>,
130    /// Tasks cleared as not responsible
131    pub cleared_tasks: Vec<String>,
132    /// Attribution confidence level: "high", "medium", "low"
133    pub attribution_confidence: String,
134    /// Whether validation passed after this repair
135    pub validation_passed: bool,
136    /// When the repair attempt completed
137    pub completed_at: String,
138}
139
140/// State of a single wave
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct WaveState {
143    /// Wave number (1-indexed)
144    pub wave_number: usize,
145    /// Rounds executed in this wave
146    pub rounds: Vec<RoundState>,
147    /// Validation result (if validation was run)
148    pub validation: Option<ValidationResult>,
149    /// Summary of what was done
150    pub summary: Option<WaveSummary>,
151    /// Git commit SHA at wave start (for tracking changes)
152    #[serde(default)]
153    pub start_commit: Option<String>,
154    /// Review result (if review was run)
155    #[serde(default)]
156    pub review: Option<ReviewState>,
157    /// Repair attempts (if validation failed)
158    #[serde(default)]
159    pub repairs: Vec<RepairAttempt>,
160    /// Start time
161    pub started_at: String,
162    /// End time (set when complete)
163    pub completed_at: Option<String>,
164}
165
166impl WaveState {
167    pub fn new(wave_number: usize) -> Self {
168        Self {
169            wave_number,
170            rounds: Vec::new(),
171            validation: None,
172            summary: None,
173            start_commit: get_current_commit(),
174            review: None,
175            repairs: Vec::new(),
176            started_at: chrono::Utc::now().to_rfc3339(),
177            completed_at: None,
178        }
179    }
180
181    pub fn mark_complete(&mut self) {
182        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
183    }
184
185    /// Get all task IDs from all rounds
186    pub fn all_task_ids(&self) -> Vec<String> {
187        self.rounds
188            .iter()
189            .flat_map(|r| r.task_ids.clone())
190            .collect()
191    }
192
193    /// Get task ID to tag mapping
194    pub fn task_tags(&self) -> Vec<(String, String)> {
195        self.rounds
196            .iter()
197            .flat_map(|r| {
198                r.task_ids
199                    .iter()
200                    .zip(r.tags.iter())
201                    .map(|(id, tag)| (id.clone(), tag.clone()))
202            })
203            .collect()
204    }
205}
206
207/// Full swarm session state
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct SwarmSession {
210    /// Session name
211    pub session_name: String,
212    /// Tag being executed
213    pub tag: String,
214    /// Terminal type
215    pub terminal: String,
216    /// Working directory
217    pub working_dir: String,
218    /// Round size (max tasks per round)
219    pub round_size: usize,
220    /// Waves executed
221    pub waves: Vec<WaveState>,
222    /// Session start time
223    pub started_at: String,
224    /// Session end time
225    pub completed_at: Option<String>,
226}
227
228impl SwarmSession {
229    pub fn new(
230        session_name: &str,
231        tag: &str,
232        terminal: &str,
233        working_dir: &str,
234        round_size: usize,
235    ) -> Self {
236        Self {
237            session_name: session_name.to_string(),
238            tag: tag.to_string(),
239            terminal: terminal.to_string(),
240            working_dir: working_dir.to_string(),
241            round_size,
242            waves: Vec::new(),
243            started_at: chrono::Utc::now().to_rfc3339(),
244            completed_at: None,
245        }
246    }
247
248    pub fn mark_complete(&mut self) {
249        self.completed_at = Some(chrono::Utc::now().to_rfc3339());
250    }
251
252    /// Get total tasks executed
253    pub fn total_tasks(&self) -> usize {
254        self.waves
255            .iter()
256            .flat_map(|w| &w.rounds)
257            .map(|r| r.task_ids.len())
258            .sum()
259    }
260
261    /// Get total failures
262    pub fn total_failures(&self) -> usize {
263        self.waves
264            .iter()
265            .flat_map(|w| &w.rounds)
266            .map(|r| r.failures.len())
267            .sum()
268    }
269
270    /// Get brief summary of the previous wave (if any)
271    /// This is just "what was done", not accumulated context
272    pub fn get_previous_summary(&self) -> Option<String> {
273        self.waves
274            .last()
275            .and_then(|w| w.summary.as_ref().map(|s| s.to_text()))
276    }
277}
278
279/// Get the swarm session directory
280pub fn swarm_dir(project_root: Option<&PathBuf>) -> PathBuf {
281    let root = project_root
282        .cloned()
283        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
284    root.join(".scud").join("swarm")
285}
286
287/// Get the path to the session lock file for a given tag.
288/// Lock names include worktree ID when running inside a git worktree,
289/// allowing parallel salvo swarms on different worktrees.
290pub fn lock_file_path(project_root: Option<&PathBuf>, tag: &str) -> PathBuf {
291    let root = project_root
292        .cloned()
293        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
294    let worktree_id = get_worktree_id(&root);
295    let lock_name = match worktree_id {
296        Some(wt_id) => format!("{}-{}.lock", tag, wt_id),
297        None => format!("{}.lock", tag),
298    };
299    swarm_dir(project_root).join(lock_name)
300}
301
302/// Detect if we're in a git worktree (has .git file, not .git directory).
303/// Returns the worktree directory name as an identifier.
304fn get_worktree_id(project_root: &std::path::Path) -> Option<String> {
305    let git_path = project_root.join(".git");
306    if git_path.is_file() {
307        // In a worktree, .git is a file pointing to the main repo
308        project_root
309            .file_name()
310            .and_then(|n| n.to_str())
311            .map(|s| s.to_string())
312    } else {
313        None
314    }
315}
316
317/// A session lock that prevents concurrent swarm sessions on the same tag.
318/// The lock is automatically released when this struct is dropped.
319pub struct SessionLock {
320    _file: fs::File,
321    path: PathBuf,
322}
323
324impl SessionLock {
325    /// Get the path to the lock file
326    pub fn path(&self) -> &PathBuf {
327        &self.path
328    }
329}
330
331impl Drop for SessionLock {
332    fn drop(&mut self) {
333        // Lock is released automatically when file is dropped
334        // Optionally remove the lock file
335        let _ = fs::remove_file(&self.path);
336    }
337}
338
339/// Acquire an exclusive session lock for a tag.
340/// Returns a SessionLock that will be released when dropped.
341/// Returns an error if another session already holds the lock.
342pub fn acquire_session_lock(project_root: Option<&PathBuf>, tag: &str) -> Result<SessionLock> {
343    use fs2::FileExt;
344
345    let dir = swarm_dir(project_root);
346    fs::create_dir_all(&dir)?;
347
348    let lock_path = lock_file_path(project_root, tag);
349    let file = fs::OpenOptions::new()
350        .write(true)
351        .create(true)
352        .truncate(true)
353        .open(&lock_path)?;
354
355    // Try to acquire exclusive lock (non-blocking)
356    file.try_lock_exclusive().map_err(|_| {
357        anyhow::anyhow!(
358            "Another swarm session is already running for tag '{}'. \
359             If this is incorrect, remove the lock file: {}",
360            tag,
361            lock_path.display()
362        )
363    })?;
364
365    // Write PID and timestamp to lock file for debugging
366    use std::io::Write;
367    let mut file = file;
368    writeln!(
369        file,
370        "pid={}\nstarted={}",
371        std::process::id(),
372        chrono::Utc::now().to_rfc3339()
373    )?;
374
375    Ok(SessionLock {
376        _file: file,
377        path: lock_path,
378    })
379}
380
381/// Get the path to a session's state file
382pub fn session_file(project_root: Option<&PathBuf>, session_name: &str) -> PathBuf {
383    swarm_dir(project_root).join(format!("{}.json", session_name))
384}
385
386/// Save swarm session state
387pub fn save_session(project_root: Option<&PathBuf>, session: &SwarmSession) -> Result<()> {
388    let dir = swarm_dir(project_root);
389    fs::create_dir_all(&dir)?;
390
391    let file = session_file(project_root, &session.session_name);
392    let json = serde_json::to_string_pretty(session)?;
393    fs::write(file, json)?;
394
395    Ok(())
396}
397
398/// Load swarm session state
399pub fn load_session(project_root: Option<&PathBuf>, session_name: &str) -> Result<SwarmSession> {
400    let file = session_file(project_root, session_name);
401    let json = fs::read_to_string(&file)?;
402    let session: SwarmSession = serde_json::from_str(&json)?;
403    Ok(session)
404}
405
406/// List all swarm sessions
407pub fn list_sessions(project_root: Option<&PathBuf>) -> Result<Vec<String>> {
408    let dir = swarm_dir(project_root);
409    if !dir.exists() {
410        return Ok(Vec::new());
411    }
412
413    let mut sessions = Vec::new();
414    for entry in fs::read_dir(dir)? {
415        let entry = entry?;
416        let path = entry.path();
417        if path.extension().map(|e| e == "json").unwrap_or(false) {
418            if let Some(stem) = path.file_stem() {
419                sessions.push(stem.to_string_lossy().to_string());
420            }
421        }
422    }
423
424    Ok(sessions)
425}
426
427// ============================================================================
428// Extension-based Agent Spawning
429// ============================================================================
430
431use std::path::Path;
432use tokio::sync::mpsc;
433
434use crate::commands::spawn::agent::generate_prompt;
435use crate::commands::spawn::terminal::Harness;
436use crate::extensions::runner::{load_agent_config, AgentEvent, AgentRunner, SpawnConfig};
437use crate::models::task::Task;
438
439/// Agent info for wave execution
440#[derive(Debug, Clone)]
441pub struct WaveAgent {
442    /// Task being executed
443    pub task: Task,
444    /// Tag/phase the task belongs to
445    pub tag: String,
446}
447
448impl WaveAgent {
449    /// Create a WaveAgent from a task and tag
450    pub fn new(task: Task, tag: impl Into<String>) -> Self {
451        Self {
452            task,
453            tag: tag.into(),
454        }
455    }
456
457    /// Create WaveAgents from a collection of (task, tag) pairs
458    ///
459    /// This is the primary conversion point from graph computation output
460    /// to extension-based spawning input.
461    pub fn from_task_pairs<I>(pairs: I) -> Vec<Self>
462    where
463        I: IntoIterator<Item = (Task, String)>,
464    {
465        pairs.into_iter().map(|(task, tag)| Self::new(task, tag)).collect()
466    }
467
468    /// Get the task ID
469    pub fn task_id(&self) -> &str {
470        &self.task.id
471    }
472}
473
474/// Result from wave execution
475#[derive(Debug, Clone)]
476pub struct WaveExecutionResult {
477    /// Round state with execution info
478    pub round_state: RoundState,
479    /// Results from each agent
480    pub agent_results: Vec<crate::extensions::runner::AgentResult>,
481}
482
483impl WaveExecutionResult {
484    /// Check if all agents completed successfully
485    pub fn all_succeeded(&self) -> bool {
486        self.agent_results.iter().all(|r| r.success)
487    }
488
489    /// Get task IDs that completed successfully
490    pub fn successful_task_ids(&self) -> Vec<String> {
491        self.agent_results
492            .iter()
493            .filter(|r| r.success)
494            .map(|r| r.task_id.clone())
495            .collect()
496    }
497
498    /// Get task IDs that failed
499    pub fn failed_task_ids(&self) -> Vec<String> {
500        self.agent_results
501            .iter()
502            .filter(|r| !r.success)
503            .map(|r| r.task_id.clone())
504            .collect()
505    }
506
507    /// Get total execution duration in milliseconds
508    pub fn total_duration_ms(&self) -> u64 {
509        self.agent_results.iter().map(|r| r.duration_ms).max().unwrap_or(0)
510    }
511}
512
513impl WaveState {
514    /// Update this wave state from an execution result
515    ///
516    /// This integrates the extension-based execution results into
517    /// the existing wave tracking structure.
518    pub fn apply_execution_result(&mut self, result: WaveExecutionResult) {
519        self.rounds.push(result.round_state);
520    }
521
522    /// Create a WaveState and immediately apply an execution result
523    pub fn from_execution_result(wave_number: usize, result: WaveExecutionResult) -> Self {
524        let mut state = Self::new(wave_number);
525        state.apply_execution_result(result);
526        state
527    }
528}
529
530/// Execute a wave of agents using extension-based spawning (no tmux)
531///
532/// This function spawns agents as direct subprocesses and waits for them
533/// to complete, collecting their results.
534///
535/// # Arguments
536/// * `agents` - The agents to execute in this wave
537/// * `working_dir` - Working directory for agents
538/// * `round_number` - Round number for state tracking
539/// * `default_harness` - Default harness if agent doesn't specify one
540/// * `event_callback` - Optional callback for agent events
541///
542/// # Returns
543/// WaveExecutionResult with round state and agent results
544pub async fn execute_wave_async(
545    agents: &[WaveAgent],
546    working_dir: &Path,
547    round_number: usize,
548    default_harness: Harness,
549) -> Result<WaveExecutionResult> {
550    let mut round_state = RoundState::new(round_number);
551    let mut runner = AgentRunner::new(agents.len() * 10);
552
553    // Spawn all agents
554    for agent in agents {
555        // Resolve agent config (harness, model) from agent_type
556        let (harness, model) = load_agent_config(
557            agent.task.agent_type.as_deref(),
558            default_harness,
559            None,
560            working_dir,
561        );
562
563        // Generate prompt for the agent
564        let prompt = generate_prompt(&agent.task, &agent.tag);
565
566        let config = SpawnConfig {
567            task_id: agent.task.id.clone(),
568            prompt,
569            working_dir: working_dir.to_path_buf(),
570            harness,
571            model,
572        };
573
574        match runner.spawn(config).await {
575            Ok(()) => {
576                round_state.task_ids.push(agent.task.id.clone());
577                round_state.tags.push(agent.tag.clone());
578            }
579            Err(e) => {
580                round_state.failures.push(agent.task.id.clone());
581                eprintln!("Failed to spawn agent for {}: {}", agent.task.id, e);
582            }
583        }
584    }
585
586    // Wait for all agents to complete
587    let agent_results = runner.wait_all().await;
588
589    round_state.mark_complete();
590
591    Ok(WaveExecutionResult {
592        round_state,
593        agent_results,
594    })
595}
596
597/// Execute a wave of agents with event streaming
598///
599/// Similar to execute_wave_async but allows receiving events during execution.
600///
601/// # Arguments
602/// * `agents` - The agents to execute in this wave
603/// * `working_dir` - Working directory for agents
604/// * `round_number` - Round number for state tracking
605/// * `default_harness` - Default harness if agent doesn't specify one
606/// * `event_tx` - Channel to send events to
607///
608/// # Returns
609/// WaveExecutionResult with round state and agent results
610pub async fn execute_wave_with_events(
611    agents: &[WaveAgent],
612    working_dir: &Path,
613    round_number: usize,
614    default_harness: Harness,
615    event_tx: mpsc::Sender<AgentEvent>,
616) -> Result<WaveExecutionResult> {
617    use crate::extensions::runner::spawn_agent;
618
619    let mut round_state = RoundState::new(round_number);
620    let mut handles = Vec::new();
621
622    // Spawn all agents
623    for agent in agents {
624        // Resolve agent config (harness, model) from agent_type
625        let (harness, model) = load_agent_config(
626            agent.task.agent_type.as_deref(),
627            default_harness,
628            None,
629            working_dir,
630        );
631
632        // Generate prompt for the agent
633        let prompt = generate_prompt(&agent.task, &agent.tag);
634
635        let config = SpawnConfig {
636            task_id: agent.task.id.clone(),
637            prompt,
638            working_dir: working_dir.to_path_buf(),
639            harness,
640            model,
641        };
642
643        match spawn_agent(config, event_tx.clone()).await {
644            Ok(handle) => {
645                handles.push(handle);
646                round_state.task_ids.push(agent.task.id.clone());
647                round_state.tags.push(agent.tag.clone());
648            }
649            Err(e) => {
650                round_state.failures.push(agent.task.id.clone());
651                let _ = event_tx
652                    .send(AgentEvent::SpawnFailed {
653                        task_id: agent.task.id.clone(),
654                        error: e.to_string(),
655                    })
656                    .await;
657            }
658        }
659    }
660
661    // Wait for all agents to complete
662    let mut agent_results = Vec::new();
663    for handle in handles {
664        if let Ok(result) = handle.await {
665            agent_results.push(result);
666        }
667    }
668
669    round_state.mark_complete();
670
671    Ok(WaveExecutionResult {
672        round_state,
673        agent_results,
674    })
675}
676
677/// Execute a complete wave with state tracking
678///
679/// This is the high-level interface for executing a wave of agents using
680/// extension-based spawning. It handles:
681/// - Converting task info to WaveAgent format
682/// - Spawning and managing agents
683/// - Updating wave state with results
684///
685/// # Arguments
686/// * `wave_number` - The wave number for state tracking
687/// * `task_pairs` - Iterator of (Task, tag) pairs from graph computation
688/// * `working_dir` - Working directory for agents
689/// * `default_harness` - Default harness if agent doesn't specify one
690///
691/// # Returns
692/// A tuple of (WaveState, Vec<AgentResult>) with the tracked state and raw results
693pub async fn execute_wave_with_tracking<I>(
694    wave_number: usize,
695    task_pairs: I,
696    working_dir: &Path,
697    default_harness: Harness,
698) -> Result<(WaveState, Vec<crate::extensions::runner::AgentResult>)>
699where
700    I: IntoIterator<Item = (Task, String)>,
701{
702    let agents = WaveAgent::from_task_pairs(task_pairs);
703    let result = execute_wave_async(&agents, working_dir, 0, default_harness).await?;
704
705    let wave_state = WaveState::from_execution_result(wave_number, result.clone());
706
707    Ok((wave_state, result.agent_results))
708}
709
710/// Execute multiple rounds within a wave with state tracking
711///
712/// This function handles chunking agents into rounds based on round_size
713/// and executes them sequentially, tracking state for each round.
714///
715/// # Arguments
716/// * `wave_number` - The wave number for state tracking
717/// * `task_pairs` - Iterator of (Task, tag) pairs from graph computation
718/// * `working_dir` - Working directory for agents
719/// * `round_size` - Maximum number of agents per round
720/// * `default_harness` - Default harness if agent doesn't specify one
721///
722/// # Returns
723/// WaveState with all rounds tracked
724pub async fn execute_wave_in_rounds<I>(
725    wave_number: usize,
726    task_pairs: I,
727    working_dir: &Path,
728    round_size: usize,
729    default_harness: Harness,
730) -> Result<WaveState>
731where
732    I: IntoIterator<Item = (Task, String)>,
733{
734    let agents: Vec<WaveAgent> = WaveAgent::from_task_pairs(task_pairs);
735    let mut wave_state = WaveState::new(wave_number);
736
737    for (round_idx, chunk) in agents.chunks(round_size).enumerate() {
738        let result = execute_wave_async(chunk, working_dir, round_idx, default_harness).await?;
739        wave_state.apply_execution_result(result);
740    }
741
742    wave_state.mark_complete();
743    Ok(wave_state)
744}
745
746/// Spawn a single agent using extension-based spawning (async, no tmux)
747///
748/// This is a convenience function for spawning a single agent.
749pub async fn spawn_subagent(
750    task: &Task,
751    tag: &str,
752    working_dir: &Path,
753    default_harness: Harness,
754) -> Result<crate::extensions::runner::AgentResult> {
755    let agents = vec![WaveAgent {
756        task: task.clone(),
757        tag: tag.to_string(),
758    }];
759
760    let result = execute_wave_async(&agents, working_dir, 0, default_harness).await?;
761
762    result
763        .agent_results
764        .into_iter()
765        .next()
766        .ok_or_else(|| anyhow::anyhow!("No result from agent"))
767}
768
769#[cfg(test)]
770mod tests {
771    use super::*;
772
773    #[test]
774    fn test_round_state_new() {
775        let round = RoundState::new(0);
776        assert_eq!(round.round_number, 0);
777        assert!(round.task_ids.is_empty());
778        assert!(round.completed_at.is_none());
779    }
780
781    #[test]
782    fn test_wave_state_all_task_ids() {
783        let mut wave = WaveState::new(1);
784
785        let mut round1 = RoundState::new(0);
786        round1.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
787
788        let mut round2 = RoundState::new(1);
789        round2.task_ids = vec!["task:3".to_string()];
790
791        wave.rounds.push(round1);
792        wave.rounds.push(round2);
793
794        let all_ids = wave.all_task_ids();
795        assert_eq!(all_ids.len(), 3);
796        assert!(all_ids.contains(&"task:1".to_string()));
797        assert!(all_ids.contains(&"task:2".to_string()));
798        assert!(all_ids.contains(&"task:3".to_string()));
799    }
800
801    #[test]
802    fn test_swarm_session_total_tasks() {
803        let mut session = SwarmSession::new("test-session", "test-tag", "tmux", "/test/path", 5);
804
805        let mut wave = WaveState::new(1);
806        let mut round = RoundState::new(0);
807        round.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
808        wave.rounds.push(round);
809        session.waves.push(wave);
810
811        assert_eq!(session.total_tasks(), 2);
812    }
813
814    #[test]
815    fn test_wave_summary_to_text() {
816        let summary = WaveSummary {
817            wave_number: 1,
818            tasks_completed: vec!["task:1".to_string(), "task:2".to_string()],
819            files_changed: vec!["src/main.rs".to_string()],
820        };
821
822        let text = summary.to_text();
823        assert!(text.contains("Wave 1"));
824        assert!(text.contains("task:1"));
825        assert!(text.contains("src/main.rs"));
826    }
827
828    #[test]
829    fn test_get_previous_summary() {
830        let mut session = SwarmSession::new("test", "tag", "tmux", "/path", 5);
831
832        // No waves yet
833        assert!(session.get_previous_summary().is_none());
834
835        // Add wave with summary
836        let mut wave = WaveState::new(1);
837        wave.summary = Some(WaveSummary {
838            wave_number: 1,
839            tasks_completed: vec!["task:1".to_string()],
840            files_changed: vec![],
841        });
842        session.waves.push(wave);
843
844        let summary = session.get_previous_summary();
845        assert!(summary.is_some());
846        assert!(summary.unwrap().contains("task:1"));
847    }
848
849    #[test]
850    fn test_session_lock_contention() {
851        use tempfile::TempDir;
852
853        // Create a temporary directory for testing
854        let temp_dir = TempDir::new().unwrap();
855        let project_root = temp_dir.path().to_path_buf();
856
857        // Acquire first lock
858        let _lock1 = acquire_session_lock(Some(&project_root), "test-tag")
859            .expect("First lock should succeed");
860
861        // Try to acquire second lock for same tag while first is held
862        let result = acquire_session_lock(Some(&project_root), "test-tag");
863
864        // Verify the second attempt fails and error message mentions "already running"
865        match result {
866            Ok(_) => panic!("Second lock should fail"),
867            Err(e) => {
868                let error_msg = e.to_string();
869                assert!(
870                    error_msg.contains("already running"),
871                    "Error message should mention 'already running', got: {}",
872                    error_msg
873                );
874            }
875        }
876    }
877
878    #[test]
879    fn test_get_current_commit() {
880        let result = get_current_commit();
881
882        // Should return Some(sha) since we're in a git repo
883        assert!(result.is_some(), "Expected Some(sha) in a git repository");
884
885        let sha = result.unwrap();
886
887        // Verify the SHA is 40 characters long (full SHA)
888        assert_eq!(
889            sha.len(),
890            40,
891            "Expected SHA to be 40 characters long, got {}",
892            sha.len()
893        );
894
895        // Verify the SHA contains only hex characters (0-9, a-f)
896        assert!(
897            sha.chars().all(|c| c.is_ascii_hexdigit()),
898            "Expected SHA to contain only hex characters, got: {}",
899            sha
900        );
901    }
902
903    #[test]
904    fn test_wave_agent_new() {
905        let task = Task::new(
906            "task:1".to_string(),
907            "Test task".to_string(),
908            "Description".to_string(),
909        );
910        let agent = WaveAgent::new(task.clone(), "test-tag");
911
912        assert_eq!(agent.task_id(), "task:1");
913        assert_eq!(agent.tag, "test-tag");
914    }
915
916    #[test]
917    fn test_wave_agent_from_task_pairs() {
918        let task1 = Task::new(
919            "task:1".to_string(),
920            "Task 1".to_string(),
921            "Description".to_string(),
922        );
923        let task2 = Task::new(
924            "task:2".to_string(),
925            "Task 2".to_string(),
926            "Description".to_string(),
927        );
928
929        let pairs = vec![
930            (task1, "tag-a".to_string()),
931            (task2, "tag-b".to_string()),
932        ];
933
934        let agents = WaveAgent::from_task_pairs(pairs);
935
936        assert_eq!(agents.len(), 2);
937        assert_eq!(agents[0].task_id(), "task:1");
938        assert_eq!(agents[0].tag, "tag-a");
939        assert_eq!(agents[1].task_id(), "task:2");
940        assert_eq!(agents[1].tag, "tag-b");
941    }
942
943    #[test]
944    fn test_wave_execution_result_helpers() {
945        use crate::extensions::runner::AgentResult;
946
947        let result = WaveExecutionResult {
948            round_state: RoundState::new(0),
949            agent_results: vec![
950                AgentResult {
951                    task_id: "task:1".to_string(),
952                    success: true,
953                    exit_code: Some(0),
954                    output: String::new(),
955                    duration_ms: 1000,
956                },
957                AgentResult {
958                    task_id: "task:2".to_string(),
959                    success: false,
960                    exit_code: Some(1),
961                    output: String::new(),
962                    duration_ms: 2000,
963                },
964            ],
965        };
966
967        assert!(!result.all_succeeded());
968        assert_eq!(result.successful_task_ids(), vec!["task:1"]);
969        assert_eq!(result.failed_task_ids(), vec!["task:2"]);
970        assert_eq!(result.total_duration_ms(), 2000);
971    }
972
973    #[test]
974    fn test_wave_state_from_execution_result() {
975        use crate::extensions::runner::AgentResult;
976
977        let mut round_state = RoundState::new(0);
978        round_state.task_ids = vec!["task:1".to_string()];
979
980        let result = WaveExecutionResult {
981            round_state,
982            agent_results: vec![AgentResult {
983                task_id: "task:1".to_string(),
984                success: true,
985                exit_code: Some(0),
986                output: String::new(),
987                duration_ms: 1000,
988            }],
989        };
990
991        let wave_state = WaveState::from_execution_result(1, result);
992
993        assert_eq!(wave_state.wave_number, 1);
994        assert_eq!(wave_state.rounds.len(), 1);
995        assert_eq!(wave_state.rounds[0].task_ids, vec!["task:1"]);
996    }
997
998    #[test]
999    fn test_wave_state_apply_execution_result() {
1000        use crate::extensions::runner::AgentResult;
1001
1002        let mut wave_state = WaveState::new(1);
1003        assert!(wave_state.rounds.is_empty());
1004
1005        let mut round_state = RoundState::new(0);
1006        round_state.task_ids = vec!["task:1".to_string()];
1007
1008        let result = WaveExecutionResult {
1009            round_state,
1010            agent_results: vec![AgentResult {
1011                task_id: "task:1".to_string(),
1012                success: true,
1013                exit_code: Some(0),
1014                output: String::new(),
1015                duration_ms: 1000,
1016            }],
1017        };
1018
1019        wave_state.apply_execution_result(result);
1020
1021        assert_eq!(wave_state.rounds.len(), 1);
1022        assert_eq!(wave_state.all_task_ids(), vec!["task:1"]);
1023    }
1024}