Skip to main content

rullama_agent/
resource_checker.rs

1//! Cross-resource conflict detection
2//!
3//! Provides bidirectional checking between file locks and resource locks:
4//! - Builds should wait if source files are being edited
5//! - File writes should wait if build/test is in progress
6//!
7//! This ensures consistency and prevents race conditions between
8//! file editing and build/test operations.
9
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Instant;
13
14use crate::communication::{ConflictInfo, ConflictType};
15use crate::file_locks::{FileLockManager, LockType};
16use crate::operation_tracker::OperationTracker;
17use crate::resource_locks::{ResourceLockManager, ResourceScope, ResourceType};
18
19/// Result of checking for conflicts before an operation
20#[derive(Debug, Clone)]
21pub enum ConflictCheck {
22    /// No conflicts, operation can proceed
23    Clear,
24    /// Operation is blocked by active conflicts - must wait
25    Blocked(Vec<Conflict>),
26    /// Conflicts exist but are warnings only - can proceed with caution
27    Warning(Vec<Conflict>),
28}
29
30impl ConflictCheck {
31    /// Returns true if the operation can proceed (Clear or Warning)
32    pub fn can_proceed(&self) -> bool {
33        matches!(self, ConflictCheck::Clear | ConflictCheck::Warning(_))
34    }
35
36    /// Returns true if the operation is blocked
37    pub fn is_blocked(&self) -> bool {
38        matches!(self, ConflictCheck::Blocked(_))
39    }
40
41    /// Get all conflicts (blocking or warning)
42    pub fn conflicts(&self) -> &[Conflict] {
43        match self {
44            ConflictCheck::Clear => &[],
45            ConflictCheck::Blocked(c) | ConflictCheck::Warning(c) => c,
46        }
47    }
48}
49
50/// Information about a detected conflict
51#[derive(Debug, Clone)]
52pub struct Conflict {
53    /// Type of conflict
54    pub conflict_type: ResourceConflictType,
55    /// Agent holding the conflicting resource
56    pub holder_agent: String,
57    /// Resource identifier (path or scope description)
58    pub resource: String,
59    /// When the conflict started
60    pub started_at: Instant,
61    /// Current status of the blocking operation
62    pub status: String,
63    /// Description of what the holder is doing
64    pub description: String,
65}
66
67impl Conflict {
68    /// Convert to the communication ConflictInfo type for messaging
69    pub fn to_conflict_info(&self) -> ConflictInfo {
70        ConflictInfo {
71            conflict_type: match &self.conflict_type {
72                ResourceConflictType::FileWriteBlocksBuild { path } => {
73                    ConflictType::FileWriteBlocksBuild { path: path.clone() }
74                }
75                ResourceConflictType::BuildBlocksFileWrite => ConflictType::BuildBlocksFileWrite,
76                ResourceConflictType::TestBlocksFileWrite => ConflictType::TestBlocksFileWrite,
77                ResourceConflictType::GitBlocksFileWrite => ConflictType::GitBlocksFileWrite,
78                ResourceConflictType::FileWriteBlocksGit { path } => {
79                    ConflictType::FileWriteBlocksGit { path: path.clone() }
80                }
81                ResourceConflictType::BuildBlocksGit => ConflictType::BuildBlocksGit,
82            },
83            holder_agent: self.holder_agent.clone(),
84            resource: self.resource.clone(),
85            duration_secs: self.started_at.elapsed().as_secs(),
86            status: self.status.clone(),
87        }
88    }
89}
90
91/// Types of cross-resource conflicts
92#[derive(Debug, Clone)]
93pub enum ResourceConflictType {
94    /// File write lock blocks a build operation
95    FileWriteBlocksBuild {
96        /// Path of the locked file.
97        path: PathBuf,
98    },
99    /// Build in progress blocks file write
100    BuildBlocksFileWrite,
101    /// Test in progress blocks file write
102    TestBlocksFileWrite,
103    /// Git operation blocks file write
104    GitBlocksFileWrite,
105    /// File write lock blocks git operation
106    FileWriteBlocksGit {
107        /// Path of the locked file.
108        path: PathBuf,
109    },
110    /// Build in progress blocks git operation
111    BuildBlocksGit,
112}
113
114/// Proposed operation to check for conflicts
115#[derive(Debug, Clone)]
116pub enum ProposedOperation {
117    /// File write operation
118    FileWrite {
119        /// Path of the file to write.
120        path: PathBuf,
121        /// Agent performing the write.
122        agent_id: String,
123    },
124    /// Build operation
125    Build {
126        /// Scope of the build.
127        scope: ResourceScope,
128        /// Agent performing the build.
129        agent_id: String,
130    },
131    /// Test operation
132    Test {
133        /// Scope of the test.
134        scope: ResourceScope,
135        /// Agent performing the test.
136        agent_id: String,
137    },
138    /// Git staging operation
139    GitStaging {
140        /// Scope of the staging operation.
141        scope: ResourceScope,
142        /// Agent performing the staging.
143        agent_id: String,
144    },
145    /// Git commit operation
146    GitCommit {
147        /// Scope of the commit operation.
148        scope: ResourceScope,
149        /// Agent performing the commit.
150        agent_id: String,
151    },
152    /// Git push operation
153    GitPush {
154        /// Scope of the push operation.
155        scope: ResourceScope,
156        /// Agent performing the push.
157        agent_id: String,
158    },
159    /// Git pull operation
160    GitPull {
161        /// Scope of the pull operation.
162        scope: ResourceScope,
163        /// Agent performing the pull.
164        agent_id: String,
165    },
166}
167
168impl ProposedOperation {
169    /// Get the agent ID for this proposed operation.
170    pub fn agent_id(&self) -> &str {
171        match self {
172            ProposedOperation::FileWrite { agent_id, .. }
173            | ProposedOperation::Build { agent_id, .. }
174            | ProposedOperation::Test { agent_id, .. }
175            | ProposedOperation::GitStaging { agent_id, .. }
176            | ProposedOperation::GitCommit { agent_id, .. }
177            | ProposedOperation::GitPush { agent_id, .. }
178            | ProposedOperation::GitPull { agent_id, .. } => agent_id,
179        }
180    }
181}
182
183/// Cross-resource conflict checker
184///
185/// Checks for conflicts between:
186/// - File locks and build/test operations
187/// - Build/test operations and file writes
188/// - Git operations and file/build operations
189pub struct ResourceChecker {
190    file_locks: Arc<FileLockManager>,
191    resource_locks: Arc<ResourceLockManager>,
192    _operation_tracker: Option<Arc<OperationTracker>>,
193    /// File patterns to check for build conflicts (e.g., "src/**/*.rs")
194    source_patterns: Vec<String>,
195}
196
197impl ResourceChecker {
198    /// Create a new resource checker
199    pub fn new(file_locks: Arc<FileLockManager>, resource_locks: Arc<ResourceLockManager>) -> Self {
200        Self {
201            file_locks,
202            resource_locks,
203            _operation_tracker: None,
204            source_patterns: default_source_patterns(),
205        }
206    }
207
208    /// Create a resource checker with operation tracker integration
209    pub fn with_operation_tracker(
210        file_locks: Arc<FileLockManager>,
211        resource_locks: Arc<ResourceLockManager>,
212        operation_tracker: Arc<OperationTracker>,
213    ) -> Self {
214        Self {
215            file_locks,
216            resource_locks,
217            _operation_tracker: Some(operation_tracker),
218            source_patterns: default_source_patterns(),
219        }
220    }
221
222    /// Set custom source file patterns for build conflict detection
223    pub fn with_source_patterns(mut self, patterns: Vec<String>) -> Self {
224        self.source_patterns = patterns;
225        self
226    }
227
228    /// Check if a build can start (no active file write locks in project)
229    ///
230    /// Returns `Clear` if no source files are being edited,
231    /// or `Blocked` with details of which files are locked.
232    pub async fn can_start_build(&self, scope: &ResourceScope, agent_id: &str) -> ConflictCheck {
233        let file_locks = self.file_locks.list_locks().await;
234
235        let mut conflicts = Vec::new();
236
237        for (path, lock_info) in file_locks {
238            // Skip if same agent
239            if lock_info.agent_id == agent_id {
240                continue;
241            }
242
243            // Only check write locks
244            if lock_info.lock_type != LockType::Write {
245                continue;
246            }
247
248            // Check if this file is within the scope and is a source file
249            if self.is_in_scope(&path, scope) && self.is_source_file(&path) {
250                conflicts.push(Conflict {
251                    conflict_type: ResourceConflictType::FileWriteBlocksBuild {
252                        path: path.clone(),
253                    },
254                    holder_agent: lock_info.agent_id.clone(),
255                    resource: path.display().to_string(),
256                    started_at: lock_info.acquired_at,
257                    status: "File locked for editing".to_string(),
258                    description: format!("Write lock on {}", path.display()),
259                });
260            }
261        }
262
263        if conflicts.is_empty() {
264            ConflictCheck::Clear
265        } else {
266            ConflictCheck::Blocked(conflicts)
267        }
268    }
269
270    /// Check if a file can be written (no active build/test in project)
271    ///
272    /// Returns `Clear` if no build/test is running,
273    /// or `Blocked` with details of the blocking operation.
274    pub async fn can_write_file(&self, path: &Path, agent_id: &str) -> ConflictCheck {
275        let resource_locks = self.resource_locks.list_locks().await;
276
277        let mut conflicts = Vec::new();
278
279        // Determine the scope from the file path
280        let file_scope = self.scope_for_path(path);
281
282        for lock_info in resource_locks {
283            // Skip if same agent
284            if lock_info.agent_id == agent_id {
285                continue;
286            }
287
288            // Check if the lock scope overlaps with the file's scope
289            if !self.scopes_overlap(&lock_info.scope, &file_scope) {
290                continue;
291            }
292
293            // Only source files are affected by builds/tests
294            if !self.is_source_file(path) {
295                continue;
296            }
297
298            // Check for build/test conflicts
299            match lock_info.resource_type {
300                ResourceType::Build | ResourceType::BuildTest => {
301                    conflicts.push(Conflict {
302                        conflict_type: ResourceConflictType::BuildBlocksFileWrite,
303                        holder_agent: lock_info.agent_id.clone(),
304                        resource: format!("{} ({})", lock_info.resource_type, lock_info.scope),
305                        started_at: lock_info.acquired_at,
306                        status: lock_info.status.clone(),
307                        description: lock_info.description.clone(),
308                    });
309                }
310                ResourceType::Test => {
311                    conflicts.push(Conflict {
312                        conflict_type: ResourceConflictType::TestBlocksFileWrite,
313                        holder_agent: lock_info.agent_id.clone(),
314                        resource: format!("{} ({})", lock_info.resource_type, lock_info.scope),
315                        started_at: lock_info.acquired_at,
316                        status: lock_info.status.clone(),
317                        description: lock_info.description.clone(),
318                    });
319                }
320                ResourceType::GitIndex
321                | ResourceType::GitCommit
322                | ResourceType::GitRemoteWrite
323                | ResourceType::GitRemoteMerge
324                | ResourceType::GitBranch
325                | ResourceType::GitDestructive => {
326                    // Git operations that modify the working tree block file writes
327                    if lock_info.resource_type == ResourceType::GitRemoteMerge
328                        || lock_info.resource_type == ResourceType::GitDestructive
329                    {
330                        conflicts.push(Conflict {
331                            conflict_type: ResourceConflictType::GitBlocksFileWrite,
332                            holder_agent: lock_info.agent_id.clone(),
333                            resource: format!("{} ({})", lock_info.resource_type, lock_info.scope),
334                            started_at: lock_info.acquired_at,
335                            status: lock_info.status.clone(),
336                            description: lock_info.description.clone(),
337                        });
338                    }
339                }
340            }
341        }
342
343        if conflicts.is_empty() {
344            ConflictCheck::Clear
345        } else {
346            ConflictCheck::Blocked(conflicts)
347        }
348    }
349
350    /// Check if a git operation can start
351    ///
352    /// Git operations are blocked by:
353    /// - Active file write locks (for operations that read working tree)
354    /// - Active builds (for commit/push operations)
355    pub async fn can_start_git_operation(
356        &self,
357        git_op: ResourceType,
358        scope: &ResourceScope,
359        agent_id: &str,
360    ) -> ConflictCheck {
361        let mut conflicts = Vec::new();
362
363        // Check file locks for git operations that read the working tree
364        if matches!(
365            git_op,
366            ResourceType::GitIndex | ResourceType::GitCommit | ResourceType::GitRemoteWrite
367        ) {
368            let file_locks = self.file_locks.list_locks().await;
369
370            for (path, lock_info) in file_locks {
371                if lock_info.agent_id == agent_id {
372                    continue;
373                }
374                if lock_info.lock_type != LockType::Write {
375                    continue;
376                }
377                if self.is_in_scope(&path, scope) && self.is_source_file(&path) {
378                    conflicts.push(Conflict {
379                        conflict_type: ResourceConflictType::FileWriteBlocksGit {
380                            path: path.clone(),
381                        },
382                        holder_agent: lock_info.agent_id.clone(),
383                        resource: path.display().to_string(),
384                        started_at: lock_info.acquired_at,
385                        status: "File locked for editing".to_string(),
386                        description: format!("Write lock on {}", path.display()),
387                    });
388                }
389            }
390        }
391
392        // Check for build conflicts for commit/push operations
393        if matches!(
394            git_op,
395            ResourceType::GitCommit | ResourceType::GitRemoteWrite
396        ) {
397            let resource_locks = self.resource_locks.list_locks().await;
398
399            for lock_info in resource_locks {
400                if lock_info.agent_id == agent_id {
401                    continue;
402                }
403                if !self.scopes_overlap(&lock_info.scope, scope) {
404                    continue;
405                }
406
407                if matches!(
408                    lock_info.resource_type,
409                    ResourceType::Build | ResourceType::Test | ResourceType::BuildTest
410                ) {
411                    conflicts.push(Conflict {
412                        conflict_type: ResourceConflictType::BuildBlocksGit,
413                        holder_agent: lock_info.agent_id.clone(),
414                        resource: format!("{} ({})", lock_info.resource_type, lock_info.scope),
415                        started_at: lock_info.acquired_at,
416                        status: lock_info.status.clone(),
417                        description: lock_info.description.clone(),
418                    });
419                }
420            }
421        }
422
423        if conflicts.is_empty() {
424            ConflictCheck::Clear
425        } else {
426            ConflictCheck::Blocked(conflicts)
427        }
428    }
429
430    /// Check all conflicts for a proposed operation
431    pub async fn check_conflicts(&self, operation: &ProposedOperation) -> ConflictCheck {
432        match operation {
433            ProposedOperation::FileWrite { path, agent_id } => {
434                self.can_write_file(path, agent_id).await
435            }
436            ProposedOperation::Build { scope, agent_id } => {
437                self.can_start_build(scope, agent_id).await
438            }
439            ProposedOperation::Test { scope, agent_id } => {
440                // Same checks as build
441                self.can_start_build(scope, agent_id).await
442            }
443            ProposedOperation::GitStaging { scope, agent_id } => {
444                self.can_start_git_operation(ResourceType::GitIndex, scope, agent_id)
445                    .await
446            }
447            ProposedOperation::GitCommit { scope, agent_id } => {
448                self.can_start_git_operation(ResourceType::GitCommit, scope, agent_id)
449                    .await
450            }
451            ProposedOperation::GitPush { scope, agent_id } => {
452                self.can_start_git_operation(ResourceType::GitRemoteWrite, scope, agent_id)
453                    .await
454            }
455            ProposedOperation::GitPull { scope, agent_id } => {
456                self.can_start_git_operation(ResourceType::GitRemoteMerge, scope, agent_id)
457                    .await
458            }
459        }
460    }
461
462    /// Get all current conflicts that would block a build
463    pub async fn get_build_blockers(&self, scope: &ResourceScope, agent_id: &str) -> Vec<Conflict> {
464        match self.can_start_build(scope, agent_id).await {
465            ConflictCheck::Blocked(conflicts) => conflicts,
466            _ => Vec::new(),
467        }
468    }
469
470    /// Get all current conflicts that would block a file write
471    pub async fn get_file_write_blockers(&self, path: &Path, agent_id: &str) -> Vec<Conflict> {
472        match self.can_write_file(path, agent_id).await {
473            ConflictCheck::Blocked(conflicts) => conflicts,
474            _ => Vec::new(),
475        }
476    }
477
478    // === Helper methods ===
479
480    /// Check if a path is within a resource scope
481    fn is_in_scope(&self, path: &Path, scope: &ResourceScope) -> bool {
482        match scope {
483            ResourceScope::Global => true,
484            ResourceScope::Project(project_path) => path.starts_with(project_path),
485        }
486    }
487
488    /// Determine the scope for a file path
489    fn scope_for_path(&self, path: &Path) -> ResourceScope {
490        // Try to find the project root by looking for common markers
491        let mut current = path.parent();
492        while let Some(dir) = current {
493            if dir.join("Cargo.toml").exists()
494                || dir.join("package.json").exists()
495                || dir.join(".git").exists()
496            {
497                return ResourceScope::Project(dir.to_path_buf());
498            }
499            current = dir.parent();
500        }
501        ResourceScope::Global
502    }
503
504    /// Check if two scopes overlap
505    fn scopes_overlap(&self, scope1: &ResourceScope, scope2: &ResourceScope) -> bool {
506        match (scope1, scope2) {
507            (ResourceScope::Global, _) | (_, ResourceScope::Global) => true,
508            (ResourceScope::Project(p1), ResourceScope::Project(p2)) => {
509                p1.starts_with(p2) || p2.starts_with(p1)
510            }
511        }
512    }
513
514    /// Check if a file is a source file that should block builds
515    fn is_source_file(&self, path: &Path) -> bool {
516        let path_str = path.to_string_lossy();
517
518        // Check against source patterns
519        for pattern in &self.source_patterns {
520            if matches_pattern(&path_str, pattern) {
521                return true;
522            }
523        }
524
525        // Default: check common source extensions
526        if let Some(ext) = path.extension() {
527            let ext = ext.to_string_lossy().to_lowercase();
528            matches!(
529                ext.as_str(),
530                "rs" | "ts"
531                    | "tsx"
532                    | "js"
533                    | "jsx"
534                    | "py"
535                    | "go"
536                    | "java"
537                    | "c"
538                    | "cpp"
539                    | "h"
540                    | "hpp"
541                    | "cs"
542                    | "swift"
543                    | "kt"
544                    | "scala"
545                    | "rb"
546                    | "php"
547            )
548        } else {
549            false
550        }
551    }
552}
553
554/// Default source file patterns for build conflict detection
555fn default_source_patterns() -> Vec<String> {
556    vec![
557        "src/**/*".to_string(),
558        "lib/**/*".to_string(),
559        "crates/**/*".to_string(),
560        "packages/**/*".to_string(),
561        "app/**/*".to_string(),
562    ]
563}
564
565/// Simple glob-style pattern matching
566fn matches_pattern(path: &str, pattern: &str) -> bool {
567    if pattern.contains("**") {
568        // Handle ** (match any depth)
569        let parts: Vec<&str> = pattern.split("**").collect();
570        if parts.len() == 2 {
571            let prefix = parts[0].trim_end_matches('/');
572            let suffix = parts[1].trim_start_matches('/');
573
574            let has_prefix = prefix.is_empty() || path.starts_with(prefix);
575            let has_suffix = suffix.is_empty()
576                || suffix == "*"
577                || path.ends_with(suffix.trim_start_matches('*'));
578
579            return has_prefix && has_suffix;
580        }
581    }
582
583    if pattern.contains('*') {
584        // Handle single * (match within directory)
585        let parts: Vec<&str> = pattern.split('*').collect();
586        if parts.len() == 2 {
587            return path.starts_with(parts[0]) && path.ends_with(parts[1]);
588        }
589    }
590
591    // Exact match
592    path == pattern
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn test_matches_pattern() {
601        assert!(matches_pattern("src/main.rs", "src/**/*"));
602        assert!(matches_pattern("src/lib/utils.rs", "src/**/*"));
603        assert!(matches_pattern("crates/foo/src/lib.rs", "crates/**/*"));
604        assert!(!matches_pattern("target/debug/main", "src/**/*"));
605    }
606
607    #[test]
608    fn test_is_source_file() {
609        let checker = ResourceChecker::new(
610            Arc::new(FileLockManager::new()),
611            Arc::new(ResourceLockManager::new()),
612        );
613
614        assert!(checker.is_source_file(Path::new("src/main.rs")));
615        assert!(checker.is_source_file(Path::new("lib/index.ts")));
616        assert!(checker.is_source_file(Path::new("app.py")));
617        assert!(!checker.is_source_file(Path::new("README.md")));
618        assert!(!checker.is_source_file(Path::new("Cargo.toml")));
619    }
620
621    #[test]
622    fn test_scopes_overlap() {
623        let checker = ResourceChecker::new(
624            Arc::new(FileLockManager::new()),
625            Arc::new(ResourceLockManager::new()),
626        );
627
628        // Global overlaps with everything
629        assert!(checker.scopes_overlap(&ResourceScope::Global, &ResourceScope::Global));
630        assert!(checker.scopes_overlap(
631            &ResourceScope::Global,
632            &ResourceScope::Project(PathBuf::from("/foo"))
633        ));
634
635        // Project scopes
636        assert!(checker.scopes_overlap(
637            &ResourceScope::Project(PathBuf::from("/foo")),
638            &ResourceScope::Project(PathBuf::from("/foo"))
639        ));
640        assert!(checker.scopes_overlap(
641            &ResourceScope::Project(PathBuf::from("/foo")),
642            &ResourceScope::Project(PathBuf::from("/foo/bar"))
643        ));
644        assert!(!checker.scopes_overlap(
645            &ResourceScope::Project(PathBuf::from("/foo")),
646            &ResourceScope::Project(PathBuf::from("/baz"))
647        ));
648    }
649
650    #[tokio::test]
651    async fn test_can_start_build_no_conflicts() {
652        let file_locks = Arc::new(FileLockManager::new());
653        let resource_locks = Arc::new(ResourceLockManager::new());
654        let checker = ResourceChecker::new(file_locks, resource_locks);
655
656        let scope = ResourceScope::Project(PathBuf::from("/test/project"));
657        let result = checker.can_start_build(&scope, "agent-1").await;
658
659        assert!(matches!(result, ConflictCheck::Clear));
660    }
661
662    #[tokio::test]
663    async fn test_can_write_file_no_conflicts() {
664        let file_locks = Arc::new(FileLockManager::new());
665        let resource_locks = Arc::new(ResourceLockManager::new());
666        let checker = ResourceChecker::new(file_locks, resource_locks);
667
668        let result = checker
669            .can_write_file(Path::new("/test/project/src/main.rs"), "agent-1")
670            .await;
671
672        assert!(matches!(result, ConflictCheck::Clear));
673    }
674
675    #[tokio::test]
676    async fn test_build_blocked_by_file_write() {
677        let file_locks = Arc::new(FileLockManager::new());
678        let resource_locks = Arc::new(ResourceLockManager::new());
679
680        // Agent 2 acquires a write lock on a source file
681        file_locks
682            .acquire_lock("agent-2", "/test/project/src/main.rs", LockType::Write)
683            .await
684            .unwrap();
685
686        let checker = ResourceChecker::new(file_locks, resource_locks);
687
688        let scope = ResourceScope::Project(PathBuf::from("/test/project"));
689        let result = checker.can_start_build(&scope, "agent-1").await;
690
691        assert!(result.is_blocked());
692        let conflicts = result.conflicts();
693        assert_eq!(conflicts.len(), 1);
694        assert!(matches!(
695            conflicts[0].conflict_type,
696            ResourceConflictType::FileWriteBlocksBuild { .. }
697        ));
698    }
699
700    #[tokio::test]
701    async fn test_file_write_blocked_by_build() {
702        let file_locks = Arc::new(FileLockManager::new());
703        let resource_locks = Arc::new(ResourceLockManager::new());
704
705        // Agent 2 acquires a build lock
706        resource_locks
707            .acquire_resource(
708                "agent-2",
709                ResourceType::Build,
710                ResourceScope::Project(PathBuf::from("/test/project")),
711                "cargo build",
712            )
713            .await
714            .unwrap();
715
716        let checker = ResourceChecker::new(file_locks, resource_locks);
717
718        let result = checker
719            .can_write_file(Path::new("/test/project/src/main.rs"), "agent-1")
720            .await;
721
722        assert!(result.is_blocked());
723        let conflicts = result.conflicts();
724        assert_eq!(conflicts.len(), 1);
725        assert!(matches!(
726            conflicts[0].conflict_type,
727            ResourceConflictType::BuildBlocksFileWrite
728        ));
729    }
730
731    #[tokio::test]
732    async fn test_same_agent_no_conflict() {
733        let file_locks = Arc::new(FileLockManager::new());
734        let resource_locks = Arc::new(ResourceLockManager::new());
735
736        // Same agent has file lock and wants to build
737        file_locks
738            .acquire_lock("agent-1", "/test/project/src/main.rs", LockType::Write)
739            .await
740            .unwrap();
741
742        let checker = ResourceChecker::new(file_locks, resource_locks);
743
744        let scope = ResourceScope::Project(PathBuf::from("/test/project"));
745        let result = checker.can_start_build(&scope, "agent-1").await;
746
747        // Same agent should not conflict with itself
748        assert!(matches!(result, ConflictCheck::Clear));
749    }
750}