//! Deciding reported turns (M5c spec, section 5): every undecided turn of every waiting session up //! to the one reported, as far as the log goes; the log's last turn waits for its reply; one //! question to `brokerd` per session per walk, and none when nothing waits; a silent `brokerd` //! stops the walk and is logged once, and so is its return; a damaged log and a ledger that cannot //! be written. `brokerd` is a closure here; the socket is in `honcho_state.rs`. Do not edit. #[path = "support/logs.rs"] mod logs; #[path = "support/tmp.rs"] mod tmp; #[path = "support/turns.rs"] mod turns; use std::cell::RefCell; use bxcore::{DataClass, LogRecord, SessionId, SessionStateReply, Timestamp}; use logs::{assistant, write_log}; use memoryd::honcho::decide::{NO_REPLY, NO_SPEAKER, SECRET_CLASS}; use memoryd::honcho::ledger::{Action, Item, Ledger, Workspace}; use memoryd::honcho::turns::{ LEDGER, LOG_RUNBOOK, TurnSync, decidable, finished, log_path, read_log, }; use tmp::TempDir; use turns::{clean, fetched, running, sid, spoken, write_turns}; const BROKER_DOWN: &str = "see docs/runbook.md#broker-state-unavailable"; const BACK: &str = "memoryd: brokerd answers on its state socket again; turns are decided"; /// A home with a ledger, a `TurnSync`, and a `brokerd` whose answer the test sets. struct World { sync: TurnSync, answer: RefCell>, asked: RefCell>, lines: RefCell>, dir: TempDir, } impl World { fn new(tag: &str) -> World { let dir = TempDir::new(tag); std::fs::create_dir_all(dir.path().join("memory")).unwrap(); let ledger = Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap(); World { sync: TurnSync::new(ledger, "boxmaker0", &dir.path().join("sessions")), answer: RefCell::new(Ok(clean())), asked: RefCell::new(Vec::new()), lines: RefCell::new(Vec::new()), dir, } } fn log(&self, session: &str, turns: &[Vec]) { write_turns(self.dir.path(), session, turns); } fn updated(&mut self, session: &str, turn: u64) { let ask = |s: &SessionId| { self.asked.borrow_mut().push(s.as_str().to_string()); self.answer.borrow().clone() }; let log = |line: &str| self.lines.borrow_mut().push(line.to_string()); let now = Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap(); self.sync.updated(&sid(session), turn, &ask, now, &log); } fn action(&self, session: &str, turn: u64) -> Option { let item = Item::Turn { session: sid(session), turn, }; let path = self.dir.path().join("memory").join(LEDGER); let text = std::fs::read_to_string(path).unwrap_or_default(); let key = serde_json::to_string(&item).unwrap(); let line = text.lines().find(|l| l.contains(&key))?; let value: serde_json::Value = serde_json::from_str(line).unwrap(); Some(serde_json::from_value(value["action"].clone()).unwrap()) } fn waiting(&self) -> Vec<(String, u64)> { let waiting = self.sync.waiting(); waiting .into_iter() .map(|(s, n)| (s.as_str().to_string(), n)) .collect() } } fn to(workspace: Workspace, speaker: &str) -> Option { Some(Action::Turn { workspace, speaker: speaker.to_string(), }) } fn skip(why: &str) -> Option { Some(Action::Skip { why: why.to_string(), }) } #[test] fn the_reported_turn_is_decided_and_written_at_once() { let mut w = World::new("turns-one"); w.log("mm-a", &[turns::finished(Some("kyle"))]); w.updated("mm-a", 1); assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle")); assert_eq!(*w.asked.borrow(), vec!["mm-a"]); assert!(w.waiting().is_empty()); assert!(w.lines.borrow().is_empty(), "{:?}", w.lines.borrow()); let reopened = Ledger::open(&w.dir.path().join("memory").join(LEDGER)).unwrap(); assert_eq!(reopened.counts().decided, 1); } #[test] fn every_undecided_turn_up_to_the_reported_one_is_decided() { let mut w = World::new("turns-all"); w.log( "mm-a", &[ turns::finished(None), running(Some("kyle")), fetched(Some("kyle")), turns::finished(Some("kyle")), ], ); w.updated("mm-a", 4); assert_eq!(w.action("mm-a", 1), skip(NO_SPEAKER)); assert_eq!(w.action("mm-a", 2), skip(NO_REPLY)); assert_eq!(w.action("mm-a", 3), to(Workspace::Untrusted, "kyle")); assert_eq!(w.action("mm-a", 4), to(Workspace::Untrusted, "kyle")); assert_eq!(*w.asked.borrow(), vec!["mm-a"], "one question per session"); } #[test] fn only_as_far_as_the_report_and_the_log_go() { let mut w = World::new("turns-limit"); w.log( "mm-a", &[turns::finished(Some("kyle")), turns::finished(Some("kyle"))], ); w.updated("mm-a", 9); assert!(w.action("mm-a", 2).is_some()); assert!(w.waiting().is_empty(), "turns not in the log do not wait"); w.updated("mm-a", 9); assert_eq!( w.asked.borrow().len(), 1, "asked about turns not in the log" ); w.log( "mm-b", &[ turns::finished(Some("kyle")), turns::finished(Some("kyle")), turns::finished(Some("kyle")), ], ); w.updated("mm-b", 1); assert!(w.action("mm-b", 1).is_some()); assert_eq!(w.action("mm-b", 2), None, "turn 2 was not reported"); assert!(w.waiting().is_empty()); } #[test] fn the_logs_last_turn_waits_for_its_reply() { let mut w = World::new("turns-running"); w.log( "mm-a", &[turns::finished(Some("kyle")), running(Some("kyle"))], ); w.updated("mm-a", 2); assert!(w.action("mm-a", 1).is_some()); assert_eq!(w.action("mm-a", 2), None, "a running turn was decided"); assert_eq!(w.waiting(), vec![("mm-a".to_string(), 2)]); // Another report while it runs: it still waits, and nothing is asked for it. w.log("mm-c", &[turns::finished(Some("kyle"))]); w.updated("mm-c", 1); assert_eq!(w.action("mm-a", 2), None); assert_eq!(w.waiting(), vec![("mm-a".to_string(), 2)]); assert_eq!(*w.asked.borrow(), vec!["mm-a", "mm-c"]); // It ends; the next report of any session decides it. let mut ended = running(Some("kyle")); ended.push(assistant(3, Some("found it"), &[])); w.log("mm-a", &[turns::finished(Some("kyle")), ended]); w.log("mm-b", &[turns::finished(Some("ana"))]); w.updated("mm-b", 1); assert_eq!(w.action("mm-a", 2), to(Workspace::Trusted, "kyle")); assert_eq!(w.action("mm-b", 1), to(Workspace::Trusted, "ana")); assert!(w.waiting().is_empty()); assert_eq!(w.sync.ledger().counts().skipped, 0); } #[test] fn a_report_past_the_log_does_not_decide_a_running_turn() { let mut w = World::new("turns-inflated"); w.log( "mm-a", &[turns::finished(Some("kyle")), running(Some("kyle"))], ); w.updated("mm-a", 5); assert!(w.action("mm-a", 1).is_some()); assert_eq!(w.action("mm-a", 2), None, "a running turn was decided"); assert_eq!(w.waiting(), vec![("mm-a".to_string(), 5)]); } #[test] fn an_earlier_report_does_not_lower_the_turn() { let mut w = World::new("turns-order"); *w.answer.borrow_mut() = Err("no".to_string()); w.log( "mm-a", &[ turns::finished(Some("kyle")), turns::finished(Some("kyle")), turns::finished(Some("kyle")), ], ); w.updated("mm-a", 3); w.updated("mm-a", 1); assert_eq!(w.waiting(), vec![("mm-a".to_string(), 3)]); *w.answer.borrow_mut() = Ok(clean()); w.log("mm-b", &[turns::finished(Some("kyle"))]); w.updated("mm-b", 1); assert_eq!(w.action("mm-a", 3), to(Workspace::Trusted, "kyle")); } #[test] fn a_silent_brokerd_is_logged_once_and_its_return_once() { let mut w = World::new("turns-down"); let why = "/h/run/memory-broker/state.sock: Connection refused (os error 111)"; *w.answer.borrow_mut() = Err(why.to_string()); w.log("mm-a", &[turns::finished(Some("kyle"))]); w.log("mm-b", &[turns::finished(Some("kyle"))]); w.updated("mm-a", 1); w.updated("mm-b", 1); w.updated("mm-a", 1); assert_eq!(w.action("mm-a", 1), None); assert_eq!(w.action("mm-b", 1), None); assert_eq!( *w.lines.borrow(), vec![format!( "memoryd: {why}; turns wait undecided; {BROKER_DOWN}" )] ); assert_eq!(w.waiting().len(), 2); *w.answer.borrow_mut() = Ok(clean()); w.log("mm-c", &[turns::finished(Some("kyle"))]); w.updated("mm-c", 1); for session in ["mm-a", "mm-b", "mm-c"] { assert_eq!( w.action(session, 1), to(Workspace::Trusted, "kyle"), "{session}" ); } assert_eq!(w.lines.borrow().len(), 2); assert_eq!(w.lines.borrow()[1], BACK); assert!(w.waiting().is_empty()); } #[test] fn a_silent_brokerd_stops_the_walk_at_the_first_question() { let mut w = World::new("turns-stop"); *w.answer.borrow_mut() = Err("no".to_string()); for session in ["mm-a", "mm-b", "mm-c"] { w.log(session, &[turns::finished(Some("kyle"))]); } w.updated("mm-a", 1); w.updated("mm-b", 1); w.updated("mm-c", 1); // One question per walk, to the first waiting session in the order of the ids. assert_eq!(*w.asked.borrow(), vec!["mm-a", "mm-a", "mm-a"]); *w.answer.borrow_mut() = Ok(clean()); w.updated("mm-c", 1); assert_eq!(w.asked.borrow()[3..], ["mm-a", "mm-b", "mm-c"]); } #[test] fn nothing_undecided_asks_nothing() { let mut w = World::new("turns-quiet"); w.log("mm-a", &[turns::finished(Some("kyle"))]); w.updated("mm-a", 1); w.updated("mm-a", 1); assert_eq!(*w.asked.borrow(), vec!["mm-a"]); let lines = std::fs::read_to_string(w.dir.path().join("memory").join(LEDGER)).unwrap(); assert_eq!(lines.lines().count(), 1); // A restart reads the ledger through: nothing is decided twice. let ledger = Ledger::open(&w.dir.path().join("memory").join(LEDGER)).unwrap(); w.sync = TurnSync::new(ledger, "boxmaker0", &w.dir.path().join("sessions")); w.updated("mm-a", 1); assert_eq!(w.asked.borrow().len(), 1); } #[test] fn brokerds_state_decides_the_workspace() { let mut w = World::new("turns-state"); w.log("mm-a", &[turns::finished(Some("kyle"))]); *w.answer.borrow_mut() = Ok(SessionStateReply { class: DataClass::Private, untrusted: true, }); w.updated("mm-a", 1); assert_eq!(w.action("mm-a", 1), to(Workspace::Untrusted, "kyle")); w.log("mm-b", &[turns::finished(Some("kyle"))]); *w.answer.borrow_mut() = Ok(SessionStateReply { class: DataClass::Secret, untrusted: false, }); w.updated("mm-b", 1); assert_eq!(w.action("mm-b", 1), skip(SECRET_CLASS)); } #[test] fn a_damaged_log_waits_and_is_logged_once() { let mut w = World::new("turns-damaged"); let path = write_log(w.dir.path(), "mm-a", &turns::finished(Some("kyle"))); let text = std::fs::read_to_string(&path).unwrap(); std::fs::write(&path, format!("not json\n{text}")).unwrap(); w.log("mm-b", &[turns::finished(Some("kyle"))]); w.updated("mm-a", 1); w.updated("mm-b", 1); assert_eq!(w.action("mm-a", 1), None); assert_eq!(w.action("mm-b", 1), to(Workspace::Trusted, "kyle")); let lines = w.lines.borrow().clone(); assert_eq!(lines.len(), 1, "{lines:?}"); let start = format!("memoryd: {}:1: ", path.display()); assert!(lines[0].starts_with(&start), "{}", lines[0]); assert!( lines[0].ends_with(&format!("; {LOG_RUNBOOK}")), "{}", lines[0] ); assert_eq!(w.waiting(), vec![("mm-a".to_string(), 1)]); assert_eq!(*w.asked.borrow(), vec!["mm-b"]); // Mended, it is decided at the next report. std::fs::write(&path, text).unwrap(); w.updated("mm-b", 1); assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle")); } #[test] fn a_line_loopd_is_still_writing_is_left_out() { let mut w = World::new("turns-partial"); let path = write_log(w.dir.path(), "mm-a", &turns::finished(Some("kyle"))); let half = serde_json::to_string(&spoken("next", Some("kyle"))).unwrap(); let mut text = std::fs::read_to_string(&path).unwrap(); text.push_str(&half[..half.len() / 2]); std::fs::write(&path, text).unwrap(); w.updated("mm-a", 1); assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle")); assert!(w.lines.borrow().is_empty(), "{:?}", w.lines.borrow()); } #[test] fn a_ledger_that_cannot_be_written_stops_the_walk() { let mut w = World::new("turns-ledger"); w.log("mm-a", &[turns::finished(Some("kyle"))]); let ledger = w.dir.path().join("memory").join(LEDGER); std::fs::create_dir(&ledger).unwrap(); w.updated("mm-a", 1); let lines = w.lines.borrow().clone(); assert_eq!(lines.len(), 1, "{lines:?}"); assert!(lines[0].starts_with(&format!("memoryd: {}: ", ledger.display()))); assert!(lines[0].ends_with("; see docs/runbook.md#honcho-ledger-damaged")); assert!(!w.sync.ledger().is_decided(&Item::Turn { session: sid("mm-a"), turn: 1 })); assert_eq!(w.waiting(), vec![("mm-a".to_string(), 1)]); std::fs::remove_dir(&ledger).unwrap(); w.updated("mm-a", 1); assert_eq!(w.action("mm-a", 1), to(Workspace::Trusted, "kyle")); } #[test] fn reading_a_log() { let dir = TempDir::new("turns-read"); let path = log_path(&dir.path().join("sessions"), &sid("mm-a")); assert_eq!(path, dir.path().join("sessions/mm-a/0.jsonl")); assert_eq!(read_log(&path), Ok(Vec::new()), "a missing log"); let records = turns::finished(Some("kyle")); write_log(dir.path(), "mm-a", &records); assert_eq!(read_log(&path), Ok(records.clone())); let mut text = std::fs::read_to_string(&path).unwrap(); text.push_str("{\"type\":\"us"); std::fs::write(&path, &text).unwrap(); assert_eq!(read_log(&path), Ok(records)); std::fs::write(&path, "{\"type\":\"user\"}\n").unwrap(); let err = read_log(&path).unwrap_err(); assert!(err.starts_with(&format!("{}:1: ", path.display())), "{err}"); assert!(err.ends_with(&format!("; {LOG_RUNBOOK}")), "{err}"); std::fs::remove_file(&path).unwrap(); std::fs::create_dir(&path).unwrap(); let err = read_log(&path).unwrap_err(); assert!(err.starts_with(&format!("{}: ", path.display())), "{err}"); assert_eq!(LOG_RUNBOOK, "see docs/runbook.md#session-log-damaged"); assert_eq!(LEDGER, "honcho.jsonl"); } #[test] fn a_turn_is_finished_by_an_answer_without_tool_calls() { assert!(finished(&turns::finished(Some("kyle")))); assert!(finished(&fetched(None))); assert!(!finished(&running(None))); assert!(!finished(&[spoken("hello", None)])); let mut empty = vec![spoken("hello", None), assistant(2, None, &[])]; assert!(finished(&empty), "an empty answer still ends the turn"); empty.push(assistant(3, Some("and"), &[("fetch", "{}")])); assert!(!finished(&empty)); } #[test] fn how_far_a_log_may_be_decided() { let done = |n: usize| -> Vec { (0..n).flat_map(|_| turns::finished(Some("kyle"))).collect() }; assert_eq!(decidable(&[], 3), (0, false)); assert_eq!(decidable(&done(3), 0), (0, false)); assert_eq!(decidable(&done(3), 2), (2, false)); assert_eq!(decidable(&done(3), 3), (3, false)); assert_eq!(decidable(&done(3), 7), (3, false)); let mut open = done(2); open.extend(running(Some("kyle"))); assert_eq!(decidable(&open, 2), (2, false)); assert_eq!(decidable(&open, 3), (2, true)); assert_eq!(decidable(&open, 9), (2, true)); assert_eq!(decidable(&running(None), 1), (0, true)); }