#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] //! Adaptive quality-review triggering, build/test probing, staleness //! sweeps for stored lessons, and the pending-lesson approval workflow. use std::process::Command; use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::TurnEvent; use crate::app::state::types::{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}; /// How much trust a lesson's origin/verification warrants. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Confidence { Human, Verified, Unverified, Auto, } /// Where a lesson sits in its life cycle, from freshly written to superseded. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum LessonLifecycle { New, Active, Stale, Contradicted, Superseded, } /// Whether a lesson applies to the current project only or globally. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum LessonScope { Project, Global, } /// Records who/what produced a lesson and in which session/turn. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Provenance { pub session_turn: String, pub session_id: String, pub reviewer: Origin, } /// A single learned fact/pattern surfaced by a review, prior to being /// written to persistent memory. #[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, } /// Decide whether an adaptive quality review should fire for this turn. /// /// Flow: only `Origin::Main` turns are eligible → require review enabled /// in settings → fire every 5th edit unconditionally → otherwise, once /// `consecutive_empty_reviews` reaches `adaptive_review_max_skip` (min 2), /// fire on an exponentially growing skip interval (2^n, capped at 2^10) /// to avoid reviewing every single edit once reviews keep coming back empty. /// /// Why: balances review usefulness against wasted subagent calls when /// reviews consistently find nothing. /// /// Return: `true` if a review should be triggered this turn. pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool { if origin != Origin::Main { return false; } let Some(runtime) = &state.session_runtime else { 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 } /// Outcome of running a build/test probe command against a workspace. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProbeResult { pub command: String, pub passed: bool, pub output: String, pub timed_out: bool, } /// Run a build/test verification command in the first workspace root and /// capture its outcome, to back a review with a real pass/fail signal. /// /// Flow: pick the first workspace → resolve the verify command (explicit /// override or auto-detected via `resolve_verify_command`) → spawn it → /// poll `try_wait` in a loop, killing the child if `timeout_ms` elapses → /// capture combined stdout+stderr (truncated) on completion. /// /// Why: polling instead of a blocking wait lets the timeout be enforced /// without spawning a watcher thread. /// /// Return: `None` if no workspace exists, no command could be resolved, /// or the process failed to spawn/poll; otherwise `Some(ProbeResult)` /// describing pass/fail/timeout and truncated output. 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_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string())); 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!("{stdout}\n{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 } } /// Determine the shell command to build/test a workspace, auto-detecting /// the project type from marker files when no override is given. /// /// Flow: use `override_cmd` verbatim if non-empty → otherwise probe for /// language/tool marker files (Cargo.toml, go.mod, package.json, etc.) /// in priority order and return that ecosystem's conventional test/build /// command. /// /// Why: covers a broad set of ecosystems so review probing works without /// per-project configuration in the common case. /// /// Return: `Some(command)` if a command could be determined, `None` if /// no marker files matched (e.g. plain Python project with no test dir). 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()).as_ref().is_some_and(|s| !s.is_empty()) { return Some("npm test 2>&1".to_string()); } if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) { 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 } /// Truncate a string to at most `max` characters, appending a marker if cut. /// /// Return: the original string if short enough, otherwise the first `max` /// characters plus `"... (truncated)"`. 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 } } /// Spawn a background quality-review subagent for the current session. /// /// Flow: build a "quality-reviewer" subagent context → probe build/test /// status via `probe_build_test` to give the reviewer a real pass/fail /// signal → compose a system prompt embedding the probe result and lesson /// tagging instructions → spawn a thread running `run_subagent` → on /// completion, push a `TurnEvent::SystemNote` with the verdict's first /// line (or error) → push an "in progress" toast immediately. /// /// Why: runs on a plain OS thread (not tokio) so it doesn't block the /// async event loop; communicates its result back via `turn_events` /// rather than a channel receiver (the `_rx` half is intentionally unused). /// /// Return: `Ok(())` once the review has been kicked off; errors only /// propagate from constructing the subagent context, not from the review /// itself (that failure is reported via a `SystemNote` instead). #[allow(clippy::unnecessary_debug_formatting)] pub fn trigger_review(state: &mut AppStateRest) { let def = AgentDefinition::new( "quality-reviewer".to_string(), "reviewer".to_string(), ); let mut ctx = build_subagent_context(&def); ctx.session_dir.clone_from(&state.session_dir); ctx.workspaces.clone_from(&state.workspace_roots); 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(), }; let session_dir = &state.session_dir; ctx.system_prompt = format!( "You are a code quality reviewer. Review the recent code changes \ for correctness, 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: {session_dir:?}\n\n\ Build/Test Probe:\n{probe_note}\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.", ); // Use a drain thread for subagent events (so blocking_send never // fails on a closed channel) and log events at debug level for // observability during review runs. let (tx, rx) = tokio::sync::mpsc::channel(32); let _drain_thread = std::thread::spawn(move || { use crate::app::subagent::event::SubagentEvent; let mut rx = rx; while let Some(event) = rx.blocking_recv() { match &event { SubagentEvent::ToolCall { tool, .. } => { tracing::debug!("[review] tool call: {}", tool); } SubagentEvent::ToolResult { tool, .. } => { tracing::debug!("[review] tool result: {}", tool); } SubagentEvent::StepCompleted { .. } => { tracing::trace!("[review] step completed"); } SubagentEvent::StepFailed { step, error } => { tracing::warn!("[review] step {} failed: {}", step, error); } SubagentEvent::Completed { .. } => { tracing::debug!("[review] completed"); } } } }); 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(), )); } const STALE_AFTER_DAYS: i64 = 60; /// Flag memory entries as stale if they haven't been updated recently. /// /// Flow: list all memory files → for each, read it → if `updated_at` is /// older than `STALE_AFTER_DAYS` and it isn't already flagged, set /// `lifecycle = "stale"` and write it back → collect flagged names. /// /// Return: names of newly-flagged memories, or an I/O error from /// `mem.write`. 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) } /// Run the staleness sweep at most once every 10 minutes, notifying via toast. /// /// Flow: skip if less than 600,000ms since `last_staleness_sweep_ms` → /// otherwise update the timestamp and run `run_staleness_sweep`, pushing /// an info toast listing flagged lessons if any were found. /// /// Why: rate-limited so the sweep (a file read/write per memory) doesn't /// run on every event-loop tick. 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(", ")), )); } } } /// A lesson awaiting confirmation before being committed to memory, /// optionally auto-resolving after a grace period. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingLesson { pub lesson: Lesson, pub created_at: i64, pub auto_resolve: bool, } /// Load the session's pending-lessons queue from disk. /// /// Return: the parsed list, or an empty `Vec` if the file is missing or /// fails to parse. 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() } /// Write the session's pending-lessons queue to disk as pretty JSON. /// /// Return: `Ok(())`, or an I/O error from writing the file. 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) } /// Commit any auto-resolvable pending lessons whose grace period has /// elapsed, and persist the remaining queue. /// /// Flow: load pending lessons → partition into those eligible to commit /// (`auto_resolve` and older than the 5s grace window) vs. still pending /// → write eligible lessons as new `Memory` entries with `lifecycle: /// "active"` → save the remaining (unresolved) queue back to disk. /// /// Why: the grace window gives the user a brief window to reject an /// auto-resolving lesson via `resolve_pending_lesson` before it commits. /// /// Return: the still-pending lessons (post-commit), or an I/O error from /// writing memory files or the queue. 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) } /// Manually resolve a single pending lesson by name: commit it to memory /// or discard it. /// /// Flow: load the queue → find the lesson matching `lesson_name` → /// if `keep` is true, write it as an active `Memory` entry; either way /// remove it from the queue → save the remaining queue. /// /// Why: lets the user (or UI action) override a pending lesson's fate /// before/without waiting for the auto-resolve grace window. /// /// Return: `Ok(())`, or an I/O error from writing the memory file or queue. 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) }