Skip to main content

autom8/
state.rs

1use crate::claude::{
2    extract_decisions, extract_files_context, extract_patterns, ClaudeUsage, FileContextEntry,
3};
4use crate::config::{self, Config};
5use crate::error::Result;
6use crate::git;
7use crate::knowledge::{Decision, FileChange, FileInfo, Pattern, ProjectKnowledge, StoryChanges};
8use crate::worktree::{get_current_session_id, MAIN_SESSION_ID};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::fs;
13use std::path::PathBuf;
14use uuid::Uuid;
15
16const STATE_FILE: &str = "state.json";
17const METADATA_FILE: &str = "metadata.json";
18const LIVE_FILE: &str = "live.json";
19const SESSIONS_DIR: &str = "sessions";
20const RUNS_DIR: &str = "runs";
21const SPEC_DIR: &str = "spec";
22
23/// Maximum number of output lines to keep in LiveState.
24/// Prevents unbounded memory growth during long Claude runs.
25const LIVE_STATE_MAX_LINES: usize = 50;
26
27/// Metadata about a session, stored separately from the full state.
28///
29/// This enables quick session listing without loading the full state file.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct SessionMetadata {
33    /// Unique session identifier (e.g., "main" or 8-char hash)
34    pub session_id: String,
35    /// Absolute path to the worktree directory
36    pub worktree_path: PathBuf,
37    /// The branch being worked on in this session
38    pub branch_name: String,
39    /// When the session was created
40    pub created_at: DateTime<Utc>,
41    /// When the session was last active (updated on each state save)
42    pub last_active_at: DateTime<Utc>,
43    /// Whether this session is currently running (has an active run).
44    /// Used for branch conflict detection - only running sessions "own" their branch.
45    #[serde(default)]
46    pub is_running: bool,
47    /// Path to the spec JSON file used for this session.
48    /// Enables the improve command to quickly load the spec without searching.
49    #[serde(default)]
50    pub spec_json_path: Option<PathBuf>,
51}
52
53/// Enriched session status for display purposes.
54///
55/// Combines session metadata with state information (current story, machine state)
56/// for the status command's `--all` flag.
57#[derive(Debug, Clone)]
58pub struct SessionStatus {
59    /// Session metadata
60    pub metadata: SessionMetadata,
61    /// Current machine state (e.g., "RunningClaude", "Reviewing")
62    pub machine_state: Option<MachineState>,
63    /// Current story ID being worked on
64    pub current_story: Option<String>,
65    /// Whether this session matches the current working directory
66    pub is_current: bool,
67    /// Whether the worktree path still exists
68    pub is_stale: bool,
69}
70
71/// Live streaming state for a session, written frequently during Claude runs.
72///
73/// This struct holds the most recent output lines and current state, enabling
74/// the monitor command to display real-time output without reading the full state.
75/// Written atomically to prevent partial reads.
76///
77/// The `last_heartbeat` field is the authoritative indicator of whether a run is
78/// still active. GUI/TUI should consider a run "active" if heartbeat is recent
79/// (< 10 seconds old).
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct LiveState {
83    /// Recent output lines from Claude (max 50 lines, newest last)
84    pub output_lines: Vec<String>,
85    /// When this live state was last updated (by output or state change)
86    pub updated_at: DateTime<Utc>,
87    /// Current machine state
88    pub machine_state: MachineState,
89    /// Heartbeat timestamp - updated every 2-3 seconds while run is active.
90    /// This is the authoritative indicator of whether the run is still alive.
91    /// GUI/TUI should consider a run "active" if this is < 10 seconds old.
92    #[serde(default = "Utc::now")]
93    pub last_heartbeat: DateTime<Utc>,
94}
95
96/// Threshold for considering a heartbeat "stale" (run likely dead).
97/// GUI/TUI should consider a run inactive if heartbeat is older than this.
98/// Set to 60 seconds to account for periods where Claude is thinking without
99/// sending output, and for phases like Reviewing/Correcting that may take time.
100pub const HEARTBEAT_STALE_THRESHOLD_SECS: i64 = 60;
101
102impl LiveState {
103    /// Create a new LiveState with the given machine state.
104    pub fn new(machine_state: MachineState) -> Self {
105        let now = Utc::now();
106        Self {
107            output_lines: Vec::new(),
108            updated_at: now,
109            machine_state,
110            last_heartbeat: now,
111        }
112    }
113
114    /// Append a line to the output, keeping at most 50 lines.
115    /// Updates the `updated_at` timestamp.
116    pub fn append_line(&mut self, line: String) {
117        self.output_lines.push(line);
118        // Keep only the last 50 lines
119        if self.output_lines.len() > LIVE_STATE_MAX_LINES {
120            let excess = self.output_lines.len() - LIVE_STATE_MAX_LINES;
121            self.output_lines.drain(0..excess);
122        }
123        self.updated_at = Utc::now();
124    }
125
126    /// Update the heartbeat timestamp to indicate the run is still active.
127    /// This should be called every 2-3 seconds during an active run.
128    pub fn update_heartbeat(&mut self) {
129        self.last_heartbeat = Utc::now();
130    }
131
132    /// Update the machine state and refresh timestamps.
133    /// Called when the state machine transitions to a new state.
134    pub fn update_state(&mut self, new_state: MachineState) {
135        self.machine_state = new_state;
136        let now = Utc::now();
137        self.updated_at = now;
138        self.last_heartbeat = now;
139    }
140
141    /// Check if the heartbeat is recent enough to consider the run active.
142    /// Returns true if the heartbeat is less than HEARTBEAT_STALE_THRESHOLD_SECS old.
143    pub fn is_heartbeat_fresh(&self) -> bool {
144        let age = Utc::now()
145            .signed_duration_since(self.last_heartbeat)
146            .num_seconds();
147        age < HEARTBEAT_STALE_THRESHOLD_SECS
148    }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum RunStatus {
154    Running,
155    Completed,
156    Failed,
157    Interrupted,
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "kebab-case")]
162pub enum MachineState {
163    Idle,
164    LoadingSpec,
165    GeneratingSpec,
166    Initializing,
167    PickingStory,
168    RunningClaude,
169    Reviewing,
170    Correcting,
171    Committing,
172    #[serde(rename = "creating-pr")]
173    CreatingPR,
174    Completed,
175    Failed,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct IterationRecord {
180    pub number: u32,
181    pub story_id: String,
182    pub started_at: DateTime<Utc>,
183    pub finished_at: Option<DateTime<Utc>>,
184    pub status: IterationStatus,
185    pub output_snippet: String,
186    /// Summary of what was accomplished in this iteration, for cross-task context
187    #[serde(default)]
188    pub work_summary: Option<String>,
189    /// Token usage data for this iteration
190    #[serde(default)]
191    pub usage: Option<ClaudeUsage>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum IterationStatus {
197    Running,
198    Success,
199    Failed,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct RunState {
204    pub run_id: String,
205    pub status: RunStatus,
206    pub machine_state: MachineState,
207    pub spec_json_path: PathBuf,
208    #[serde(default)]
209    pub spec_md_path: Option<PathBuf>,
210    pub branch: String,
211    pub current_story: Option<String>,
212    pub iteration: u32,
213    /// Tracks the current review cycle (1, 2, or 3) during the review loop
214    #[serde(default)]
215    pub review_iteration: u32,
216    pub started_at: DateTime<Utc>,
217    pub finished_at: Option<DateTime<Utc>>,
218    pub iterations: Vec<IterationRecord>,
219    /// Configuration snapshot taken at run start.
220    /// This ensures resumed runs use the same config they started with.
221    #[serde(default)]
222    pub config: Option<Config>,
223    /// Cumulative project knowledge tracked across agent runs.
224    /// Contains file info, decisions, patterns, and story changes.
225    #[serde(default)]
226    pub knowledge: ProjectKnowledge,
227    /// Git commit hash captured before starting each story.
228    /// Used to calculate diffs for what changed during the story.
229    #[serde(default)]
230    pub pre_story_commit: Option<String>,
231    /// Session identifier for worktree-based parallel execution.
232    /// Deterministic ID derived from worktree path (or "main" for main repo).
233    #[serde(default)]
234    pub session_id: Option<String>,
235    /// Total accumulated token usage across all phases of the run.
236    #[serde(default)]
237    pub total_usage: Option<ClaudeUsage>,
238    /// Token usage broken down by phase.
239    /// Keys are story IDs (e.g., "US-001") or pseudo-phase names:
240    /// - "Planning": spec generation
241    /// - "US-001", "US-002", etc.: user story implementation
242    /// - "Final Review": review iterations + corrections
243    /// - "PR & Commit": commit generation + PR creation
244    #[serde(default)]
245    pub phase_usage: HashMap<String, ClaudeUsage>,
246}
247
248impl RunState {
249    pub fn new(spec_json_path: PathBuf, branch: String) -> Self {
250        Self {
251            run_id: Uuid::new_v4().to_string(),
252            status: RunStatus::Running,
253            machine_state: MachineState::Initializing,
254            spec_json_path,
255            spec_md_path: None,
256            branch,
257            current_story: None,
258            iteration: 0,
259            review_iteration: 0,
260            started_at: Utc::now(),
261            finished_at: None,
262            iterations: Vec::new(),
263            config: None,
264            knowledge: ProjectKnowledge::default(),
265            pre_story_commit: None,
266            session_id: None,
267            total_usage: None,
268            phase_usage: HashMap::new(),
269        }
270    }
271
272    /// Create a new RunState with a config snapshot.
273    pub fn new_with_config(spec_json_path: PathBuf, branch: String, config: Config) -> Self {
274        Self {
275            run_id: Uuid::new_v4().to_string(),
276            status: RunStatus::Running,
277            machine_state: MachineState::Initializing,
278            spec_json_path,
279            spec_md_path: None,
280            branch,
281            current_story: None,
282            iteration: 0,
283            review_iteration: 0,
284            started_at: Utc::now(),
285            finished_at: None,
286            iterations: Vec::new(),
287            config: Some(config),
288            knowledge: ProjectKnowledge::default(),
289            pre_story_commit: None,
290            session_id: None,
291            total_usage: None,
292            phase_usage: HashMap::new(),
293        }
294    }
295
296    /// Create a new RunState with a session ID.
297    pub fn new_with_session(spec_json_path: PathBuf, branch: String, session_id: String) -> Self {
298        Self {
299            run_id: Uuid::new_v4().to_string(),
300            status: RunStatus::Running,
301            machine_state: MachineState::Initializing,
302            spec_json_path,
303            spec_md_path: None,
304            branch,
305            current_story: None,
306            iteration: 0,
307            review_iteration: 0,
308            started_at: Utc::now(),
309            finished_at: None,
310            iterations: Vec::new(),
311            config: None,
312            knowledge: ProjectKnowledge::default(),
313            pre_story_commit: None,
314            session_id: Some(session_id),
315            total_usage: None,
316            phase_usage: HashMap::new(),
317        }
318    }
319
320    /// Create a new RunState with config and session ID.
321    pub fn new_with_config_and_session(
322        spec_json_path: PathBuf,
323        branch: String,
324        config: Config,
325        session_id: String,
326    ) -> Self {
327        Self {
328            run_id: Uuid::new_v4().to_string(),
329            status: RunStatus::Running,
330            machine_state: MachineState::Initializing,
331            spec_json_path,
332            spec_md_path: None,
333            branch,
334            current_story: None,
335            iteration: 0,
336            review_iteration: 0,
337            started_at: Utc::now(),
338            finished_at: None,
339            iterations: Vec::new(),
340            config: Some(config),
341            knowledge: ProjectKnowledge::default(),
342            pre_story_commit: None,
343            session_id: Some(session_id),
344            total_usage: None,
345            phase_usage: HashMap::new(),
346        }
347    }
348
349    pub fn from_spec(spec_md_path: PathBuf, spec_json_path: PathBuf) -> Self {
350        Self {
351            run_id: Uuid::new_v4().to_string(),
352            status: RunStatus::Running,
353            machine_state: MachineState::LoadingSpec,
354            spec_json_path,
355            spec_md_path: Some(spec_md_path),
356            branch: String::new(), // Will be set after spec generation
357            current_story: None,
358            iteration: 0,
359            review_iteration: 0,
360            started_at: Utc::now(),
361            finished_at: None,
362            iterations: Vec::new(),
363            config: None,
364            knowledge: ProjectKnowledge::default(),
365            pre_story_commit: None,
366            session_id: None,
367            total_usage: None,
368            phase_usage: HashMap::new(),
369        }
370    }
371
372    /// Create a RunState from spec with a config snapshot.
373    pub fn from_spec_with_config(
374        spec_md_path: PathBuf,
375        spec_json_path: PathBuf,
376        config: Config,
377    ) -> Self {
378        Self {
379            run_id: Uuid::new_v4().to_string(),
380            status: RunStatus::Running,
381            machine_state: MachineState::LoadingSpec,
382            spec_json_path,
383            spec_md_path: Some(spec_md_path),
384            branch: String::new(), // Will be set after spec generation
385            current_story: None,
386            iteration: 0,
387            review_iteration: 0,
388            started_at: Utc::now(),
389            finished_at: None,
390            iterations: Vec::new(),
391            config: Some(config),
392            knowledge: ProjectKnowledge::default(),
393            pre_story_commit: None,
394            session_id: None,
395            total_usage: None,
396            phase_usage: HashMap::new(),
397        }
398    }
399
400    /// Create a RunState from spec with config and session ID.
401    pub fn from_spec_with_config_and_session(
402        spec_md_path: PathBuf,
403        spec_json_path: PathBuf,
404        config: Config,
405        session_id: String,
406    ) -> Self {
407        Self {
408            run_id: Uuid::new_v4().to_string(),
409            status: RunStatus::Running,
410            machine_state: MachineState::LoadingSpec,
411            spec_json_path,
412            spec_md_path: Some(spec_md_path),
413            branch: String::new(), // Will be set after spec generation
414            current_story: None,
415            iteration: 0,
416            review_iteration: 0,
417            started_at: Utc::now(),
418            finished_at: None,
419            iterations: Vec::new(),
420            config: Some(config),
421            knowledge: ProjectKnowledge::default(),
422            pre_story_commit: None,
423            session_id: Some(session_id),
424            total_usage: None,
425            phase_usage: HashMap::new(),
426        }
427    }
428
429    /// Get the effective config for this run.
430    /// Returns the stored config if available, otherwise the default.
431    pub fn effective_config(&self) -> Config {
432        self.config.clone().unwrap_or_default()
433    }
434
435    pub fn transition_to(&mut self, state: MachineState) {
436        self.machine_state = state;
437        match state {
438            MachineState::Completed => {
439                self.status = RunStatus::Completed;
440                self.finished_at = Some(Utc::now());
441            }
442            MachineState::Failed => {
443                self.status = RunStatus::Failed;
444                self.finished_at = Some(Utc::now());
445            }
446            _ => {}
447        }
448    }
449
450    pub fn start_iteration(&mut self, story_id: &str) {
451        self.iteration += 1;
452        self.current_story = Some(story_id.to_string());
453        self.machine_state = MachineState::RunningClaude;
454
455        self.iterations.push(IterationRecord {
456            number: self.iteration,
457            story_id: story_id.to_string(),
458            started_at: Utc::now(),
459            finished_at: None,
460            status: IterationStatus::Running,
461            output_snippet: String::new(),
462            work_summary: None,
463            usage: None,
464        });
465    }
466
467    pub fn finish_iteration(&mut self, status: IterationStatus, output_snippet: String) {
468        if let Some(iter) = self.iterations.last_mut() {
469            iter.finished_at = Some(Utc::now());
470            iter.status = status;
471            iter.output_snippet = output_snippet;
472        }
473        self.machine_state = MachineState::PickingStory;
474    }
475
476    /// Set the work summary on the current (last) iteration
477    pub fn set_work_summary(&mut self, summary: Option<String>) {
478        if let Some(iter) = self.iterations.last_mut() {
479            iter.work_summary = summary;
480        }
481    }
482
483    pub fn current_iteration_duration(&self) -> u64 {
484        if let Some(iter) = self.iterations.last() {
485            let end = iter.finished_at.unwrap_or_else(Utc::now);
486            (end - iter.started_at).num_seconds().max(0) as u64
487        } else {
488            0
489        }
490    }
491
492    /// Get the total run duration in seconds.
493    ///
494    /// Returns the time between `started_at` and `finished_at` (or now if not finished).
495    pub fn run_duration_secs(&self) -> u64 {
496        let end = self.finished_at.unwrap_or_else(Utc::now);
497        (end - self.started_at).num_seconds().max(0) as u64
498    }
499
500    /// Capture the current HEAD commit before starting a story.
501    ///
502    /// This stores the commit hash so we can later calculate what changed
503    /// during the story implementation. For non-git projects, this is a no-op.
504    ///
505    /// On the first call (when `baseline_commit` is not set), this also captures
506    /// the baseline commit for the entire run. This is used to track which files
507    /// autom8 touched vs external changes (US-010).
508    pub fn capture_pre_story_state(&mut self) {
509        if git::is_git_repo() {
510            if let Ok(head) = git::get_head_commit() {
511                // Capture baseline commit on first story (US-010)
512                if self.knowledge.baseline_commit.is_none() {
513                    self.knowledge.baseline_commit = Some(head.clone());
514                }
515                self.pre_story_commit = Some(head);
516            }
517        }
518    }
519
520    /// Record changes made during a story and update project knowledge.
521    ///
522    /// This method:
523    /// 1. Captures the git diff since `pre_story_commit`
524    /// 2. Creates a `StoryChanges` record
525    /// 3. Adds it to the project knowledge
526    ///
527    /// For non-git projects or if `pre_story_commit` is not set, this creates
528    /// an empty `StoryChanges` record.
529    ///
530    /// # Arguments
531    /// * `story_id` - The ID of the story that was just implemented
532    /// * `commit_hash` - Optional commit hash if the changes were committed
533    pub fn record_story_changes(&mut self, story_id: &str, commit_hash: Option<String>) {
534        let mut files_created = Vec::new();
535        let mut files_modified = Vec::new();
536        let mut files_deleted = Vec::new();
537
538        // If we have a pre-story commit, calculate the diff
539        if let Some(ref base_commit) = self.pre_story_commit {
540            if git::is_git_repo() {
541                if let Ok(entries) = git::get_diff_since(base_commit) {
542                    for entry in entries {
543                        let file_change = FileChange {
544                            path: entry.path.clone(),
545                            additions: entry.additions,
546                            deletions: entry.deletions,
547                            purpose: None,
548                            key_symbols: Vec::new(),
549                        };
550
551                        match entry.status {
552                            git::DiffStatus::Added => files_created.push(file_change),
553                            git::DiffStatus::Modified => files_modified.push(file_change),
554                            git::DiffStatus::Deleted => files_deleted.push(entry.path),
555                        }
556                    }
557                }
558            }
559        }
560
561        let story_changes = StoryChanges {
562            story_id: story_id.to_string(),
563            files_created,
564            files_modified,
565            files_deleted,
566            commit_hash,
567        };
568
569        self.knowledge.story_changes.push(story_changes);
570
571        // Clear pre_story_commit after recording
572        self.pre_story_commit = None;
573    }
574
575    /// Capture story knowledge after agent completion.
576    ///
577    /// This method combines two sources of truth:
578    /// 1. Git diff data for empirical knowledge of what files were created/modified
579    /// 2. Agent-provided semantic information (files context, decisions, patterns)
580    ///
581    /// The method:
582    /// - Gets git diff since `pre_story_commit` (if available)
583    /// - Filters changes to only include files autom8 touched (see US-010)
584    /// - Extracts structured context from the agent's output
585    /// - Creates a `StoryChanges` record combining both sources
586    /// - Merges file info into the `knowledge.files` registry
587    /// - Appends decisions and patterns to knowledge
588    ///
589    /// For non-git projects, only agent-provided context is used.
590    ///
591    /// # Arguments
592    /// * `story_id` - The ID of the story that was just implemented
593    /// * `agent_output` - The full output from the Claude agent
594    /// * `commit_hash` - Optional commit hash if the changes were committed
595    pub fn capture_story_knowledge(
596        &mut self,
597        story_id: &str,
598        agent_output: &str,
599        commit_hash: Option<String>,
600    ) {
601        // Extract structured context from agent output
602        let files_context = extract_files_context(agent_output);
603        let agent_decisions = extract_decisions(agent_output);
604        let agent_patterns = extract_patterns(agent_output);
605
606        // Build a map of agent-provided context for enriching git diff data
607        let context_by_path: std::collections::HashMap<PathBuf, &FileContextEntry> = files_context
608            .iter()
609            .map(|fc| (fc.path.clone(), fc))
610            .collect();
611
612        let mut files_created = Vec::new();
613        let mut files_modified = Vec::new();
614        let mut files_deleted: Vec<PathBuf> = Vec::new();
615
616        // If we have a pre-story commit, calculate the diff
617        if let Some(ref base_commit) = self.pre_story_commit {
618            if git::is_git_repo() {
619                if let Ok(all_entries) = git::get_diff_since(base_commit) {
620                    // Filter to only include changes autom8 made (US-010)
621                    let entries = self.knowledge.filter_our_changes(&all_entries);
622
623                    for entry in entries {
624                        // Enrich with agent-provided context if available
625                        let (purpose, key_symbols) = context_by_path
626                            .get(&entry.path)
627                            .map(|fc| (Some(fc.purpose.clone()), fc.key_symbols.clone()))
628                            .unwrap_or((None, Vec::new()));
629
630                        let file_change = FileChange {
631                            path: entry.path.clone(),
632                            additions: entry.additions,
633                            deletions: entry.deletions,
634                            purpose,
635                            key_symbols,
636                        };
637
638                        match entry.status {
639                            git::DiffStatus::Added => files_created.push(file_change),
640                            git::DiffStatus::Modified => files_modified.push(file_change),
641                            git::DiffStatus::Deleted => files_deleted.push(entry.path),
642                        }
643                    }
644                }
645            }
646        }
647
648        // For non-git projects or when no diff available, use agent context directly
649        if files_created.is_empty() && files_modified.is_empty() && files_deleted.is_empty() {
650            // Create file changes from agent context only
651            for fc in &files_context {
652                // We can't know from agent context alone if a file was created vs modified,
653                // so we treat them as modified (safer assumption)
654                files_modified.push(FileChange {
655                    path: fc.path.clone(),
656                    additions: 0,
657                    deletions: 0,
658                    purpose: Some(fc.purpose.clone()),
659                    key_symbols: fc.key_symbols.clone(),
660                });
661            }
662        }
663
664        // Create and add story changes
665        let story_changes = StoryChanges {
666            story_id: story_id.to_string(),
667            files_created: files_created.clone(),
668            files_modified: files_modified.clone(),
669            files_deleted: files_deleted.clone(),
670            commit_hash,
671        };
672        self.knowledge.story_changes.push(story_changes);
673
674        // Merge file info into the files registry
675        for change in files_created.iter().chain(files_modified.iter()) {
676            let file_info = self
677                .knowledge
678                .files
679                .entry(change.path.clone())
680                .or_insert_with(|| FileInfo {
681                    purpose: change.purpose.clone().unwrap_or_default(),
682                    key_symbols: Vec::new(),
683                    touched_by: Vec::new(),
684                    line_count: 0,
685                });
686
687            // Update purpose if we have a new one
688            if let Some(ref purpose) = change.purpose {
689                file_info.purpose = purpose.clone();
690            }
691
692            // Merge key symbols (avoid duplicates)
693            for symbol in &change.key_symbols {
694                if !file_info.key_symbols.contains(symbol) {
695                    file_info.key_symbols.push(symbol.clone());
696                }
697            }
698
699            // Add story to touched_by if not already present
700            if !file_info.touched_by.contains(&story_id.to_string()) {
701                file_info.touched_by.push(story_id.to_string());
702            }
703
704            // Update line count if available
705            if change.additions > 0 {
706                file_info.line_count = file_info.line_count.saturating_add(change.additions);
707                file_info.line_count = file_info.line_count.saturating_sub(change.deletions);
708            }
709        }
710
711        // Remove deleted files from registry
712        for deleted_path in &files_deleted {
713            self.knowledge.files.remove(deleted_path);
714        }
715
716        // Append decisions to knowledge
717        for agent_decision in agent_decisions {
718            self.knowledge.decisions.push(Decision {
719                story_id: story_id.to_string(),
720                topic: agent_decision.topic,
721                choice: agent_decision.choice,
722                rationale: agent_decision.rationale,
723            });
724        }
725
726        // Append patterns to knowledge
727        for agent_pattern in agent_patterns {
728            self.knowledge.patterns.push(Pattern {
729                story_id: story_id.to_string(),
730                description: agent_pattern.description,
731                example_file: None, // Agent doesn't provide example file in pattern output
732            });
733        }
734
735        // Clear pre_story_commit after recording
736        self.pre_story_commit = None;
737    }
738
739    /// Capture usage from a Claude call and add it to the appropriate phase.
740    ///
741    /// This method:
742    /// 1. Adds the usage to the specified phase in `phase_usage`
743    /// 2. Accumulates the usage into `total_usage`
744    ///
745    /// If usage is `None`, this is a no-op.
746    ///
747    /// # Arguments
748    /// * `phase_key` - The phase identifier (e.g., "Planning", "US-001", "Final Review", "PR & Commit")
749    /// * `usage` - The usage data from the Claude call, or None if not available
750    pub fn capture_usage(&mut self, phase_key: &str, usage: Option<ClaudeUsage>) {
751        if let Some(usage) = usage {
752            // Add to phase_usage
753            self.phase_usage
754                .entry(phase_key.to_string())
755                .and_modify(|existing| existing.add(&usage))
756                .or_insert(usage.clone());
757
758            // Accumulate into total_usage
759            match &mut self.total_usage {
760                Some(existing) => existing.add(&usage),
761                None => self.total_usage = Some(usage),
762            }
763        }
764    }
765
766    /// Set usage on the current (last) iteration.
767    ///
768    /// This stores the usage data in the IterationRecord for per-story tracking.
769    /// If usage is `None`, this is a no-op.
770    pub fn set_iteration_usage(&mut self, usage: Option<ClaudeUsage>) {
771        if let Some(iter) = self.iterations.last_mut() {
772            iter.usage = usage;
773        }
774    }
775}
776
777/// Manages session state storage with per-session directory structure.
778///
779/// State is stored in: `~/.config/autom8/<project>/sessions/<session-id>/`
780/// Each session has:
781/// - `state.json` - The full run state
782/// - `metadata.json` - Quick metadata for session listing
783///
784/// The spec/ and runs/ directories remain at the project level (shared across sessions).
785pub struct StateManager {
786    /// Base config directory for the project: `~/.config/autom8/<project>/`
787    base_dir: PathBuf,
788    /// Session ID for this manager (auto-detected from CWD or specified)
789    session_id: String,
790}
791
792impl StateManager {
793    /// Create a StateManager using the config directory for the current project.
794    /// Auto-detects session ID from the current working directory.
795    /// Uses `~/.config/autom8/<project-name>/` as the base directory.
796    pub fn new() -> Result<Self> {
797        let base_dir = config::project_config_dir()?;
798        let session_id = get_current_session_id()?;
799        let mut manager = Self {
800            base_dir,
801            session_id,
802        };
803        manager.migrate_legacy_state()?;
804        Ok(manager)
805    }
806
807    /// Create a StateManager for a specific session.
808    /// Uses `~/.config/autom8/<project-name>/` as the base directory.
809    pub fn with_session(session_id: String) -> Result<Self> {
810        let base_dir = config::project_config_dir()?;
811        let mut manager = Self {
812            base_dir,
813            session_id,
814        };
815        manager.migrate_legacy_state()?;
816        Ok(manager)
817    }
818
819    /// Create a StateManager for a specific project name.
820    /// Auto-detects session ID from the current working directory.
821    /// Uses `~/.config/autom8/<project-name>/` as the base directory.
822    pub fn for_project(project_name: &str) -> Result<Self> {
823        let base_dir = config::project_config_dir_for(project_name)?;
824        let session_id = get_current_session_id()?;
825        let mut manager = Self {
826            base_dir,
827            session_id,
828        };
829        manager.migrate_legacy_state()?;
830        Ok(manager)
831    }
832
833    /// Create a StateManager for a specific project and session.
834    /// Uses `~/.config/autom8/<project-name>/` as the base directory.
835    pub fn for_project_session(project_name: &str, session_id: String) -> Result<Self> {
836        let base_dir = config::project_config_dir_for(project_name)?;
837        let mut manager = Self {
838            base_dir,
839            session_id,
840        };
841        manager.migrate_legacy_state()?;
842        Ok(manager)
843    }
844
845    /// Create a StateManager with a custom base directory (for testing).
846    pub fn with_dir(dir: PathBuf) -> Self {
847        Self {
848            base_dir: dir,
849            session_id: MAIN_SESSION_ID.to_string(),
850        }
851    }
852
853    /// Create a StateManager with a custom base directory and session ID (for testing).
854    pub fn with_dir_and_session(dir: PathBuf, session_id: String) -> Self {
855        Self {
856            base_dir: dir,
857            session_id,
858        }
859    }
860
861    /// Get the session ID for this manager.
862    pub fn session_id(&self) -> &str {
863        &self.session_id
864    }
865
866    /// Path to the sessions directory: `~/.config/autom8/<project>/sessions/`
867    fn sessions_dir(&self) -> PathBuf {
868        self.base_dir.join(SESSIONS_DIR)
869    }
870
871    /// Path to this session's directory: `~/.config/autom8/<project>/sessions/<session-id>/`
872    fn session_dir(&self) -> PathBuf {
873        self.sessions_dir().join(&self.session_id)
874    }
875
876    /// Path to the state file for this session
877    fn state_file(&self) -> PathBuf {
878        self.session_dir().join(STATE_FILE)
879    }
880
881    /// Path to the metadata file for this session
882    fn metadata_file(&self) -> PathBuf {
883        self.session_dir().join(METADATA_FILE)
884    }
885
886    /// Path to the live state file for this session
887    fn live_file(&self) -> PathBuf {
888        self.session_dir().join(LIVE_FILE)
889    }
890
891    /// Path to the legacy state file (for migration)
892    fn legacy_state_file(&self) -> PathBuf {
893        self.base_dir.join(STATE_FILE)
894    }
895
896    /// Path to the runs directory (archived runs)
897    pub fn runs_dir(&self) -> PathBuf {
898        self.base_dir.join(RUNS_DIR)
899    }
900
901    /// Path to the spec directory
902    pub fn spec_dir(&self) -> PathBuf {
903        self.base_dir.join(SPEC_DIR)
904    }
905
906    /// Migrate legacy state.json to the new sessions structure.
907    ///
908    /// On first run after upgrade, if there's a state.json in the project root,
909    /// migrate it to sessions/main/state.json and create appropriate metadata.
910    fn migrate_legacy_state(&mut self) -> Result<()> {
911        let legacy_path = self.legacy_state_file();
912
913        // Only migrate if legacy file exists and sessions dir doesn't have main session
914        if !legacy_path.exists() {
915            return Ok(());
916        }
917
918        let main_session_dir = self.sessions_dir().join(MAIN_SESSION_ID);
919        let main_state_file = main_session_dir.join(STATE_FILE);
920
921        // Skip if already migrated
922        if main_state_file.exists() {
923            // Remove the legacy file since migration was already done
924            let _ = fs::remove_file(&legacy_path);
925            return Ok(());
926        }
927
928        // Read the legacy state
929        let content = fs::read_to_string(&legacy_path)?;
930        let mut state: RunState = serde_json::from_str(&content)?;
931
932        // Update the state to have the main session ID
933        if state.session_id.is_none() {
934            state.session_id = Some(MAIN_SESSION_ID.to_string());
935        }
936
937        // Create the sessions/main/ directory
938        fs::create_dir_all(&main_session_dir)?;
939
940        // Write state to new location
941        let state_content = serde_json::to_string_pretty(&state)?;
942        fs::write(&main_state_file, state_content)?;
943
944        // Create metadata for the migrated session
945        let worktree_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
946        let metadata = SessionMetadata {
947            session_id: MAIN_SESSION_ID.to_string(),
948            worktree_path,
949            branch_name: state.branch.clone(),
950            created_at: state.started_at,
951            last_active_at: state.finished_at.unwrap_or_else(Utc::now),
952            is_running: state.status == RunStatus::Running,
953            spec_json_path: Some(state.spec_json_path.clone()),
954        };
955        let metadata_content = serde_json::to_string_pretty(&metadata)?;
956        fs::write(main_session_dir.join(METADATA_FILE), metadata_content)?;
957
958        // Remove the legacy state file
959        fs::remove_file(&legacy_path)?;
960
961        Ok(())
962    }
963
964    pub fn ensure_dirs(&self) -> Result<()> {
965        fs::create_dir_all(&self.base_dir)?;
966        fs::create_dir_all(self.session_dir())?;
967        fs::create_dir_all(self.runs_dir())?;
968        Ok(())
969    }
970
971    /// Ensure spec directory exists
972    pub fn ensure_spec_dir(&self) -> Result<PathBuf> {
973        let dir = self.spec_dir();
974        fs::create_dir_all(&dir)?;
975        Ok(dir)
976    }
977
978    /// List all spec JSON files in the config directory's spec/, sorted by modification time (newest first)
979    pub fn list_specs(&self) -> Result<Vec<PathBuf>> {
980        let spec_dir = self.spec_dir();
981        if !spec_dir.exists() {
982            return Ok(Vec::new());
983        }
984
985        let mut specs: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
986        for entry in fs::read_dir(&spec_dir)? {
987            let entry = entry?;
988            let path = entry.path();
989            if path.extension().is_some_and(|e| e == "json") {
990                if let Ok(metadata) = entry.metadata() {
991                    if let Ok(mtime) = metadata.modified() {
992                        specs.push((path, mtime));
993                    }
994                }
995            }
996        }
997
998        // Sort by modification time, newest first
999        specs.sort_by(|a, b| b.1.cmp(&a.1));
1000        Ok(specs.into_iter().map(|(p, _)| p).collect())
1001    }
1002
1003    pub fn load_current(&self) -> Result<Option<RunState>> {
1004        let path = self.state_file();
1005        if !path.exists() {
1006            return Ok(None);
1007        }
1008        let content = fs::read_to_string(&path)?;
1009        let state: RunState = serde_json::from_str(&content)?;
1010        Ok(Some(state))
1011    }
1012
1013    /// Load the metadata for the current session.
1014    pub fn load_metadata(&self) -> Result<Option<SessionMetadata>> {
1015        let path = self.metadata_file();
1016        if !path.exists() {
1017            return Ok(None);
1018        }
1019        let content = fs::read_to_string(&path)?;
1020        let metadata: SessionMetadata = serde_json::from_str(&content)?;
1021        Ok(Some(metadata))
1022    }
1023
1024    /// Save the run state and update session metadata.
1025    pub fn save(&self, state: &RunState) -> Result<()> {
1026        self.ensure_dirs()?;
1027
1028        // Save the state
1029        let content = serde_json::to_string_pretty(state)?;
1030        fs::write(self.state_file(), content)?;
1031
1032        // Update or create metadata
1033        self.save_metadata(state)?;
1034
1035        Ok(())
1036    }
1037
1038    /// Save session metadata based on the current state.
1039    fn save_metadata(&self, state: &RunState) -> Result<()> {
1040        let worktree_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1041        let is_running = state.status == RunStatus::Running;
1042
1043        // Load existing metadata or create new
1044        let metadata = if let Some(existing) = self.load_metadata()? {
1045            SessionMetadata {
1046                session_id: self.session_id.clone(),
1047                worktree_path,
1048                branch_name: state.branch.clone(),
1049                created_at: existing.created_at,
1050                last_active_at: Utc::now(),
1051                is_running,
1052                spec_json_path: Some(state.spec_json_path.clone()),
1053            }
1054        } else {
1055            SessionMetadata {
1056                session_id: self.session_id.clone(),
1057                worktree_path,
1058                branch_name: state.branch.clone(),
1059                created_at: state.started_at,
1060                last_active_at: Utc::now(),
1061                is_running,
1062                spec_json_path: Some(state.spec_json_path.clone()),
1063            }
1064        };
1065
1066        let content = serde_json::to_string_pretty(&metadata)?;
1067        fs::write(self.metadata_file(), content)?;
1068
1069        Ok(())
1070    }
1071
1072    pub fn clear_current(&self) -> Result<()> {
1073        let path = self.state_file();
1074        if path.exists() {
1075            fs::remove_file(path)?;
1076        }
1077        // Also clear metadata
1078        let metadata_path = self.metadata_file();
1079        if metadata_path.exists() {
1080            fs::remove_file(metadata_path)?;
1081        }
1082        // Also clear live state
1083        self.clear_live()?;
1084        // Try to remove the session directory if empty
1085        let session_dir = self.session_dir();
1086        let _ = fs::remove_dir(&session_dir); // Ignore error if not empty
1087        Ok(())
1088    }
1089
1090    /// Save live state atomically (write to temp file, then rename).
1091    ///
1092    /// Atomic writes prevent the monitor from reading a partial/corrupted file.
1093    pub fn save_live(&self, live_state: &LiveState) -> Result<()> {
1094        self.ensure_dirs()?;
1095
1096        let live_path = self.live_file();
1097        let temp_path = live_path.with_extension("json.tmp");
1098
1099        // Write to temp file
1100        let content = serde_json::to_string(live_state)?;
1101        fs::write(&temp_path, content)?;
1102
1103        // Atomic rename
1104        fs::rename(&temp_path, &live_path)?;
1105
1106        Ok(())
1107    }
1108
1109    /// Load live state, returning None if file doesn't exist or is corrupted.
1110    ///
1111    /// Gracefully handles missing or malformed files so the monitor can
1112    /// recover without crashing.
1113    pub fn load_live(&self) -> Option<LiveState> {
1114        let path = self.live_file();
1115        if !path.exists() {
1116            return None;
1117        }
1118
1119        let content = fs::read_to_string(&path).ok()?;
1120        serde_json::from_str(&content).ok()
1121    }
1122
1123    /// Remove the live state file.
1124    pub fn clear_live(&self) -> Result<()> {
1125        let path = self.live_file();
1126        if path.exists() {
1127            fs::remove_file(path)?;
1128        }
1129        Ok(())
1130    }
1131
1132    pub fn archive(&self, state: &RunState) -> Result<PathBuf> {
1133        self.ensure_dirs()?;
1134        let filename = format!(
1135            "{}_{}.json",
1136            state.started_at.format("%Y%m%d_%H%M%S"),
1137            &state.run_id[..8]
1138        );
1139        let archive_path = self.runs_dir().join(filename);
1140        let content = serde_json::to_string_pretty(state)?;
1141        fs::write(&archive_path, content)?;
1142        Ok(archive_path)
1143    }
1144
1145    pub fn list_archived(&self) -> Result<Vec<RunState>> {
1146        let runs_dir = self.runs_dir();
1147        if !runs_dir.exists() {
1148            return Ok(Vec::new());
1149        }
1150
1151        let mut runs = Vec::new();
1152        for entry in fs::read_dir(runs_dir)? {
1153            let entry = entry?;
1154            let path = entry.path();
1155            if path.extension().is_some_and(|e| e == "json") {
1156                if let Ok(content) = fs::read_to_string(&path) {
1157                    if let Ok(state) = serde_json::from_str::<RunState>(&content) {
1158                        runs.push(state);
1159                    }
1160                }
1161            }
1162        }
1163
1164        runs.sort_by(|a, b| b.started_at.cmp(&a.started_at));
1165        Ok(runs)
1166    }
1167
1168    pub fn has_active_run(&self) -> Result<bool> {
1169        if let Some(state) = self.load_current()? {
1170            Ok(state.status == RunStatus::Running)
1171        } else {
1172            Ok(false)
1173        }
1174    }
1175
1176    /// List all sessions for this project with their metadata.
1177    ///
1178    /// Returns sessions sorted by last_active_at descending (most recent first).
1179    /// Sessions without valid metadata are skipped.
1180    pub fn list_sessions(&self) -> Result<Vec<SessionMetadata>> {
1181        let sessions_dir = self.sessions_dir();
1182        if !sessions_dir.exists() {
1183            return Ok(Vec::new());
1184        }
1185
1186        let mut sessions = Vec::new();
1187        for entry in fs::read_dir(&sessions_dir)? {
1188            let entry = entry?;
1189            let path = entry.path();
1190            if path.is_dir() {
1191                let metadata_path = path.join(METADATA_FILE);
1192                if let Ok(content) = fs::read_to_string(&metadata_path) {
1193                    if let Ok(metadata) = serde_json::from_str::<SessionMetadata>(&content) {
1194                        sessions.push(metadata);
1195                    }
1196                }
1197            }
1198        }
1199
1200        // Sort by last_active_at descending
1201        sessions.sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at));
1202        Ok(sessions)
1203    }
1204
1205    /// Get a specific session by ID.
1206    ///
1207    /// Returns a new StateManager configured for the specified session.
1208    /// Returns None if the session doesn't exist.
1209    pub fn get_session(&self, session_id: &str) -> Option<StateManager> {
1210        let session_dir = self.sessions_dir().join(session_id);
1211        if session_dir.exists() && session_dir.join(STATE_FILE).exists() {
1212            Some(StateManager {
1213                base_dir: self.base_dir.clone(),
1214                session_id: session_id.to_string(),
1215            })
1216        } else {
1217            None
1218        }
1219    }
1220
1221    /// List all sessions with enriched status information.
1222    ///
1223    /// Returns sessions sorted with current session first, then by last_active_at
1224    /// descending. Includes state details (machine state, current story) and
1225    /// marks stale sessions (deleted worktrees).
1226    pub fn list_sessions_with_status(&self) -> Result<Vec<SessionStatus>> {
1227        let sessions = self.list_sessions()?;
1228        let current_dir = std::env::current_dir().ok();
1229
1230        let mut statuses: Vec<SessionStatus> = sessions
1231            .into_iter()
1232            .map(|metadata| {
1233                // Check if this is the current session
1234                let is_current = current_dir
1235                    .as_ref()
1236                    .map(|cwd| cwd == &metadata.worktree_path)
1237                    .unwrap_or(false);
1238
1239                // Check if worktree still exists
1240                let is_stale = !metadata.worktree_path.exists();
1241
1242                // Load state for this session to get machine_state and current_story
1243                let (machine_state, current_story) =
1244                    if let Some(session_sm) = self.get_session(&metadata.session_id) {
1245                        if let Ok(Some(state)) = session_sm.load_current() {
1246                            (Some(state.machine_state), state.current_story)
1247                        } else {
1248                            (None, None)
1249                        }
1250                    } else {
1251                        (None, None)
1252                    };
1253
1254                SessionStatus {
1255                    metadata,
1256                    machine_state,
1257                    current_story,
1258                    is_current,
1259                    is_stale,
1260                }
1261            })
1262            .collect();
1263
1264        // Sort: current first, then by last_active_at descending
1265        statuses.sort_by(|a, b| {
1266            // Current session always first
1267            match (a.is_current, b.is_current) {
1268                (true, false) => std::cmp::Ordering::Less,
1269                (false, true) => std::cmp::Ordering::Greater,
1270                _ => b.metadata.last_active_at.cmp(&a.metadata.last_active_at),
1271            }
1272        });
1273
1274        Ok(statuses)
1275    }
1276
1277    /// Find the most recent session that worked on the specified branch.
1278    ///
1279    /// Searches all sessions in the project and returns the one with the most
1280    /// recent `last_active_at` timestamp that matches the branch name. This is
1281    /// used by the `improve` command to load accumulated knowledge from previous
1282    /// runs on the same branch.
1283    ///
1284    /// Both worktree sessions and main repo sessions are searched.
1285    ///
1286    /// # Arguments
1287    /// * `branch` - The branch name to search for
1288    ///
1289    /// # Returns
1290    /// * `Ok(Some(metadata))` - Found a session that worked on this branch
1291    /// * `Ok(None)` - No session found for this branch (graceful degradation)
1292    /// * `Err` - Error reading session data
1293    pub fn find_session_for_branch(&self, branch: &str) -> Result<Option<SessionMetadata>> {
1294        let sessions = self.list_sessions()?;
1295
1296        // list_sessions() already returns sessions sorted by last_active_at descending,
1297        // so the first match is the most recent
1298        for session in sessions {
1299            if session.branch_name == branch {
1300                return Ok(Some(session));
1301            }
1302        }
1303
1304        Ok(None)
1305    }
1306
1307    /// Check for branch conflicts with other active sessions.
1308    ///
1309    /// Returns the conflicting session metadata if another session is already
1310    /// using the specified branch. A session "owns" a branch only while it is
1311    /// actively running (status == Running).
1312    ///
1313    /// Stale sessions (where the worktree directory no longer exists) are
1314    /// automatically skipped and do not cause conflicts.
1315    ///
1316    /// # Arguments
1317    /// * `branch_name` - The branch name to check for conflicts
1318    ///
1319    /// # Returns
1320    /// * `Ok(Some(metadata))` - Another session is using this branch
1321    /// * `Ok(None)` - No conflict, branch is available
1322    /// * `Err` - Error reading session data
1323    pub fn check_branch_conflict(&self, branch_name: &str) -> Result<Option<SessionMetadata>> {
1324        let sessions = self.list_sessions()?;
1325
1326        for session in sessions {
1327            // Skip our own session
1328            if session.session_id == self.session_id {
1329                continue;
1330            }
1331
1332            // Skip sessions not using this branch
1333            if session.branch_name != branch_name {
1334                continue;
1335            }
1336
1337            // Skip sessions that aren't running
1338            if !session.is_running {
1339                continue;
1340            }
1341
1342            // Check if the worktree still exists (detect stale sessions)
1343            if !session.worktree_path.exists() {
1344                // Stale session - worktree deleted but metadata remains
1345                // Don't block on this session
1346                continue;
1347            }
1348
1349            // Found a conflict - another active session is using this branch
1350            return Ok(Some(session));
1351        }
1352
1353        Ok(None)
1354    }
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360    use tempfile::TempDir;
1361
1362    // =========================================================================
1363    // RunState Creation and Transitions
1364    // =========================================================================
1365
1366    #[test]
1367    fn test_run_state_creation_and_defaults() {
1368        let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1369        assert_eq!(state.branch, "test-branch");
1370        assert_eq!(state.review_iteration, 0);
1371        assert_eq!(state.machine_state, MachineState::Initializing);
1372        assert_eq!(state.status, RunStatus::Running);
1373        assert!(state.config.is_none());
1374        assert!(state.session_id.is_none());
1375
1376        let state_with_config = RunState::new_with_config(
1377            PathBuf::from("test.json"),
1378            "test-branch".to_string(),
1379            crate::config::Config::default(),
1380        );
1381        assert!(state_with_config.config.is_some());
1382    }
1383
1384    #[test]
1385    fn test_state_transitions() {
1386        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1387
1388        // Normal workflow
1389        state.transition_to(MachineState::PickingStory);
1390        assert_eq!(state.machine_state, MachineState::PickingStory);
1391        assert_eq!(state.status, RunStatus::Running);
1392
1393        state.transition_to(MachineState::RunningClaude);
1394        state.transition_to(MachineState::Reviewing);
1395        state.transition_to(MachineState::Correcting);
1396        state.transition_to(MachineState::Reviewing);
1397        state.review_iteration = 2;
1398        assert_eq!(state.review_iteration, 2);
1399
1400        state.transition_to(MachineState::Committing);
1401        state.transition_to(MachineState::CreatingPR);
1402        assert_eq!(state.status, RunStatus::Running);
1403
1404        state.transition_to(MachineState::Completed);
1405        assert_eq!(state.status, RunStatus::Completed);
1406
1407        // Failed transition
1408        let mut failed = RunState::new(PathBuf::from("test.json"), "branch".to_string());
1409        failed.transition_to(MachineState::Failed);
1410        assert_eq!(failed.status, RunStatus::Failed);
1411    }
1412
1413    // =========================================================================
1414    // Serialization
1415    // =========================================================================
1416
1417    #[test]
1418    fn test_serialization_roundtrip() {
1419        // MachineState
1420        for (state, expected) in [
1421            (MachineState::Idle, "\"idle\""),
1422            (MachineState::RunningClaude, "\"running-claude\""),
1423            (MachineState::CreatingPR, "\"creating-pr\""),
1424        ] {
1425            let json = serde_json::to_string(&state).unwrap();
1426            assert_eq!(json, expected);
1427            assert_eq!(serde_json::from_str::<MachineState>(&json).unwrap(), state);
1428        }
1429
1430        // RunStatus
1431        for (status, expected) in [
1432            (RunStatus::Running, "\"running\""),
1433            (RunStatus::Completed, "\"completed\""),
1434        ] {
1435            let json = serde_json::to_string(&status).unwrap();
1436            assert_eq!(json, expected);
1437            assert_eq!(serde_json::from_str::<RunStatus>(&json).unwrap(), status);
1438        }
1439
1440        // Full RunState
1441        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1442        state.start_iteration("US-001");
1443        state.set_work_summary(Some("Summary".to_string()));
1444        let json = serde_json::to_string(&state).unwrap();
1445        let back: RunState = serde_json::from_str(&json).unwrap();
1446        assert_eq!(back.branch, state.branch);
1447    }
1448
1449    #[test]
1450    fn test_backwards_compatibility() {
1451        let legacy = r#"{"number":1,"story_id":"US-001","started_at":"2024-01-01T00:00:00Z","finished_at":null,"status":"running","output_snippet":""}"#;
1452        let record: IterationRecord = serde_json::from_str(legacy).unwrap();
1453        assert!(record.work_summary.is_none());
1454    }
1455
1456    // =========================================================================
1457    // Iteration Management
1458    // =========================================================================
1459
1460    #[test]
1461    fn test_iteration_management() {
1462        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1463        state.start_iteration("US-001");
1464        assert!(state.iterations[0].work_summary.is_none());
1465
1466        state.set_work_summary(Some("Feature".to_string()));
1467        assert_eq!(
1468            state.iterations[0].work_summary,
1469            Some("Feature".to_string())
1470        );
1471
1472        state.set_work_summary(None);
1473        assert!(state.iterations[0].work_summary.is_none());
1474
1475        // No crash with empty iterations
1476        let mut empty = RunState::new(PathBuf::from("test.json"), "branch".to_string());
1477        empty.set_work_summary(Some("Safe".to_string()));
1478    }
1479
1480    // =========================================================================
1481    // StateManager CRUD
1482    // =========================================================================
1483
1484    #[test]
1485    fn test_state_manager_save_load_clear() {
1486        let temp_dir = TempDir::new().unwrap();
1487        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1488
1489        let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1490        sm.save(&state).unwrap();
1491
1492        assert!(temp_dir
1493            .path()
1494            .join(SESSIONS_DIR)
1495            .join(MAIN_SESSION_ID)
1496            .join(STATE_FILE)
1497            .exists());
1498
1499        let loaded = sm.load_current().unwrap().unwrap();
1500        assert_eq!(loaded.branch, "test-branch");
1501
1502        sm.clear_current().unwrap();
1503        assert!(sm.load_current().unwrap().is_none());
1504    }
1505
1506    #[test]
1507    fn test_state_manager_archive() {
1508        let temp_dir = TempDir::new().unwrap();
1509        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1510
1511        let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
1512        sm.save(&state).unwrap();
1513
1514        let archive_path = sm.archive(&state).unwrap();
1515        assert!(archive_path.exists());
1516
1517        let archived = sm.list_archived().unwrap();
1518        assert_eq!(archived.len(), 1);
1519    }
1520
1521    #[test]
1522    fn test_state_manager_directory_structure() {
1523        let temp_dir = TempDir::new().unwrap();
1524        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1525        sm.ensure_dirs().unwrap();
1526
1527        assert!(temp_dir.path().join(RUNS_DIR).is_dir());
1528        assert!(temp_dir.path().join(SESSIONS_DIR).is_dir());
1529        // Note: SPEC_DIR is created by ensure_spec_dir(), not ensure_dirs()
1530    }
1531
1532    // =========================================================================
1533    // Session Management
1534    // =========================================================================
1535
1536    #[test]
1537    fn test_session_isolation() {
1538        let temp_dir = TempDir::new().unwrap();
1539
1540        let sm1 = StateManager::with_dir_and_session(
1541            temp_dir.path().to_path_buf(),
1542            "session-a".to_string(),
1543        );
1544        let sm2 = StateManager::with_dir_and_session(
1545            temp_dir.path().to_path_buf(),
1546            "session-b".to_string(),
1547        );
1548
1549        sm1.save(&RunState::new(
1550            PathBuf::from("a.json"),
1551            "branch-a".to_string(),
1552        ))
1553        .unwrap();
1554        sm2.save(&RunState::new(
1555            PathBuf::from("b.json"),
1556            "branch-b".to_string(),
1557        ))
1558        .unwrap();
1559
1560        assert_eq!(sm1.load_current().unwrap().unwrap().branch, "branch-a");
1561        assert_eq!(sm2.load_current().unwrap().unwrap().branch, "branch-b");
1562        assert_eq!(sm1.list_sessions().unwrap().len(), 2);
1563    }
1564
1565    #[test]
1566    fn test_session_metadata() {
1567        let temp_dir = TempDir::new().unwrap();
1568        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1569
1570        assert!(sm.load_metadata().unwrap().is_none());
1571
1572        let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1573        sm.save(&state).unwrap();
1574
1575        let metadata = sm.load_metadata().unwrap().unwrap();
1576        assert!(metadata.is_running);
1577
1578        let mut completed = sm.load_current().unwrap().unwrap();
1579        completed.transition_to(MachineState::Completed);
1580        sm.save(&completed).unwrap();
1581        assert!(!sm.load_metadata().unwrap().unwrap().is_running);
1582    }
1583
1584    // =========================================================================
1585    // LiveState
1586    // =========================================================================
1587
1588    #[test]
1589    fn test_live_state() {
1590        let mut live = LiveState::new(MachineState::RunningClaude);
1591        assert!(live.is_heartbeat_fresh());
1592
1593        for i in 0..60 {
1594            live.append_line(format!("line {}", i));
1595        }
1596        assert_eq!(live.output_lines.len(), 50); // Max 50
1597
1598        live.last_heartbeat = chrono::Utc::now() - chrono::Duration::seconds(65);
1599        assert!(!live.is_heartbeat_fresh());
1600    }
1601
1602    #[test]
1603    fn test_live_state_persistence() {
1604        let temp_dir = TempDir::new().unwrap();
1605        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1606
1607        assert!(sm.load_live().is_none());
1608
1609        let mut live = LiveState::new(MachineState::RunningClaude);
1610        live.append_line("output".to_string());
1611        sm.save_live(&live).unwrap();
1612
1613        assert!(sm.load_live().is_some());
1614        sm.clear_live().unwrap();
1615        assert!(sm.load_live().is_none());
1616    }
1617
1618    // =========================================================================
1619    // Config and Knowledge
1620    // =========================================================================
1621
1622    #[test]
1623    fn test_config_preservation() {
1624        let temp_dir = TempDir::new().unwrap();
1625        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
1626
1627        let mut config = crate::config::Config::default();
1628        config.review = false;
1629        let state =
1630            RunState::new_with_config(PathBuf::from("test.json"), "branch".to_string(), config);
1631        sm.save(&state).unwrap();
1632
1633        assert!(
1634            !sm.load_current()
1635                .unwrap()
1636                .unwrap()
1637                .effective_config()
1638                .review
1639        );
1640    }
1641
1642    #[test]
1643    fn test_knowledge_tracking() {
1644        let mut state = RunState::new(PathBuf::from("test.json"), "branch".to_string());
1645        state.knowledge.story_changes.push(StoryChanges {
1646            story_id: "US-001".to_string(),
1647            files_created: vec![],
1648            files_modified: vec![FileChange {
1649                path: PathBuf::from("src/main.rs"),
1650                additions: 10,
1651                deletions: 2,
1652                purpose: Some("Main entry point".to_string()),
1653                key_symbols: vec![],
1654            }],
1655            files_deleted: vec![],
1656            commit_hash: None,
1657        });
1658        assert!(state
1659            .knowledge
1660            .story_changes
1661            .iter()
1662            .any(|c| c.story_id == "US-001"));
1663    }
1664
1665    // Tests for token usage fields (US-004)
1666
1667    #[test]
1668    fn test_iteration_record_usage_initialized_as_none() {
1669        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1670        state.start_iteration("US-001");
1671        assert!(state.iterations[0].usage.is_none());
1672    }
1673
1674    #[test]
1675    fn test_iteration_record_usage_can_be_set() {
1676        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1677        state.start_iteration("US-001");
1678        state.iterations[0].usage = Some(ClaudeUsage {
1679            input_tokens: 1000,
1680            output_tokens: 500,
1681            cache_read_tokens: 200,
1682            cache_creation_tokens: 100,
1683            thinking_tokens: 50,
1684            model: Some("claude-sonnet-4-20250514".to_string()),
1685        });
1686        assert!(state.iterations[0].usage.is_some());
1687        assert_eq!(
1688            state.iterations[0].usage.as_ref().unwrap().input_tokens,
1689            1000
1690        );
1691    }
1692
1693    #[test]
1694    fn test_iteration_record_backwards_compatible_without_usage() {
1695        // Simulate a legacy state.json that doesn't have the usage field
1696        let legacy_json = r#"{
1697            "number": 1,
1698            "story_id": "US-001",
1699            "started_at": "2024-01-01T00:00:00Z",
1700            "finished_at": null,
1701            "status": "running",
1702            "output_snippet": ""
1703        }"#;
1704
1705        let record: IterationRecord = serde_json::from_str(legacy_json).unwrap();
1706        assert!(record.usage.is_none());
1707        assert_eq!(record.story_id, "US-001");
1708    }
1709
1710    #[test]
1711    fn test_run_state_total_usage_initialized_as_none() {
1712        let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1713        assert!(state.total_usage.is_none());
1714    }
1715
1716    #[test]
1717    fn test_run_state_phase_usage_initialized_empty() {
1718        let state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1719        assert!(state.phase_usage.is_empty());
1720    }
1721
1722    #[test]
1723    fn test_run_state_total_usage_can_be_set() {
1724        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1725        state.total_usage = Some(ClaudeUsage {
1726            input_tokens: 5000,
1727            output_tokens: 2500,
1728            cache_read_tokens: 1000,
1729            cache_creation_tokens: 500,
1730            thinking_tokens: 250,
1731            model: Some("claude-sonnet-4-20250514".to_string()),
1732        });
1733        assert!(state.total_usage.is_some());
1734        assert_eq!(state.total_usage.as_ref().unwrap().total_tokens(), 7500);
1735    }
1736
1737    #[test]
1738    fn test_run_state_phase_usage_can_be_populated() {
1739        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1740
1741        // Add usage for various phases
1742        state.phase_usage.insert(
1743            "Planning".to_string(),
1744            ClaudeUsage {
1745                input_tokens: 1000,
1746                output_tokens: 500,
1747                ..Default::default()
1748            },
1749        );
1750        state.phase_usage.insert(
1751            "US-001".to_string(),
1752            ClaudeUsage {
1753                input_tokens: 2000,
1754                output_tokens: 1000,
1755                ..Default::default()
1756            },
1757        );
1758        state.phase_usage.insert(
1759            "Final Review".to_string(),
1760            ClaudeUsage {
1761                input_tokens: 500,
1762                output_tokens: 250,
1763                ..Default::default()
1764            },
1765        );
1766        state.phase_usage.insert(
1767            "PR & Commit".to_string(),
1768            ClaudeUsage {
1769                input_tokens: 300,
1770                output_tokens: 150,
1771                ..Default::default()
1772            },
1773        );
1774
1775        assert_eq!(state.phase_usage.len(), 4);
1776        assert!(state.phase_usage.contains_key("Planning"));
1777        assert!(state.phase_usage.contains_key("US-001"));
1778        assert!(state.phase_usage.contains_key("Final Review"));
1779        assert!(state.phase_usage.contains_key("PR & Commit"));
1780    }
1781
1782    #[test]
1783    fn test_run_state_backwards_compatible_without_usage_fields() {
1784        // Simulate a legacy RunState JSON without total_usage and phase_usage fields
1785        let legacy_json = r#"{
1786            "run_id": "test-run-id",
1787            "status": "running",
1788            "machine_state": "running-claude",
1789            "spec_json_path": "test.json",
1790            "branch": "test-branch",
1791            "current_story": "US-001",
1792            "iteration": 1,
1793            "started_at": "2024-01-01T00:00:00Z",
1794            "finished_at": null,
1795            "iterations": []
1796        }"#;
1797
1798        let state: RunState = serde_json::from_str(legacy_json).unwrap();
1799        assert!(state.total_usage.is_none());
1800        assert!(state.phase_usage.is_empty());
1801        assert_eq!(state.run_id, "test-run-id");
1802        assert_eq!(state.branch, "test-branch");
1803    }
1804
1805    #[test]
1806    fn test_iteration_record_usage_serialization_roundtrip() {
1807        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1808        state.start_iteration("US-001");
1809        state.iterations[0].usage = Some(ClaudeUsage {
1810            input_tokens: 1000,
1811            output_tokens: 500,
1812            cache_read_tokens: 200,
1813            cache_creation_tokens: 100,
1814            thinking_tokens: 50,
1815            model: Some("claude-sonnet-4-20250514".to_string()),
1816        });
1817
1818        // Serialize
1819        let json = serde_json::to_string(&state).unwrap();
1820        assert!(json.contains("\"inputTokens\":1000"));
1821        assert!(json.contains("\"outputTokens\":500"));
1822
1823        // Deserialize
1824        let deserialized: RunState = serde_json::from_str(&json).unwrap();
1825        assert!(deserialized.iterations[0].usage.is_some());
1826        let usage = deserialized.iterations[0].usage.as_ref().unwrap();
1827        assert_eq!(usage.input_tokens, 1000);
1828        assert_eq!(usage.output_tokens, 500);
1829        assert_eq!(usage.cache_read_tokens, 200);
1830        assert_eq!(usage.cache_creation_tokens, 100);
1831        assert_eq!(usage.thinking_tokens, 50);
1832        assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
1833    }
1834
1835    #[test]
1836    fn test_run_state_usage_serialization_roundtrip() {
1837        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1838        state.total_usage = Some(ClaudeUsage {
1839            input_tokens: 5000,
1840            output_tokens: 2500,
1841            ..Default::default()
1842        });
1843        state.phase_usage.insert(
1844            "US-001".to_string(),
1845            ClaudeUsage {
1846                input_tokens: 2000,
1847                output_tokens: 1000,
1848                ..Default::default()
1849            },
1850        );
1851
1852        // Serialize
1853        let json = serde_json::to_string(&state).unwrap();
1854        // RunState uses snake_case serialization (no rename_all attribute)
1855        assert!(json.contains("\"total_usage\""));
1856        assert!(json.contains("\"phase_usage\""));
1857
1858        // Deserialize
1859        let deserialized: RunState = serde_json::from_str(&json).unwrap();
1860        assert!(deserialized.total_usage.is_some());
1861        assert_eq!(
1862            deserialized.total_usage.as_ref().unwrap().input_tokens,
1863            5000
1864        );
1865        assert_eq!(deserialized.phase_usage.len(), 1);
1866        assert!(deserialized.phase_usage.contains_key("US-001"));
1867        assert_eq!(
1868            deserialized.phase_usage.get("US-001").unwrap().input_tokens,
1869            2000
1870        );
1871    }
1872
1873    #[test]
1874    fn test_from_spec_constructors_initialize_usage_fields() {
1875        let state = RunState::from_spec(
1876            PathBuf::from("spec-feature.md"),
1877            PathBuf::from("spec-feature.json"),
1878        );
1879        assert!(state.total_usage.is_none());
1880        assert!(state.phase_usage.is_empty());
1881
1882        let state2 = RunState::from_spec_with_config(
1883            PathBuf::from("spec-feature.md"),
1884            PathBuf::from("spec-feature.json"),
1885            Config::default(),
1886        );
1887        assert!(state2.total_usage.is_none());
1888        assert!(state2.phase_usage.is_empty());
1889    }
1890
1891    // ======================================================================
1892    // Tests for US-005: capture_usage and set_iteration_usage methods
1893    // ======================================================================
1894
1895    #[test]
1896    fn test_capture_usage_first_call_initializes_totals() {
1897        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1898
1899        let usage = ClaudeUsage {
1900            input_tokens: 100,
1901            output_tokens: 50,
1902            cache_read_tokens: 10,
1903            cache_creation_tokens: 5,
1904            thinking_tokens: 3,
1905            model: Some("claude-sonnet-4".to_string()),
1906        };
1907
1908        state.capture_usage("Planning", Some(usage.clone()));
1909
1910        // total_usage should be set
1911        assert!(state.total_usage.is_some());
1912        let total = state.total_usage.as_ref().unwrap();
1913        assert_eq!(total.input_tokens, 100);
1914        assert_eq!(total.output_tokens, 50);
1915        assert_eq!(total.cache_read_tokens, 10);
1916
1917        // phase_usage should have Planning entry
1918        assert!(state.phase_usage.contains_key("Planning"));
1919        let planning = state.phase_usage.get("Planning").unwrap();
1920        assert_eq!(planning.input_tokens, 100);
1921        assert_eq!(planning.output_tokens, 50);
1922    }
1923
1924    #[test]
1925    fn test_capture_usage_accumulates_into_existing_phase() {
1926        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1927
1928        let usage1 = ClaudeUsage {
1929            input_tokens: 100,
1930            output_tokens: 50,
1931            ..Default::default()
1932        };
1933        let usage2 = ClaudeUsage {
1934            input_tokens: 200,
1935            output_tokens: 100,
1936            ..Default::default()
1937        };
1938
1939        state.capture_usage("Final Review", Some(usage1));
1940        state.capture_usage("Final Review", Some(usage2));
1941
1942        // Phase usage should be accumulated
1943        let review = state.phase_usage.get("Final Review").unwrap();
1944        assert_eq!(review.input_tokens, 300);
1945        assert_eq!(review.output_tokens, 150);
1946
1947        // Total usage should also be accumulated
1948        let total = state.total_usage.as_ref().unwrap();
1949        assert_eq!(total.input_tokens, 300);
1950        assert_eq!(total.output_tokens, 150);
1951    }
1952
1953    #[test]
1954    fn test_capture_usage_multiple_phases() {
1955        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
1956
1957        state.capture_usage(
1958            "Planning",
1959            Some(ClaudeUsage {
1960                input_tokens: 1000,
1961                output_tokens: 500,
1962                ..Default::default()
1963            }),
1964        );
1965        state.capture_usage(
1966            "US-001",
1967            Some(ClaudeUsage {
1968                input_tokens: 2000,
1969                output_tokens: 1000,
1970                ..Default::default()
1971            }),
1972        );
1973        state.capture_usage(
1974            "US-002",
1975            Some(ClaudeUsage {
1976                input_tokens: 1500,
1977                output_tokens: 750,
1978                ..Default::default()
1979            }),
1980        );
1981        state.capture_usage(
1982            "Final Review",
1983            Some(ClaudeUsage {
1984                input_tokens: 500,
1985                output_tokens: 250,
1986                ..Default::default()
1987            }),
1988        );
1989        state.capture_usage(
1990            "PR & Commit",
1991            Some(ClaudeUsage {
1992                input_tokens: 300,
1993                output_tokens: 150,
1994                ..Default::default()
1995            }),
1996        );
1997
1998        // Verify all phases are tracked
1999        assert_eq!(state.phase_usage.len(), 5);
2000        assert_eq!(
2001            state.phase_usage.get("Planning").unwrap().input_tokens,
2002            1000
2003        );
2004        assert_eq!(state.phase_usage.get("US-001").unwrap().input_tokens, 2000);
2005        assert_eq!(state.phase_usage.get("US-002").unwrap().input_tokens, 1500);
2006        assert_eq!(
2007            state.phase_usage.get("Final Review").unwrap().input_tokens,
2008            500
2009        );
2010        assert_eq!(
2011            state.phase_usage.get("PR & Commit").unwrap().input_tokens,
2012            300
2013        );
2014
2015        // Verify total is sum of all phases
2016        let total = state.total_usage.as_ref().unwrap();
2017        assert_eq!(total.input_tokens, 1000 + 2000 + 1500 + 500 + 300);
2018        assert_eq!(total.output_tokens, 500 + 1000 + 750 + 250 + 150);
2019    }
2020
2021    #[test]
2022    fn test_capture_usage_with_none_is_noop() {
2023        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2024
2025        state.capture_usage("Planning", None);
2026
2027        // Should remain unset
2028        assert!(state.total_usage.is_none());
2029        assert!(state.phase_usage.is_empty());
2030    }
2031
2032    #[test]
2033    fn test_capture_usage_none_after_some_preserves_existing() {
2034        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2035
2036        state.capture_usage(
2037            "Planning",
2038            Some(ClaudeUsage {
2039                input_tokens: 100,
2040                output_tokens: 50,
2041                ..Default::default()
2042            }),
2043        );
2044
2045        // Calling with None should not change anything
2046        state.capture_usage("Planning", None);
2047
2048        assert_eq!(state.phase_usage.get("Planning").unwrap().input_tokens, 100);
2049        assert_eq!(state.total_usage.as_ref().unwrap().input_tokens, 100);
2050    }
2051
2052    #[test]
2053    fn test_set_iteration_usage_sets_on_current_iteration() {
2054        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2055        state.start_iteration("US-001");
2056
2057        let usage = ClaudeUsage {
2058            input_tokens: 500,
2059            output_tokens: 250,
2060            model: Some("claude-sonnet-4".to_string()),
2061            ..Default::default()
2062        };
2063
2064        state.set_iteration_usage(Some(usage.clone()));
2065
2066        assert!(state.iterations.last().unwrap().usage.is_some());
2067        let iter_usage = state.iterations.last().unwrap().usage.as_ref().unwrap();
2068        assert_eq!(iter_usage.input_tokens, 500);
2069        assert_eq!(iter_usage.output_tokens, 250);
2070    }
2071
2072    #[test]
2073    fn test_set_iteration_usage_with_none_does_not_set() {
2074        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2075        state.start_iteration("US-001");
2076
2077        state.set_iteration_usage(None);
2078
2079        assert!(state.iterations.last().unwrap().usage.is_none());
2080    }
2081
2082    #[test]
2083    fn test_set_iteration_usage_no_iteration_is_noop() {
2084        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2085
2086        // No iteration started, should not panic
2087        state.set_iteration_usage(Some(ClaudeUsage {
2088            input_tokens: 100,
2089            ..Default::default()
2090        }));
2091
2092        // No iterations exist
2093        assert!(state.iterations.is_empty());
2094    }
2095
2096    #[test]
2097    fn test_capture_usage_preserves_model_from_first_call() {
2098        let mut state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2099
2100        state.capture_usage(
2101            "Planning",
2102            Some(ClaudeUsage {
2103                input_tokens: 100,
2104                model: Some("claude-sonnet-4".to_string()),
2105                ..Default::default()
2106            }),
2107        );
2108        state.capture_usage(
2109            "Planning",
2110            Some(ClaudeUsage {
2111                input_tokens: 200,
2112                model: Some("claude-opus-4".to_string()),
2113                ..Default::default()
2114            }),
2115        );
2116
2117        // Model should be preserved from first call (add() preserves existing model)
2118        let planning = state.phase_usage.get("Planning").unwrap();
2119        assert_eq!(planning.model, Some("claude-sonnet-4".to_string()));
2120    }
2121
2122    // ======================================================================
2123    // Tests for find_session_for_branch (US-002)
2124    // ======================================================================
2125
2126    #[test]
2127    fn test_find_session_for_branch_returns_none_when_no_sessions() {
2128        let temp_dir = TempDir::new().unwrap();
2129        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2130
2131        let result = sm.find_session_for_branch("feature/test").unwrap();
2132        assert!(result.is_none());
2133    }
2134
2135    #[test]
2136    fn test_find_session_for_branch_returns_none_when_no_match() {
2137        let temp_dir = TempDir::new().unwrap();
2138        let sm = StateManager::with_dir_and_session(
2139            temp_dir.path().to_path_buf(),
2140            "session-1".to_string(),
2141        );
2142
2143        // Create a session with a different branch
2144        let state = RunState::new(PathBuf::from("test.json"), "feature/other".to_string());
2145        sm.save(&state).unwrap();
2146
2147        let result = sm.find_session_for_branch("feature/test").unwrap();
2148        assert!(result.is_none());
2149    }
2150
2151    #[test]
2152    fn test_find_session_for_branch_returns_matching_session() {
2153        let temp_dir = TempDir::new().unwrap();
2154        let sm = StateManager::with_dir_and_session(
2155            temp_dir.path().to_path_buf(),
2156            "session-1".to_string(),
2157        );
2158
2159        let state = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
2160        sm.save(&state).unwrap();
2161
2162        let result = sm.find_session_for_branch("feature/test").unwrap();
2163        assert!(result.is_some());
2164        let metadata = result.unwrap();
2165        assert_eq!(metadata.branch_name, "feature/test");
2166        assert_eq!(metadata.session_id, "session-1");
2167    }
2168
2169    #[test]
2170    fn test_find_session_for_branch_returns_most_recent() {
2171        let temp_dir = TempDir::new().unwrap();
2172
2173        // Create two sessions with the same branch, different times
2174        let sm1 = StateManager::with_dir_and_session(
2175            temp_dir.path().to_path_buf(),
2176            "session-old".to_string(),
2177        );
2178        let state1 = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
2179        sm1.save(&state1).unwrap();
2180
2181        // Sleep briefly to ensure different timestamps
2182        std::thread::sleep(std::time::Duration::from_millis(10));
2183
2184        let sm2 = StateManager::with_dir_and_session(
2185            temp_dir.path().to_path_buf(),
2186            "session-new".to_string(),
2187        );
2188        let state2 = RunState::new(PathBuf::from("test.json"), "feature/test".to_string());
2189        sm2.save(&state2).unwrap();
2190
2191        // Query should return the most recent session
2192        let result = sm1.find_session_for_branch("feature/test").unwrap();
2193        assert!(result.is_some());
2194        let metadata = result.unwrap();
2195        assert_eq!(metadata.session_id, "session-new");
2196    }
2197
2198    #[test]
2199    fn test_find_session_for_branch_searches_all_sessions() {
2200        let temp_dir = TempDir::new().unwrap();
2201
2202        // Create sessions with different branches
2203        let sm1 = StateManager::with_dir_and_session(
2204            temp_dir.path().to_path_buf(),
2205            "session-a".to_string(),
2206        );
2207        sm1.save(&RunState::new(
2208            PathBuf::from("a.json"),
2209            "feature/a".to_string(),
2210        ))
2211        .unwrap();
2212
2213        let sm2 = StateManager::with_dir_and_session(
2214            temp_dir.path().to_path_buf(),
2215            "session-b".to_string(),
2216        );
2217        sm2.save(&RunState::new(
2218            PathBuf::from("b.json"),
2219            "feature/b".to_string(),
2220        ))
2221        .unwrap();
2222
2223        let sm3 = StateManager::with_dir_and_session(
2224            temp_dir.path().to_path_buf(),
2225            MAIN_SESSION_ID.to_string(),
2226        );
2227        sm3.save(&RunState::new(
2228            PathBuf::from("main.json"),
2229            "feature/main".to_string(),
2230        ))
2231        .unwrap();
2232
2233        // Query from any session manager should find the right branch
2234        let result_a = sm3.find_session_for_branch("feature/a").unwrap();
2235        assert!(result_a.is_some());
2236        assert_eq!(result_a.unwrap().session_id, "session-a");
2237
2238        let result_b = sm1.find_session_for_branch("feature/b").unwrap();
2239        assert!(result_b.is_some());
2240        assert_eq!(result_b.unwrap().session_id, "session-b");
2241
2242        let result_main = sm2.find_session_for_branch("feature/main").unwrap();
2243        assert!(result_main.is_some());
2244        assert_eq!(result_main.unwrap().session_id, MAIN_SESSION_ID);
2245    }
2246
2247    // ======================================================================
2248    // Tests for spec_json_path in SessionMetadata (US-003)
2249    // ======================================================================
2250
2251    #[test]
2252    fn test_session_metadata_spec_json_path_defaults_to_none() {
2253        // Simulate a legacy metadata JSON without spec_json_path field
2254        let legacy_json = r#"{
2255            "sessionId": "test-session",
2256            "worktreePath": "/path/to/worktree",
2257            "branchName": "feature/test",
2258            "createdAt": "2024-01-01T00:00:00Z",
2259            "lastActiveAt": "2024-01-01T01:00:00Z",
2260            "isRunning": false
2261        }"#;
2262
2263        let metadata: SessionMetadata = serde_json::from_str(legacy_json).unwrap();
2264        assert!(metadata.spec_json_path.is_none());
2265        assert_eq!(metadata.session_id, "test-session");
2266        assert_eq!(metadata.branch_name, "feature/test");
2267    }
2268
2269    #[test]
2270    fn test_session_metadata_spec_json_path_serialization_roundtrip() {
2271        let metadata = SessionMetadata {
2272            session_id: "test-session".to_string(),
2273            worktree_path: PathBuf::from("/path/to/worktree"),
2274            branch_name: "feature/test".to_string(),
2275            created_at: Utc::now(),
2276            last_active_at: Utc::now(),
2277            is_running: false,
2278            spec_json_path: Some(PathBuf::from("/path/to/spec.json")),
2279        };
2280
2281        // Serialize
2282        let json = serde_json::to_string(&metadata).unwrap();
2283        assert!(json.contains("\"specJsonPath\""));
2284        assert!(json.contains("/path/to/spec.json"));
2285
2286        // Deserialize
2287        let deserialized: SessionMetadata = serde_json::from_str(&json).unwrap();
2288        assert_eq!(
2289            deserialized.spec_json_path,
2290            Some(PathBuf::from("/path/to/spec.json"))
2291        );
2292    }
2293
2294    #[test]
2295    fn test_save_metadata_populates_spec_json_path() {
2296        let temp_dir = TempDir::new().unwrap();
2297        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2298
2299        let state = RunState::new(
2300            PathBuf::from("/config/spec/spec-feature.json"),
2301            "feature/test".to_string(),
2302        );
2303        sm.save(&state).unwrap();
2304
2305        let metadata = sm.load_metadata().unwrap().unwrap();
2306        assert_eq!(
2307            metadata.spec_json_path,
2308            Some(PathBuf::from("/config/spec/spec-feature.json"))
2309        );
2310    }
2311
2312    #[test]
2313    fn test_save_metadata_updates_spec_json_path() {
2314        let temp_dir = TempDir::new().unwrap();
2315        let sm = StateManager::with_dir(temp_dir.path().to_path_buf());
2316
2317        // First save with one spec
2318        let state1 = RunState::new(PathBuf::from("spec-v1.json"), "feature/test".to_string());
2319        sm.save(&state1).unwrap();
2320
2321        let metadata1 = sm.load_metadata().unwrap().unwrap();
2322        assert_eq!(
2323            metadata1.spec_json_path,
2324            Some(PathBuf::from("spec-v1.json"))
2325        );
2326
2327        // Second save with different spec
2328        let state2 = RunState::new(PathBuf::from("spec-v2.json"), "feature/test".to_string());
2329        sm.save(&state2).unwrap();
2330
2331        let metadata2 = sm.load_metadata().unwrap().unwrap();
2332        assert_eq!(
2333            metadata2.spec_json_path,
2334            Some(PathBuf::from("spec-v2.json"))
2335        );
2336    }
2337
2338    #[test]
2339    fn test_find_session_for_branch_returns_spec_json_path() {
2340        let temp_dir = TempDir::new().unwrap();
2341        let sm = StateManager::with_dir_and_session(
2342            temp_dir.path().to_path_buf(),
2343            "session-1".to_string(),
2344        );
2345
2346        let state = RunState::new(
2347            PathBuf::from("spec-feature.json"),
2348            "feature/test".to_string(),
2349        );
2350        sm.save(&state).unwrap();
2351
2352        let result = sm.find_session_for_branch("feature/test").unwrap();
2353        assert!(result.is_some());
2354        let metadata = result.unwrap();
2355        assert_eq!(
2356            metadata.spec_json_path,
2357            Some(PathBuf::from("spec-feature.json"))
2358        );
2359    }
2360}