Skip to main content

brainwires_agents/
validator_agent.rs

1//! ValidatorAgent - Standalone read-only agent that runs external validators
2//!
3//! Unlike the inline validation inside `TaskAgent`, the `ValidatorAgent` can be
4//! triggered independently by an orchestrator — e.g., after multiple task agents
5//! finish work — without coupling validation to any single task agent.
6//!
7//! The agent acquires **read locks** on the working-set files, calls
8//! [`run_validation`], and returns a structured [`ValidatorAgentResult`].
9//!
10//! This is intentionally **not** an `AgentRuntime` implementation: it is a
11//! deterministic pipeline (no AI provider loop), following the same pattern as
12//! [`PlanExecutorAgent`](crate::plan_executor::PlanExecutorAgent).
13
14use std::sync::Arc;
15use std::time::Instant;
16
17use anyhow::Result;
18use tokio::sync::RwLock;
19
20use crate::communication::{AgentMessage, CommunicationHub};
21use crate::file_locks::{FileLockManager, LockGuard, LockType};
22use crate::validation_loop::{
23    ValidationConfig, ValidationResult, format_validation_feedback, run_validation,
24};
25
26// ── Types ────────────────────────────────────────────────────────────────────
27
28/// Current status of a `ValidatorAgent`.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ValidatorAgentStatus {
31    /// Not yet started.
32    Idle,
33    /// Acquiring read locks on working-set files.
34    AcquiringLocks,
35    /// Running validation checks.
36    Validating,
37    /// All checks passed.
38    Passed,
39    /// One or more checks failed. The `usize` is the issue count.
40    Failed(usize),
41    /// An unrecoverable error occurred.
42    Error(String),
43}
44
45impl std::fmt::Display for ValidatorAgentStatus {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::Idle => write!(f, "Idle"),
49            Self::AcquiringLocks => write!(f, "Acquiring locks"),
50            Self::Validating => write!(f, "Validating"),
51            Self::Passed => write!(f, "Passed"),
52            Self::Failed(n) => write!(f, "Failed ({} issues)", n),
53            Self::Error(e) => write!(f, "Error: {}", e),
54        }
55    }
56}
57
58/// Configuration for [`ValidatorAgent`].
59#[derive(Debug, Clone)]
60pub struct ValidatorAgentConfig {
61    /// The underlying validation pipeline configuration.
62    pub validation_config: ValidationConfig,
63    /// Wall-clock timeout in seconds for the entire validation run.
64    /// Default: 120.
65    pub timeout_secs: u64,
66}
67
68impl Default for ValidatorAgentConfig {
69    fn default() -> Self {
70        Self {
71            validation_config: ValidationConfig::default(),
72            timeout_secs: 120,
73        }
74    }
75}
76
77impl ValidatorAgentConfig {
78    /// Create a config wrapping a [`ValidationConfig`].
79    pub fn new(validation_config: ValidationConfig) -> Self {
80        Self {
81            validation_config,
82            ..Default::default()
83        }
84    }
85
86    /// Set the wall-clock timeout.
87    pub fn with_timeout(mut self, secs: u64) -> Self {
88        self.timeout_secs = secs;
89        self
90    }
91}
92
93/// Result returned by [`ValidatorAgent::validate`].
94#[derive(Debug, Clone)]
95pub struct ValidatorAgentResult {
96    /// The validator agent's unique ID.
97    pub agent_id: String,
98    /// Whether all checks passed.
99    pub success: bool,
100    /// The raw validation result from the pipeline.
101    pub validation_result: ValidationResult,
102    /// Human-readable feedback string.
103    pub feedback: String,
104    /// Wall-clock duration of the validation run.
105    pub duration: std::time::Duration,
106    /// Number of files that were checked.
107    pub files_checked: usize,
108    /// Number of read locks that were successfully acquired.
109    pub locks_acquired: usize,
110}
111
112// ── ValidatorAgent ───────────────────────────────────────────────────────────
113
114/// A standalone, read-only agent that runs external validators and returns a
115/// structured result to the orchestrator.
116pub struct ValidatorAgent {
117    /// Unique identifier for this validator agent.
118    pub id: String,
119    /// Configuration.
120    pub config: ValidatorAgentConfig,
121    /// Communication hub for broadcasting status messages.
122    pub communication_hub: Arc<CommunicationHub>,
123    /// File lock manager for acquiring read locks.
124    pub file_lock_manager: Arc<FileLockManager>,
125    /// Observable status.
126    pub status: Arc<RwLock<ValidatorAgentStatus>>,
127}
128
129impl ValidatorAgent {
130    /// Create a new `ValidatorAgent`.
131    pub fn new(
132        id: impl Into<String>,
133        config: ValidatorAgentConfig,
134        communication_hub: Arc<CommunicationHub>,
135        file_lock_manager: Arc<FileLockManager>,
136    ) -> Self {
137        Self {
138            id: id.into(),
139            config,
140            communication_hub,
141            file_lock_manager,
142            status: Arc::new(RwLock::new(ValidatorAgentStatus::Idle)),
143        }
144    }
145
146    /// Run the full validation pipeline.
147    ///
148    /// 1. Register with the communication hub, broadcast `AgentSpawned`.
149    /// 2. Acquire **read** locks on all `working_set_files` (best-effort).
150    /// 3. Run [`run_validation`] with a wall-clock timeout.
151    /// 4. Release locks, broadcast `AgentCompleted`, unregister.
152    #[tracing::instrument(name = "validator_agent.validate", skip(self), fields(agent_id = %self.id))]
153    pub async fn validate(&self) -> Result<ValidatorAgentResult> {
154        let start = Instant::now();
155
156        // ── 1. Register & broadcast spawn ────────────────────────────────
157        self.communication_hub
158            .register_agent(self.id.clone())
159            .await?;
160
161        if let Err(e) = self
162            .communication_hub
163            .broadcast(
164                self.id.clone(),
165                AgentMessage::AgentSpawned {
166                    agent_id: self.id.clone(),
167                    task_id: format!("validation-{}", self.id),
168                },
169            )
170            .await
171        {
172            tracing::warn!(agent_id = %self.id, "Failed to broadcast validator spawn: {}", e);
173        }
174
175        // ── 2. Acquire read locks (best-effort) ─────────────────────────
176        self.set_status(ValidatorAgentStatus::AcquiringLocks).await;
177
178        let mut lock_guards: Vec<LockGuard> = Vec::new();
179        for file in &self.config.validation_config.working_set_files {
180            let path = std::path::PathBuf::from(&self.config.validation_config.working_directory)
181                .join(file);
182            match self
183                .file_lock_manager
184                .acquire_lock(&self.id, &path, LockType::Read)
185                .await
186            {
187                Ok(guard) => {
188                    lock_guards.push(guard);
189                }
190                Err(e) => {
191                    tracing::warn!(
192                        agent_id = %self.id,
193                        file = %file,
194                        "Failed to acquire read lock (best-effort, continuing): {}",
195                        e
196                    );
197                }
198            }
199        }
200        let locks_acquired = lock_guards.len();
201
202        // ── 3. Run validation with timeout ──────────────────────────────
203        self.set_status(ValidatorAgentStatus::Validating).await;
204
205        let timeout = tokio::time::Duration::from_secs(self.config.timeout_secs);
206        let validation_result =
207            match tokio::time::timeout(timeout, run_validation(&self.config.validation_config))
208                .await
209            {
210                Ok(Ok(result)) => result,
211                Ok(Err(e)) => {
212                    let err_msg = format!("Validation error: {}", e);
213                    self.set_status(ValidatorAgentStatus::Error(err_msg.clone()))
214                        .await;
215                    self.cleanup(&lock_guards, false, &err_msg, start).await;
216                    return Err(e);
217                }
218                Err(_elapsed) => {
219                    let err_msg =
220                        format!("Validation timed out after {}s", self.config.timeout_secs);
221                    self.set_status(ValidatorAgentStatus::Error(err_msg.clone()))
222                        .await;
223                    self.cleanup(&lock_guards, false, &err_msg, start).await;
224                    return Err(anyhow::anyhow!("{}", err_msg));
225                }
226            };
227
228        // ── 4. Build result, drop locks, broadcast completion ───────────
229        let success = validation_result.passed;
230        let issue_count = validation_result.issues.len();
231        let files_checked = self.config.validation_config.working_set_files.len();
232        let feedback = format_validation_feedback(&validation_result);
233
234        if success {
235            self.set_status(ValidatorAgentStatus::Passed).await;
236        } else {
237            self.set_status(ValidatorAgentStatus::Failed(issue_count))
238                .await;
239        }
240
241        let summary = if success {
242            "All validation checks passed".to_string()
243        } else {
244            format!("Validation failed with {} issues", issue_count)
245        };
246
247        self.cleanup(&lock_guards, success, &summary, start).await;
248
249        Ok(ValidatorAgentResult {
250            agent_id: self.id.clone(),
251            success,
252            validation_result,
253            feedback,
254            duration: start.elapsed(),
255            files_checked,
256            locks_acquired,
257        })
258    }
259
260    /// Set the observable status.
261    async fn set_status(&self, status: ValidatorAgentStatus) {
262        *self.status.write().await = status;
263    }
264
265    /// Drop lock guards, broadcast completion, unregister from hub.
266    async fn cleanup(
267        &self,
268        _lock_guards: &[LockGuard],
269        _success: bool,
270        summary: &str,
271        _start: Instant,
272    ) {
273        // Lock guards are borrowed — they'll be dropped when the caller's
274        // `lock_guards` Vec goes out of scope. As a safety net, release all
275        // locks explicitly.
276        self.file_lock_manager.release_all_locks(&self.id).await;
277
278        if let Err(e) = self
279            .communication_hub
280            .broadcast(
281                self.id.clone(),
282                AgentMessage::AgentCompleted {
283                    agent_id: self.id.clone(),
284                    task_id: format!("validation-{}", self.id),
285                    summary: summary.to_string(),
286                },
287            )
288            .await
289        {
290            tracing::warn!(agent_id = %self.id, "Failed to broadcast validator completion: {}", e);
291        }
292
293        if let Err(e) = self.communication_hub.unregister_agent(&self.id).await {
294            tracing::warn!(agent_id = %self.id, "Failed to unregister validator agent: {}", e);
295        }
296    }
297}
298
299/// Spawn a `ValidatorAgent` on a Tokio task and return a join handle.
300///
301/// Mirrors [`spawn_task_agent`](crate::task_agent::spawn_task_agent).
302pub fn spawn_validator_agent(
303    agent: Arc<ValidatorAgent>,
304) -> tokio::task::JoinHandle<Result<ValidatorAgentResult>> {
305    tokio::spawn(async move { agent.validate().await })
306}
307
308// ── Tests ────────────────────────────────────────────────────────────────────
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    fn make_hub_and_locks() -> (Arc<CommunicationHub>, Arc<FileLockManager>) {
315        (
316            Arc::new(CommunicationHub::new()),
317            Arc::new(FileLockManager::new()),
318        )
319    }
320
321    #[tokio::test]
322    async fn test_validator_disabled() {
323        let (hub, locks) = make_hub_and_locks();
324        let config = ValidatorAgentConfig::new(ValidationConfig::disabled());
325        let agent = ValidatorAgent::new("val-disabled", config, hub, locks);
326
327        let result = agent.validate().await.unwrap();
328        assert!(result.success);
329        assert!(result.validation_result.issues.is_empty());
330    }
331
332    #[tokio::test]
333    async fn test_validator_detects_missing_file() {
334        let (hub, locks) = make_hub_and_locks();
335
336        let dir = tempfile::tempdir().unwrap();
337        let mut vc = ValidationConfig::default();
338        vc.working_directory = dir.path().to_string_lossy().to_string();
339        vc.working_set_files = vec!["nonexistent.rs".to_string()];
340
341        let config = ValidatorAgentConfig::new(vc);
342        let agent = ValidatorAgent::new("val-missing", config, hub, locks);
343
344        let result = agent.validate().await.unwrap();
345        assert!(!result.success);
346        assert!(
347            result
348                .validation_result
349                .issues
350                .iter()
351                .any(|i| i.check == "file_existence")
352        );
353    }
354
355    #[tokio::test]
356    async fn test_validator_registers_and_unregisters() {
357        let (hub, locks) = make_hub_and_locks();
358        let config = ValidatorAgentConfig::new(ValidationConfig::disabled());
359        let agent = ValidatorAgent::new("val-hub", config, Arc::clone(&hub), locks);
360
361        // Before validate — not registered
362        assert!(!hub.is_registered("val-hub").await);
363
364        let _result = agent.validate().await.unwrap();
365
366        // After validate — unregistered
367        assert!(!hub.is_registered("val-hub").await);
368    }
369
370    #[tokio::test]
371    async fn test_validator_acquires_read_locks() {
372        let (hub, locks) = make_hub_and_locks();
373
374        let dir = tempfile::tempdir().unwrap();
375        let file_path = dir.path().join("locked.rs");
376        std::fs::write(&file_path, "fn main() {}").unwrap();
377
378        // Pre-acquire a WRITE lock as another agent on the same file
379        let _write_guard = locks
380            .acquire_lock("other-agent", &file_path, LockType::Write)
381            .await
382            .unwrap();
383
384        let mut vc = ValidationConfig::disabled();
385        vc.working_directory = dir.path().to_string_lossy().to_string();
386        vc.working_set_files = vec!["locked.rs".to_string()];
387
388        let config = ValidatorAgentConfig::new(vc);
389        let agent = ValidatorAgent::new("val-lock", config, hub, locks);
390
391        // Validation should still succeed (best-effort lock acquisition)
392        let result = agent.validate().await.unwrap();
393        assert!(result.success);
394        // Lock was blocked so 0 acquired
395        assert_eq!(result.locks_acquired, 0);
396    }
397
398    #[tokio::test]
399    async fn test_validator_concurrent_read_locks() {
400        let (hub, locks) = make_hub_and_locks();
401
402        let dir = tempfile::tempdir().unwrap();
403        let file_path = dir.path().join("shared.rs");
404        std::fs::write(&file_path, "fn main() {}").unwrap();
405
406        // Pre-acquire a READ lock as another agent
407        let _read_guard = locks
408            .acquire_lock("reader-agent", &file_path, LockType::Read)
409            .await
410            .unwrap();
411
412        let mut vc = ValidationConfig::disabled();
413        vc.working_directory = dir.path().to_string_lossy().to_string();
414        vc.working_set_files = vec!["shared.rs".to_string()];
415
416        let config = ValidatorAgentConfig::new(vc);
417        let agent = ValidatorAgent::new("val-read", config, hub, locks);
418
419        let result = agent.validate().await.unwrap();
420        assert!(result.success);
421        // Both readers can hold the lock simultaneously
422        assert_eq!(result.locks_acquired, 1);
423    }
424
425    #[tokio::test]
426    async fn test_spawn_validator_agent() {
427        let (hub, locks) = make_hub_and_locks();
428        let config = ValidatorAgentConfig::new(ValidationConfig::disabled());
429        let agent = Arc::new(ValidatorAgent::new("val-spawn", config, hub, locks));
430
431        let handle = spawn_validator_agent(agent);
432        let result = handle.await.unwrap().unwrap();
433        assert!(result.success);
434        assert_eq!(result.agent_id, "val-spawn");
435    }
436
437    #[tokio::test]
438    async fn test_result_metadata() {
439        let (hub, locks) = make_hub_and_locks();
440
441        let dir = tempfile::tempdir().unwrap();
442        let file1 = dir.path().join("a.rs");
443        let file2 = dir.path().join("b.rs");
444        std::fs::write(&file1, "fn a() {}").unwrap();
445        std::fs::write(&file2, "fn b() {}").unwrap();
446
447        let mut vc = ValidationConfig::disabled();
448        vc.working_directory = dir.path().to_string_lossy().to_string();
449        vc.working_set_files = vec!["a.rs".to_string(), "b.rs".to_string()];
450
451        let config = ValidatorAgentConfig::new(vc);
452        let agent = ValidatorAgent::new("val-meta", config, hub, locks);
453
454        let result = agent.validate().await.unwrap();
455        assert_eq!(result.agent_id, "val-meta");
456        assert_eq!(result.files_checked, 2);
457        assert!(result.duration.as_nanos() > 0);
458        assert!(result.success);
459    }
460}