//! The Honcho worker's other duties (M5c spec, section 5): with nothing to send it decides what //! `brokerd` could not answer for; after a restart nothing is sent again, and a session with a //! decided turn waits again (a session without one does not); a running turn is never decided; //! actions of later tasks wait; a ledger that cannot be written stops it; no log line holds a //! turn's text. Then `memoryd` itself: a reported turn reaches Honcho, woken at once, and a key //! that cannot be read stops the start. Do not edit. #[path = "support/brokerd.rs"] mod brokerd; #[path = "support/honcho.rs"] mod honcho; #[path = "support/logs.rs"] mod logs; #[path = "support/tmp.rs"] mod tmp; #[path = "support/turns.rs"] mod turns; #[path = "support/worker.rs"] mod worker; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use brokerd::{Answer, FakeBrokerd}; use bxcore::{Envelope, Message, SessionId, SessionUpdated, read_frame, write_frame}; use honcho::{FakeHoncho, Mode}; use logs::assistant; use memoryd::config::Config; use memoryd::honcho::ledger::{Item, Record}; use memoryd::honcho::worker::{Step, Tuning}; use memoryd::serve::{self, StartError}; use tmp::TempDir; use turns::{clean, running, sid, spoken}; use worker::{FAST, Home}; fn turn(text: &str, speaker: &str, reply: &str) -> Vec { vec![spoken(text, Some(speaker)), assistant(2, Some(reply), &[])] } fn messages(fake: &FakeHoncho) -> usize { fake.requests() .iter() .filter(|r| r.path.ends_with("/messages")) .count() } fn is_sent(records: &[Record], session: &str, turn: u64) -> bool { let item = Item::Turn { session: sid(session), turn, }; records .iter() .any(|r| matches!(r, Record::Sent { item: i, .. } if *i == item)) } fn decided(records: &[Record], session: &str, turn: u64) -> bool { let item = Item::Turn { session: sid(session), turn, }; records .iter() .any(|r| matches!(r, Record::Decided { item: i, .. } if *i == item)) } #[test] fn the_default_waits() { assert_eq!( Tuning::DEFAULT, Tuning { backoff: Duration::from_secs(5), backoff_max: Duration::from_secs(600), idle: Duration::from_secs(30), } ); } #[test] fn with_nothing_to_send_undecided_turns_are_decided() { let fake = FakeHoncho::start(); let home = Home::new("worker-retry"); home.log("mm-a", &[turn("a", "kyle", "a")]); *home.answer.lock().unwrap() = Err("brokerd is down".to_string()); home.report("mm-a", 1); assert!(!decided(&home.records(), "mm-a", 1)); let mut w = home.worker(&fake, FAST); assert_eq!(w.step(), Step::Idle, "brokerd still down"); *home.answer.lock().unwrap() = Ok(clean()); assert_eq!(w.step(), Step::Done, "decided now"); assert!(decided(&home.records(), "mm-a", 1)); assert_eq!(w.step(), Step::Done, "sent"); assert!(is_sent(&home.records(), "mm-a", 1)); } #[test] fn an_outage_that_ends_is_followed_by_the_retry() { let fake = FakeHoncho::start(); let home = Home::new("worker-after-outage"); home.log("mm-a", &[turn("a", "kyle", "a")]); home.log("mm-b", &[turn("b", "kyle", "b")]); home.report("mm-a", 1); *home.answer.lock().unwrap() = Err("brokerd is down".to_string()); home.report("mm-b", 1); let mut w = home.worker(&fake, FAST); fake.set(Mode::Status(502)); assert!(matches!(w.step(), Step::Backoff(_))); *home.answer.lock().unwrap() = Ok(clean()); fake.set(Mode::Fixtures); assert_eq!(w.step(), Step::Done); // mm-a is sent, and the outage's end decided mm-b before any idle step. assert!(decided(&home.records(), "mm-b", 1)); } #[test] fn a_restart_sends_nothing_again_and_resumes_the_sessions_it_knows() { let fake = FakeHoncho::start(); let mut home = Home::new("worker-restart"); home.log("mm-a", &[turn("a", "kyle", "a")]); home.report("mm-a", 1); home.worker(&fake, FAST).step(); assert_eq!(messages(&fake), 1); // While memoryd is down: mm-a has a second turn, never reported; mm-z, never seen, one. home.log("mm-a", &[turn("a", "kyle", "a"), turn("b", "kyle", "b")]); home.log("mm-z", &[turn("z", "kyle", "z")]); home.restart(); let mut w = home.worker(&fake, FAST); assert_eq!(w.step(), Step::Done, "mm-a's second turn decided"); assert_eq!(w.step(), Step::Done, "and sent"); assert_eq!(w.step(), Step::Idle); let records = home.records(); assert!(is_sent(&records, "mm-a", 2)); assert!(!decided(&records, "mm-z", 1), "a session never reported"); assert_eq!(messages(&fake), 2, "turn 1 was sent again"); } #[test] fn a_restart_never_decides_a_running_turn() { let fake = FakeHoncho::start(); let mut home = Home::new("worker-running"); home.log("mm-a", &[turn("a", "kyle", "a")]); home.report("mm-a", 1); home.worker(&fake, FAST).step(); home.log("mm-a", &[turn("a", "kyle", "a"), running(Some("kyle"))]); home.restart(); let mut w = home.worker(&fake, FAST); assert_eq!(w.step(), Step::Idle); assert!(!decided(&home.records(), "mm-a", 2)); } #[test] fn actions_of_later_tasks_wait() { let fake = FakeHoncho::start(); let mut home = Home::new("worker-later"); let conclude = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"conclude","workspace":"trusted","peer":"kyle","session":"mm-a","text":"a fact"}}"#; std::fs::write(home.ledger_path(), format!("{conclude}\n")).unwrap(); home.restart(); let mut w = home.worker(&fake, FAST); assert_eq!(w.step(), Step::Idle); assert_eq!(home.records().len(), 1, "the conclusion was failed or sent"); assert!(fake.requests().is_empty()); } #[test] fn a_ledger_that_cannot_be_written_stops_the_worker() { let fake = FakeHoncho::start(); let home = Home::new("worker-ledger"); home.log("mm-a", &[turn("a", "kyle", "a")]); home.report("mm-a", 1); let mut w = home.worker(&fake, FAST); let ledger = home.ledger_path(); let kept = std::fs::read_to_string(&ledger).unwrap(); std::fs::remove_file(&ledger).unwrap(); std::fs::create_dir(&ledger).unwrap(); assert_eq!(w.step(), Step::Stopped); assert_eq!(w.step(), Step::Stopped); assert_eq!( messages(&fake), 1, "sent once; its sent line could not be written" ); let lines = home.lines(); assert_eq!(lines.len(), 1, "{lines:?}"); assert!(lines[0].ends_with( "; the Honcho worker stops until memoryd restarts; see docs/runbook.md#honcho-ledger-damaged" )); std::fs::remove_dir(&ledger).unwrap(); std::fs::write(&ledger, kept).unwrap(); } #[test] fn no_log_line_holds_a_turns_text() { let fake = FakeHoncho::start(); let home = Home::new("worker-quiet"); for (session, status) in [("mm-a", 503), ("mm-b", 422), ("mm-c", 401)] { home.log( session, &[turn("private-question", "kyle", "private-answer")], ); home.report(session, 1); let mut w = home.worker(&fake, FAST); fake.set(Mode::Status(status)); w.step(); } let lines = home.lines(); assert_eq!(lines.len(), 3, "{lines:?}"); for line in lines { assert!(!line.contains("private"), "{line}"); } } /// `memoryd serve`'s config for `home`, with `[honcho]` at `url`. fn serve_config(home: &TempDir, url: &str, extra: &str) -> String { let soul = home.write("etc/soul.md", "You are Boxmaker.\n"); format!( "[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n[honcho]\nurl = \"{url}\"\nagent = \"boxmaker0\"\nowner = \"kyle\"\ntrusted = \"boxmaker0-trusted\"\nuntrusted = \"boxmaker0-untrusted\"\nfleet = \"fleet\"\n{extra}", home.path().display(), soul.display() ) } #[test] fn memoryd_sends_a_reported_turn_at_once() { let fake = FakeHoncho::start(); let dir = TempDir::new("worker-serve"); turns::write_turns(dir.path(), "mm-a", &[turn("a", "kyle", "a")]); let config = Config::parse(&serve_config(&dir, &fake.url, "")).unwrap(); let _brokerd = FakeBrokerd::start(&config.state_socket(), Answer::State(clean())); let lines = Arc::new(Mutex::new(Vec::new())); let sink = Arc::clone(&lines); let log: serve::Log = Arc::new(move |l: &str| sink.lock().unwrap().push(l.to_string())); let running = serve::start(config.clone(), log).unwrap(); std::thread::spawn(move || running.run()); // The worker has slept since start (30 s when idle): the report wakes it. std::thread::sleep(Duration::from_millis(100)); let mut stream = std::os::unix::net::UnixStream::connect(config.loop_socket()).unwrap(); stream .set_read_timeout(Some(Duration::from_secs(10))) .unwrap(); let report = Envelope { v: 1, id: 1, r#final: true, msg: Message::SessionUpdated(SessionUpdated { session: SessionId::new("mm-a").unwrap(), turn: 1, }), }; write_frame(&mut stream, &report).unwrap(); assert!(matches!( read_frame(&mut stream).unwrap().msg, Message::Ok(_) )); let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline && messages(&fake) == 0 { std::thread::sleep(Duration::from_millis(10)); } assert_eq!(messages(&fake), 1, "{:?}", lines.lock().unwrap()); } #[test] fn a_key_that_cannot_be_read_stops_the_start() { let fake = FakeHoncho::start(); let dir = TempDir::new("worker-key-start"); let text = serve_config( &dir, &fake.url, "[secrets.honcho_trusted]\nenv = \"MEMORYD_TEST_UNSET_KEY\"\n", ); let config = Config::parse(&text).unwrap(); let log: serve::Log = Arc::new(|_: &str| {}); let err = match serve::start(config.clone(), log) { Err(err) => err, Ok(_) => panic!("memoryd started"), }; assert!(matches!(err, StartError::Secret(_)), "{err:?}"); assert!(!config.loop_socket().exists(), "a socket was bound"); // The process: exit 1 with the secret's own lines and pointer, not the start pointer. let path = dir.write("memoryd.toml", &text); let mut child = Daemon( Command::new(env!("CARGO_BIN_EXE_memoryd")) .args(["serve", "--config"]) .arg(&path) .env_remove("MEMORYD_TEST_UNSET_KEY") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) .spawn() .unwrap(), ); let deadline = Instant::now() + Duration::from_secs(10); let mut code = None; while Instant::now() < deadline { if let Ok(Some(status)) = child.0.try_wait() { code = status.code(); break; } std::thread::sleep(Duration::from_millis(20)); } let _ = child.0.kill(); let _ = child.0.wait(); let mut stderr = String::new(); if let Some(mut pipe) = child.0.stderr.take() { let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr); } assert_eq!(code, Some(1), "{stderr}"); assert!( stderr.starts_with("memoryd: secret honcho_trusted: "), "{stderr}" ); assert!( stderr.ends_with("\nsee docs/runbook.md#secret-unavailable\n"), "{stderr}" ); assert!(!stderr.contains("memoryd-start-failed"), "{stderr}"); } /// A `memoryd` process, killed and waited for when dropped. struct Daemon(std::process::Child); impl Drop for Daemon { fn drop(&mut self) { let _ = self.0.kill(); let _ = self.0.wait(); } }