2026-07-12 11:28:39 +07:00
|
|
|
//! Adaptive quality-review triggering, build/test probing, staleness
|
|
|
|
|
//! sweeps for stored lessons, and the pending-lesson approval workflow.
|
2026-07-17 09:03:37 +07:00
|
|
|
|
|
|
|
|
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};
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
use crate::app::state::rest::AppStateRest;
|
2026-07-11 20:21:59 +07:00
|
|
|
use crate::app::state::runtime::TurnEvent;
|
2026-07-12 03:14:52 +07:00
|
|
|
use crate::app::state::types::{Origin, Toast, ToastKind};
|
2026-07-11 20:21:59 +07:00
|
|
|
use crate::app::subagent::context::build_subagent_context;
|
|
|
|
|
use crate::app::subagent::engine::run_subagent;
|
2026-07-17 09:03:37 +07:00
|
|
|
use crate::app::subagent::event::SubagentEvent;
|
|
|
|
|
use crate::app::subagent::spawn::{spawn_subagent_with_drain, AgentDefinition};
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
|
|
|
|
if origin != Origin::Main {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2026-07-17 06:44:31 +07:00
|
|
|
let Some(runtime) = &state.session_runtime else {
|
|
|
|
|
return false;
|
|
|
|
|
};
|
2026-07-16 07:42:03 +07:00
|
|
|
if !state.settings.flags.review_enabled {
|
2026-07-11 13:16:10 +07:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
2026-07-11 21:06:22 +07:00
|
|
|
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
|
|
|
|
|
let consecutive = runtime.consecutive_empty_reviews;
|
|
|
|
|
if consecutive >= base {
|
2026-07-11 21:25:30 +07:00
|
|
|
let skip = 1u32 << (consecutive - base).min(10);
|
2026-07-11 21:06:22 +07:00
|
|
|
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
false
|
|
|
|
|
}
|
2026-07-15 03:11:49 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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).
|
2026-07-13 08:12:02 +07:00
|
|
|
pub fn trigger_review(state: &mut AppStateRest) {
|
2026-07-15 03:11:49 +07:00
|
|
|
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;
|
2026-07-17 06:44:31 +07:00
|
|
|
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"
|
|
|
|
|
};
|
2026-07-15 03:11:49 +07:00
|
|
|
let _ = writeln!(file, "{prefix}docs/lesson/");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 06:44:31 +07:00
|
|
|
let mut def = AgentDefinition::new("lesson-generator".to_string(), "reviewer".to_string());
|
2026-07-15 03:11:49 +07:00
|
|
|
// Explicitly allow write_file for docs/lesson
|
|
|
|
|
def.allowed_tools = Some(vec![
|
|
|
|
|
"read".to_string(),
|
|
|
|
|
"write".to_string(),
|
|
|
|
|
"grep".to_string(),
|
|
|
|
|
"glob".to_string(),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-13 08:12:02 +07:00
|
|
|
let mut ctx = build_subagent_context(&def);
|
|
|
|
|
ctx.session_dir.clone_from(&state.session_dir);
|
|
|
|
|
ctx.workspaces.clone_from(&state.workspace_roots);
|
2026-07-17 06:44:31 +07:00
|
|
|
|
2026-07-17 09:03:37 +07:00
|
|
|
let probe_result = probe::probe_build_test(
|
2026-07-11 21:06:22 +07:00
|
|
|
&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 {
|
2026-07-15 03:11:49 +07:00
|
|
|
format!("Build/test verification passed ({}).", r.command)
|
2026-07-11 21:06:22 +07:00
|
|
|
} else if r.timed_out {
|
2026-07-15 03:11:49 +07:00
|
|
|
format!("Build/test verification timed out ({}).", r.command)
|
2026-07-11 21:06:22 +07:00
|
|
|
} else {
|
2026-07-17 06:44:31 +07:00
|
|
|
format!(
|
|
|
|
|
"Build/test verification failed ({}). Output: {}",
|
|
|
|
|
r.command, r.output
|
|
|
|
|
)
|
2026-07-11 21:06:22 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 03:11:49 +07:00
|
|
|
None => "No build/test probe matched.".to_string(),
|
2026-07-11 21:06:22 +07:00
|
|
|
};
|
|
|
|
|
|
2026-07-17 09:03:37 +07:00
|
|
|
ctx.system_prompt = prompt::compose_review_prompt(state, &probe_note);
|
2026-07-11 20:21:59 +07:00
|
|
|
|
2026-07-15 03:11:49 +07:00
|
|
|
let turn_events_for_drain = state.turn_events.clone();
|
2026-07-17 09:03:37 +07:00
|
|
|
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,
|
|
|
|
|
});
|
2026-07-13 04:41:26 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-07-17 06:44:31 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let turn_events = state.turn_events.clone();
|
|
|
|
|
|
|
|
|
|
std::thread::spawn(move || {
|
2026-07-13 08:12:02 +07:00
|
|
|
let result = run_subagent(&ctx, &tx);
|
2026-07-11 20:21:59 +07:00
|
|
|
let message = match result {
|
|
|
|
|
Ok(verdict) => {
|
|
|
|
|
let first_line = verdict.lines().next().unwrap_or(&verdict);
|
2026-07-15 03:11:49 +07:00
|
|
|
format!("Lesson created: {first_line}")
|
2026-07-11 20:21:59 +07:00
|
|
|
}
|
2026-07-15 03:11:49 +07:00
|
|
|
Err(e) => format!("Lesson generation failed: {e}"),
|
2026-07-11 20:21:59 +07:00
|
|
|
};
|
|
|
|
|
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,
|
2026-07-15 03:11:49 +07:00
|
|
|
"Generating lesson...".to_string(),
|
2026-07-11 13:16:10 +07:00
|
|
|
));
|
|
|
|
|
}
|