roboticus-db 0.11.4

SQLite persistence layer with 28 tables, FTS5 search, WAL mode, and migration system
Documentation
//! Delegation outcome persistence.
//!
//! Records the result of each delegated task including quality scores,
//! skill utilization, and retry counts for the output quality gate.

use crate::{Database, DbResultExt};
use roboticus_core::Result;
use serde::{Deserialize, Serialize};

/// A single delegation outcome record.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationOutcomeRow {
    pub id: String,
    pub turn_id: String,
    pub session_id: String,
    pub task_description: String,
    pub subtask_count: i64,
    pub pattern: String,
    pub assigned_agents_json: String,
    pub total_duration_ms: Option<i64>,
    pub success: bool,
    pub quality_score: Option<f64>,
    pub skill_utilization_json: Option<String>,
    pub retry_count: i32,
    pub created_at: String,
}

/// Per-agent delegation statistics over a time window.
#[derive(Debug, Clone, Serialize)]
pub struct AgentDelegationStats {
    pub agent_id: String,
    pub total_delegations: i64,
    pub successes: i64,
    pub success_rate: f64,
}

pub fn insert_delegation_outcome(db: &Database, row: &DelegationOutcomeRow) -> Result<()> {
    let conn = db.conn();
    conn.execute(
        "INSERT INTO delegation_outcomes \
         (id, turn_id, session_id, task_description, subtask_count, pattern, \
          assigned_agents_json, total_duration_ms, success, quality_score, \
          skill_utilization_json, retry_count, created_at) \
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, \
                 COALESCE(NULLIF(?13, ''), datetime('now')))",
        rusqlite::params![
            row.id,
            row.turn_id,
            row.session_id,
            row.task_description,
            row.subtask_count,
            row.pattern,
            row.assigned_agents_json,
            row.total_duration_ms,
            row.success as i32,
            row.quality_score,
            row.skill_utilization_json,
            row.retry_count,
            row.created_at,
        ],
    )
    .db_err()?;
    Ok(())
}

pub fn recent_delegation_outcomes(db: &Database, limit: i64) -> Result<Vec<DelegationOutcomeRow>> {
    let conn = db.conn();
    let mut stmt = conn
        .prepare(
            "SELECT id, turn_id, session_id, task_description, subtask_count, pattern, \
             assigned_agents_json, total_duration_ms, success, quality_score, \
             skill_utilization_json, retry_count, created_at \
             FROM delegation_outcomes ORDER BY created_at DESC LIMIT ?1",
        )
        .db_err()?;
    let rows = stmt
        .query_map(rusqlite::params![limit], |row| {
            Ok(DelegationOutcomeRow {
                id: row.get(0)?,
                turn_id: row.get(1)?,
                session_id: row.get(2)?,
                task_description: row.get(3)?,
                subtask_count: row.get(4)?,
                pattern: row.get(5)?,
                assigned_agents_json: row.get(6)?,
                total_duration_ms: row.get(7)?,
                success: row.get::<_, i32>(8)? != 0,
                quality_score: row.get(9)?,
                skill_utilization_json: row.get(10)?,
                retry_count: row.get(11)?,
                created_at: row.get(12)?,
            })
        })
        .db_err()?;
    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
}

