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
466            .into_iter()
467            .map(|(task, tag)| Self::new(task, tag))
468            .collect()
469    }
470
471    /// Get the task ID
472    pub fn task_id(&self) -> &str {
473        &self.task.id
474    }
475}
476
477/// Result from wave execution
478#[derive(Debug, Clone)]
479pub struct WaveExecutionResult {
480    /// Round state with execution info
481    pub round_state: RoundState,
482    /// Results from each agent
483    pub agent_results: Vec<crate::extensions::runner::AgentResult>,
484}
485
486impl WaveExecutionResult {
487    /// Check if all agents completed successfully
488    pub fn all_succeeded(&self) -> bool {
489        self.agent_results.iter().all(|r| r.success)
490    }
491
492    /// Get task IDs that completed successfully
493    pub fn successful_task_ids(&self) -> Vec<String> {
494        self.agent_results
495            .iter()
496            .filter(|r| r.success)
497            .map(|r| r.task_id.clone())
498            .collect()
499    }
500
501    /// Get task IDs that failed
502    pub fn failed_task_ids(&self) -> Vec<String> {
503        self.agent_results
504            .iter()
505            .filter(|r| !r.success)
506            .map(|r| r.task_id.clone())
507            .collect()
508    }
509
510    /// Get total execution duration in milliseconds
511    pub fn total_duration_ms(&self) -> u64 {
512        self.agent_results
513            .iter()
514            .map(|r| r.duration_ms)
515            .max()
516            .unwrap_or(0)
517    }
518}
519
520impl WaveState {
521    /// Update this wave state from an execution result
522    ///
523    /// This integrates the extension-based execution results into
524    /// the existing wave tracking structure.
525    pub fn apply_execution_result(&mut self, result: WaveExecutionResult) {
526        self.rounds.push(result.round_state);
527    }
528
529    /// Create a WaveState and immediately apply an execution result
530    pub fn from_execution_result(wave_number: usize, result: WaveExecutionResult) -> Self {
531        let mut state = Self::new(wave_number);
532        state.apply_execution_result(result);
533        state
534    }
535}
536
537/// Execute a wave of agents using extension-based spawning (no tmux)
538///
539/// This function spawns agents as direct subprocesses and waits for them
540/// to complete, collecting their results.
541///
542/// # Arguments
543/// * `agents` - The agents to execute in this wave
544/// * `working_dir` - Working directory for agents
545/// * `round_number` - Round number for state tracking
546/// * `default_harness` - Default harness if agent doesn't specify one
547/// * `event_callback` - Optional callback for agent events
548///
549/// # Returns
550/// WaveExecutionResult with round state and agent results
551pub async fn execute_wave_async(
552    agents: &[WaveAgent],
553    working_dir: &Path,
554    round_number: usize,
555    default_harness: Harness,
556) -> Result<WaveExecutionResult> {
557    let mut round_state = RoundState::new(round_number);
558    let mut runner = AgentRunner::new(agents.len() * 10);
559
560    // Spawn all agents
561    for agent in agents {
562        // Resolve agent config (harness, model) from agent_type
563        let (harness, model) = load_agent_config(
564            agent.task.agent_type.as_deref(),
565            default_harness,
566            None,
567            working_dir,
568        );
569
570        // Generate prompt for the agent
571        let prompt = generate_prompt(&agent.task, &agent.tag);
572
573        let config = SpawnConfig {
574            task_id: agent.task.id.clone(),
575            prompt,
576            working_dir: working_dir.to_path_buf(),
577            harness,
578            model,
579        };
580
581        match runner.spawn(config).await {
582            Ok(()) => {
583                round_state.task_ids.push(agent.task.id.clone());
584                round_state.tags.push(agent.tag.clone());
585            }
586            Err(e) => {
587                round_state.failures.push(agent.task.id.clone());
588                eprintln!("Failed to spawn agent for {}: {}", agent.task.id, e);
589            }
590        }
591    }
592
593    // Wait for all agents to complete
594    let agent_results = runner.wait_all().await;
595
596    round_state.mark_complete();
597
598    Ok(WaveExecutionResult {
599        round_state,
600        agent_results,
601    })
602}
603
604/// Execute a wave of agents with event streaming
605///
606/// Similar to execute_wave_async but allows receiving events during execution.
607///
608/// # Arguments
609/// * `agents` - The agents to execute in this wave
610/// * `working_dir` - Working directory for agents
611/// * `round_number` - Round number for state tracking
612/// * `default_harness` - Default harness if agent doesn't specify one
613/// * `event_tx` - Channel to send events to
614///
615/// # Returns
616/// WaveExecutionResult with round state and agent results
617pub async fn execute_wave_with_events(
618    agents: &[WaveAgent],
619    working_dir: &Path,
620    round_number: usize,
621    default_harness: Harness,
622    event_tx: mpsc::Sender<AgentEvent>,
623) -> Result<WaveExecutionResult> {
624    use crate::extensions::runner::spawn_agent;
625
626    let mut round_state = RoundState::new(round_number);
627    let mut handles = Vec::new();
628
629    // Spawn all agents
630    for agent in agents {
631        // Resolve agent config (harness, model) from agent_type
632        let (harness, model) = load_agent_config(
633            agent.task.agent_type.as_deref(),
634            default_harness,
635            None,
636            working_dir,
637        );
638
639        // Generate prompt for the agent
640        let prompt = generate_prompt(&agent.task, &agent.tag);
641
642        let config = SpawnConfig {
643            task_id: agent.task.id.clone(),
644            prompt,
645            working_dir: working_dir.to_path_buf(),
646            harness,
647            model,
648        };
649
650        match spawn_agent(config, event_tx.clone()).await {
651            Ok(handle) => {
652                handles.push(handle);
653                round_state.task_ids.push(agent.task.id.clone());
654                round_state.tags.push(agent.tag.clone());
655            }
656            Err(e) => {
657                round_state.failures.push(agent.task.id.clone());
658                let _ = event_tx
659                    .send(AgentEvent::SpawnFailed {
660                        task_id: agent.task.id.clone(),
661                        error: e.to_string(),
662                    })
663                    .await;
664            }
665        }
666    }
667
668    // Wait for all agents to complete
669    let mut agent_results = Vec::new();
670    for handle in handles {
671        if let Ok(result) = handle.await {
672            agent_results.push(result);
673        }
674    }
675
676    round_state.mark_complete();
677
678    Ok(WaveExecutionResult {
679        round_state,
680        agent_results,
681    })
682}
683
684/// Execute a complete wave with state tracking
685///
686/// This is the high-level interface for executing a wave of agents using
687/// extension-based spawning. It handles:
688/// - Converting task info to WaveAgent format
689/// - Spawning and managing agents
690/// - Updating wave state with results
691///
692/// # Arguments
693/// * `wave_number` - The wave number for state tracking
694/// * `task_pairs` - Iterator of (Task, tag) pairs from graph computation
695/// * `working_dir` - Working directory for agents
696/// * `default_harness` - Default harness if agent doesn't specify one
697///
698/// # Returns
699/// A tuple of (WaveState, Vec<AgentResult>) with the tracked state and raw results
700pub async fn execute_wave_with_tracking<I>(
701    wave_number: usize,
702    task_pairs: I,
703    working_dir: &Path,
704    default_harness: Harness,
705) -> Result<(WaveState, Vec<crate::extensions::runner::AgentResult>)>
706where
707    I: IntoIterator<Item = (Task, String)>,
708{
709    let agents = WaveAgent::from_task_pairs(task_pairs);
710    let result = execute_wave_async(&agents, working_dir, 0, default_harness).await?;
711
712    let wave_state = WaveState::from_execution_result(wave_number, result.clone());
713
714    Ok((wave_state, result.agent_results))
715}
716
717/// Execute multiple rounds within a wave with state tracking
718///
719/// This function handles chunking agents into rounds based on round_size
720/// and executes them sequentially, tracking state for each round.
721///
722/// # Arguments
723/// * `wave_number` - The wave number for state tracking
724/// * `task_pairs` - Iterator of (Task, tag) pairs from graph computation
725/// * `working_dir` - Working directory for agents
726/// * `round_size` - Maximum number of agents per round
727/// * `default_harness` - Default harness if agent doesn't specify one
728///
729/// # Returns
730/// WaveState with all rounds tracked
731pub async fn execute_wave_in_rounds<I>(
732    wave_number: usize,
733    task_pairs: I,
734    working_dir: &Path,
735    round_size: usize,
736    default_harness: Harness,
737) -> Result<WaveState>
738where
739    I: IntoIterator<Item = (Task, String)>,
740{
741    let agents: Vec<WaveAgent> = WaveAgent::from_task_pairs(task_pairs);
742    let mut wave_state = WaveState::new(wave_number);
743
744    for (round_idx, chunk) in agents.chunks(round_size).enumerate() {
745        let result = execute_wave_async(chunk, working_dir, round_idx, default_harness).await?;
746        wave_state.apply_execution_result(result);
747    }
748
749    wave_state.mark_complete();
750    Ok(wave_state)
751}
752
753/// Spawn a single agent using extension-based spawning (async, no tmux)
754///
755/// This is a convenience function for spawning a single agent.
756pub async fn spawn_subagent(
757    task: &Task,
758    tag: &str,
759    working_dir: &Path,
760    default_harness: Harness,
761) -> Result<crate::extensions::runner::AgentResult> {
762    let agents = vec![WaveAgent {
763        task: task.clone(),
764        tag: tag.to_string(),
765    }];
766
767    let result = execute_wave_async(&agents, working_dir, 0, default_harness).await?;
768
769    result
770        .agent_results
771        .into_iter()
772        .next()
773        .ok_or_else(|| anyhow::anyhow!("No result from agent"))
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    #[test]
781    fn test_round_state_new() {
782        let round = RoundState::new(0);
783        assert_eq!(round.round_number, 0);
784        assert!(round.task_ids.is_empty());
785        assert!(round.completed_at.is_none());
786    }
787
788    #[test]
789    fn test_wave_state_all_task_ids() {
790        let mut wave = WaveState::new(1);
791
792        let mut round1 = RoundState::new(0);
793        round1.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
794
795        let mut round2 = RoundState::new(1);
796        round2.task_ids = vec!["task:3".to_string()];
797
798        wave.rounds.push(round1);
799        wave.rounds.push(round2);
800
801        let all_ids = wave.all_task_ids();
802        assert_eq!(all_ids.len(), 3);
803        assert!(all_ids.contains(&"task:1".to_string()));
804        assert!(all_ids.contains(&"task:2".to_string()));
805        assert!(all_ids.contains(&"task:3".to_string()));
806    }
807
808    #[test]
809    fn test_swarm_session_total_tasks() {
810        let mut session = SwarmSession::new("test-session", "test-tag", "tmux", "/test/path", 5);
811
812        let mut wave = WaveState::new(1);
813        let mut round = RoundState::new(0);
814        round.task_ids = vec!["task:1".to_string(), "task:2".to_string()];
815        wave.rounds.push(round);
816        session.waves.push(wave);
817
818        assert_eq!(session.total_tasks(), 2);
819    }
820
821    #[test]
822    fn test_wave_summary_to_text() {
823        let summary = WaveSummary {
824            wave_number: 1,
825            tasks_completed: vec!["task:1".to_string(), "task:2".to_string()],
826            files_changed: vec!["src/main.rs".to_string()],
827        };
828
829        let text = summary.to_text();
830        assert!(text.contains("Wave 1"));
831        assert!(text.contains("task:1"));
832        assert!(text.contains("src/main.rs"));
833    }
834
835    #[test]
836    fn test_get_previous_summary() {
837        let mut session = SwarmSession::new("test", "tag", "tmux", "/path", 5);
838
839        // No waves yet
840        assert!(session.get_previous_summary().is_none());
841
842        // Add wave with summary
843        let mut wave = WaveState::new(1);
844        wave.summary = Some(WaveSummary {
845            wave_number: 1,
846            tasks_completed: vec!["task:1".to_string()],
847            files_changed: vec![],
848        });
849        session.waves.push(wave);
850
851        let summary = session.get_previous_summary();
852        assert!(summary.is_some());
853        assert!(summary.unwrap().contains("task:1"));
854    }
855
856    #[test]
857    fn test_session_lock_contention() {
858        use tempfile::TempDir;
859
860        // Create a temporary directory for testing
861        let temp_dir = TempDir::new().unwrap();
862        let project_root = temp_dir.path().to_path_buf();
863
864        // Acquire first lock
865        let _lock1 = acquire_session_lock(Some(&project_root), "test-tag")
866            .expect("First lock should succeed");
867
868        // Try to acquire second lock for same tag while first is held
869        let result = acquire_session_lock(Some(&project_root), "test-tag");
870
871        // Verify the second attempt fails and error message mentions "already running"
872        match result {
873            Ok(_) => panic!("Second lock should fail"),
874            Err(e) => {
875                let error_msg = e.to_string();
876                assert!(
877                    error_msg.contains("already running"),
878                    "Error message should mention 'already running', got: {}",
879                    error_msg
880                );
881            }
882        }
883    }
884
885    #[test]
886    fn test_get_current_commit() {
887        let result = get_current_commit();
888
889        // Should return Some(sha) since we're in a git repo
890        assert!(result.is_some(), "Expected Some(sha) in a git repository");
891
892        let sha = result.unwrap();
893
894        // Verify the SHA is 40 characters long (full SHA)
895        assert_eq!(
896            sha.len(),
897            40,
898            "Expected SHA to be 40 characters long, got {}",
899            sha.len()
900        );
901
902        // Verify the SHA contains only hex characters (0-9, a-f)
903        assert!(
904            sha.chars().all(|c| c.is_ascii_hexdigit()),
905            "Expected SHA to contain only hex characters, got: {}",
906            sha
907        );
908    }
909
910    #[test]
911    fn test_wave_agent_new() {
912        let task = Task::new(
913            "task:1".to_string(),
914            "Test task".to_string(),
915            "Description".to_string(),
916        );
917        let agent = WaveAgent::new(task.clone(), "test-tag");
918
919        assert_eq!(agent.task_id(), "task:1");
920        assert_eq!(agent.tag, "test-tag");
921    }
922
923    #[test]
924    fn test_wave_agent_from_task_pairs() {
925        let task1 = Task::new(
926            "task:1".to_string(),
927            "Task 1".to_string(),
928            "Description".to_string(),
929        );
930        let task2 = Task::new(
931            "task:2".to_string(),
932            "Task 2".to_string(),
933            "Description".to_string(),
934        );
935
936        let pairs = vec![(task1, "tag-a".to_string()), (task2, "tag-b".to_string())];
937
938        let agents = WaveAgent::from_task_pairs(pairs);
939
940        assert_eq!(agents.len(), 2);
941        assert_eq!(agents[0].task_id(), "task:1");
942        assert_eq!(agents[0].tag, "tag-a");
943        assert_eq!(agents[1].task_id(), "task:2");
944        assert_eq!(agents[1].tag, "tag-b");
945    }
946
947    #[test]
948    fn test_wave_execution_result_helpers() {
949        use crate::extensions::runner::AgentResult;
950
951        let result = WaveExecutionResult {
952            round_state: RoundState::new(0),
953            agent_results: vec![
954                AgentResult {
955                    task_id: "task:1".to_string(),
956                    success: true,
957                    exit_code: Some(0),
958                    output: String::new(),
959                    duration_ms: 1000,
960                },
961                AgentResult {
962                    task_id: "task:2".to_string(),
963                    success: false,
964                    exit_code: Some(1),
965                    output: String::new(),
966                    duration_ms: 2000,
967                },
968            ],
969        };
970
971        assert!(!result.all_succeeded());
972        assert_eq!(result.successful_task_ids(), vec!["task:1"]);
973        assert_eq!(result.failed_task_ids(), vec!["task:2"]);
974        assert_eq!(result.total_duration_ms(), 2000);
975    }
976
977    #[test]
978    fn test_wave_state_from_execution_result() {
979        use crate::extensions::runner::AgentResult;
980
981        let mut round_state = RoundState::new(0);
982        round_state.task_ids = vec!["task:1".to_string()];
983
984        let result = WaveExecutionResult {
985            round_state,
986            agent_results: vec![AgentResult {
987                task_id: "task:1".to_string(),
988                success: true,
989                exit_code: Some(0),
990                output: String::new(),
991                duration_ms: 1000,
992            }],
993        };
994
995        let wave_state = WaveState::from_execution_result(1, result);
996
997        assert_eq!(wave_state.wave_number, 1);
998        assert_eq!(wave_state.rounds.len(), 1);
999        assert_eq!(wave_state.rounds[0].task_ids, vec!["task:1"]);
1000    }
1001
1002    #[test]
1003    fn test_wave_state_apply_execution_result() {
1004        use crate::extensions::runner::AgentResult;
1005
1006        let mut wave_state = WaveState::new(1);
1007        assert!(wave_state.rounds.is_empty());
1008
1009        let mut round_state = RoundState::new(0);
1010        round_state.task_ids = vec!["task:1".to_string()];
1011
1012        let result = WaveExecutionResult {
1013            round_state,
1014            agent_results: vec![AgentResult {
1015                task_id: "task:1".to_string(),
1016                success: true,
1017                exit_code: Some(0),
1018                output: String::new(),
1019                duration_ms: 1000,
1020            }],
1021        };
1022
1023        wave_state.apply_execution_result(result);
1024
1025        assert_eq!(wave_state.rounds.len(), 1);
1026        assert_eq!(wave_state.all_task_ids(), vec!["task:1"]);
1027    }
1028}