memoryd: sync begins when Honcho is turned on, and conclusions are matched by peer

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 13:40:48 -07:00
co-authored by ornith-1.5-35b-a3b
parent fb2afa1979
commit 5f4e0d9997
25 changed files with 720 additions and 80 deletions
+133
View File
@@ -0,0 +1,133 @@
//! When sync began, as `memoryd` serves (M5c spec, section 5): the `began` line written once, at
//! the first start, before any socket is bound; a second start leaves it as it is; and a turn
//! logged before it, reported after, is skipped. Every wait has a deadline. 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::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use brokerd::{Answer, FakeBrokerd};
use bxcore::{Envelope, Message, SessionId, SessionUpdated, Timestamp, read_frame, write_frame};
use logs::assistant;
use memoryd::config::Config;
use memoryd::honcho::decide::BEFORE_SYNC;
use memoryd::honcho::ledger::{Action, Ledger, Record};
use memoryd::honcho::start;
use memoryd::serve;
use tmp::TempDir;
use turns::{clean, spoken_at, 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\"
";
fn config(dir: &TempDir) -> Config {
let soul = dir.write("etc/soul.md", "You are Boxmaker.\n");
let text = format!(
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n{SECTION}",
dir.path().display(),
soul.display()
);
Config::parse(&text).unwrap()
}
fn ledger(dir: &TempDir) -> PathBuf {
dir.path().join("memory/honcho.jsonl")
}
fn records(dir: &TempDir) -> Vec<Record> {
std::fs::read_to_string(ledger(dir))
.unwrap_or_default()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
#[test]
fn the_first_start_writes_when_sync_began_once() {
let dir = TempDir::new("began-start");
let config = config(&dir);
let before = Timestamp::now();
let log: serve::Log = Arc::new(|_: &str| {});
let running = serve::start(config.clone(), log).unwrap();
// `start` has returned: the sockets are bound, and the line is already on disk.
assert!(config.loop_socket().exists());
let began = match records(&dir).as_slice() {
[Record::Began { time }] => *time,
other => panic!("{other:?}"),
};
assert!(began >= before && began <= Timestamp::now());
drop(running);
// A start again (the ledger opened and begun as at start) leaves it as it is.
let honcho = config.honcho_config().unwrap().unwrap();
let log: serve::Log = Arc::new(|_: &str| {});
let opened = start::open(&config, &honcho, &log).unwrap();
opened.begin().unwrap();
assert_eq!(records(&dir), vec![Record::Began { time: began }]);
assert_eq!(Ledger::open(&ledger(&dir)).unwrap().began(), Some(began));
}
#[test]
fn a_turn_from_before_sync_began_is_skipped() {
let dir = TempDir::new("began-old");
let config = config(&dir);
let old = Timestamp::parse("2026-09-01T10:00:00.000Z").unwrap();
write_turns(
dir.path(),
"mm-a",
&[vec![
spoken_at(old, "hello", Some("kyle")),
assistant(2, Some("hi"), &[]),
]],
);
let _brokerd = FakeBrokerd::start(&config.state_socket(), Answer::State(clean()));
let log: serve::Log = Arc::new(|_: &str| {});
let running = serve::start(config.clone(), log).unwrap();
std::thread::spawn(move || running.run());
let mut stream = 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);
let skipped = Action::Skip {
why: BEFORE_SYNC.to_string(),
};
let decided = || {
records(&dir)
.into_iter()
.any(|r| matches!(&r, Record::Decided { action, .. } if *action == skipped))
};
while Instant::now() < deadline && !decided() {
std::thread::sleep(Duration::from_millis(10));
}
assert!(decided(), "{:?}", records(&dir));
}
+8 -7
View File
@@ -1,6 +1,7 @@
//! Where a finished turn goes in Honcho (M5c spec, sections 4 and 5): the trusted or untrusted
//! workspace, or nowhere and why, from `brokerd`'s answer and the session's log; the order of the
//! checks; and the helpers that find a turn in the log. Do not edit.
//! checks; and the helpers that find a turn in the log. With no time sync began (`None`); that
//! check is in `honcho_decide_began.rs`. Do not edit.
use bxcore::sanitize::TAGS;
use bxcore::{
@@ -134,7 +135,7 @@ fn skip(why: &str) -> Action {
/// `decide` for the last turn of `records`.
fn last(state: &SessionStateReply, records: &[LogRecord]) -> Action {
decide(state, records, turns(records), AGENT).unwrap()
decide(state, records, turns(records), AGENT, None).unwrap()
}
/// `n` characters of `A1b2`…, which the secret rules take for a key's run.
@@ -192,14 +193,14 @@ fn a_turn_not_in_the_log_is_an_error() {
turn("two", Some("kyle"), "b"),
]);
for n in [0, 3, u64::MAX] {
let err = decide(&clean(), &records, n, AGENT).unwrap_err();
let err = decide(&clean(), &records, n, AGENT, None).unwrap_err();
assert_eq!(err, NoSuchTurn { turn: n, turns: 2 });
}
assert_eq!(
NoSuchTurn { turn: 3, turns: 2 }.to_string(),
"turn 3 is not in the session's log, which has 2 turns"
);
let err = decide(&clean(), &[], 1, AGENT).unwrap_err();
let err = decide(&clean(), &[], 1, AGENT, None).unwrap_err();
assert_eq!(err, NoSuchTurn { turn: 1, turns: 0 });
}
@@ -251,11 +252,11 @@ fn a_label_after_the_turn_does_not_count() {
turn_with_tool(Some("kyle"), true),
]);
assert_eq!(
decide(&clean(), &records, 1, AGENT).unwrap(),
decide(&clean(), &records, 1, AGENT, None).unwrap(),
to(Workspace::Trusted, "kyle")
);
assert_eq!(
decide(&clean(), &records, 2, AGENT).unwrap(),
decide(&clean(), &records, 2, AGENT, None).unwrap(),
to(Workspace::Untrusted, "kyle")
);
}
@@ -305,7 +306,7 @@ fn a_speaker_with_the_agents_name_goes_nowhere() {
assert_eq!(last(&clean(), &records), skip(AGENT_SPEAKER));
let records = log(&[turn("hello", Some("box.maker"), "hi")]);
assert_eq!(
decide(&clean(), &records, 1, "box_maker").unwrap(),
decide(&clean(), &records, 1, "box_maker", None).unwrap(),
skip(AGENT_SPEAKER)
);
let records = log(&[turn("hello", Some("Boxmaker0"), "hi")]);
+110
View File
@@ -0,0 +1,110 @@
//! A turn from before sync began goes nowhere (M5c spec, section 5): the first check, before
//! every other, by the time of the turn's `User` record against the ledger's `Began`. Do not
//! edit.
use bxcore::{DataClass, LogRecord, SessionStateReply, Timestamp};
use memoryd::honcho::decide::{BEFORE_SYNC, SECRET_CLASS, decide};
use memoryd::honcho::ledger::{Action, Workspace};
const AGENT: &str = "boxmaker0";
fn at(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
/// One finished turn, its `User` record at `time`.
fn turn(time: &str, speaker: Option<&str>) -> Vec<LogRecord> {
vec![
LogRecord::User {
time: at(time),
content: "hello".to_string(),
speaker: speaker.map(str::to_string),
},
LogRecord::Assistant {
time: at(time),
content: Some("hi".to_string()),
reasoning_content: None,
tool_calls: Vec::new(),
},
]
}
fn state(class: DataClass) -> SessionStateReply {
SessionStateReply {
class,
untrusted: false,
}
}
fn skip(why: &str) -> Action {
Action::Skip {
why: why.to_string(),
}
}
const BEGAN: &str = "2026-09-28T10:00:00.000Z";
#[test]
fn the_text() {
assert_eq!(BEFORE_SYNC, "the turn is from before sync began");
}
#[test]
fn a_turn_from_before_sync_began_goes_nowhere() {
let began = Some(at(BEGAN));
let clean = state(DataClass::Private);
let before = turn("2026-09-28T09:59:59.999Z", Some("kyle"));
assert_eq!(
decide(&clean, &before, 1, AGENT, began),
Ok(skip(BEFORE_SYNC))
);
let same = turn(BEGAN, Some("kyle"));
let to_trusted = Action::Turn {
workspace: Workspace::Trusted,
speaker: "kyle".to_string(),
};
assert_eq!(
decide(&clean, &same, 1, AGENT, began),
Ok(to_trusted.clone())
);
let after = turn("2026-09-28T10:00:00.001Z", Some("kyle"));
assert_eq!(
decide(&clean, &after, 1, AGENT, began),
Ok(to_trusted.clone())
);
// Without a time sync began, no turn is too old.
assert_eq!(decide(&clean, &before, 1, AGENT, None), Ok(to_trusted));
}
#[test]
fn it_is_the_first_check() {
let began = Some(at(BEGAN));
// A turn from before that also breaks the class and the speaker rules: before-sync wins.
let before = turn("2026-09-28T09:00:00.000Z", None);
assert_eq!(
decide(&state(DataClass::Secret), &before, 1, AGENT, began),
Ok(skip(BEFORE_SYNC))
);
// The same turn after it: the next rule decides.
let after = turn("2026-09-28T11:00:00.000Z", None);
assert_eq!(
decide(&state(DataClass::Secret), &after, 1, AGENT, began),
Ok(skip(SECRET_CLASS))
);
}
#[test]
fn each_turn_by_its_own_time() {
let began = Some(at(BEGAN));
let mut records = turn("2026-09-28T09:00:00.000Z", Some("kyle"));
records.extend(turn("2026-09-28T11:00:00.000Z", Some("kyle")));
let clean = state(DataClass::Private);
assert_eq!(
decide(&clean, &records, 1, AGENT, began),
Ok(skip(BEFORE_SYNC))
);
assert!(matches!(
decide(&clean, &records, 2, AGENT, began),
Ok(Action::Turn { .. })
));
}
+64 -3
View File
@@ -49,9 +49,15 @@ fn conclude(peer: &str, text: &str) -> Action {
}
}
/// The deletion of the owner's conclusion `text` (a `user.md` entry dropped).
fn unconclude(text: &str) -> Action {
unconclude_about("kyle", text)
}
fn unconclude_about(peer: &str, text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
text: text.to_string(),
}
}
@@ -144,7 +150,8 @@ fn a_conclusion_goes_into_the_sessions_memory_session() {
);
let sync = home.sync.lock().unwrap();
assert_eq!(
sync.ledger().conclusion(Workspace::Trusted, "Prefers UTC."),
sync.ledger()
.conclusion(Workspace::Trusted, "kyle", "Prefers UTC."),
Some((edit("e1", 0), CONCLUSION.to_string()))
);
}
@@ -204,9 +211,12 @@ fn a_replace_deletes_then_makes_and_an_undo_reverses_it() {
Some(vec![CONCLUSION.to_string()])
);
let sync = home.sync.lock().unwrap();
assert_eq!(sync.ledger().conclusion(Workspace::Trusted, "new"), None);
assert_eq!(
sync.ledger().conclusion(Workspace::Trusted, "old"),
sync.ledger().conclusion(Workspace::Trusted, "kyle", "new"),
None
);
assert_eq!(
sync.ledger().conclusion(Workspace::Trusted, "kyle", "old"),
Some((edit("e3", 1), CONCLUSION.to_string()))
);
}
@@ -238,6 +248,57 @@ fn a_deletion_with_nothing_to_delete_is_sent_with_no_id() {
);
}
#[test]
fn the_same_text_about_another_peer_is_not_deleted() {
// "Uses tabs." is in user.md (about the owner) and in memory.md (about the agent); the
// user.md entry is removed: only the owner's conclusion goes.
let fake = FakeHoncho::start();
let home = Home::new("edits-peers");
decide(&home, edit("e1", 0), conclude("kyle", "Uses tabs."));
decide(&home, edit("e2", 0), conclude("boxmaker0", "Uses tabs."));
decide(&home, edit("e3", 0), unconclude_about("kyle", "Uses tabs."));
let mut w = home.worker(&fake, FAST);
for _ in 0..3 {
assert_eq!(w.step(), Step::Done);
}
let sync = home.sync.lock().unwrap();
assert_eq!(
sync.ledger()
.conclusion(Workspace::Trusted, "kyle", "Uses tabs."),
None
);
assert_eq!(
sync.ledger()
.conclusion(Workspace::Trusted, "boxmaker0", "Uses tabs."),
Some((edit("e2", 0), CONCLUSION.to_string()))
);
assert_eq!(
sent_ids(&home, &edit("e3", 0)),
Some(vec![CONCLUSION.to_string()])
);
// Nothing about the owner is left to delete for this text any more.
drop(sync);
decide(&home, edit("e4", 0), unconclude_about("kyle", "Uses tabs."));
fake.clear();
assert_eq!(w.step(), Step::Done);
assert!(fake.requests().is_empty(), "{:?}", fake.requests());
// The memory.md entry removed: the agent's conclusion goes.
decide(
&home,
edit("e5", 0),
unconclude_about("boxmaker0", "Uses tabs."),
);
assert_eq!(w.step(), Step::Done);
assert_eq!(fake.requests().len(), 1, "{:?}", fake.requests());
assert_eq!(fake.requests()[0].method, "DELETE");
let sync = home.sync.lock().unwrap();
assert_eq!(
sync.ledger()
.conclusion(Workspace::Trusted, "boxmaker0", "Uses tabs."),
None
);
}
#[test]
fn a_404_on_delete_is_done() {
let fake = FakeHoncho::start();
+15 -15
View File
@@ -41,7 +41,7 @@ fn examples() -> Vec<(Record, &'static str)> {
&edit("2026-09-28-0002", 1),
unconclude(Workspace::Trusted, "Kyle takes his coffee black."),
),
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0002","part":1},"action":{"type":"unconclude","workspace":"trusted","text":"Kyle takes his coffee black."}}"#,
r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0002","part":1},"action":{"type":"unconclude","workspace":"trusted","peer":"kyle","text":"Kyle takes his coffee black."}}"#,
),
(
decided(
@@ -329,11 +329,11 @@ fn a_conclusion_is_the_latest_one_not_undone() {
ledger
.append(decided(&first, conclude(Workspace::Trusted, text)))
.unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, text), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", text), None);
ledger.append(sent(&first, &["c-1"])).unwrap();
let found = |id: &str, item: &Item| Some((item.clone(), id.to_string()));
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
ledger.conclusion(Workspace::Trusted, "kyle", text),
found("c-1", &first)
);
ledger
@@ -341,26 +341,26 @@ fn a_conclusion_is_the_latest_one_not_undone() {
.unwrap();
ledger.append(sent(&second, &["c-2"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
ledger.conclusion(Workspace::Trusted, "kyle", text),
found("c-2", &second)
);
ledger
.append(decided(&undo1, unconclude(Workspace::Trusted, text)))
.unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
ledger.conclusion(Workspace::Trusted, "kyle", text),
found("c-2", &second)
);
ledger.append(sent(&undo1, &[])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, text),
ledger.conclusion(Workspace::Trusted, "kyle", text),
found("c-1", &first)
);
ledger
.append(decided(&undo2, unconclude(Workspace::Trusted, text)))
.unwrap();
ledger.append(sent(&undo2, &[])).unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, text), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", text), None);
// The latest is the one sent last, not the one decided last.
let (early, late) = (edit("2026-09-28-0005", 0), edit("2026-09-28-0006", 0));
for item in [&early, &late] {
@@ -371,7 +371,7 @@ fn a_conclusion_is_the_latest_one_not_undone() {
ledger.append(sent(&late, &["c-late"])).unwrap();
ledger.append(sent(&early, &["c-early"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, "y"),
ledger.conclusion(Workspace::Trusted, "kyle", "y"),
found("c-early", &early)
);
}
@@ -404,14 +404,14 @@ fn a_conclusion_is_found_only_by_its_workspace_and_text_once_sent() {
ledger.append(sent(&other, &["c-other"])).unwrap();
ledger.append(sent(&undo_elsewhere, &[])).unwrap();
let a = Some((sent_one.clone(), "c-a".to_string()));
assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), a);
assert_eq!(ledger.conclusion(Workspace::Trusted, "b"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "c"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "A"), None);
assert_eq!(ledger.conclusion(Workspace::Untrusted, "a"), None);
assert_eq!(ledger.conclusion(Workspace::Fleet, "a"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "a"), a);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "b"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "c"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "A"), None);
assert_eq!(ledger.conclusion(Workspace::Untrusted, "kyle", "a"), None);
assert_eq!(ledger.conclusion(Workspace::Fleet, "kyle", "a"), None);
ledger.append(sent(&undo_unsent, &[])).unwrap();
assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), None);
assert_eq!(ledger.conclusion(Workspace::Trusted, "kyle", "a"), None);
}
#[test]
+152
View File
@@ -0,0 +1,152 @@
//! When sync began (M5c spec, section 5): the ledger's first line, `began`, once; a ledger read
//! back that does not start with it, or has a second, is damaged; and conclusions matched by their
//! peer as well as their workspace and text. Do not edit.
#[path = "support/ledger.rs"]
mod ledger;
#[path = "support/tmp.rs"]
mod tmp;
use ledger::{
Home, TIME, began, began_line, conclude, decided, edit, line, sent, time, to_trusted, turn,
};
use memoryd::honcho::ledger::{Action, Ledger, LedgerError, Workspace};
const POINTER: &str = "; see docs/runbook.md#honcho-ledger-damaged";
#[test]
fn the_began_line() {
assert_eq!(
serde_json::to_string(&began()).unwrap(),
format!(r#"{{"type":"began","time":"{TIME}"}}"#)
);
assert_eq!(
began_line(),
format!("{{\"type\":\"began\",\"time\":\"{TIME}\"}}\n")
);
}
#[test]
fn began_is_kept_and_read_back() {
let home = Home::new("began-kept");
let fresh = home.open();
assert_eq!(fresh.began(), None);
let mut ledger = home.begun();
assert_eq!(ledger.began(), Some(time()));
ledger
.append(decided(&turn("chat-1", 1), to_trusted("kyle")))
.unwrap();
assert_eq!(home.text().lines().next(), Some(began_line().trim_end()));
let again = home.open();
assert_eq!(again.began(), Some(time()));
assert_eq!(again.counts().decided, 1, "began is no item");
}
#[test]
fn began_only_first_and_once() {
let home = Home::new("began-once");
let mut ledger = home.begun();
let err = ledger.append(began()).unwrap_err();
assert!(matches!(err, LedgerError::BeganNotFirst), "{err:?}");
assert_eq!(err.to_string(), "sync began, but not on the first line");
let home = Home::new("began-late");
let mut ledger = home.open();
ledger
.append(decided(&turn("chat-1", 1), to_trusted("kyle")))
.unwrap();
assert!(matches!(
ledger.append(began()),
Err(LedgerError::BeganNotFirst)
));
}
/// `open`'s error for `text`: its line and its display.
fn damaged(tag: &str, text: &str) -> (usize, String, Home) {
let home = Home::new(tag);
home.write(text);
let err = Ledger::open(&home.path).unwrap_err();
let shown = err.to_string();
match err {
LedgerError::Damaged { line, .. } => (line, shown, home),
other => panic!("not Damaged: {other:?}"),
}
}
#[test]
fn a_ledger_that_does_not_start_with_began_is_damaged() {
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
let (number, shown, home) = damaged("began-missing", &first);
assert_eq!(number, 1);
assert_eq!(
shown,
format!(
"{}:1: the first line is not when sync began{POINTER}",
home.path.display()
)
);
let twice = format!("{}{first}{}", began_line(), began_line());
let (number, shown, home) = damaged("began-twice", &twice);
assert_eq!(number, 3);
assert_eq!(
shown,
format!(
"{}:3: sync began, but not on the first line{POINTER}",
home.path.display()
)
);
// An empty ledger has no line to be wrong: it is empty.
let home = Home::new("began-empty");
home.write("");
assert_eq!(home.open().began(), None);
}
fn unconclude_about(peer: &str, text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
text: text.to_string(),
}
}
#[test]
fn a_conclusion_is_found_by_its_peer_too() {
let home = Home::new("began-peer");
let mut ledger = home.begun();
let (owner, agent, undo) = (edit("e1", 0), edit("e2", 0), edit("e3", 0));
ledger
.append(decided(&owner, conclude(Workspace::Trusted, "Uses tabs.")))
.unwrap();
let mut about_agent = conclude(Workspace::Trusted, "Uses tabs.");
if let Action::Conclude { peer, .. } = &mut about_agent {
*peer = "boxmaker0".to_string();
}
ledger.append(decided(&agent, about_agent)).unwrap();
ledger.append(sent(&owner, &["c-owner"])).unwrap();
ledger.append(sent(&agent, &["c-agent"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, "kyle", "Uses tabs."),
Some((owner.clone(), "c-owner".to_string()))
);
assert_eq!(
ledger.conclusion(Workspace::Trusted, "boxmaker0", "Uses tabs."),
Some((agent.clone(), "c-agent".to_string()))
);
// The owner's is undone: the agent's stays.
ledger
.append(decided(&undo, unconclude_about("kyle", "Uses tabs.")))
.unwrap();
ledger.append(sent(&undo, &["c-owner"])).unwrap();
assert_eq!(
ledger.conclusion(Workspace::Trusted, "kyle", "Uses tabs."),
None
);
assert_eq!(
ledger.conclusion(Workspace::Trusted, "boxmaker0", "Uses tabs."),
Some((agent, "c-agent".to_string()))
);
let again = home.open();
assert_eq!(
again.conclusion(Workspace::Trusted, "kyle", "Uses tabs."),
None
);
}
+26 -15
View File
@@ -1,6 +1,7 @@
//! The Honcho ledger's file (M5c spec, section 5): read through at start, a line cut off by a
//! crash dropped and cut from the file, a damaged line refused with its number and the runbook
//! pointer, the file's mode, and a failed write that changes nothing. Do not edit.
//! pointer, the file's mode, and a failed write that changes nothing. From M5c-1 task 15 every
//! ledger that is read back starts with when sync began. Do not edit.
#[path = "support/ledger.rs"]
mod ledger;
@@ -10,7 +11,8 @@ mod tmp;
use std::os::unix::fs::PermissionsExt;
use ledger::{
Home, conclude, decided, edit, failed, line, sent, skip, to_trusted, turn, unconclude,
Home, began_line, conclude, decided, edit, failed, line, sent, skip, to_trusted, turn,
unconclude,
};
use memoryd::honcho::ledger::{Counts, Item, Ledger, LedgerError, Workspace};
@@ -56,7 +58,7 @@ fn an_empty_file_is_an_empty_ledger() {
#[test]
fn reopening_reads_the_ledger_through() {
let home = Home::new("ledger-reopen");
let mut ledger = home.open();
let mut ledger = home.begun();
let (a, b, c, d, e) = (
turn("chat-1", 1),
turn("chat-1", 2),
@@ -81,7 +83,7 @@ fn reopening_reads_the_ledger_through() {
assert_eq!(again.counts(), ledger.counts());
assert_eq!(again.sent_ids(&c), Some(&["c-1".to_string()][..]));
assert_eq!(
again.conclusion(Workspace::Trusted, "x"),
again.conclusion(Workspace::Trusted, "kyle", "x"),
Some((c.clone(), "c-1".to_string()))
);
for item in [&a, &b, &c, &d, &e] {
@@ -111,7 +113,8 @@ fn reopening_reads_the_ledger_through() {
fn a_cut_off_last_line_is_dropped_and_cut_from_the_file() {
let (a, b, c) = (turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3));
let whole = format!(
"{}{}",
"{}{}{}",
began_line(),
line(&decided(&a, to_trusted("kyle"))),
line(&decided(&b, to_trusted("kyle")))
);
@@ -150,7 +153,11 @@ fn a_cut_off_first_line_leaves_an_empty_file() {
fn a_line_cut_inside_a_character_is_dropped_too() {
let home = Home::new("ledger-torn-utf8");
let a = edit("2026-09-28-0001", 0);
let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle")));
let first = format!(
"{}{}",
began_line(),
line(&decided(&turn("chat-1", 1), to_trusted("kyle")))
);
let second = line(&decided(&a, conclude(Workspace::Trusted, "Kyle's café")));
let at = second.find('é').unwrap() + 1;
let mut bytes = first.clone().into_bytes();
@@ -175,11 +182,11 @@ fn a_damaged_line_stops_the_ledger_with_its_number_and_the_pointer() {
] {
for tail in ["", "{\"type\":\"se"] {
let home = Home::new("ledger-damaged");
let text = format!("{first}{second}{third}{tail}");
let text = format!("{}{first}{second}{third}{tail}", began_line());
home.write(&text);
let (number, shown) = damaged(&home);
assert_eq!(number, 2, "{second}");
let start = format!("{}:2: ", home.path.display());
assert_eq!(number, 3, "{second}");
let start = format!("{}:3: ", home.path.display());
assert!(shown.starts_with(&start), "{shown}");
assert!(shown.contains(why), "{shown}");
assert!(shown.ends_with(POINTER), "{shown}");
@@ -194,17 +201,17 @@ fn a_line_that_breaks_the_rules_is_damage() {
let cases = [
(
vec![sent(&a, &["m"])],
1,
2,
"turn 1 of session chat-1: not waiting to be sent",
),
(
vec![decided(&a, to_trusted("kyle")), decided(&a, skip("again"))],
2,
3,
"turn 1 of session chat-1: decided already",
),
(
vec![decided(&b, skip("flagged")), failed(&b, Some(400))],
2,
3,
"part 2 of edit 2026-09-28-0001: not waiting to be sent",
),
(
@@ -214,13 +221,17 @@ fn a_line_that_breaks_the_rules_is_damage() {
decided(&b, to_trusted("kyle")),
failed(&a, None),
],
4,
5,
"turn 1 of session chat-1: not waiting to be sent",
),
];
for (records, number, why) in cases {
let home = Home::new("ledger-rules");
let text: String = records.iter().map(line).collect();
let text = format!(
"{}{}",
began_line(),
records.iter().map(line).collect::<String>()
);
home.write(&text);
let (got, shown) = damaged(&home);
assert_eq!(got, number, "{text}");
@@ -266,7 +277,7 @@ fn a_missing_directory_is_an_io_error_and_changes_nothing() {
#[test]
fn a_failed_write_changes_nothing_in_memory() {
let home = Home::new("ledger-failed-write");
let mut ledger = home.open();
let mut ledger = home.begun();
let (a, b) = (turn("chat-1", 1), turn("chat-1", 2));
ledger.append(decided(&a, to_trusted("kyle"))).unwrap();
let kept = home.text();
+25 -8
View File
@@ -69,9 +69,10 @@ fn conclude(peer: &str, text: &str) -> Action {
}
}
fn unconclude(text: &str) -> Action {
fn unconclude(peer: &str, text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
text: text.to_string(),
}
}
@@ -139,13 +140,16 @@ fn a_replace_deletes_the_old_then_makes_the_new() {
&["x", "old"],
&["x", "new"],
);
assert_eq!(of(&r), vec![unconclude("old"), conclude(OWNER_PEER, "new")]);
assert_eq!(
of(&r),
vec![unconclude(OWNER_PEER, "old"), conclude(OWNER_PEER, "new")]
);
}
#[test]
fn a_remove_deletes() {
let r = record("e1", "remove", Some("memory.md"), &["x", "gone"], &["x"]);
assert_eq!(of(&r), vec![unconclude("gone")]);
assert_eq!(of(&r), vec![unconclude(AGENT, "gone")]);
let reorder = record(
"e2",
"consolidate",
@@ -156,6 +160,15 @@ fn a_remove_deletes() {
assert!(of(&reorder).is_empty());
}
#[test]
fn a_deletion_names_the_peer_of_its_file() {
// The same text in both files: each file's removal deletes its own peer's conclusion.
let user = record("e1", "remove", Some("user.md"), &["Uses tabs."], &[]);
assert_eq!(of(&user), vec![unconclude(OWNER_PEER, "Uses tabs.")]);
let memory = record("e2", "remove", Some("memory.md"), &["Uses tabs."], &[]);
assert_eq!(of(&memory), vec![unconclude(AGENT, "Uses tabs.")]);
}
#[test]
fn a_consolidate_deletes_the_dropped_and_not_the_moved() {
let mut r = record(
@@ -172,8 +185,8 @@ fn a_consolidate_deletes_the_dropped_and_not_the_moved() {
assert_eq!(
of(&r),
vec![
unconclude("old"),
unconclude("dropped"),
unconclude(AGENT, "old"),
unconclude(AGENT, "dropped"),
conclude(AGENT, "rewritten"),
conclude(AGENT, "new"),
]
@@ -224,11 +237,15 @@ fn notes_by_trust_and_flag() {
fn an_undo_is_the_same_diff_backwards() {
// An undo's before is the file now, its after the undone edit's before.
let cases = [
(&["x", "added"][..], &["x"][..], vec![unconclude("added")]),
(
&["x", "added"][..],
&["x"][..],
vec![unconclude(OWNER_PEER, "added")],
),
(
&["x", "new"][..],
&["x", "old"][..],
vec![unconclude("new"), conclude(OWNER_PEER, "old")],
vec![unconclude(OWNER_PEER, "new"), conclude(OWNER_PEER, "old")],
),
(
&["x"][..],
@@ -268,7 +285,7 @@ fn an_owners_undo_carries_the_undone_edits_session() {
assert_eq!(
actions(&replace_undo, None, AGENT, OWNER_PEER),
vec![
unconclude("new"),
unconclude(OWNER_PEER, "new"),
Action::Skip {
why: NO_SESSION.to_string()
}
@@ -140,9 +140,11 @@ fn conclude(peer: &str, session: &str, text: &str) -> Action {
}
}
/// The deletion of the owner's conclusion `text`: every one these tests make is from `user.md`.
fn unconclude(text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
peer: "kyle".to_string(),
text: text.to_string(),
}
}
+6
View File
@@ -45,6 +45,12 @@ fn hs(name: &str, speakers: &[&str]) -> Option<HonchoSession> {
fn ledger(dir: &TempDir, decisions: &[(Item, Action)]) -> Ledger {
let path = dir.path().join("honcho.jsonl");
let mut ledger = Ledger::open(&path).unwrap();
// Every ledger begins with when sync began (M5c-1 task 15).
ledger
.append(Record::Began {
time: Timestamp::parse("2026-09-28T09:00:00.000Z").unwrap(),
})
.unwrap();
for (item, action) in decisions {
ledger
.append(Record::Decided {
+19 -6
View File
@@ -42,6 +42,9 @@ 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"}}"#;
@@ -142,10 +145,12 @@ fn a_reported_turn_is_decided_after_loopd_has_its_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 as `DECIDED`, but for its time, which is the decision's.
// 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(), 1, "{text}");
let mut got: serde_json::Value = serde_json::from_str(&text).unwrap();
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;
@@ -196,7 +201,11 @@ fn a_silent_brokerd_holds_up_neither_loopd_nor_memory() {
assert_eq!(outage.len(), 1, "{logged:?}");
let start = format!("memoryd: {}: ", home.config.state_socket().display());
assert!(outage[0].starts_with(&start), "{}", outage[0]);
assert_eq!(home.ledger_text(), "");
assert!(
!home.ledger_text().contains("decided"),
"{}",
home.ledger_text()
);
}
#[test]
@@ -267,7 +276,11 @@ fn a_damaged_ledger_stops_the_start() {
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!("{SKIPPED}\n{{\"type\":\"se")).unwrap();
std::fs::write(
home.ledger(),
format!("{BEGAN}\n{SKIPPED}\n{{\"type\":\"se"),
)
.unwrap();
let lines = home.serve();
assert_eq!(
*lines.lock().unwrap(),
@@ -276,7 +289,7 @@ fn a_cut_off_last_line_is_dropped_and_logged() {
home.ledger().display()
)]
);
assert_eq!(home.ledger_text(), format!("{SKIPPED}\n"));
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
+1
View File
@@ -48,6 +48,7 @@ fn to_trusted() -> Action {
/// 1 skipped.
fn mixed(dir: &TempDir) -> Ledger {
let mut l = Ledger::open(&dir.path().join("honcho.jsonl")).unwrap();
l.append(Record::Began { time: at(0) }).unwrap();
let decided = |item: Item, action: Action| Record::Decided {
time: at(0),
item,
+9 -3
View File
@@ -16,7 +16,7 @@ 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::ledger::{Action, Item, Ledger, Record, Workspace};
use memoryd::honcho::turns::{
LEDGER, LOG_RUNBOOK, TurnSync, decidable, finished, log_path, read_log,
};
@@ -39,7 +39,10 @@ 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();
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())),
@@ -283,7 +286,7 @@ fn nothing_undecided_asks_nothing() {
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);
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"));
@@ -356,6 +359,8 @@ 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();
@@ -368,6 +373,7 @@ fn a_ledger_that_cannot_be_written_stops_the_walk() {
}));
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"));
}
+5 -1
View File
@@ -156,7 +156,11 @@ fn a_retraction_waits() {
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();
std::fs::write(
home.ledger_path(),
format!("{}\n{retract}\n", worker::began_line()),
)
.unwrap();
home.restart();
let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Idle);
+20
View File
@@ -28,6 +28,14 @@ impl Home {
Ledger::open(&self.path).unwrap()
}
/// A new ledger that has begun (`began()`): what `memoryd` makes at its first start, and so
/// what a test that reads its ledger back needs (M5c-1 task 15).
pub fn begun(&self) -> Ledger {
let mut ledger = self.open();
ledger.append(began()).unwrap();
ledger
}
/// The file's bytes as text; "" when there is no file.
pub fn text(&self) -> String {
std::fs::read_to_string(&self.path).unwrap_or_default()
@@ -85,13 +93,25 @@ pub fn conclude(workspace: Workspace, text: &str) -> Action {
}
}
/// The deletion of the owner's (`kyle`'s) conclusion `text`, as `conclude` makes it.
pub fn unconclude(workspace: Workspace, text: &str) -> Action {
Action::Unconclude {
workspace,
peer: "kyle".to_string(),
text: text.to_string(),
}
}
/// When sync began, `TIME`.
pub fn began() -> Record {
Record::Began { time: time() }
}
/// `began()`'s line.
pub fn began_line() -> String {
line(&began())
}
pub fn decided(item: &Item, action: Action) -> Record {
Record::Decided {
time: time(),
+9 -3
View File
@@ -4,9 +4,9 @@
use std::path::Path;
use bxcore::{DataClass, LogRecord, SessionId, SessionStateReply};
use bxcore::{DataClass, LogRecord, SessionId, SessionStateReply, Timestamp};
use crate::logs::{assistant, at, result};
use crate::logs::{assistant, result};
pub fn sid(s: &str) -> SessionId {
SessionId::new(s).unwrap()
@@ -19,9 +19,15 @@ pub fn clean() -> SessionStateReply {
}
}
/// A speaker's message. Its time is the last one there is, so that it is after any time sync
/// began (M5c-1 task 15); `spoken_at` gives another.
pub fn spoken(content: &str, speaker: Option<&str>) -> LogRecord {
spoken_at(Timestamp::MAX, content, speaker)
}
pub fn spoken_at(time: Timestamp, content: &str, speaker: Option<&str>) -> LogRecord {
LogRecord::User {
time: at(1),
time,
content: content.to_string(),
speaker: speaker.map(str::to_string),
}
+18 -2
View File
@@ -28,6 +28,18 @@ pub const FAST: Tuning = Tuning {
idle: Duration::from_millis(1),
};
/// When the tests' ledgers began: before every turn they log (`spoken`).
pub const BEGAN: &str = "2026-09-28T09:00:00.000Z";
pub fn began() -> Timestamp {
Timestamp::parse(BEGAN).unwrap()
}
/// The ledger line of `BEGAN`, for a test that writes a ledger itself.
pub fn began_line() -> String {
format!(r#"{{"type":"began","time":"{BEGAN}"}}"#)
}
pub fn config(url: &str) -> HonchoConfig {
HonchoConfig {
url: url.to_string(),
@@ -50,12 +62,15 @@ pub struct Home {
}
impl Home {
/// A home whose ledger has begun, at `BEGAN`, as `memoryd` makes it at its first start.
pub fn new(tag: &str) -> Home {
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();
ledger.append(Record::Began { time: began() }).unwrap();
Home {
sync: Arc::new(Mutex::new(TurnSync::new(
Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap(),
ledger,
"boxmaker0",
&dir.path().join("sessions"),
))),
@@ -118,12 +133,13 @@ impl Home {
)
}
/// Every record of the ledger on disk, in order.
/// Every record of the ledger on disk but its `Began`, in order.
pub fn records(&self) -> Vec<Record> {
std::fs::read_to_string(self.ledger_path())
.unwrap_or_default()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.filter(|r| !matches!(r, Record::Began { .. }))
.collect()
}