Skip to main content

ralph_workflow/pipeline/idle_timeout/
file_activity.rs

1//! File activity tracking for timeout detection.
2//!
3//! This module provides infrastructure to detect when an agent is actively
4//! writing files, even when there's minimal stdout/stderr output. This prevents
5//! false timeout kills when agents are making progress through file updates.
6//!
7//! Only `.agent/` directory files (PLAN.md, ISSUES.md, NOTES.md, STATUS.md,
8//! commit-message.txt) and `.agent/tmp/*.xml` are checked. The workspace-wide
9//! recursive scan was removed because it caused false positives: cargo build
10//! artifacts, editor temp files, and other unrelated file modifications would
11//! suppress the idle timeout even though they are not evidence of agent progress.
12
13use crate::workspace::Workspace;
14use std::path::Path;
15use std::time::{Duration, SystemTime};
16
17pub struct FileActivityTracker {
18    _private: (),
19}
20
21impl FileActivityTracker {
22    #[must_use]
23    pub const fn new() -> Self {
24        Self { _private: () }
25    }
26
27    pub fn check_for_recent_activity(
28        &self,
29        workspace: &dyn Workspace,
30        timeout: Duration,
31        now: SystemTime,
32    ) -> std::io::Result<bool> {
33        check_for_recent_activity_with_time(workspace, timeout, now)
34    }
35
36    fn is_ai_generated_file(path: &Path) -> bool {
37        let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
38            return false;
39        };
40
41        let has_excluded_ext = path.extension().is_some_and(|ext| {
42            ext.eq_ignore_ascii_case("log")
43                || ext.eq_ignore_ascii_case("swp")
44                || ext.eq_ignore_ascii_case("tmp")
45                || ext.eq_ignore_ascii_case("bak")
46        });
47
48        if has_excluded_ext
49            || file_name == "checkpoint.json"
50            || file_name == "start_commit"
51            || file_name == "review_baseline.txt"
52            || file_name.ends_with('~')
53        {
54            return false;
55        }
56
57        matches!(
58            file_name,
59            "PLAN.md" | "ISSUES.md" | "NOTES.md" | "STATUS.md" | "commit-message.txt"
60        )
61    }
62}
63
64impl Default for FileActivityTracker {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70fn file_age(now: SystemTime, mtime: SystemTime) -> Duration {
71    now.duration_since(mtime).unwrap_or(Duration::ZERO)
72}
73
74fn check_for_recent_activity_with_time(
75    workspace: &dyn Workspace,
76    timeout: Duration,
77    now: SystemTime,
78) -> std::io::Result<bool> {
79    let agent_dir = Path::new(".agent");
80
81    if workspace.exists(agent_dir) {
82        let entries = workspace.read_dir(agent_dir)?;
83
84        let has_recent_activity = entries
85            .into_iter()
86            .filter(|entry| entry.is_file())
87            .filter_map(|entry| {
88                let path = entry.path();
89                if !FileActivityTracker::is_ai_generated_file(path) {
90                    return None;
91                }
92                entry.modified().map(|mtime| (path.to_path_buf(), mtime))
93            })
94            .any(|(_, mtime)| file_age(now, mtime) <= timeout);
95
96        if has_recent_activity {
97            return Ok(true);
98        }
99    }
100
101    let tmp_dir = Path::new(".agent/tmp");
102    if workspace.exists(tmp_dir) {
103        if let Ok(tmp_entries) = workspace.read_dir(tmp_dir) {
104            let has_recent_xml = tmp_entries
105                .into_iter()
106                .filter(|entry| entry.is_file())
107                .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "xml"))
108                .filter_map(|entry| entry.modified())
109                .any(|mtime| file_age(now, mtime) <= timeout);
110
111            if has_recent_xml {
112                return Ok(true);
113            }
114        }
115    }
116
117    Ok(false)
118}