Skip to main content

roboticus_agent/
governor.rs

1use chrono::{DateTime, Utc};
2use roboticus_core::config::{DigestConfig, LearningConfig, SessionConfig};
3use roboticus_db::Database;
4use roboticus_llm::format::UnifiedMessage;
5use std::path::PathBuf;
6
7pub struct SessionGovernor {
8    config: SessionConfig,
9    digest_config: DigestConfig,
10    learning_config: LearningConfig,
11    skills_dir: Option<PathBuf>,
12}
13
14impl SessionGovernor {
15    pub fn new(config: SessionConfig) -> Self {
16        Self {
17            config,
18            digest_config: DigestConfig::default(),
19            learning_config: LearningConfig::default(),
20            skills_dir: None,
21        }
22    }
23
24    pub fn with_digest(mut self, digest_config: DigestConfig) -> Self {
25        self.digest_config = digest_config;
26        self
27    }
28
29    pub fn with_learning(mut self, learning_config: LearningConfig, skills_dir: PathBuf) -> Self {
30        self.learning_config = learning_config;
31        self.skills_dir = Some(skills_dir);
32        self
33    }
34
35    /// Run a single maintenance tick: expire stale sessions based on TTL.
36    /// Returns the number of sessions actually expired.
37    pub fn tick(&self, db: &Database) -> roboticus_core::Result<usize> {
38        let stale =
39            roboticus_db::sessions::list_stale_active_session_ids(db, self.config.ttl_seconds)?;
40        let mut expired = 0usize;
41        for session_id in &stale {
42            if let Err(e) = self.compact_before_archive(db, session_id) {
43                tracing::warn!(error = %e, session_id = %session_id, "compaction failed before archive, proceeding with expiry");
44            }
45            // Generate episodic digest before the session status changes
46            if let Ok(Some(session)) = roboticus_db::sessions::get_session(db, session_id) {
47                crate::digest::digest_on_close(db, &self.digest_config, &session);
48                if let Some(ref skills_dir) = self.skills_dir {
49                    crate::learning::learn_on_close(
50                        db,
51                        &self.learning_config,
52                        &session,
53                        skills_dir,
54                    );
55                }
56            }
57            // Clean up checkpoints before expiry
58            if let Err(e) = roboticus_db::checkpoint::clear_checkpoints(db, session_id) {
59                tracing::warn!(error = %e, session_id = %session_id, "failed to clear checkpoints");
60            }
61            if let Err(e) = roboticus_db::sessions::set_session_status(
62                db,
63                session_id,
64                roboticus_db::sessions::SessionStatus::Expired,
65            ) {
66                tracing::error!(error = %e, session_id = %session_id, "failed to expire session");
67                continue;
68            }
69            expired += 1;
70        }
71        if let Err(e) = self.decay_episodic_importance(db) {
72            tracing::warn!(error = %e, "episodic importance decay failed during governor tick");
73        }
74        if self.skills_dir.is_some()
75            && let Err(e) = self.adjust_learned_skill_priorities(db)
76        {
77            tracing::warn!(error = %e, "learned skill priority adjustment failed during governor tick");
78        }
79        // Retrieval-hygiene: prune stale procedural entries and dead learned skills.
80        self.run_retrieval_hygiene(db);
81        Ok(expired)
82    }
83
84    /// Spawn a new scoped session for the given agent, returning the session id.
85    pub fn spawn(
86        &self,
87        db: &Database,
88        agent_id: &str,
89        scope: Option<&roboticus_db::sessions::SessionScope>,
90    ) -> roboticus_core::Result<String> {
91        roboticus_db::sessions::find_or_create(db, agent_id, scope)
92    }
93
94    /// Rotate active agent-scope sessions by archiving them and creating a new
95    /// active session for the same agent.
96    pub fn rotate_agent_scope_sessions(
97        &self,
98        db: &Database,
99        agent_id: &str,
100    ) -> roboticus_core::Result<usize> {
101        let sessions = roboticus_db::sessions::list_active_sessions(db, Some(agent_id))?;
102        let agent_scoped: Vec<_> = sessions
103            .into_iter()
104            .filter(|s| s.scope_key.as_deref() == Some("agent"))
105            .collect();
106        for s in &agent_scoped {
107            if let Err(e) = self.compact_before_archive(db, &s.id) {
108                tracing::warn!(error = %e, session_id = %s.id, "compaction failed before rotation");
109            }
110            crate::digest::digest_on_close(db, &self.digest_config, s);
111            if let Some(ref skills_dir) = self.skills_dir {
112                crate::learning::learn_on_close(db, &self.learning_config, s, skills_dir);
113            }
114            if let Err(e) = roboticus_db::checkpoint::clear_checkpoints(db, &s.id) {
115                tracing::warn!(error = %e, session_id = %s.id, "failed to clear checkpoints on rotation");
116            }
117        }
118        let archived = agent_scoped.len();
119        if archived == 0 {
120            return Ok(0);
121        }
122        let _ = roboticus_db::sessions::rotate_agent_session(db, agent_id)?;
123        Ok(archived)
124    }
125
126    fn compact_before_archive(
127        &self,
128        db: &Database,
129        session_id: &str,
130    ) -> roboticus_core::Result<()> {
131        let msgs = roboticus_db::sessions::list_messages(db, session_id, None)?;
132        if msgs.len() < 4 {
133            return Ok(());
134        }
135        // Idempotency guard: skip if a summary was already appended by a prior
136        // compaction pass (e.g. rotation followed by tick expiry on the same session).
137        if msgs
138            .iter()
139            .any(|m| m.role == "system" && m.content.contains("[Conversation Summary"))
140        {
141            return Ok(());
142        }
143        let keep_recent = 4usize;
144        let trim_end = msgs.len().saturating_sub(keep_recent);
145        let trimmed: Vec<UnifiedMessage> = msgs[..trim_end]
146            .iter()
147            .map(|m| UnifiedMessage {
148                role: m.role.clone(),
149                content: m.content.clone(),
150                parts: None,
151            })
152            .collect();
153        if trimmed.is_empty() {
154            return Ok(());
155        }
156
157        // Progressive compaction: pick the least aggressive stage that fits ~500 tokens.
158        let current_tokens = crate::context::count_tokens(&trimmed);
159        let target_tokens = 500usize;
160        let excess_ratio = current_tokens as f64 / target_tokens.max(1) as f64;
161        let stage = crate::context::CompactionStage::from_excess(excess_ratio);
162        let compacted = crate::context::compact_to_stage(&trimmed, stage);
163
164        // Format the compacted messages into a summary block.
165        let summary_lines: Vec<String> = compacted
166            .iter()
167            .filter(|m| m.role != "system")
168            .map(|m| format!("{}: {}", m.role, m.content))
169            .collect();
170        let summary_body = if summary_lines.is_empty() {
171            compacted
172                .iter()
173                .map(|m| m.content.clone())
174                .collect::<Vec<_>>()
175                .join("\n")
176        } else {
177            summary_lines.join("\n")
178        };
179        let digest = format!(
180            "[Conversation Summary — {stage:?}]\n{}",
181            summary_body.chars().take(2_000).collect::<String>()
182        );
183        roboticus_db::sessions::append_message(db, session_id, "system", &digest)?;
184        Ok(())
185    }
186
187    /// Adjust learned skill priorities based on success/failure ratios.
188    ///
189    /// - Skills with > 5 total uses and > 80% success → boost priority
190    /// - Skills where failures exceed successes → decay priority
191    ///
192    /// Returns the number of skills whose priority was actually changed.
193    fn adjust_learned_skill_priorities(&self, db: &Database) -> roboticus_core::Result<usize> {
194        if !self.learning_config.enabled {
195            return Ok(0);
196        }
197        let skills = roboticus_db::learned_skills::list_learned_skills(db, 200)?;
198        let mut adjusted = 0usize;
199        let boost = self.learning_config.priority_boost_on_success as i64;
200        let decay = self.learning_config.priority_decay_on_failure as i64;
201
202        for skill in &skills {
203            let total = skill.success_count + skill.failure_count;
204            let ratio = if total > 0 {
205                skill.success_count as f64 / total as f64
206            } else {
207                0.0
208            };
209
210            let new_priority = if total > 5 && ratio > 0.8 {
211                // Reliable skill — boost
212                (skill.priority + boost).min(100)
213            } else if skill.failure_count > skill.success_count {
214                // Unreliable skill — decay
215                (skill.priority - decay).max(0)
216            } else {
217                continue;
218            };
219
220            if new_priority != skill.priority {
221                if let Err(e) = roboticus_db::learned_skills::update_learned_skill_priority(
222                    db,
223                    &skill.name,
224                    new_priority,
225                ) {
226                    tracing::warn!(error = %e, skill = %skill.name, "failed to adjust skill priority");
227                } else {
228                    adjusted += 1;
229                }
230            }
231        }
232        Ok(adjusted)
233    }
234
235    /// Retrieval-hygiene sweep: prune stale procedural entries and remove
236    /// dead learned skills along with their on-disk `.md` files.
237    ///
238    /// Thresholds are controlled by `LearningConfig::stale_procedural_days`
239    /// and `LearningConfig::dead_skill_priority_threshold`.
240    ///
241    /// Every sweep is recorded in the `hygiene_log` table for forensics
242    /// and future auto-tuning.
243    fn run_retrieval_hygiene(&self, db: &Database) {
244        let stale_days = self.learning_config.stale_procedural_days;
245        let dead_threshold = self.learning_config.dead_skill_priority_threshold;
246
247        // ── Pre-sweep metrics ────────────────────────────────────
248        let conn = db.conn();
249        let proc_total: i64 = conn
250            .query_row("SELECT COUNT(*) FROM procedural_memory", [], |r| r.get(0))
251            .unwrap_or(0);
252        let proc_stale: i64 = conn
253            .query_row(
254                "SELECT COUNT(*) FROM procedural_memory \
255                 WHERE success_count = 0 AND failure_count = 0 \
256                   AND updated_at < datetime('now', ?1)",
257                [format!("-{stale_days} days")],
258                |r| r.get(0),
259            )
260            .unwrap_or(0);
261        let skills_total: i64 = conn
262            .query_row("SELECT COUNT(*) FROM learned_skills", [], |r| r.get(0))
263            .unwrap_or(0);
264        let skills_dead: i64 = conn
265            .query_row(
266                "SELECT COUNT(*) FROM learned_skills WHERE priority <= ?1",
267                [dead_threshold],
268                |r| r.get(0),
269            )
270            .unwrap_or(0);
271        let avg_skill_priority: f64 = conn
272            .query_row(
273                "SELECT COALESCE(AVG(priority), 0) FROM learned_skills",
274                [],
275                |r| r.get(0),
276            )
277            .unwrap_or(0.0);
278        drop(conn); // release lock before mutating
279
280        // ── 1. Prune stale procedural_memory entries ─────────────
281        let proc_pruned: i64 = match roboticus_db::memory::prune_stale_procedural(db, stale_days) {
282            Ok(0) => 0,
283            Ok(n) => {
284                tracing::info!(count = n, "pruned stale procedural memory entries");
285                n as i64
286            }
287            Err(e) => {
288                tracing::warn!(error = %e, "stale procedural pruning failed");
289                0
290            }
291        };
292
293        // ── 2. Prune dead learned skills ─────────────────────────
294        // Phase 1: find dead rows (DB rows survive if we crash here)
295        // Phase 2: delete .md files on disk
296        // Phase 3: delete DB rows (crash-safe: orphan DB rows are re-pruned next cycle)
297        let skills_pruned: i64 =
298            match roboticus_db::learned_skills::find_dead_learned_skills(db, dead_threshold) {
299                Ok(dead) if dead.is_empty() => 0,
300                Ok(dead) => {
301                    let count = dead.len() as i64;
302                    // Delete files first so a crash never leaves orphan .md files
303                    for (name, md_path) in &dead {
304                        if let Some(path) = md_path
305                            && let Err(e) = std::fs::remove_file(path)
306                            && e.kind() != std::io::ErrorKind::NotFound
307                        {
308                            tracing::warn!(
309                                error = %e, skill = %name, path = %path,
310                                "failed to remove dead learned skill file"
311                            );
312                        }
313                        tracing::info!(skill = %name, "pruned dead learned skill");
314                    }
315                    // Now safe to remove DB rows
316                    let names: Vec<String> = dead.iter().map(|(n, _)| n.clone()).collect();
317                    if let Err(e) =
318                        roboticus_db::learned_skills::delete_learned_skills_by_names(db, &names)
319                    {
320                        tracing::warn!(error = %e, "failed to delete dead learned skill DB rows");
321                    }
322                    count
323                }
324                Err(e) => {
325                    tracing::warn!(error = %e, "dead learned skill pruning failed");
326                    0
327                }
328            };
329
330        // ── 3. Record sweep in audit log ─────────────────────────
331        let sweep_input = roboticus_db::hygiene_log::HygieneSweepInput {
332            stale_procedural_days: stale_days,
333            dead_skill_priority_threshold: dead_threshold,
334            proc_total,
335            proc_stale,
336            proc_pruned,
337            skills_total,
338            skills_dead,
339            skills_pruned,
340            avg_skill_priority,
341        };
342        if let Err(e) = roboticus_db::hygiene_log::log_hygiene_sweep(db, &sweep_input) {
343            tracing::warn!(error = %e, "failed to log hygiene sweep");
344        }
345    }
346
347    // ── Diagnostic ────────────────────────────────────────────────
348
349    /// Return a human-readable report on retrieval-memory health.
350    ///
351    /// The mechanic can call this to decide whether the hygiene thresholds
352    /// in `LearningConfig` need adjusting — no auto-tuning, just data.
353    pub fn diagnose_retrieval_health(&self, db: &Database) -> String {
354        let mut lines = Vec::new();
355        let conn = db.conn();
356
357        // Procedural memory stats
358        let proc_total: i64 = conn
359            .query_row("SELECT COUNT(*) FROM procedural_memory", [], |r| r.get(0))
360            .unwrap_or(0);
361        let proc_stale: i64 = conn
362            .query_row(
363                "SELECT COUNT(*) FROM procedural_memory \
364                 WHERE success_count = 0 AND failure_count = 0 \
365                   AND updated_at < datetime('now', ?1)",
366                [format!(
367                    "-{} days",
368                    self.learning_config.stale_procedural_days
369                )],
370                |r| r.get(0),
371            )
372            .unwrap_or(0);
373        lines.push(format!(
374            "procedural_memory: {proc_total} total, {proc_stale} stale \
375             (zero-activity, >{} days)",
376            self.learning_config.stale_procedural_days
377        ));
378
379        // Learned skills stats
380        let skill_total: i64 = conn
381            .query_row("SELECT COUNT(*) FROM learned_skills", [], |r| r.get(0))
382            .unwrap_or(0);
383        let skill_dead: i64 = conn
384            .query_row(
385                "SELECT COUNT(*) FROM learned_skills WHERE priority <= ?1",
386                [self.learning_config.dead_skill_priority_threshold],
387                |r| r.get(0),
388            )
389            .unwrap_or(0);
390        let skill_low: i64 = conn
391            .query_row(
392                "SELECT COUNT(*) FROM learned_skills WHERE priority > ?1 AND priority < 20",
393                [self.learning_config.dead_skill_priority_threshold],
394                |r| r.get(0),
395            )
396            .unwrap_or(0);
397        let avg_priority: f64 = conn
398            .query_row(
399                "SELECT COALESCE(AVG(priority), 0) FROM learned_skills",
400                [],
401                |r| r.get(0),
402            )
403            .unwrap_or(0.0);
404        lines.push(format!(
405            "learned_skills: {skill_total} total, {skill_dead} dead (priority ≤ {}), \
406             {skill_low} low (< 20), avg priority {avg_priority:.0}",
407            self.learning_config.dead_skill_priority_threshold
408        ));
409
410        // Config snapshot
411        lines.push(format!(
412            "config: stale_procedural_days={}, dead_skill_priority_threshold={}, \
413             max_learned_skills={}",
414            self.learning_config.stale_procedural_days,
415            self.learning_config.dead_skill_priority_threshold,
416            self.learning_config.max_learned_skills,
417        ));
418
419        lines.join("\n")
420    }
421
422    fn decay_episodic_importance(&self, db: &Database) -> roboticus_core::Result<usize> {
423        let half_life_days = self.digest_config.decay_half_life_days as f64;
424        if half_life_days <= 0.0 {
425            return Ok(0);
426        }
427
428        let now = Utc::now();
429        let conn = db.conn();
430        let mut stmt = conn
431            .prepare("SELECT id, importance, created_at FROM episodic_memory")
432            .map_err(|e| roboticus_core::RoboticusError::Database(e.to_string()))?;
433        let rows = stmt
434            .query_map([], |row| {
435                let id: String = row.get(0)?;
436                let importance: i32 = row.get(1)?;
437                let created_at: String = row.get(2)?;
438                Ok((id, importance, created_at))
439            })
440            .map_err(|e| roboticus_core::RoboticusError::Database(e.to_string()))?;
441
442        let mut updates: Vec<(String, i32)> = Vec::new();
443        for row in rows {
444            let (id, importance, created_at) =
445                row.map_err(|e| roboticus_core::RoboticusError::Database(e.to_string()))?;
446            if let Ok(created_dt) = DateTime::parse_from_rfc3339(&created_at) {
447                let age_days = (now - created_dt.with_timezone(&Utc))
448                    .to_std()
449                    .map(|d| d.as_secs_f64() / 86_400.0)
450                    .unwrap_or(0.0);
451                let decayed = crate::digest::decay_importance(importance, age_days, half_life_days);
452                if decayed != importance {
453                    updates.push((id, decayed));
454                }
455            }
456        }
457        drop(stmt);
458
459        // Batch all updates in a single transaction to avoid holding the Mutex
460        // across N individual UPDATEs and to ensure atomicity.
461        if !updates.is_empty() {
462            conn.execute_batch("BEGIN")
463                .map_err(|e| roboticus_core::RoboticusError::Database(e.to_string()))?;
464            for (id, new_importance) in &updates {
465                conn.execute(
466                    "UPDATE episodic_memory SET importance = ?1 WHERE id = ?2",
467                    (&new_importance, id),
468                )
469                .map_err(|e| {
470                    let _ = conn.execute_batch("ROLLBACK");
471                    roboticus_core::RoboticusError::Database(e.to_string())
472                })?;
473            }
474            conn.execute_batch("COMMIT")
475                .map_err(|e| roboticus_core::RoboticusError::Database(e.to_string()))?;
476        }
477
478        Ok(updates.len())
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    fn test_db() -> Database {
487        Database::new(":memory:").unwrap()
488    }
489
490    #[test]
491    fn governor_tick_no_sessions() {
492        let gov = SessionGovernor::new(SessionConfig::default());
493        let db = test_db();
494        let expired = gov.tick(&db).unwrap();
495        assert_eq!(expired, 0);
496    }
497
498    #[test]
499    fn governor_spawn_creates_session() {
500        let gov = SessionGovernor::new(SessionConfig::default());
501        let db = test_db();
502        let sid = gov.spawn(&db, "gov-agent", None).unwrap();
503        assert!(!sid.is_empty());
504
505        let sid2 = gov.spawn(&db, "gov-agent", None).unwrap();
506        assert_eq!(sid, sid2, "same agent should reuse session");
507    }
508
509    #[test]
510    fn governor_spawn_with_scope() {
511        let gov = SessionGovernor::new(SessionConfig::default());
512        let db = test_db();
513
514        let scope = roboticus_db::sessions::SessionScope::Peer {
515            peer_id: "alice".into(),
516            channel: "telegram".into(),
517        };
518        let sid_scoped = gov.spawn(&db, "gov-agent", Some(&scope)).unwrap();
519        let sid_plain = gov.spawn(&db, "gov-agent", None).unwrap();
520        assert_ne!(sid_scoped, sid_plain);
521    }
522
523    #[test]
524    fn rotate_agent_scope_sessions_keeps_single_active_session() {
525        let gov = SessionGovernor::new(SessionConfig::default());
526        let db = test_db();
527        let sid1 = roboticus_db::sessions::create_new(&db, "gov-rotate", None).unwrap();
528
529        let rotated = gov.rotate_agent_scope_sessions(&db, "gov-rotate").unwrap();
530        assert_eq!(rotated, 1);
531
532        let active = roboticus_db::sessions::list_active_sessions(&db, Some("gov-rotate")).unwrap();
533        assert_eq!(active.len(), 1);
534        assert_eq!(active[0].scope_key.as_deref(), Some("agent"));
535        assert_ne!(active[0].id, sid1);
536
537        let archived = roboticus_db::sessions::get_session(&db, &sid1)
538            .unwrap()
539            .unwrap();
540        assert_eq!(archived.status, "archived");
541    }
542
543    // ── compact_before_archive tests (BUG-084) ─────────────────────
544
545    #[test]
546    fn compact_before_archive_fewer_than_4_messages_is_noop() {
547        let gov = SessionGovernor::new(SessionConfig::default());
548        let db = test_db();
549        let sid = roboticus_db::sessions::create_new(&db, "compact-few", None).unwrap();
550
551        // Add only 2 messages (< 4 threshold)
552        roboticus_db::sessions::append_message(&db, &sid, "user", "hello").unwrap();
553        roboticus_db::sessions::append_message(&db, &sid, "assistant", "hi there").unwrap();
554
555        gov.compact_before_archive(&db, &sid).unwrap();
556
557        // No extra system message should be appended
558        let msgs = roboticus_db::sessions::list_messages(&db, &sid, Some(50)).unwrap();
559        assert_eq!(msgs.len(), 2);
560        assert!(
561            !msgs
562                .iter()
563                .any(|m| m.content.contains("[Conversation Summary"))
564        );
565    }
566
567    #[test]
568    fn compact_before_archive_with_enough_messages_appends_digest() {
569        let gov = SessionGovernor::new(SessionConfig::default());
570        let db = test_db();
571        let sid = roboticus_db::sessions::create_new(&db, "compact-enough", None).unwrap();
572
573        // Add 6 messages (>= 4 threshold)
574        for i in 0..6 {
575            let role = if i % 2 == 0 { "user" } else { "assistant" };
576            roboticus_db::sessions::append_message(&db, &sid, role, &format!("message number {i}"))
577                .unwrap();
578        }
579
580        gov.compact_before_archive(&db, &sid).unwrap();
581
582        let msgs = roboticus_db::sessions::list_messages(&db, &sid, Some(50)).unwrap();
583        // Should have original 6 + 1 compaction system message = 7
584        assert_eq!(msgs.len(), 7);
585        let last = msgs.last().unwrap();
586        assert_eq!(last.role, "system");
587        assert!(
588            last.content.contains("[Conversation Summary"),
589            "expected summary header"
590        );
591    }
592
593    #[test]
594    fn compact_before_archive_trims_old_keeps_recent_4() {
595        let gov = SessionGovernor::new(SessionConfig::default());
596        let db = test_db();
597        let sid = roboticus_db::sessions::create_new(&db, "compact-trim", None).unwrap();
598
599        // Add 8 messages
600        for i in 0..8 {
601            let role = if i % 2 == 0 { "user" } else { "assistant" };
602            roboticus_db::sessions::append_message(&db, &sid, role, &format!("content-{i}"))
603                .unwrap();
604        }
605
606        gov.compact_before_archive(&db, &sid).unwrap();
607
608        let msgs = roboticus_db::sessions::list_messages(&db, &sid, Some(50)).unwrap();
609        let summary_msg = msgs
610            .iter()
611            .find(|m| m.content.contains("[Conversation Summary"))
612            .unwrap();
613
614        // The summary should reference content from trimmed messages (0..4)
615        assert!(
616            summary_msg.content.contains("content-0"),
617            "summary should include trimmed message 0"
618        );
619        assert!(
620            summary_msg.content.contains("content-3"),
621            "summary should include trimmed message 3"
622        );
623    }
624
625    #[test]
626    fn compact_before_archive_exactly_4_messages_is_noop() {
627        let gov = SessionGovernor::new(SessionConfig::default());
628        let db = test_db();
629        let sid = roboticus_db::sessions::create_new(&db, "compact-exact", None).unwrap();
630
631        // Add exactly 4 messages — trimmed slice would be empty
632        for i in 0..4 {
633            let role = if i % 2 == 0 { "user" } else { "assistant" };
634            roboticus_db::sessions::append_message(&db, &sid, role, &format!("msg-{i}")).unwrap();
635        }
636
637        gov.compact_before_archive(&db, &sid).unwrap();
638
639        // keep_recent = 4, trim_end = 4 - 4 = 0, trimmed slice is empty -> early return
640        let msgs = roboticus_db::sessions::list_messages(&db, &sid, Some(50)).unwrap();
641        assert_eq!(msgs.len(), 4);
642    }
643
644    #[test]
645    fn compact_before_archive_idempotent_on_double_call() {
646        let gov = SessionGovernor::new(SessionConfig::default());
647        let db = test_db();
648        let sid = roboticus_db::sessions::create_new(&db, "compact-idem", None).unwrap();
649
650        for i in 0..6 {
651            let role = if i % 2 == 0 { "user" } else { "assistant" };
652            roboticus_db::sessions::append_message(&db, &sid, role, &format!("msg-{i}")).unwrap();
653        }
654
655        // First call should append a summary
656        gov.compact_before_archive(&db, &sid).unwrap();
657        let msgs_after_first = roboticus_db::sessions::list_messages(&db, &sid, Some(50)).unwrap();
658        let summary_count_1 = msgs_after_first
659            .iter()
660            .filter(|m| m.content.contains("[Conversation Summary"))
661            .count();
662        assert_eq!(summary_count_1, 1);
663
664        // Second call should be a no-op (idempotency guard)
665        gov.compact_before_archive(&db, &sid).unwrap();
666        let msgs_after_second = roboticus_db::sessions::list_messages(&db, &sid, Some(50)).unwrap();
667        let summary_count_2 = msgs_after_second
668            .iter()
669            .filter(|m| m.content.contains("[Conversation Summary"))
670            .count();
671        assert_eq!(summary_count_2, 1, "should not append a second summary");
672    }
673
674    #[test]
675    fn tick_expires_stale_sessions_with_compaction() {
676        let gov = SessionGovernor::new(SessionConfig {
677            ttl_seconds: 0, // immediate expiry
678            ..SessionConfig::default()
679        });
680        let db = test_db();
681        let sid = roboticus_db::sessions::create_new(&db, "stale-agent", None).unwrap();
682
683        // Add enough messages to trigger compaction
684        for i in 0..6 {
685            let role = if i % 2 == 0 { "user" } else { "assistant" };
686            roboticus_db::sessions::append_message(&db, &sid, role, &format!("stale-msg-{i}"))
687                .unwrap();
688        }
689
690        // Allow the session to become stale
691        std::thread::sleep(std::time::Duration::from_millis(50));
692
693        let expired = gov.tick(&db).unwrap();
694        assert_eq!(expired, 1);
695
696        // Check the session is now expired
697        let session = roboticus_db::sessions::get_session(&db, &sid)
698            .unwrap()
699            .unwrap();
700        assert_eq!(session.status, "expired");
701
702        // Compaction should have run — check for summary message
703        let msgs = roboticus_db::sessions::list_messages(&db, &sid, Some(50)).unwrap();
704        assert!(
705            msgs.iter()
706                .any(|m| m.content.contains("[Conversation Summary")),
707            "compaction should have appended a summary"
708        );
709    }
710
711    #[test]
712    fn rotate_with_no_sessions_returns_zero() {
713        let gov = SessionGovernor::new(SessionConfig::default());
714        let db = test_db();
715        let rotated = gov
716            .rotate_agent_scope_sessions(&db, "nonexistent-agent")
717            .unwrap();
718        assert_eq!(rotated, 0);
719    }
720
721    // ── Learned skill priority adjustment ─────────────────────────
722
723    #[test]
724    fn adjust_priorities_boosts_reliable_skills() {
725        let gov = SessionGovernor::new(SessionConfig::default());
726        let db = test_db();
727
728        // Create a skill with > 5 uses and > 80% success ratio
729        roboticus_db::learned_skills::store_learned_skill(
730            &db,
731            "reliable-skill",
732            "A reliable skill",
733            "[]",
734            "[]",
735            None,
736        )
737        .unwrap();
738        // Start at priority 50, success_count=1. Add more successes.
739        for _ in 0..6 {
740            roboticus_db::learned_skills::record_learned_skill_success(&db, "reliable-skill")
741                .unwrap();
742        }
743        // Now: success_count=7, failure_count=0, ratio=1.0, total=7 > 5
744
745        let adjusted = gov.adjust_learned_skill_priorities(&db).unwrap();
746        assert_eq!(adjusted, 1);
747
748        let skill = roboticus_db::learned_skills::get_learned_skill_by_name(&db, "reliable-skill")
749            .unwrap()
750            .unwrap();
751        assert!(
752            skill.priority > 50,
753            "priority should have been boosted from 50, got {}",
754            skill.priority
755        );
756    }
757
758    #[test]
759    fn adjust_priorities_decays_unreliable_skills() {
760        let gov = SessionGovernor::new(SessionConfig::default());
761        let db = test_db();
762
763        roboticus_db::learned_skills::store_learned_skill(
764            &db,
765            "flaky-skill",
766            "An unreliable skill",
767            "[]",
768            "[]",
769            None,
770        )
771        .unwrap();
772        // success_count=1. Add many failures so failure > success.
773        for _ in 0..3 {
774            roboticus_db::learned_skills::record_learned_skill_failure(&db, "flaky-skill").unwrap();
775        }
776        // Now: success_count=1, failure_count=3, failure > success
777
778        let adjusted = gov.adjust_learned_skill_priorities(&db).unwrap();
779        assert_eq!(adjusted, 1);
780
781        let skill = roboticus_db::learned_skills::get_learned_skill_by_name(&db, "flaky-skill")
782            .unwrap()
783            .unwrap();
784        assert!(
785            skill.priority < 50,
786            "priority should have decayed from 50, got {}",
787            skill.priority
788        );
789    }
790
791    #[test]
792    fn adjust_priorities_disabled_config_skips() {
793        let learning_config = LearningConfig {
794            enabled: false,
795            ..Default::default()
796        };
797        let gov = SessionGovernor::new(SessionConfig::default())
798            .with_learning(learning_config, PathBuf::from("/tmp"));
799        let db = test_db();
800
801        let adjusted = gov.adjust_learned_skill_priorities(&db).unwrap();
802        assert_eq!(adjusted, 0);
803    }
804}