use std::process::Command; use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::TurnEvent; use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind}; use crate::app::subagent::context::build_subagent_context; use crate::app::subagent::engine::run_subagent; use crate::app::subagent::spawn::AgentDefinition; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Confidence { Human, Verified, Unverified, Auto, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum LessonLifecycle { New, Active, Stale, Contradicted, Superseded, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum LessonScope { Project, Global, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Provenance { pub session_turn: String, pub session_id: String, pub reviewer: Origin, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Lesson { pub name: String, pub content: String, pub confidence: Confidence, pub outcome: Option, pub lifecycle: LessonLifecycle, pub scope: LessonScope, pub contradiction_with: Option, pub provenance: Provenance, } pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool { if state.mode == AgentMode::Plan { return false; } if origin != Origin::Main { return false; } let runtime = match &state.session_runtime { Some(r) => r, None => return false, }; if !state.settings.review_enabled { return false; } if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 { return true; } let base: u32 = state.settings.adaptive_review_max_skip.max(2); let consecutive = runtime.consecutive_empty_reviews; if consecutive >= base { let skip = 1u32 << (consecutive - base).min(10); if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) { return true; } return false; } false } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProbeResult { pub command: String, pub passed: bool, pub output: String, pub timed_out: bool, } pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option { let probe_dir = workspaces.first()?; let cmd = resolve_verify_command(probe_dir, verify_command)?; let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string())) .unwrap_or_else(|| (cmd.clone(), String::new())); let Ok(mut child) = Command::new(&cmd_prog) .args(cmd_args.split_whitespace()) .current_dir(probe_dir) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() else { return None }; let start = std::time::Instant::now(); let timed_out = loop { if start.elapsed().as_millis() as u64 >= timeout_ms { let _ = child.kill(); break true; } match child.try_wait() { Ok(Some(status)) => { let output = child.wait_with_output().ok(); let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default(); let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) }; return Some(ProbeResult { command: cmd.clone(), passed: status.success(), output: truncate_output(&combined, 2048), timed_out: false, }); } Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); } Err(_) => return None, } }; if timed_out { Some(ProbeResult { command: cmd.clone(), passed: false, output: "timed out".to_string(), timed_out: true, }) } else { None } } fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option { if let Some(cmd) = override_cmd { if !cmd.trim().is_empty() { return Some(cmd.trim().to_string()); } } let has_file = |name: &str| probe_dir.join(name).exists(); let has_dir = |name: &str| probe_dir.join(name).is_dir(); if has_file("Cargo.toml") { if has_dir("src") || has_dir("tests") { return Some("cargo build 2>&1 && cargo test 2>&1".to_string()); } return Some("cargo build 2>&1".to_string()); } if has_file("go.mod") { return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string()); } if has_file("package.json") { let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?; if let Ok(v) = serde_json::from_str::(&pkg) { let scripts = v.get("scripts")?; if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() { return Some("npm test 2>&1".to_string()); } if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() { return Some("npm run build 2>&1".to_string()); } } return Some("npm test 2>&1".to_string()); } if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") { if has_file("pyproject.toml") { let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default(); if content.contains("[tool.pytest") { return Some("python -m pytest --tb=short -q 2>&1".to_string()); } } if has_dir("tests") || has_dir("test") { return Some("python -m pytest --tb=short -q 2>&1".to_string()); } return None; } if has_file("Cargo.lock") { return Some("cargo build 2>&1".to_string()); } if has_file("Gemfile") || has_file("Rakefile") || has_file("*.gemspec") { return Some("bundle exec rake 2>&1".to_string()); } if has_file("Makefile") || has_file("makefile") || has_file("GNUmakefile") { return Some("make test 2>&1 || make build 2>&1".to_string()); } if has_file("justfile") || has_file("justfile") { return Some("just test 2>&1 || just build 2>&1".to_string()); } if has_file("deno.json") || has_file("deno.jsonc") { return Some("deno test 2>&1".to_string()); } if has_file("bun.lock") || has_file("bun.lockb") { return Some("bun test 2>&1".to_string()); } if has_file("pnpm-lock.yaml") { return Some("pnpm test 2>&1 || pnpm build 2>&1".to_string()); } if has_file("yarn.lock") { return Some("yarn test 2>&1 || yarn build 2>&1".to_string()); } if has_file("composer.json") { return Some("composer test 2>&1 || composer run build 2>&1".to_string()); } if has_file("build.gradle") || has_file("build.gradle.kts") || has_file("gradlew") { return Some("gradle build 2>&1 && gradle test 2>&1".to_string()); } if has_file("pom.xml") || has_file("mvnw") { return Some("mvn test 2>&1".to_string()); } if has_file("stack.yaml") || has_file("package.yaml") || has_file("cabal.project") { return Some("cabal test all 2>&1 || stack test 2>&1".to_string()); } if has_file("mix.exs") { return Some("mix test 2>&1".to_string()); } if has_file("rebar.config") || has_file("rebar.lock") { return Some("rebar3 ct 2>&1 || rebar3 eunit 2>&1".to_string()); } if has_file("dune-project") || has_file("jbuild") || has_file("Makefile") { return Some("dune runtest 2>&1".to_string()); } if has_file("shard.yml") { return Some("crystal spec 2>&1".to_string()); } if has_file("Project.toml") || has_file("JuliaProject.toml") { return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string()); } None } fn truncate_output(s: &str, max: usize) -> String { if s.len() <= max { s.to_string() } else { let mut t: String = s.chars().take(max).collect(); t.push_str("... (truncated)"); t } } pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { let def = AgentDefinition::new( "quality-reviewer".to_string(), "reviewer".to_string(), ); let mut ctx = build_subagent_context(def); ctx.session_dir = state.session_dir.clone(); let probe_result = probe_build_test( &state.workspace_roots, state.settings.verify_command.as_deref(), state.settings.verify_timeout_ms, ); let probe_note = match &probe_result { Some(r) => { if r.passed { format!("Build/test verification passed ({}). Confidence: verified.", r.command) } else if r.timed_out { format!("Build/test verification timed out ({}). Confidence: opinion (no reproducible result).", r.command) } else { format!("Build/test verification failed ({}). Output: {}", r.command, r.output) } } None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(), }; ctx.system_prompt = format!( "You are a code quality reviewer. Review the recent code changes \ for correctness, security, and adherence to best practices. \ Use read-only tools (read, grep, glob, recall, remember) to \ inspect the session files and provide a concise review verdict. \ Session directory: {:?}\n\n\ Build/Test Probe:\n{}\n\n\ When writing a lesson via remember(), set tags appropriately:\n\ - If build/test verification printed any FAILED/ERROR lines, tag\n\ the lesson as \"confidence: verified\" (backed by a real failure).\n\ - If the probe passed or was skipped, tag as \"confidence: opinion\"\n\ (reviewer judgment only).\n\ Check for duplicate lessons via recall before writing a new one.", state.session_dir, probe_note, ); let (tx, _rx) = tokio::sync::mpsc::channel(32); let turn_events = state.turn_events.clone(); std::thread::spawn(move || { let result = run_subagent(ctx, tx); let message = match result { Ok(verdict) => { let first_line = verdict.lines().next().unwrap_or(&verdict); format!("Quality review: {}", first_line) } Err(e) => format!("Quality review failed: {}", e), }; if let Ok(mut q) = turn_events.lock() { q.push_back(TurnEvent::SystemNote { kind: "review".to_string(), message, }); } }); state.push_toast(Toast::new( ToastKind::Info, "Quality review triggered".to_string(), )); Ok(()) } const STALE_AFTER_DAYS: i64 = 60; pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result> { let mut flagged = Vec::new(); let names = crate::model::memory::Memory::list(memory_dir); let now = chrono::Utc::now().timestamp_millis(); let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000; for name in names { if let Ok(mut mem) = crate::model::memory::Memory::read(memory_dir, &name) { if mem.updated_at < cutoff && mem.lifecycle != "stale" { mem.lifecycle = "stale".to_string(); mem.write(memory_dir)?; flagged.push(name); } } } Ok(flagged) } pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) { let now = chrono::Utc::now().timestamp_millis(); if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 { return; } state.misc.last_staleness_sweep_ms = now; if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) { if !flagged.is_empty() { state.push_toast(Toast::new( ToastKind::Info, format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")), )); } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingLesson { pub lesson: Lesson, pub created_at: i64, pub auto_resolve: bool, } pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec { let path = session_dir.join("pending_lessons.json"); std::fs::read_to_string(&path) .ok() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default() } pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> { let path = session_dir.join("pending_lessons.json"); let data = serde_json::to_string_pretty(pending)?; std::fs::write(&path, data) } pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result> { let pending = load_pending_lessons(session_dir); let now = chrono::Utc::now().timestamp_millis(); let grace_window = 5_000; let mut remaining = Vec::new(); let mut to_keep = Vec::new(); for p in &pending { if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window { to_keep.push(p.lesson.clone()); } else { remaining.push(p.clone()); } } for lesson in &to_keep { let mem = crate::model::memory::Memory { name: lesson.name.clone(), description: lesson.content.chars().take(80).collect(), content: lesson.content.clone(), kind: "lesson".to_string(), created_at: now, updated_at: now, outcome: None, lifecycle: "active".to_string(), scope: Some("project".to_string()), before_snippet: None, after_snippet: None, provenances: vec![], }; mem.write(memory_dir)?; } save_pending_lessons(session_dir, &remaining)?; Ok(remaining) } pub fn resolve_pending_lesson( session_dir: &std::path::Path, memory_dir: &std::path::Path, lesson_name: &str, keep: bool, ) -> std::io::Result<()> { let pending = load_pending_lessons(session_dir); let mut remaining = Vec::new(); let now = chrono::Utc::now().timestamp_millis(); for p in pending { if p.lesson.name == lesson_name { if keep { let mem = crate::model::memory::Memory { name: p.lesson.name.clone(), description: p.lesson.content.chars().take(80).collect(), content: p.lesson.content.clone(), kind: "lesson".to_string(), created_at: now, updated_at: now, outcome: None, lifecycle: "active".to_string(), scope: Some("project".to_string()), before_snippet: None, after_snippet: None, provenances: vec![], }; mem.write(memory_dir)?; } } else { remaining.push(p); } } save_pending_lessons(session_dir, &remaining) }