330 lines
12 KiB
Rust
330 lines
12 KiB
Rust
//! 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;
|
|
//! a retraction waits (nothing decides one); 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<bxcore::LogRecord> {
|
|
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 a_retraction_waits() {
|
|
// Nothing decides a retraction any more (M5c spec, section 4, 2026-09-28): one in the
|
|
// ledger is neither sent nor failed.
|
|
let fake = FakeHoncho::start();
|
|
let mut home = Home::new("worker-later");
|
|
let retract = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"retract","session":"mm-a","text":"a note"}}"#;
|
|
std::fs::write(home.ledger_path(), format!("{retract}\n")).unwrap();
|
|
home.restart();
|
|
let mut w = home.worker(&fake, FAST);
|
|
assert_eq!(w.step(), Step::Idle);
|
|
assert_eq!(home.records().len(), 1, "the retraction 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();
|
|
}
|
|
}
|