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 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 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 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 self.run_retrieval_hygiene(db);
81 Ok(expired)
82 }
83
84 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 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 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 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 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 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 (skill.priority + boost).min(100)
213 } else if skill.failure_count > skill.success_count {
214 (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 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 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); 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 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 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 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 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 pub fn diagnose_retrieval_health(&self, db: &Database) -> String {
354 let mut lines = Vec::new();
355 let conn = db.conn();
356
357 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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, ..SessionConfig::default()
679 });
680 let db = test_db();
681 let sid = roboticus_db::sessions::create_new(&db, "stale-agent", None).unwrap();
682
683 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 std::thread::sleep(std::time::Duration::from_millis(50));
692
693 let expired = gov.tick(&db).unwrap();
694 assert_eq!(expired, 1);
695
696 let session = roboticus_db::sessions::get_session(&db, &sid)
698 .unwrap()
699 .unwrap();
700 assert_eq!(session.status, "expired");
701
702 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 #[test]
724 fn adjust_priorities_boosts_reliable_skills() {
725 let gov = SessionGovernor::new(SessionConfig::default());
726 let db = test_db();
727
728 roboticus_db::learned_skills::store_learned_skill(
730 &db,
731 "reliable-skill",
732 "A reliable skill",
733 "[]",
734 "[]",
735 None,
736 )
737 .unwrap();
738 for _ in 0..6 {
740 roboticus_db::learned_skills::record_learned_skill_success(&db, "reliable-skill")
741 .unwrap();
742 }
743 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 for _ in 0..3 {
774 roboticus_db::learned_skills::record_learned_skill_failure(&db, "flaky-skill").unwrap();
775 }
776 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}