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
7use crate::workspace::Workspace;
8use std::path::Path;
9use std::time::{Duration, SystemTime};
10
11pub struct FileActivityTracker {
12    _private: (),
13}
14
15impl FileActivityTracker {
16    #[must_use]
17    pub const fn new() -> Self {
18        Self { _private: () }
19    }
20
21    pub fn check_for_recent_activity(
22        &self,
23        workspace: &dyn Workspace,
24        timeout: Duration,
25        now: SystemTime,
26    ) -> std::io::Result<bool> {
27        check_for_recent_activity_with_time(workspace, timeout, now)
28    }
29
30    fn is_ai_generated_file(path: &Path) -> bool {
31        let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
32            return false;
33        };
34
35        let has_excluded_ext = path.extension().is_some_and(|ext| {
36            ext.eq_ignore_ascii_case("log")
37                || ext.eq_ignore_ascii_case("swp")
38                || ext.eq_ignore_ascii_case("tmp")
39                || ext.eq_ignore_ascii_case("bak")
40        });
41
42        if has_excluded_ext
43            || file_name == "checkpoint.json"
44            || file_name == "start_commit"
45            || file_name == "review_baseline.txt"
46            || file_name.ends_with('~')
47        {
48            return false;
49        }
50
51        matches!(
52            file_name,
53            "PLAN.md" | "ISSUES.md" | "NOTES.md" | "STATUS.md" | "commit-message.txt"
54        )
55    }
56
57    pub(super) fn is_excluded_workspace_dir(path: &Path) -> bool {
58        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
59            return false;
60        };
61        matches!(name, ".git" | "target" | "tmp" | "node_modules" | ".agent")
62    }
63
64    pub(super) fn is_excluded_workspace_file(path: &Path) -> bool {
65        let has_excluded_ext = path.extension().is_some_and(|ext| {
66            ext.eq_ignore_ascii_case("log")
67                || ext.eq_ignore_ascii_case("swp")
68                || ext.eq_ignore_ascii_case("tmp")
69                || ext.eq_ignore_ascii_case("bak")
70        });
71        let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
72        has_excluded_ext || file_name.ends_with('~')
73    }
74}
75
76impl Default for FileActivityTracker {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82#[derive(Clone, Copy)]
83struct ScanState {
84    found_recent_activity: bool,
85    warned_unreadable_dir: bool,
86}
87
88fn file_age(now: SystemTime, mtime: SystemTime) -> Duration {
89    now.duration_since(mtime).unwrap_or(Duration::ZERO)
90}
91
92const MAX_SCAN_DEPTH: usize = 8;
93
94#[inline(never)]
95pub(crate) fn scan_dir_recursive(
96    workspace: &dyn Workspace,
97    dir: &Path,
98    now: SystemTime,
99    timeout: Duration,
100    remaining_depth: usize,
101    is_root: bool,
102) -> std::io::Result<bool> {
103    scan_dir_recursive_with_state(
104        workspace,
105        dir,
106        now,
107        timeout,
108        remaining_depth,
109        is_root,
110        false,
111    )
112    .map(|state| state.found_recent_activity)
113}
114
115#[inline(never)]
116#[expect(
117    clippy::print_stderr,
118    reason = "diagnostic warning for filesystem issues"
119)]
120fn scan_dir_recursive_with_state(
121    workspace: &dyn Workspace,
122    dir: &Path,
123    now: SystemTime,
124    timeout: Duration,
125    remaining_depth: usize,
126    is_root: bool,
127    warned_unreadable_dir: bool,
128) -> std::io::Result<ScanState> {
129    let entries = match workspace.read_dir(dir) {
130        Ok(entries) => entries,
131        Err(e) => {
132            if is_root {
133                return Err(e);
134            }
135
136            if !warned_unreadable_dir {
137                eprintln!(
138                    "Warning: workspace scan skipped unreadable directory '{}' ({e}); file-activity detection may be incomplete",
139                    dir.display()
140                );
141            }
142
143            return Ok(ScanState {
144                found_recent_activity: false,
145                warned_unreadable_dir: true,
146            });
147        }
148    };
149
150    entries.into_iter().try_fold(
151        ScanState {
152            found_recent_activity: false,
153            warned_unreadable_dir,
154        },
155        |state, entry| {
156            if state.found_recent_activity {
157                return Ok(state);
158            }
159
160            let path = entry.path();
161            if entry.is_file() {
162                let found_recent_activity = !FileActivityTracker::is_excluded_workspace_file(path)
163                    && entry
164                        .modified()
165                        .is_some_and(|mtime| file_age(now, mtime) <= timeout);
166                return Ok(ScanState {
167                    found_recent_activity,
168                    warned_unreadable_dir: state.warned_unreadable_dir,
169                });
170            }
171
172            if entry.is_dir() {
173                if FileActivityTracker::is_excluded_workspace_dir(path) {
174                    return Ok(state);
175                }
176
177                return remaining_depth
178                    .checked_sub(1)
179                    .map_or(Ok(state), |remaining| {
180                        scan_dir_recursive_with_state(
181                            workspace,
182                            entry.path(),
183                            now,
184                            timeout,
185                            remaining,
186                            false,
187                            state.warned_unreadable_dir,
188                        )
189                    });
190            }
191
192            Ok(state)
193        },
194    )
195}
196
197pub(crate) const MAX_SCAN_DEPTH_CONST: usize = MAX_SCAN_DEPTH;
198
199pub(crate) fn check_for_recent_activity_with_time(
200    workspace: &dyn Workspace,
201    timeout: Duration,
202    now: SystemTime,
203) -> std::io::Result<bool> {
204    let agent_dir = Path::new(".agent");
205
206    if workspace.exists(agent_dir) {
207        let entries = workspace.read_dir(agent_dir)?;
208
209        let has_recent_activity = entries
210            .into_iter()
211            .filter(|entry| entry.is_file())
212            .filter_map(|entry| {
213                let path = entry.path();
214                if !FileActivityTracker::is_ai_generated_file(path) {
215                    return None;
216                }
217                entry.modified().map(|mtime| (path.to_path_buf(), mtime))
218            })
219            .any(|(_, mtime)| file_age(now, mtime) <= timeout);
220
221        if has_recent_activity {
222            return Ok(true);
223        }
224    }
225
226    let tmp_dir = Path::new(".agent/tmp");
227    if workspace.exists(tmp_dir) {
228        if let Ok(tmp_entries) = workspace.read_dir(tmp_dir) {
229            let has_recent_xml = tmp_entries
230                .into_iter()
231                .filter(|entry| entry.is_file())
232                .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "xml"))
233                .filter_map(|entry| entry.modified())
234                .any(|mtime| file_age(now, mtime) <= timeout);
235
236            if has_recent_xml {
237                return Ok(true);
238            }
239        }
240    }
241
242    if scan_dir_recursive(
243        workspace,
244        Path::new(""),
245        now,
246        timeout,
247        MAX_SCAN_DEPTH_CONST,
248        true,
249    )? {
250        return Ok(true);
251    }
252
253    Ok(false)
254}