Skip to main content

ralph/session/
mod.rs

1//! Session persistence for crash recovery.
2//!
3//! Responsibilities:
4//! - Save, load, and clear session state to/from .ralph/cache/session.jsonc.
5//! - Validate session state against current queue state.
6//! - Provide session recovery detection and prompts.
7//!
8//! Not handled here:
9//! - Session state definition (see crate::contracts::session).
10//! - Task execution logic (see crate::commands::run).
11//!
12//! Invariants/assumptions:
13//! - Session file is written atomically using fsutil::write_atomic.
14//! - Session is considered stale if task no longer exists or is not Doing.
15//! - Session timeout is checked before allowing resume.
16
17use std::io::{self, IsTerminal, Write};
18use std::path::{Path, PathBuf};
19
20use anyhow::{Context, Result};
21
22use crate::constants::paths::SESSION_FILENAME;
23use crate::contracts::{QueueFile, SessionState, TaskStatus};
24use crate::fsutil;
25use crate::timeutil;
26
27/// Get the path to the session file.
28pub fn session_path(cache_dir: &Path) -> PathBuf {
29    cache_dir.join(SESSION_FILENAME)
30}
31
32/// Check if a session file exists.
33pub fn session_exists(cache_dir: &Path) -> bool {
34    session_path(cache_dir).exists()
35}
36
37/// Save session state to disk.
38pub fn save_session(cache_dir: &Path, session: &SessionState) -> Result<()> {
39    let path = session_path(cache_dir);
40    let json = serde_json::to_string_pretty(session).context("serialize session state")?;
41    fsutil::write_atomic(&path, json.as_bytes()).context("write session file")?;
42    log::debug!("Session saved: task_id={}", session.task_id);
43    Ok(())
44}
45
46/// Load session state from disk.
47pub fn load_session(cache_dir: &Path) -> Result<Option<SessionState>> {
48    let path = session_path(cache_dir);
49    if !path.exists() {
50        return Ok(None);
51    }
52
53    let content = std::fs::read_to_string(&path).context("read session file")?;
54    let session: SessionState = serde_json::from_str(&content).context("parse session file")?;
55
56    // Version check for forward compatibility
57    if session.version > crate::contracts::SESSION_STATE_VERSION {
58        log::warn!(
59            "Session file version {} is newer than supported version {}. \
60             Attempting to load anyway.",
61            session.version,
62            crate::contracts::SESSION_STATE_VERSION
63        );
64    }
65
66    Ok(Some(session))
67}
68
69/// Clear (delete) the session file.
70pub fn clear_session(cache_dir: &Path) -> Result<()> {
71    let path = session_path(cache_dir);
72    if path.exists() {
73        std::fs::remove_file(&path).context("remove session file")?;
74        log::debug!("Session cleared");
75    }
76    Ok(())
77}
78
79/// Increment the session's tasks_completed_in_loop counter and persist.
80///
81/// Returns Ok(()) on success, or an error if session load/save fails.
82/// Logs a warning on failure but does not propagate the error to avoid
83/// disrupting the run loop.
84pub fn increment_session_progress(cache_dir: &Path) -> Result<()> {
85    let mut session = match load_session(cache_dir)? {
86        Some(s) => s,
87        None => {
88            log::debug!("No session to increment progress for");
89            return Ok(());
90        }
91    };
92
93    let now = crate::timeutil::now_utc_rfc3339_or_fallback();
94    session.mark_task_complete(now);
95    save_session(cache_dir, &session)
96}
97
98/// Result of session validation.
99// Allow large enum variant because SessionState is naturally large (contains strings and phase
100// settings) and boxing would add complexity to all usage sites without meaningful benefit.
101#[allow(clippy::large_enum_variant)]
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum SessionValidationResult {
104    /// Session is valid and can be resumed.
105    Valid(SessionState),
106    /// No session file exists.
107    NoSession,
108    /// Session is stale (task completed, rejected, or no longer exists).
109    Stale { reason: String },
110    /// Session has timed out (older than threshold).
111    Timeout { hours: u64, session: SessionState },
112}
113
114/// Internal helper that accepts an injected `now` for deterministic testing.
115///
116/// Compares `now` against the session's `last_updated_at` to detect timeouts.
117/// Uses `OffsetDateTime` directly to avoid string roundtrip issues.
118fn validate_session_with_now(
119    session: &SessionState,
120    queue: &QueueFile,
121    timeout_hours: Option<u64>,
122    now: time::OffsetDateTime,
123) -> SessionValidationResult {
124    // Check if task still exists and is in Doing status
125    let task = match queue.tasks.iter().find(|t| t.id.trim() == session.task_id) {
126        Some(t) => t,
127        None => {
128            return SessionValidationResult::Stale {
129                reason: format!("Task {} no longer exists in queue", session.task_id),
130            };
131        }
132    };
133
134    if task.status != TaskStatus::Doing {
135        return SessionValidationResult::Stale {
136            reason: format!(
137                "Task {} is not in Doing status (current: {})",
138                session.task_id, task.status
139            ),
140        };
141    }
142
143    // Check session timeout using the injected `now`
144    if let Some(timeout) = timeout_hours
145        && let Ok(session_time) = timeutil::parse_rfc3339(&session.last_updated_at)
146    {
147        // Calculate duration by subtracting earlier from later
148        if now > session_time {
149            let elapsed = now - session_time;
150            let hours = elapsed.whole_hours() as u64;
151            if hours >= timeout {
152                return SessionValidationResult::Timeout {
153                    hours,
154                    session: session.clone(),
155                };
156            }
157        }
158    }
159
160    SessionValidationResult::Valid(session.clone())
161}
162
163/// Validate a session against the current queue state.
164///
165/// Uses the current UTC time for timeout comparisons. For deterministic testing,
166/// use `validate_session_with_now` directly.
167pub fn validate_session(
168    session: &SessionState,
169    queue: &QueueFile,
170    timeout_hours: Option<u64>,
171) -> SessionValidationResult {
172    validate_session_with_now(
173        session,
174        queue,
175        timeout_hours,
176        time::OffsetDateTime::now_utc(),
177    )
178}
179
180/// Check for existing session and return validation result.
181pub fn check_session(
182    cache_dir: &Path,
183    queue: &QueueFile,
184    timeout_hours: Option<u64>,
185) -> Result<SessionValidationResult> {
186    let session = match load_session(cache_dir)? {
187        Some(s) => s,
188        None => return Ok(SessionValidationResult::NoSession),
189    };
190
191    Ok(validate_session(&session, queue, timeout_hours))
192}
193
194/// Prompt the user for session recovery confirmation.
195///
196/// When `non_interactive` is true or stdin is not a TTY, returns `Ok(false)`
197/// without prompting, choosing the safe default of not resuming.
198pub fn prompt_session_recovery(session: &SessionState, non_interactive: bool) -> Result<bool> {
199    if non_interactive || !std::io::stdin().is_terminal() {
200        log::info!(
201            "Non-interactive environment detected; skipping session resume for {}",
202            session.task_id
203        );
204        return Ok(false); // Safe default: don't resume
205    }
206
207    println!();
208    println!("╔══════════════════════════════════════════════════════════════╗");
209    println!("║  Incomplete session detected                                 ║");
210    println!("╠══════════════════════════════════════════════════════════════╣");
211    println!("║  Task:        {}", pad_right(&session.task_id, 45));
212    println!("║  Started:     {}", pad_right(&session.run_started_at, 45));
213    println!(
214        "║  Iterations:  {}/{}",
215        session.iterations_completed, session.iterations_planned
216    );
217    println!(
218        "║  Phase:       {}",
219        pad_right(&format!("{}", session.current_phase), 45)
220    );
221
222    // Display per-phase settings if available
223    if session.phase1_settings.is_some()
224        || session.phase2_settings.is_some()
225        || session.phase3_settings.is_some()
226    {
227        println!("╠══════════════════════════════════════════════════════════════╣");
228        println!("║  Phase Settings:                                             ║");
229
230        if let Some(ref p1) = session.phase1_settings {
231            let effort_str = p1
232                .reasoning_effort
233                .map(|e| format!(", effort={:?}", e))
234                .unwrap_or_default();
235            let settings_str = format!("{:?}/{}{}", p1.runner, p1.model, effort_str);
236            println!("║    Phase 1:   {}", pad_right(&settings_str, 41));
237        }
238
239        if let Some(ref p2) = session.phase2_settings {
240            let effort_str = p2
241                .reasoning_effort
242                .map(|e| format!(", effort={:?}", e))
243                .unwrap_or_default();
244            let settings_str = format!("{:?}/{}{}", p2.runner, p2.model, effort_str);
245            println!("║    Phase 2:   {}", pad_right(&settings_str, 41));
246        }
247
248        if let Some(ref p3) = session.phase3_settings {
249            let effort_str = p3
250                .reasoning_effort
251                .map(|e| format!(", effort={:?}", e))
252                .unwrap_or_default();
253            let settings_str = format!("{:?}/{}{}", p3.runner, p3.model, effort_str);
254            println!("║    Phase 3:   {}", pad_right(&settings_str, 41));
255        }
256    }
257
258    println!("╚══════════════════════════════════════════════════════════════╝");
259    println!();
260    print!("Resume this session? [Y/n]: ");
261    io::stdout().flush().context("flush stdout")?;
262
263    let mut input = String::new();
264    io::stdin().read_line(&mut input).context("read stdin")?;
265
266    let input = input.trim().to_lowercase();
267    Ok(input.is_empty() || input == "y" || input == "yes")
268}
269
270/// Prompt the user for session recovery with timeout warning.
271///
272/// When `non_interactive` is true or stdin is not a TTY, returns `Ok(false)`
273/// without prompting, choosing the safe default of not resuming.
274///
275/// # Arguments
276/// * `session` - The session state to potentially resume
277/// * `hours` - The actual age of the session in hours
278/// * `threshold_hours` - The configured timeout threshold that was exceeded
279/// * `non_interactive` - Whether to skip interactive prompting
280pub fn prompt_session_recovery_timeout(
281    session: &SessionState,
282    hours: u64,
283    threshold_hours: u64,
284    non_interactive: bool,
285) -> Result<bool> {
286    if non_interactive || !std::io::stdin().is_terminal() {
287        log::info!(
288            "Non-interactive environment detected; skipping stale session resume for {} ({} hours old)",
289            session.task_id,
290            hours
291        );
292        return Ok(false); // Safe default: don't resume
293    }
294
295    println!();
296    println!("╔══════════════════════════════════════════════════════════════╗");
297    println!(
298        "║  STALE session detected ({} hours old)",
299        pad_right(&hours.to_string(), 27)
300    );
301    println!("╠══════════════════════════════════════════════════════════════╣");
302    println!("║  Task:        {}", pad_right(&session.task_id, 45));
303    println!("║  Started:     {}", pad_right(&session.run_started_at, 45));
304    println!(
305        "║  Last update: {}",
306        pad_right(&session.last_updated_at, 45)
307    );
308    println!(
309        "║  Iterations:  {}/{}",
310        session.iterations_completed, session.iterations_planned
311    );
312
313    // Display per-phase settings if available
314    if session.phase1_settings.is_some()
315        || session.phase2_settings.is_some()
316        || session.phase3_settings.is_some()
317    {
318        println!("╠══════════════════════════════════════════════════════════════╣");
319        println!("║  Phase Settings:                                             ║");
320
321        if let Some(ref p1) = session.phase1_settings {
322            let effort_str = p1
323                .reasoning_effort
324                .map(|e| format!(", effort={:?}", e))
325                .unwrap_or_default();
326            let settings_str = format!("{:?}/{}{}", p1.runner, p1.model, effort_str);
327            println!("║    Phase 1:   {}", pad_right(&settings_str, 41));
328        }
329
330        if let Some(ref p2) = session.phase2_settings {
331            let effort_str = p2
332                .reasoning_effort
333                .map(|e| format!(", effort={:?}", e))
334                .unwrap_or_default();
335            let settings_str = format!("{:?}/{}{}", p2.runner, p2.model, effort_str);
336            println!("║    Phase 2:   {}", pad_right(&settings_str, 41));
337        }
338
339        if let Some(ref p3) = session.phase3_settings {
340            let effort_str = p3
341                .reasoning_effort
342                .map(|e| format!(", effort={:?}", e))
343                .unwrap_or_default();
344            let settings_str = format!("{:?}/{}{}", p3.runner, p3.model, effort_str);
345            println!("║    Phase 3:   {}", pad_right(&settings_str, 41));
346        }
347    }
348
349    println!("╚══════════════════════════════════════════════════════════════╝");
350    println!();
351    println!(
352        "Warning: This session is older than {} hour{}.",
353        threshold_hours,
354        if threshold_hours == 1 { "" } else { "s" }
355    );
356    print!("Resume anyway? [y/N]: ");
357    io::stdout().flush().context("flush stdout")?;
358
359    let mut input = String::new();
360    io::stdin().read_line(&mut input).context("read stdin")?;
361
362    let input = input.trim().to_lowercase();
363    Ok(input == "y" || input == "yes")
364}
365
366fn pad_right(s: &str, width: usize) -> String {
367    if s.len() >= width {
368        s.to_string()
369    } else {
370        format!("{}{}", s, " ".repeat(width - s.len()))
371    }
372}
373
374/// Get the git HEAD commit hash for session tracking.
375pub fn get_git_head_commit(repo_root: &Path) -> Option<String> {
376    let output = std::process::Command::new("git")
377        .arg("-C")
378        .arg(repo_root)
379        .arg("rev-parse")
380        .arg("HEAD")
381        .output()
382        .ok()?;
383
384    if output.status.success() {
385        Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
386    } else {
387        None
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::contracts::{Task, TaskPriority};
395    use tempfile::TempDir;
396    use time::Duration;
397
398    fn test_task(id: &str, status: TaskStatus) -> Task {
399        Task {
400            id: id.to_string(),
401            status,
402            title: "Test".to_string(),
403            description: None,
404            priority: TaskPriority::Medium,
405            tags: vec![],
406            scope: vec![],
407            evidence: vec![],
408            plan: vec![],
409            notes: vec![],
410            request: None,
411            agent: None,
412            created_at: None,
413            updated_at: None,
414            completed_at: None,
415            started_at: None,
416            scheduled_start: None,
417            depends_on: vec![],
418            blocks: vec![],
419            relates_to: vec![],
420            duplicates: None,
421            custom_fields: Default::default(),
422            parent_id: None,
423            estimated_minutes: None,
424            actual_minutes: None,
425        }
426    }
427
428    /// Fixed reference timestamp for deterministic tests.
429    const TEST_NOW: &str = "2026-02-07T12:00:00.000000000Z";
430
431    fn test_now() -> time::OffsetDateTime {
432        timeutil::parse_rfc3339(TEST_NOW).unwrap()
433    }
434
435    fn test_session_with_time(task_id: &str, last_updated_at: &str) -> SessionState {
436        SessionState::new(
437            "test-session-id".to_string(),
438            task_id.to_string(),
439            last_updated_at.to_string(),
440            1,
441            crate::contracts::Runner::Claude,
442            "sonnet".to_string(),
443            0,
444            None,
445            None, // phase_settings
446        )
447    }
448
449    fn test_session(task_id: &str) -> SessionState {
450        test_session_with_time(task_id, TEST_NOW)
451    }
452
453    #[test]
454    fn save_and_load_session_roundtrip() {
455        let temp_dir = TempDir::new().unwrap();
456        let session = test_session("RQ-0001");
457
458        save_session(temp_dir.path(), &session).unwrap();
459        let loaded = load_session(temp_dir.path()).unwrap().unwrap();
460
461        assert_eq!(loaded.session_id, session.session_id);
462        assert_eq!(loaded.task_id, session.task_id);
463        assert_eq!(loaded.iterations_planned, session.iterations_planned);
464    }
465
466    #[test]
467    fn clear_session_removes_file() {
468        let temp_dir = TempDir::new().unwrap();
469        let session = test_session("RQ-0001");
470
471        save_session(temp_dir.path(), &session).unwrap();
472        assert!(session_exists(temp_dir.path()));
473
474        clear_session(temp_dir.path()).unwrap();
475        assert!(!session_exists(temp_dir.path()));
476    }
477
478    #[test]
479    fn validate_session_valid_when_task_doing() {
480        let session = test_session("RQ-0001");
481        let queue = QueueFile {
482            version: 1,
483            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
484        };
485
486        let result = validate_session(&session, &queue, None);
487        assert!(matches!(result, SessionValidationResult::Valid(_)));
488    }
489
490    #[test]
491    fn validate_session_stale_when_task_not_doing() {
492        let session = test_session("RQ-0001");
493        let queue = QueueFile {
494            version: 1,
495            tasks: vec![test_task("RQ-0001", TaskStatus::Todo)],
496        };
497
498        let result = validate_session(&session, &queue, None);
499        assert!(matches!(result, SessionValidationResult::Stale { .. }));
500    }
501
502    #[test]
503    fn validate_session_stale_when_task_missing() {
504        let session = test_session("RQ-0001");
505        let queue = QueueFile {
506            version: 1,
507            tasks: vec![test_task("RQ-0002", TaskStatus::Doing)],
508        };
509
510        let result = validate_session(&session, &queue, None);
511        assert!(matches!(result, SessionValidationResult::Stale { .. }));
512    }
513
514    #[test]
515    fn check_session_returns_no_session_when_file_missing() {
516        let temp_dir = TempDir::new().unwrap();
517        let queue = QueueFile {
518            version: 1,
519            tasks: vec![],
520        };
521
522        let result = check_session(temp_dir.path(), &queue, None).unwrap();
523        assert_eq!(result, SessionValidationResult::NoSession);
524    }
525
526    #[test]
527    fn session_path_returns_correct_path() {
528        let temp_dir = TempDir::new().unwrap();
529        let path = session_path(temp_dir.path());
530        assert_eq!(path, temp_dir.path().join("session.jsonc"));
531    }
532
533    #[test]
534    fn prompt_session_recovery_returns_false_when_non_interactive() {
535        let session = test_session("RQ-0001");
536        // When non_interactive=true, should return false without prompting
537        let result = prompt_session_recovery(&session, true).unwrap();
538        assert!(
539            !result,
540            "non_interactive=true should return false (do not resume)"
541        );
542    }
543
544    #[test]
545    fn prompt_session_recovery_timeout_returns_false_when_non_interactive() {
546        let session = test_session("RQ-0001");
547        // When non_interactive=true, should return false without prompting
548        let result = prompt_session_recovery_timeout(&session, 48, 24, true).unwrap();
549        assert!(
550            !result,
551            "non_interactive=true should return false (do not resume)"
552        );
553    }
554
555    #[test]
556    fn validate_session_returns_timeout_when_older_than_threshold() {
557        // Use deterministic "now" and session time 48 hours before that
558        let now = test_now();
559        let session_time = now - Duration::hours(48);
560        let session =
561            test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
562        let queue = QueueFile {
563            version: 1,
564            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
565        };
566
567        // With 24-hour threshold, should timeout
568        let result = validate_session_with_now(&session, &queue, Some(24), now);
569        match result {
570            SessionValidationResult::Timeout {
571                hours,
572                session: timed_out,
573            } => {
574                assert_eq!(hours, 48, "Expected exactly 48 hours, got {hours}");
575                assert_eq!(timed_out.task_id, session.task_id);
576                assert_eq!(timed_out.session_id, session.session_id);
577            }
578            other => panic!("expected Timeout, got {other:?}"),
579        }
580    }
581
582    /// Regression test for RQ-0632: check_session must return Timeout with the embedded
583    /// session state so callers don't need to re-load (which could panic if session.json
584    /// disappears between the first load and the re-load).
585    ///
586    /// Note: This test uses the real wall-clock time via `check_session`, so we only assert
587    /// that the result is a Timeout with the session embedded, not the exact hours value.
588    #[test]
589    fn check_session_returns_timeout_and_includes_loaded_session() {
590        let temp_dir = TempDir::new().unwrap();
591
592        // Create a session with a very old timestamp (1 year ago) to ensure it times out
593        // regardless of when the test runs
594        let session_time = time::OffsetDateTime::now_utc() - Duration::days(365);
595        let session =
596            test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
597
598        save_session(temp_dir.path(), &session).unwrap();
599
600        let queue = QueueFile {
601            version: 1,
602            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
603        };
604
605        let result = check_session(temp_dir.path(), &queue, Some(24)).unwrap();
606
607        match result {
608            SessionValidationResult::Timeout {
609                hours,
610                session: timed_out,
611            } => {
612                // Just verify we got a reasonable timeout value (at least 24 hours)
613                assert!(hours >= 24, "Expected at least 24 hours, got {hours}");
614                assert_eq!(timed_out.task_id, session.task_id);
615                assert_eq!(timed_out.session_id, session.session_id);
616                assert_eq!(timed_out.last_updated_at, session.last_updated_at);
617            }
618            other => panic!("expected Timeout, got {other:?}"),
619        }
620    }
621
622    #[test]
623    fn validate_session_returns_valid_when_within_custom_threshold() {
624        // Session 12 hours old with 48-hour threshold should be valid
625        let now = test_now();
626        let session_time = now - Duration::hours(12);
627        let session =
628            test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
629        let queue = QueueFile {
630            version: 1,
631            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
632        };
633
634        // With 48-hour threshold, 12-hour session should be valid
635        let result = validate_session_with_now(&session, &queue, Some(48), now);
636        assert!(
637            matches!(result, SessionValidationResult::Valid(_)),
638            "Session within custom threshold should return Valid"
639        );
640    }
641
642    #[test]
643    fn validate_session_returns_valid_when_within_default_threshold() {
644        // Session 1 hour old with 24-hour threshold should be valid
645        let now = test_now();
646        let session_time = now - Duration::hours(1);
647        let session =
648            test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
649        let queue = QueueFile {
650            version: 1,
651            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
652        };
653
654        // With default 24-hour threshold, 1-hour session should be valid
655        let result = validate_session_with_now(&session, &queue, Some(24), now);
656        assert!(
657            matches!(result, SessionValidationResult::Valid(_)),
658            "Session within default threshold should return Valid"
659        );
660    }
661
662    #[test]
663    fn validate_session_returns_valid_when_no_timeout_configured() {
664        let session = test_session("RQ-0001");
665        let queue = QueueFile {
666            version: 1,
667            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
668        };
669
670        // With no timeout configured (None), session should always be valid
671        let result = validate_session(&session, &queue, None);
672        assert!(
673            matches!(result, SessionValidationResult::Valid(_)),
674            "Session should be Valid when no timeout is configured"
675        );
676    }
677
678    #[test]
679    fn validate_session_invalid_last_updated_does_not_timeout() {
680        // Session with unparsable timestamp should not trigger timeout (kept for safety)
681        let session = test_session_with_time("RQ-0001", "not-a-valid-timestamp");
682        let queue = QueueFile {
683            version: 1,
684            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
685        };
686
687        // Even with a short timeout, invalid timestamp means we can't compute age
688        let result = validate_session_with_now(
689            &session,
690            &queue,
691            Some(1), // 1 hour threshold
692            test_now(),
693        );
694        assert!(
695            matches!(result, SessionValidationResult::Valid(_)),
696            "Session with invalid timestamp should be Valid (can't compute timeout)"
697        );
698    }
699
700    #[test]
701    fn validate_session_exact_boundary_returns_timeout() {
702        // Session exactly at the threshold boundary should timeout (>=)
703        let now = test_now();
704        let session_time = now - Duration::hours(24); // exactly 24 hours old
705        let session =
706            test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
707        let queue = QueueFile {
708            version: 1,
709            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
710        };
711
712        // With 24-hour threshold, exactly 24 hours should timeout
713        let result = validate_session_with_now(&session, &queue, Some(24), now);
714        assert!(
715            matches!(result, SessionValidationResult::Timeout { .. }),
716            "Session exactly at threshold should timeout"
717        );
718    }
719
720    #[test]
721    fn validate_session_future_timestamp_no_timeout() {
722        // Session with future timestamp should not timeout (now <= session_time)
723        let now = test_now();
724        let session_time = now + Duration::hours(1); // 1 hour in the future
725        let session =
726            test_session_with_time("RQ-0001", &timeutil::format_rfc3339(session_time).unwrap());
727        let queue = QueueFile {
728            version: 1,
729            tasks: vec![test_task("RQ-0001", TaskStatus::Doing)],
730        };
731
732        let result = validate_session_with_now(&session, &queue, Some(1), now);
733        assert!(
734            matches!(result, SessionValidationResult::Valid(_)),
735            "Session with future timestamp should be Valid (no timeout)"
736        );
737    }
738
739    #[test]
740    fn increment_session_progress_updates_and_persists() {
741        let temp_dir = TempDir::new().unwrap();
742        let cache_dir = temp_dir.path().join("cache");
743        std::fs::create_dir_all(&cache_dir).unwrap();
744
745        // Create initial session
746        let session = test_session("RQ-0001");
747        save_session(&cache_dir, &session).unwrap();
748
749        assert_eq!(session.tasks_completed_in_loop, 0);
750
751        // Increment once
752        increment_session_progress(&cache_dir).unwrap();
753        let loaded = load_session(&cache_dir).unwrap().unwrap();
754        assert_eq!(loaded.tasks_completed_in_loop, 1);
755
756        // Increment again
757        increment_session_progress(&cache_dir).unwrap();
758        let loaded = load_session(&cache_dir).unwrap().unwrap();
759        assert_eq!(loaded.tasks_completed_in_loop, 2);
760    }
761
762    #[test]
763    fn increment_session_progress_handles_missing_session() {
764        let temp_dir = TempDir::new().unwrap();
765        let cache_dir = temp_dir.path().join("cache");
766        std::fs::create_dir_all(&cache_dir).unwrap();
767
768        // No session exists - should succeed without error
769        increment_session_progress(&cache_dir).unwrap();
770    }
771}