Files

347 lines
12 KiB
Rust

//! `memoryd` deciding turns as it serves (M5c spec, section 5): `loopd` has its answer to
//! `session_updated` before `brokerd` is asked, and a silent `brokerd` holds up neither `loopd`
//! nor a memory call; without `[honcho]` nothing is asked or written; a damaged ledger stops the
//! start with its pointer, and a cut-off last line is dropped and logged. Every wait has a
//! deadline. (From M5c-1 task 11 a Honcho worker runs beside it; nothing here waits for it, and the
//! cut-off line is a skipped turn, so that it has nothing to send.) Do not edit.
#[path = "support/brokerd.rs"]
mod brokerd;
#[path = "support/logs.rs"]
mod logs;
#[path = "support/tmp.rs"]
mod tmp;
#[path = "support/turns.rs"]
mod turns;
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use brokerd::{Answer, FakeBrokerd};
use bxcore::{
CallId, DataClass, Envelope, MemoryCall, Message, SessionId, SessionUpdated, ToolRequest,
read_frame, write_frame,
};
use memoryd::config::Config;
use memoryd::honcho::ledger::LedgerError;
use memoryd::serve::{self, StartError};
use tmp::TempDir;
use turns::{clean, write_turns};
const SECTION: &str = "[honcho]
url = \"http://127.0.0.1:9\"
agent = \"boxmaker0\"
owner = \"kyle\"
trusted = \"boxmaker0-trusted\"
untrusted = \"boxmaker0-untrusted\"
fleet = \"fleet\"
";
const DECIDED: &str = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"mm-a","turn":1},"action":{"type":"turn","workspace":"trusted","speaker":"kyle"}}"#;
/// When sync began: every ledger's first line (M5c-1 task 15).
const BEGAN: &str = r#"{"type":"began","time":"2026-09-28T09:00:00.000Z"}"#;
/// A turn decided to go nowhere: the worker has nothing to send for it.
const SKIPPED: &str = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"mm-a","turn":1},"action":{"type":"skip","why":"the turn has no single speaker"}}"#;
struct Home {
config: Config,
text: String,
dir: TempDir,
}
impl Home {
fn new(tag: &str, honcho: bool) -> Home {
let dir = TempDir::new(tag);
let soul = dir.write("etc/soul.md", "You are Boxmaker.\n");
let text = format!(
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n{}",
dir.path().display(),
soul.display(),
if honcho { SECTION } else { "" }
);
Home {
config: Config::parse(&text).unwrap(),
text,
dir,
}
}
fn ledger(&self) -> PathBuf {
self.dir.path().join("memory/honcho.jsonl")
}
fn ledger_text(&self) -> String {
std::fs::read_to_string(self.ledger()).unwrap_or_default()
}
/// `serve::start`, run on a thread; its log lines are collected.
fn serve(&self) -> Arc<Mutex<Vec<String>>> {
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(self.config.clone(), log).unwrap();
std::thread::spawn(move || running.run());
lines
}
}
/// One request and its answer, with a 10 s limit; how long the answer took.
fn ask(socket: &Path, msg: Message) -> (Message, Duration) {
let started = Instant::now();
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let env = Envelope {
v: 1,
id: 7,
r#final: true,
msg,
};
write_frame(&mut stream, &env).unwrap();
let reply = read_frame(&mut stream).unwrap();
(reply.msg, started.elapsed())
}
fn updated(home: &Home, session: &str, turn: u64) -> (Message, Duration) {
let msg = Message::SessionUpdated(SessionUpdated {
session: SessionId::new(session).unwrap(),
turn,
});
ask(&home.config.loop_socket(), msg)
}
/// Waits up to 10 s for `done`; true if it came.
fn within_10s(done: impl Fn() -> bool) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if done() {
return true;
}
std::thread::sleep(Duration::from_millis(10));
}
false
}
#[test]
fn a_reported_turn_is_decided_after_loopd_has_its_answer() {
let home = Home::new("serve-decide", true);
write_turns(home.dir.path(), "mm-a", &[turns::finished(Some("kyle"))]);
write_turns(home.dir.path(), "mm-z", &[turns::finished(Some("kyle"))]);
let fake = FakeBrokerd::start(&home.config.state_socket(), Answer::State(clean()));
let _lines = home.serve();
// A report on another socket is refused, and decides nothing.
let wrong = Message::SessionUpdated(SessionUpdated {
session: SessionId::new("mm-z").unwrap(),
turn: 1,
});
let (answer, _) = ask(&home.config.admin_socket(), wrong);
assert!(matches!(answer, Message::Error(_)), "{answer:?}");
let (answer, _) = updated(&home, "mm-a", 1);
assert!(matches!(answer, Message::Ok(_)), "{answer:?}");
assert!(within_10s(|| home.ledger_text().contains("\"turn\":1}")));
// The line after the began line (M5c-1 task 15): as `DECIDED`, but for its time, which is the
// decision's.
let text = home.ledger_text();
assert_eq!(text.lines().count(), 2, "{text}");
assert!(text.starts_with(r#"{"type":"began","#), "{text}");
let mut got: serde_json::Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap();
let mut want: serde_json::Value = serde_json::from_str(DECIDED).unwrap();
got["time"] = serde_json::Value::Null;
want["time"] = serde_json::Value::Null;
assert_eq!(got, want);
assert_eq!(fake.seen().len(), 1);
}
#[test]
fn a_silent_brokerd_holds_up_neither_loopd_nor_memory() {
let home = Home::new("serve-silent", true);
write_turns(home.dir.path(), "mm-a", &[turns::finished(Some("kyle"))]);
write_turns(home.dir.path(), "mm-b", &[turns::finished(Some("kyle"))]);
let fake = FakeBrokerd::start(&home.config.state_socket(), Answer::Hang);
let lines = home.serve();
let (answer, took) = updated(&home, "mm-a", 1);
assert!(matches!(answer, Message::Ok(_)), "{answer:?}");
assert!(took < Duration::from_secs(1), "loopd waited {took:?}");
assert!(fake.wait_for(1), "brokerd was never asked");
// While the question hangs: another report, and a memory call.
let (answer, took) = updated(&home, "mm-b", 1);
assert!(matches!(answer, Message::Ok(_)), "{answer:?}");
assert!(took < Duration::from_secs(1), "loopd waited {took:?}");
let call = Message::MemoryCall(MemoryCall {
request: ToolRequest {
session: SessionId::new("mm-b").unwrap(),
call: CallId(1),
tool: "recall".to_string(),
arguments: r#"{"query":"hello"}"#.to_string(),
},
taint: DataClass::Private,
untrusted: false,
});
let (answer, took) = ask(&home.config.broker_socket(), call);
assert!(matches!(answer, Message::ToolResponse(_)), "{answer:?}");
assert!(
took < Duration::from_secs(1),
"the memory call waited {took:?}"
);
// After 5 s without a byte, the outage is logged once, and nothing is decided.
let pointer = "turns wait undecided; see docs/runbook.md#broker-state-unavailable";
assert!(within_10s(|| lines
.lock()
.unwrap()
.iter()
.any(|l| l.contains(pointer))));
let logged = lines.lock().unwrap().clone();
let outage: Vec<&String> = logged.iter().filter(|l| l.contains(pointer)).collect();
assert_eq!(outage.len(), 1, "{logged:?}");
let start = format!("memoryd: {}: ", home.config.state_socket().display());
assert!(outage[0].starts_with(&start), "{}", outage[0]);
assert!(
!home.ledger_text().contains("decided"),
"{}",
home.ledger_text()
);
}
#[test]
fn without_honcho_nothing_is_asked_or_written() {
let home = Home::new("serve-off", false);
write_turns(home.dir.path(), "mm-a", &[turns::finished(Some("kyle"))]);
let fake = FakeBrokerd::start(&home.config.state_socket(), Answer::State(clean()));
let lines = home.serve();
let (answer, _) = updated(&home, "mm-a", 1);
assert!(matches!(answer, Message::Ok(_)), "{answer:?}");
// A negative: nothing may happen, so there is nothing to wait for but a while.
std::thread::sleep(Duration::from_millis(300));
assert!(fake.seen().is_empty());
assert!(!home.ledger().exists());
assert!(
lines.lock().unwrap().is_empty(),
"{:?}",
lines.lock().unwrap()
);
}
#[test]
fn a_damaged_ledger_stops_the_start() {
let home = Home::new("serve-damaged", true);
std::fs::create_dir_all(home.dir.path().join("memory")).unwrap();
std::fs::write(home.ledger(), "not json\n").unwrap();
let log: serve::Log = Arc::new(|_: &str| {});
let err = match serve::start(home.config.clone(), log) {
Err(err) => err,
Ok(_) => panic!("memoryd started"),
};
assert!(
matches!(
err,
StartError::Ledger(LedgerError::Damaged { line: 1, .. })
),
"{err:?}"
);
let shown = err.to_string();
assert!(shown.starts_with(&format!("{}:1: ", home.ledger().display())));
assert!(
shown.ends_with("; see docs/runbook.md#honcho-ledger-damaged"),
"{shown}"
);
assert_eq!(shown.matches("see docs/runbook.md").count(), 1, "{shown}");
assert!(!home.config.loop_socket().exists(), "a socket was bound");
// A ledger that cannot be read is named with the same pointer.
std::fs::remove_file(home.ledger()).unwrap();
std::fs::create_dir(home.ledger()).unwrap();
let log: serve::Log = Arc::new(|_: &str| {});
let err = match serve::start(home.config.clone(), log) {
Err(err) => err,
Ok(_) => panic!("memoryd started"),
};
assert!(
matches!(err, StartError::Ledger(LedgerError::Io(..))),
"{err:?}"
);
let shown = err.to_string();
assert!(shown.starts_with(&format!("{}: ", home.ledger().display())));
assert!(
shown.ends_with("; see docs/runbook.md#honcho-ledger-damaged"),
"{shown}"
);
}
#[test]
fn a_cut_off_last_line_is_dropped_and_logged() {
let home = Home::new("serve-repaired", true);
std::fs::create_dir_all(home.dir.path().join("memory")).unwrap();
std::fs::write(
home.ledger(),
format!("{BEGAN}\n{SKIPPED}\n{{\"type\":\"se"),
)
.unwrap();
let lines = home.serve();
assert_eq!(
*lines.lock().unwrap(),
vec![format!(
"memoryd: {}: a last line cut off by a crash was dropped",
home.ledger().display()
)]
);
assert_eq!(home.ledger_text(), format!("{BEGAN}\n{SKIPPED}\n"));
}
/// A `memoryd` process, killed and waited for when dropped: declared after its `Home`, so it
/// drops first.
struct Daemon(std::process::Child);
impl Drop for Daemon {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn memoryd_exits_with_the_ledgers_line() {
let home = Home::new("serve-exit", true);
let config = home.dir.write("memoryd.toml", &home.text);
std::fs::create_dir_all(home.dir.path().join("memory")).unwrap();
std::fs::write(home.ledger(), "not json\n").unwrap();
let mut child = Daemon(
Command::new(env!("CARGO_BIN_EXE_memoryd"))
.args(["serve", "--config"])
.arg(&config)
.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}");
let start = format!("memoryd: {}:1: ", home.ledger().display());
assert!(stderr.starts_with(&start), "{stderr}");
assert!(
stderr.ends_with("; see docs/runbook.md#honcho-ledger-damaged\n"),
"{stderr}"
);
assert_eq!(stderr.lines().count(), 1, "{stderr}");
assert!(!home.config.loop_socket().exists(), "a socket was bound");
}