diff --git a/crates/agentflare-backend/src/ask_event.rs b/crates/agentflare-backend/src/ask_event.rs new file mode 100644 index 00000000..11e96c9b --- /dev/null +++ b/crates/agentflare-backend/src/ask_event.rs @@ -0,0 +1,124 @@ +use crate::error::Result; +use rusqlite::{Connection, params}; + +/// One "the supervisor asked a human" occurrence — the persisted counterpart +/// of `quota::decide::Decision::ask()`, which itself never writes anything +/// (it's pure). Recorded so attention cost stops being an ephemeral side +/// effect of a lifecycle flip and becomes queryable for performance review. +#[derive(Debug, Clone, serde::Serialize)] +pub struct AskEvent { + pub id: String, + pub project_id: String, + pub goal_item_id: Option, + pub item_id: String, + pub agent: Option, + pub reason: String, + pub gate_question: Option, + pub created_at: i64, +} + +#[allow(clippy::too_many_arguments)] +pub fn record( + conn: &Connection, + project_id: &str, + goal_item_id: Option<&str>, + item_id: &str, + agent: Option<&str>, + reason: &str, + gate_question: Option<&str>, + now: i64, +) -> Result { + let id = db_kit::ids::new_id(); + conn.execute( + "INSERT INTO ask_events + (id, project_id, goal_item_id, item_id, agent, reason, gate_question, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id, + project_id, + goal_item_id, + item_id, + agent, + reason, + gate_question, + now + ], + )?; + Ok(id) +} + +pub fn count_since( + conn: &Connection, + project_id: &str, + agent: Option<&str>, + since: i64, +) -> Result { + Ok(conn.query_row( + "SELECT COUNT(*) FROM ask_events + WHERE project_id = ?1 AND created_at >= ?2 AND (?3 IS NULL OR agent = ?3)", + params![project_id, since, agent], + |r| r.get(0), + )?) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seed_project(conn: &Connection) -> String { + let workspace = crate::workspace::create( + conn, + crate::workspace::CreateWorkspace { + name: "ws".into(), + slug: "ws".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let project = crate::project::create( + conn, + crate::project::CreateProject { + workspace_id: workspace.id, + name: "proj".into(), + identifier: "proj".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + project.id + } + + #[test] + fn record_then_count_since_scopes_by_project_and_agent() { + let conn = crate::db::open_in_memory().unwrap(); + let pid = seed_project(&conn); + record( + &conn, + &pid, + None, + "item-1", + Some("claude-code"), + "gated", + Some("q?"), + 100, + ) + .unwrap(); + record( + &conn, + &pid, + None, + "item-2", + Some("opencode"), + "gated", + None, + 200, + ) + .unwrap(); + + assert_eq!(count_since(&conn, &pid, None, 0).unwrap(), 2); + assert_eq!(count_since(&conn, &pid, Some("claude-code"), 0).unwrap(), 1); + assert_eq!(count_since(&conn, &pid, None, 150).unwrap(), 1); + } +} diff --git a/crates/agentflare-backend/src/db.rs b/crates/agentflare-backend/src/db.rs index cd300d3b..e47c0f0d 100644 --- a/crates/agentflare-backend/src/db.rs +++ b/crates/agentflare-backend/src/db.rs @@ -15,6 +15,7 @@ const MIGRATION_LIST: &[M<'static>] = &[ M::up(include_str!("migrations/0004_item_comments.sql")), M::up(include_str!("migrations/0005_items_fts.sql")), M::up(include_str!("migrations/0006_vents.sql")), + M::up(include_str!("migrations/0007_ask_events.sql")), ]; const MIGRATIONS: Migrations = Migrations::from_slice(MIGRATION_LIST); diff --git a/crates/agentflare-backend/src/lib.rs b/crates/agentflare-backend/src/lib.rs index 3bf64839..687ecea3 100644 --- a/crates/agentflare-backend/src/lib.rs +++ b/crates/agentflare-backend/src/lib.rs @@ -1,3 +1,4 @@ +pub mod ask_event; pub mod asset; pub mod claim; pub mod comment; diff --git a/crates/agentflare-backend/src/migrations/0007_ask_events.sql b/crates/agentflare-backend/src/migrations/0007_ask_events.sql new file mode 100644 index 00000000..fbd32ce6 --- /dev/null +++ b/crates/agentflare-backend/src/migrations/0007_ask_events.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS ask_events ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + goal_item_id TEXT REFERENCES items(id) ON DELETE SET NULL, + item_id TEXT NOT NULL, + agent TEXT, + reason TEXT NOT NULL, + gate_question TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_ask_events_project_created ON ask_events(project_id, created_at); diff --git a/src/cli/review.rs b/src/cli/review.rs index 4a187adb..82ada054 100644 --- a/src/cli/review.rs +++ b/src/cli/review.rs @@ -78,6 +78,29 @@ pub enum ReviewAction { #[arg(long)] json: bool, }, + /// Compute one agent's project-level performance review (quantity, + /// quality, cost, attention) and save it to the memory store as a + /// `performance_review` observation. Run `agentflare memory sync` + /// afterward to share it with other workstations. + Performance { + /// Backend project id (agentflare item-tracker project — run + /// `agentflare memory observations` or check the dashboard to find + /// it; this is NOT the same identifier as --repo). + #[arg(long)] + project: String, + /// Agent name (default: detected, same convention as `submit`). + #[arg(long)] + agent: Option, + /// Scope quality scoring to one repo (default: current repo). + #[arg(long)] + repo: Option, + /// Window size in days ending now, for quantity/attention/cost + /// (quality is always all-time — see `scores`). + #[arg(long, default_value = "7")] + days: i64, + #[arg(long)] + json: bool, + }, } impl ReviewArgs { @@ -220,6 +243,98 @@ impl ReviewArgs { } } } + ReviewAction::Performance { + project, + agent, + repo, + days, + json, + } => { + let agent = agent.unwrap_or_else(crate::review::submitter_name); + let repo = repo.or_else(|| crate::claims::resolve_repo(None)); + let now = crate::claims::now(); + let since = now - days.max(1) * 86_400; + + let backend_conn = + match agentflare_backend::db::open_db(&crate::vent::paths::backend_db_path()) { + Ok(c) => c, + Err(e) => fail(format!("cannot open backend db: {e}")), + }; + + let today = chrono::Local::now().date_naive(); + let cost_start = today - chrono::Duration::days(days.max(1) - 1); + let cost_totals = + crate::cost::summarize((cost_start, today), crate::cost::GroupBy::Project); + let cost_key = crate::mcp_server::AgentflareMcp::resolve_project_name(); + let project_cost_usd = cost_totals + .get(&cost_key) + .map(|t| t.cost_usd) + .unwrap_or(0.0); + + let review = match crate::review::performance_review( + &backend_conn, + &conn, + &project, + repo.as_deref(), + &agent, + since, + now, + project_cost_usd, + ) { + Ok(r) => r, + Err(e) => fail(format!("performance_review failed: {e}")), + }; + + if json { + println!( + "{}", + serde_json::to_string_pretty(&review).unwrap_or_default() + ); + } else { + println!("{agent} — project {project} — last {days}d"); + println!(" completed: {}", review.quantity_completed); + match review.quality_accuracy { + Some(acc) => println!( + " quality: {:.0}% ({}/{} verified, {} round(s), all-time)", + acc * 100.0, + review.quality_findings, + review.quality_findings, + review.quality_rounds + ), + None => println!(" quality: no recorded review rounds"), + } + println!(" attention: {} ask(s)", review.attention_asks); + println!( + " cost: ${:.4} (whole project, all agents, {days}d window)", + review.project_cost_usd + ); + } + + let mem_conn = match crate::memory::store::open() { + Ok(c) => c, + Err(e) => fail(format!("cannot open memory store: {e}")), + }; + let content = serde_json::to_string(&review).unwrap_or_default(); + let topic_key = format!("perf_review:{project}:{agent}"); + match crate::memory::observations::save( + &mem_conn, + None, + "performance_review", + &format!("{agent} performance — {project}"), + &content, + None, + Some(&project), + Some("workstation"), + Some(&topic_key), + ) { + Ok(_) => println!( + "\nsaved — run `agentflare memory sync` to share across workstations" + ), + Err(e) => crate::ui::error(&format!( + "warning: review computed but not saved to memory: {e}" + )), + } + } } } } diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 27c0f1e5..b5f0a336 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -652,7 +652,7 @@ impl AgentflareMcp { /// Derives a project name from the git remote (`getappz/agentflare` → /// `agentflare`) or, outside a repo, the directory basename. - fn resolve_project_name() -> String { + pub(crate) fn resolve_project_name() -> String { if let Some(repo) = Self::run_git(&["remote", "get-url", "origin"]) { let normalized = crate::claims::normalize_repo(&repo); if let Some(name) = normalized.rsplit('/').next().filter(|s| !s.is_empty()) { diff --git a/src/quota/decide.rs b/src/quota/decide.rs index 63ff41ef..9f2f6043 100644 --- a/src/quota/decide.rs +++ b/src/quota/decide.rs @@ -217,6 +217,7 @@ pub fn decide_for_supervisor( mcp: &crate::mcp_server::AgentflareMcp, item: &agentflare_backend::item::Item, ) -> EffectiveAction { + let now = crate::claims::now(); let decision = mcp .with_backend_db(|conn| decide(conn, item)) .unwrap_or_else(|_| Decision::fail_closed("could not open backend db")); @@ -240,6 +241,7 @@ pub fn decide_for_supervisor( } EffectiveActionInternal::Wait => EffectiveAction::Wait, EffectiveActionInternal::Ask => { + let goal_item_id = goal.as_ref().map(|(gi, _)| gi.id.clone()); if let Some((goal_item, mut meta)) = goal { meta.consecutive_self_repairs = 0; if let Ok(next) = meta.lifecycle.apply(super::lifecycle::LifecycleEvent::Gate) { @@ -249,6 +251,18 @@ pub fn decide_for_supervisor( super::goal::save_goal_metadata(conn, &goal_item.id, &meta) }); } + let _ = mcp.with_backend_db(|conn| { + agentflare_backend::ask_event::record( + conn, + &item.project_id, + goal_item_id.as_deref(), + &item.id, + item.assignee_agent.as_deref(), + &decision.reason, + decision.gate_question.as_deref(), + now, + ) + }); EffectiveAction::Ask( decision .gate_question @@ -455,6 +469,25 @@ mod tests { assert_eq!(decision.effective_action, EffectiveActionInternal::Ask); } + #[test] + fn gated_lifecycle_records_an_ask_event() { + let conn = test_conn(); + let (pid, sid) = seed_project(&conn); + let goal = make_goal_item(&conn, &pid, &sid, GoalLifecycle::Gated, 0); + let todo = make_todo(&conn, &pid, &sid, &goal.id); + + let decision = decide(&conn, &todo); + assert_eq!(decision.effective_action, EffectiveActionInternal::Ask); + + // decide() itself is pure and does not write; recording happens in + // decide_for_supervisor, which needs an AgentflareMcp and is covered + // by ask_event's own unit test for the write path. This pins that + // decide() still reaches Ask for a gated goal, which + // decide_for_supervisor's Ask arm relies on to call + // ask_event::record. + assert!(decision.gate_question.is_some()); + } + #[test] fn active_lifecycle_with_no_vent_does_not_ask() { let conn = test_conn(); diff --git a/src/review.rs b/src/review.rs index e5894370..07abcd70 100644 --- a/src/review.rs +++ b/src/review.rs @@ -235,6 +235,68 @@ pub fn scores(conn: &Connection, repo: Option<&str>) -> rusqlite::Result, + pub quality_findings: u32, + pub quality_rounds: u32, + pub attention_asks: u32, + pub project_cost_usd: f64, +} + +#[allow(clippy::too_many_arguments)] +pub fn performance_review( + backend_conn: &rusqlite::Connection, + main_conn: &Connection, + project_id: &str, + repo: Option<&str>, + agent: &str, + since: i64, + until: i64, + project_cost_usd: f64, +) -> rusqlite::Result { + // Not `list_by_assignee_agent` -- that query deliberately excludes + // completed/cancelled items (it's built for "what's this agent working + // on now"), the opposite of what a completed-work count needs. + let quantity_completed = agentflare_backend::item::list_by_project(backend_conn, project_id) + .unwrap_or_default() + .into_iter() + .filter(|i| i.assignee_agent.as_deref() == Some(agent)) + .filter(|i| i.completed_at.is_some_and(|t| t >= since && t <= until)) + .count() as u32; + + let agent_score = scores(main_conn, repo)? + .into_iter() + .find(|s| s.agent == agent); + + let attention_asks = + agentflare_backend::ask_event::count_since(backend_conn, project_id, Some(agent), since) + .unwrap_or(0) as u32; + + Ok(PerformanceReview { + agent: agent.to_string(), + since, + until, + quantity_completed, + quality_accuracy: agent_score.as_ref().map(|s| s.accuracy), + quality_findings: agent_score.as_ref().map(|s| s.findings).unwrap_or(0), + quality_rounds: agent_score.as_ref().map(|s| s.rounds).unwrap_or(0), + attention_asks, + project_cost_usd, + }) +} + // --- diff parsing (pure) ----------------------------------------------------- /// Parses a unified diff into the set of new-side line numbers present in each @@ -712,6 +774,107 @@ diff --git a/f b/f assert_eq!((s[0].findings, s[0].rounds), (2, 2)); } + #[test] + fn performance_review_joins_quantity_quality_and_attention() { + let backend_conn = agentflare_backend::db::open_in_memory().unwrap(); + let workspace = agentflare_backend::workspace::create( + &backend_conn, + agentflare_backend::workspace::CreateWorkspace { + name: "ws".into(), + slug: "ws".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let project = agentflare_backend::project::create( + &backend_conn, + agentflare_backend::project::CreateProject { + workspace_id: workspace.id, + name: "proj".into(), + identifier: "proj".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let states = + agentflare_backend::state::list_by_project(&backend_conn, &project.id).unwrap(); + let default_state = states.iter().find(|s| s.is_default).unwrap().id.clone(); + + let item = agentflare_backend::item::create( + &backend_conn, + agentflare_backend::item::CreateItem { + project_id: project.id.clone(), + state_id: default_state, + name: "todo".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: Some("claude-code".into()), + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let completed_state = + agentflare_backend::state::list_by_project(&backend_conn, &project.id) + .unwrap() + .into_iter() + .find(|s| s.group_name == "completed") + .unwrap() + .id; + agentflare_backend::item::update_state(&backend_conn, &item.id, &completed_state).unwrap(); + + agentflare_backend::ask_event::record( + &backend_conn, + &project.id, + None, + &item.id, + Some("claude-code"), + "gated", + Some("q?"), + 50, + ) + .unwrap(); + + let main_conn = Connection::open_in_memory().unwrap(); + migrate(&main_conn).unwrap(); + record_round( + &main_conn, + "acme/repo", + "1", + &[sf("claude-code", "a.rs", 1, None)], + &changed(&[("a.rs", &[1])]), + 100, + ) + .unwrap(); + + let review = performance_review( + &backend_conn, + &main_conn, + &project.id, + Some("acme/repo"), + "claude-code", + 0, + i64::MAX, + 1.2345, + ) + .unwrap(); + + assert_eq!(review.quantity_completed, 1); + assert_eq!(review.quality_accuracy, Some(1.0)); + assert_eq!(review.quality_findings, 1); + assert_eq!(review.quality_rounds, 1); + assert_eq!(review.attention_asks, 1); + assert_eq!(review.project_cost_usd, 1.2345); + } + #[test] fn load_and_clear_are_round_scoped() { let conn = Connection::open_in_memory().unwrap();