/// Per-agent delegation statistics over the last `hours` hours.
pub fn delegation_stats_by_agent(db: &Database, hours: i64) -> Result<Vec<AgentDelegationStats>> {
    let conn = db.conn();
    let mut stmt = conn
        .prepare(
            "SELECT agent_id, COUNT(*) as total, SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as wins \
             FROM ( \
                 SELECT json_each.value as agent_id, success \
                 FROM delegation_outcomes, json_each(assigned_agents_json) \
                 WHERE created_at >= datetime('now', '-' || ?1 || ' hours') \
             ) \
             GROUP BY agent_id",
        )
        .db_err()?;
    let rows = stmt
        .query_map(rusqlite::params![hours], |row| {
            let total: i64 = row.get(1)?;
            let wins: i64 = row.get(2)?;
            Ok(AgentDelegationStats {
                agent_id: row.get(0)?,
                total_delegations: total,
                successes: wins,
                success_rate: if total > 0 {
                    wins as f64 / total as f64
                } else {
                    0.0
                },
            })
        })
        .db_err()?;
    rows.collect::<std::result::Result<Vec<_>, _>>().db_err()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn test_db() -> Database {
        let db = Database::new(":memory:").unwrap();
        db.conn()
            .execute_batch(
                "CREATE TABLE IF NOT EXISTS delegation_outcomes (
                     id TEXT PRIMARY KEY,
                     turn_id TEXT NOT NULL,
                     session_id TEXT NOT NULL,
                     task_description TEXT NOT NULL,
                     subtask_count INTEGER NOT NULL DEFAULT 0,
                     pattern TEXT NOT NULL DEFAULT 'fan-out',
                     assigned_agents_json TEXT NOT NULL DEFAULT '[]',
                     total_duration_ms INTEGER,
                     success INTEGER NOT NULL DEFAULT 0,
                     quality_score REAL,
                     skill_utilization_json TEXT,
                     retry_count INTEGER NOT NULL DEFAULT 0,
                     created_at TEXT NOT NULL DEFAULT (datetime('now'))
                 );",
            )
            .unwrap();
        db
    }

    #[test]
    fn insert_and_retrieve_outcome() {
        let db = test_db();
        let row = DelegationOutcomeRow {
            id: "d-1".into(),
            turn_id: "t-1".into(),
            session_id: "s-1".into(),
            task_description: "test task".into(),
            subtask_count: 2,
            pattern: "fan-out".into(),
            assigned_agents_json: r#"["agent-a","agent-b"]"#.into(),
            total_duration_ms: Some(500),
            success: true,
            quality_score: Some(0.85),
            skill_utilization_json: Some(r#"["code-review"]"#.into()),
            retry_count: 0,
            created_at: String::new(),
        };
        insert_delegation_outcome(&db, &row).unwrap();

        let recent = recent_delegation_outcomes(&db, 10).unwrap();
        assert_eq!(recent.len(), 1);
        assert_eq!(recent[0].id, "d-1");
        assert!(recent[0].success);
        assert_eq!(recent[0].retry_count, 0);
        assert!(
            recent[0]
                .skill_utilization_json
                .as_ref()
                .unwrap()
                .contains("code-review")
        );
    }

    #[test]
    fn skill_utilization_persists_and_retrieves() {
        let db = test_db();
        let row = DelegationOutcomeRow {
            id: "d-util-1".into(),
            turn_id: "t-1".into(),
            session_id: "s-1".into(),
            task_description: "test utilization".into(),
            subtask_count: 1,
            pattern: "fan-out".into(),
            assigned_agents_json: r#"["agent-a"]"#.into(),
            total_duration_ms: Some(100),
            success: true,
            quality_score: Some(0.9),
            skill_utilization_json: Some(r#"["code-review","testing"]"#.into()),
            retry_count: 1,
            created_at: String::new(),
        };
        insert_delegation_outcome(&db, &row).unwrap();

        let recent = recent_delegation_outcomes(&db, 10).unwrap();
        assert_eq!(recent.len(), 1);
        assert!(
            recent[0]
                .skill_utilization_json
                .as_ref()
                .unwrap()
                .contains("code-review")
        );
        assert_eq!(recent[0].retry_count, 1);
    }

    #[test]
    fn delegation_stats_groups_by_agent() {
        let db = test_db();
        for (id, success) in [("d-1", true), ("d-2", true), ("d-3", false)] {
            let row = DelegationOutcomeRow {
                id: id.into(),
                turn_id: "t-1".into(),
                session_id: "s-1".into(),
                task_description: "task".into(),
                subtask_count: 1,
                pattern: "fan-out".into(),
                assigned_agents_json: r#"["agent-alpha"]"#.into(),
                total_duration_ms: Some(50),
                success,
                quality_score: None,
                skill_utilization_json: None,
                retry_count: 0,
                created_at: String::new(),
            };
            insert_delegation_outcome(&db, &row).unwrap();
        }

        let stats = delegation_stats_by_agent(&db, 168).unwrap();
        assert_eq!(stats.len(), 1);
        assert_eq!(stats[0].agent_id, "agent-alpha");
        assert_eq!(stats[0].total_delegations, 3);
        assert_eq!(stats[0].successes, 2);
        assert!((stats[0].success_rate - 2.0 / 3.0).abs() < 0.01);
    }
}