//! Auto-subagent orchestration: the main agent automatically delegates //! review, test-generation, architecture-review, and security-review tasks //! to subagents without requiring explicit tool calls from the LLM. //! //! Two modes: //! - **Inline** (`spawn_quick_review`): runs synchronously within the turn //! after each write/edit tool call. Results are fed back into the LLM //! conversation so the agent can act on feedback immediately. //! - **Background** (`spawn_background_*`): runs asynchronously on a //! dedicated OS thread at the end of a turn. Reports results via //! `TurnEvent::SystemNote`, consumed by the TUI on the next Tick. //! //! Why inline vs background: //! - Inline reviews give the agent an immediate feedback loop ("I just //! wrote this file, let me check if it's correct before continuing"). //! - Background reviews catch broader concerns (missing tests, architectural //! drift, security issues) without blocking the main agent's flow. use std::path::Path; use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicBool, Ordering}; use std::collections::VecDeque; use crate::app::state::runtime::TurnEvent; use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::engine::run_subagent; use crate::app::subagent::spawn::AgentDefinition; use crate::app::subagent::event::SubagentEvent; /// File extensions that should not trigger auto-review (config, lock, data). const SKIP_REVIEW_EXTENSIONS: &[&str] = &[ ".lock", ".md", ".txt", ".json", ".toml", ".yaml", ".yml", ".svg", ".png", ".jpg", ".ico", ".woff", ".woff2", ]; /// File names that should not trigger auto-review. const SKIP_REVIEW_FILES: &[&str] = &[ "Cargo.lock", "yarn.lock", "package-lock.json", ".gitignore", ".env", ".env.example", ]; /// Prevents a second background subagent of the same kind from spawning /// while one is already in flight. Without this, a chatty multi-turn edit /// session could stack overlapping test-gen/arch/security reviews of /// overlapping file sets, none of which could be told apart in the /// `SystemNote` toast stream. static TEST_GEN_RUNNING: AtomicBool = AtomicBool::new(false); static ARCH_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false); static SECURITY_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false); /// RAII guard that resets a per-kind overlap flag back to `false` on drop — /// including during a panic-triggered unwind inside the spawned thread — so /// a background review can never wedge itself permanently disabled for the /// rest of the process if the subagent run panics before reaching its /// normal completion path. struct RunningGuard(&'static AtomicBool); impl Drop for RunningGuard { fn drop(&mut self) { self.0.store(false, Ordering::SeqCst); } } /// ─── Helpers ─── /// /// Check whether a file path is worth auto-reviewing (not config/lock/data). /// /// Vendored/generated directories are matched by path *segment* rather than /// a `/target/`-style substring check — the substring form misses paths /// where the directory is the first component (e.g. `target/debug/build.rs`, /// which has no leading slash), the same class of bug fixed in /// `is_production_code` below. pub fn is_reviewable_path(path: &str) -> bool { let lower = path.to_lowercase(); if SKIP_REVIEW_FILES.iter().any(|f| lower.ends_with(f)) { return false; } if SKIP_REVIEW_EXTENSIONS.iter().any(|e| lower.ends_with(e)) { return false; } // Skip paths that are clearly generated or vendored let in_vendored_dir = std::path::Path::new(&lower).components().any(|c| { matches!( c, std::path::Component::Normal(seg) if matches!(seg.to_str(), Some("target" | "node_modules" | ".git" | "vendor")) ) }); if in_vendored_dir { return false; } true } /// Determine whether a file change looks like it modifies production logic /// (vs. tests, config, or documentation) — used to decide if a test-gen /// or security-review background subagent should fire. /// /// Matches test-ness by path *segment* (a directory literally named /// "test"/"tests"/"__tests__") or by filename convention /// (`foo_test.rs`, `foo.test.ts`, `test_foo.py`, `foo_spec.rb`), not by a /// raw substring check — a plain `.contains("test")` would wrongly exclude /// legitimate production files like `src/attestation.rs` or /// `src/latest/foo.rs`. fn is_production_code(path: &str) -> bool { let lower = path.to_lowercase(); let path_obj = std::path::Path::new(&lower); let in_test_dir = path_obj.components().any(|c| { matches!( c, std::path::Component::Normal(seg) if matches!(seg.to_str(), Some("test" | "tests" | "__tests__")) ) }); let file_stem = path_obj.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let is_test_filename = file_stem.starts_with("test_") || file_stem.ends_with("_test") || std::path::Path::new(file_stem) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("test")) || file_stem == "spec" || file_stem.ends_with("_spec") || std::path::Path::new(file_stem) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("spec")); if in_test_dir || is_test_filename { return false; } // Only source files — use Path::extension() to avoid clippy // case_sensitive_file_extension_comparisons lint path_obj .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| { matches!( ext, "rs" | "ts" | "tsx" | "js" | "jsx" | "go" | "py" | "java" | "kt" | "swift" | "c" | "cpp" | "h" | "hpp" ) }) } /// ─── Inline Quick Review (synchronous, feeds back to LLM) ─── /// /// Spawn a lightweight inline code review subagent for the given file. /// /// The subagent reads the file (read-only), checks for common issues, /// and returns a concise text verdict. This runs synchronously so the /// main agent's `run_agent_turn` can inject the result back into the /// LLM conversation for immediate action. /// /// Returns `Ok(verdict)` if the review completed, or an error if the /// subagent could not be spawned or failed internally. Callers should /// log and swallow errors gracefully — a failed inline review should /// never interrupt the main agent's flow. pub fn spawn_quick_review( file_path: &str, session_dir: &Path, workspaces: &[std::path::PathBuf], ) -> anyhow::Result { let prompt = format!( "{}\n\nFile to review: {}", crate::resources::AUTO_REVIEWER_PROMPT, file_path, ); let def = AgentDefinition::new( "quick-reviewer".to_string(), "reviewer".to_string(), ) .with_system_prompt(prompt); let mut ctx = build_subagent_context(&def); ctx.session_dir = session_dir.to_path_buf(); ctx.workspaces = workspaces.to_vec(); let (tx, mut rx) = tokio::sync::mpsc::channel(32); let _drain = std::thread::spawn(move || { while let Some(event) = rx.blocking_recv() { match &event { SubagentEvent::ToolCall { tool, .. } => { tracing::debug!("[auto-review] tool call: {}", tool); } SubagentEvent::ToolResult { tool, .. } => { tracing::debug!("[auto-review] tool result: {}", tool); } SubagentEvent::Completed { .. } => { tracing::debug!("[auto-review] completed"); } _ => {} } } }); let verdict = run_subagent(&ctx, &tx)?; tracing::info!( "[auto-review] quick review for '{}': {}", file_path, verdict.lines().next().unwrap_or(&verdict), ); Ok(verdict) } /// ─── Background Subagent Spawners (async, report via `SystemNote`) ─── /// /// Run a subagent built from `def`, retrying once if the first attempt /// fails. Background subagents call this instead of running once and /// silently swallowing the error into a note string, so a single transient /// LLM/tool failure doesn't just disappear. /// /// `abort_flag` is checked before every attempt (including the first) and /// forwarded into the subagent's own context, so a cancelled turn stops /// retrying immediately instead of burning a second attempt. /// /// Return: `Ok(output)` if either attempt succeeded, `Err(message)` /// describing the final failure if both attempts failed, or the literal /// message `"aborted by user"` if `abort_flag` was already set before an /// attempt could start. fn run_subagent_with_retry( def: &AgentDefinition, session_dir: &Path, workspaces: &[std::path::PathBuf], label: &str, abort_flag: Option<&Arc>, ) -> Result { let mut last_err = String::new(); for attempt in 1..=2 { if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) { return Err("aborted by user".to_string()); } let mut ctx = build_subagent_context(def); ctx.session_dir = session_dir.to_path_buf(); ctx.workspaces = workspaces.to_vec(); ctx.abort_flag = abort_flag.cloned(); let (tx, mut rx) = tokio::sync::mpsc::channel(32); let drain_label = label.to_string(); let _drain = std::thread::spawn(move || { while let Some(event) = rx.blocking_recv() { if let SubagentEvent::StepFailed { step, error } = &event { tracing::warn!("[{drain_label}] step {step} failed: {error}"); } } }); match run_subagent(&ctx, &tx) { Ok(output) => return Ok(output), Err(e) => { tracing::warn!("[{label}] attempt {attempt}/2 failed: {e}"); last_err = e.to_string(); } } } Err(format!("failed after 2 attempts: {last_err}")) } /// Spawn a background subagent that generates tests for modified files. /// /// Uses the test-generator prompt and has read-write access so it can /// create test files. Runs in a separate OS thread and reports completion /// via `TurnEvent::SystemNote { kind: "bg-test-gen" }`. /// /// Skipped (no-op) if a test-gen run is already in flight (guarded by /// `TEST_GEN_RUNNING`) — prevents a chatty multi-turn edit session from /// stacking overlapping runs. `abort_flag` is forwarded to /// `run_subagent_with_retry` so the run can be cancelled if the turn aborts. pub fn spawn_background_test_gen( file_paths: &[String], session_dir: &Path, workspaces: &[std::path::PathBuf], turn_events: &Arc>>, abort_flag: Arc, ) { if file_paths.is_empty() { return; } if TEST_GEN_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { tracing::debug!("[bg-test-gen] skipped — a test-gen run is already in flight"); return; } let paths = file_paths.to_vec(); let sd = session_dir.to_path_buf(); let ws = workspaces.to_vec(); let events = turn_events.clone(); std::thread::spawn(move || { let _running_guard = RunningGuard(&TEST_GEN_RUNNING); tracing::info!( "[bg-test-gen] spawning for {} file(s): {:?}", paths.len(), paths, ); let file_list = paths.join("\n"); let prompt = format!( "{}\n\nModified files that need tests:\n{}", crate::resources::TEST_GENERATOR_PROMPT, file_list, ); let def = AgentDefinition::new( "test-generator".to_string(), "coder".to_string(), // needs write access ) .with_system_prompt(prompt) ; let result = run_subagent_with_retry(&def, &sd, &ws, "bg-test-gen", Some(&abort_flag)); let message = match &result { Ok(output) => { let first = output.lines().next().unwrap_or(output); format!("Auto test-gen: {first}") } Err(e) if e.contains("aborted") => format!("Auto test-gen cancelled: {e}"), Err(e) => format!("ESCALATED: Auto test-gen {e}"), }; if let Ok(mut q) = events.lock() { q.push_back(TurnEvent::SystemNote { kind: "bg-test-gen".to_string(), message, }); } }); } /// Spawn a background architecture-review subagent. /// /// Inspects the modified files for architectural consistency (layering, /// coupling, module boundaries). Reports via /// `TurnEvent::SystemNote { kind: "bg-arch-review" }`. /// /// Skipped (no-op) if an arch-review run is already in flight (guarded by /// `ARCH_REVIEW_RUNNING`). `abort_flag` is forwarded to /// `run_subagent_with_retry` so the run can be cancelled if the turn aborts. pub fn spawn_background_arch_review( file_paths: &[String], session_dir: &Path, workspaces: &[std::path::PathBuf], turn_events: &Arc>>, abort_flag: Arc, ) { if file_paths.is_empty() { return; } if ARCH_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { tracing::debug!("[bg-arch-review] skipped — an arch-review run is already in flight"); return; } let paths = file_paths.to_vec(); let sd = session_dir.to_path_buf(); let ws = workspaces.to_vec(); let events = turn_events.clone(); std::thread::spawn(move || { let _running_guard = RunningGuard(&ARCH_REVIEW_RUNNING); let file_list = paths.join("\n"); let prompt = format!( "{}\n\nModified files for architecture review:\n{}", crate::resources::ARCH_REVIEWER_PROMPT, file_list, ); let def = AgentDefinition::new( "arch-reviewer".to_string(), "reviewer".to_string(), ) .with_system_prompt(prompt) ; let result = run_subagent_with_retry(&def, &sd, &ws, "bg-arch-review", Some(&abort_flag)); let message = match &result { Ok(output) => { let first = output.lines().next().unwrap_or(output); format!("Architecture review: {first}") } Err(e) if e.contains("aborted") => format!("Architecture review cancelled: {e}"), Err(e) => format!("ESCALATED: Architecture review {e}"), }; if let Ok(mut q) = events.lock() { q.push_back(TurnEvent::SystemNote { kind: "bg-arch-review".to_string(), message, }); } }); } /// Spawn a background security-review subagent. /// /// Checks modified files for security vulnerabilities. Reports via /// `TurnEvent::SystemNote { kind: "bg-security-review" }`. /// /// Skipped (no-op) if a security-review run is already in flight (guarded by /// `SECURITY_REVIEW_RUNNING`). `abort_flag` is forwarded to /// `run_subagent_with_retry` so the run can be cancelled if the turn aborts. pub fn spawn_background_security_review( file_paths: &[String], session_dir: &Path, workspaces: &[std::path::PathBuf], turn_events: &Arc>>, abort_flag: Arc, ) { if file_paths.is_empty() { return; } // Only review production code files for security — test files and // config files are out of scope for security review. let prod_paths: Vec = file_paths .iter() .filter(|p| is_production_code(p)) .cloned() .collect(); if prod_paths.is_empty() { return; } if SECURITY_REVIEW_RUNNING.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst).is_err() { tracing::debug!("[bg-security-review] skipped — a security-review run is already in flight"); return; } let paths = prod_paths; let sd = session_dir.to_path_buf(); let ws = workspaces.to_vec(); let events = turn_events.clone(); std::thread::spawn(move || { let _running_guard = RunningGuard(&SECURITY_REVIEW_RUNNING); let file_list = paths.join("\n"); let prompt = format!( "{}\n\nModified files for security review:\n{}", crate::resources::SECURITY_REVIEWER_PROMPT, file_list, ); let def = AgentDefinition::new( "security-reviewer".to_string(), "reviewer".to_string(), ) .with_system_prompt(prompt) ; let result = run_subagent_with_retry(&def, &sd, &ws, "bg-security-review", Some(&abort_flag)); let message = match &result { Ok(output) => { let first = output.lines().next().unwrap_or(output); format!("Security review: {first}") } Err(e) if e.contains("aborted") => format!("Security review cancelled: {e}"), Err(e) => format!("ESCALATED: Security review {e}"), }; if let Ok(mut q) = events.lock() { q.push_back(TurnEvent::SystemNote { kind: "bg-security-review".to_string(), message, }); } }); } /// Convenience: spawn all applicable background subagents for a set of edited /// file paths. Called once at the end of a main agent turn. /// /// Flow: always spawns arch-review and security-review if there are /// reviewable production files → spawns test-gen only if there are source /// files that aren't already tests. /// /// `abort_flag` is cloned and forwarded to all three spawn calls so a /// single cancellation source stops every kind of background review. pub fn spawn_all_background( file_paths: &[String], session_dir: &Path, workspaces: &[std::path::PathBuf], turn_events: &Arc>>, abort_flag: Arc, ) { if file_paths.is_empty() { return; } // Background test-gen: only for non-test source files let source_paths: Vec = file_paths .iter() .filter(|p| is_production_code(p)) .cloned() .collect(); spawn_background_test_gen(&source_paths, session_dir, workspaces, turn_events, abort_flag.clone()); // Background arch review: for all files that are reviewable let reviewable: Vec = file_paths .iter() .filter(|p| is_reviewable_path(p)) .cloned() .collect(); spawn_background_arch_review(&reviewable, session_dir, workspaces, turn_events, abort_flag.clone()); // Background security review: only production source files spawn_background_security_review(&source_paths, session_dir, workspaces, turn_events, abort_flag); } #[cfg(test)] mod tests { use super::*; #[test] fn reviewable_path_skips_lockfiles_and_known_extensions() { assert!(!is_reviewable_path("Cargo.lock")); assert!(!is_reviewable_path("package.json")); assert!(!is_reviewable_path("logo.svg")); } #[test] fn reviewable_path_skips_vendored_and_generated_dirs() { assert!(!is_reviewable_path("target/debug/build.rs")); assert!(!is_reviewable_path("node_modules/foo/index.js")); } #[test] fn reviewable_path_accepts_ordinary_source_files() { assert!(is_reviewable_path("src/main.rs")); } #[test] fn production_code_excludes_dedicated_test_directories() { assert!(!is_production_code("src/tests/foo.rs")); assert!(!is_production_code("__tests__/baz.test.ts")); } #[test] fn production_code_excludes_test_filename_conventions() { assert!(!is_production_code("src/foo_test.rs")); assert!(!is_production_code("src/test_foo.py")); assert!(!is_production_code("src/foo.spec.ts")); } #[test] fn production_code_does_not_false_positive_on_substring_test() { // Regression: a plain `.contains("test")` would wrongly exclude // these legitimate production files. assert!(is_production_code("src/attestation.rs")); assert!(is_production_code("src/latest/foo.rs")); } #[test] fn production_code_requires_known_source_extension() { assert!(!is_production_code("README.md")); assert!(is_production_code("src/main.rs")); } #[test] fn running_guard_resets_flag_on_drop_even_after_panic() { static TEST_FLAG: AtomicBool = AtomicBool::new(false); TEST_FLAG.store(true, Ordering::SeqCst); let result = std::panic::catch_unwind(|| { let _guard = RunningGuard(&TEST_FLAG); panic!("simulated failure inside guarded region"); }); assert!(result.is_err()); assert!( !TEST_FLAG.load(Ordering::SeqCst), "guard must reset the flag even when the guarded closure panics" ); } }