//! Adaptive quality-review triggering, build/test probing, staleness //! sweeps for stored lessons, and the pending-lesson approval workflow. pub mod pending; pub mod probe; pub mod prompt; pub mod staleness; pub mod types; pub use pending::{load_pending_lessons, process_pending_lessons, resolve_pending_lesson}; pub use staleness::maybe_run_staleness_sweep; pub use types::{Confidence, LessonScope}; 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::event::SubagentEvent; use crate::app::subagent::spawn::{spawn_subagent_with_drain, AgentDefinition}; /// 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.flags.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 } /// 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). pub fn trigger_review(state: &mut AppStateRest) { state.misc.lesson_running = true; if let Some(workspace) = state.workspace_roots.first() { let gitignore_path = workspace.join(".gitignore"); let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default(); if !content.contains("docs/lesson") { use std::io::Write; if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) .append(true) .open(&gitignore_path) { let prefix = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" }; let _ = writeln!(file, "{prefix}docs/lesson/"); } } } let mut def = AgentDefinition::new("lesson-generator".to_string(), "reviewer".to_string()); // Explicitly allow write_file for docs/lesson def.allowed_tools = Some(vec![ "read".to_string(), "write".to_string(), "grep".to_string(), "glob".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::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 ({}).", r.command) } else if r.timed_out { format!("Build/test verification timed out ({}).", r.command) } else { format!( "Build/test verification failed ({}). Output: {}", r.command, r.output ) } } None => "No build/test probe matched.".to_string(), }; ctx.system_prompt = prompt::compose_review_prompt(state, &probe_note); let turn_events_for_drain = state.turn_events.clone(); let (tx, _drain_thread) = spawn_subagent_with_drain(move |event| { 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::Progress(_) => {} SubagentEvent::Completed => tracing::debug!("[review] completed"), SubagentEvent::Usage { tokens_in, tokens_out, } => { if let Ok(mut q) = turn_events_for_drain.lock() { q.push_back(TurnEvent::ReviewUsage { tokens_in: *tokens_in, tokens_out: *tokens_out, }); } } } }); 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!("Lesson created: {first_line}") } Err(e) => format!("Lesson generation 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, "Generating lesson...".to_string(), )); }