435 lines
16 KiB
Rust
435 lines
16 KiB
Rust
//! 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, Record, 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<Result<SessionStateReply, String>>,
|
|
asked: RefCell<Vec<String>>,
|
|
lines: RefCell<Vec<String>>,
|
|
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 mut ledger = Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap();
|
|
// Every ledger begins with when sync began (M5c-1 task 15), before these tests' turns.
|
|
let began = Timestamp::parse("2026-09-28T09:00:00.000Z").unwrap();
|
|
ledger.append(Record::Began { time: began }).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<LogRecord>]) {
|
|
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<Action> {
|
|
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<Action> {
|
|
Some(Action::Turn {
|
|
workspace,
|
|
speaker: speaker.to_string(),
|
|
})
|
|
}
|
|
|
|
fn skip(why: &str) -> Option<Action> {
|
|
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(), 2, "the began line and one decision");
|
|
// 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);
|
|
let kept = std::fs::read_to_string(&ledger).unwrap();
|
|
std::fs::remove_file(&ledger).unwrap();
|
|
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();
|
|
std::fs::write(&ledger, kept).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<LogRecord> {
|
|
(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));
|
|
}
|