From 5f4e0d9997148169925e15349116473b0c731932 Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Mon, 28 Sep 2026 13:40:48 -0700 Subject: [PATCH] memoryd: sync begins when Honcho is turned on, and conclusions are matched by peer Co-Authored-By: ornith-1.5-35b-a3b --- crates/memoryd/src/honcho/decide.rs | 14 +- crates/memoryd/src/honcho/ledger.rs | 60 +++++++- crates/memoryd/src/honcho/mirror.rs | 1 + crates/memoryd/src/honcho/start.rs | 18 ++- crates/memoryd/src/honcho/turns.rs | 3 +- crates/memoryd/src/honcho/worker.rs | 11 +- crates/memoryd/src/serve.rs | 7 +- crates/memoryd/tests/honcho_began_serve.rs | 133 +++++++++++++++++ crates/memoryd/tests/honcho_decide.rs | 15 +- crates/memoryd/tests/honcho_decide_began.rs | 110 ++++++++++++++ crates/memoryd/tests/honcho_edits.rs | 67 ++++++++- crates/memoryd/tests/honcho_ledger.rs | 30 ++-- crates/memoryd/tests/honcho_ledger_began.rs | 152 ++++++++++++++++++++ crates/memoryd/tests/honcho_ledger_file.rs | 41 ++++-- crates/memoryd/tests/honcho_mirror.rs | 33 +++-- crates/memoryd/tests/honcho_mirror_serve.rs | 2 + crates/memoryd/tests/honcho_naming.rs | 6 + crates/memoryd/tests/honcho_serve.rs | 25 +++- crates/memoryd/tests/honcho_sync.rs | 1 + crates/memoryd/tests/honcho_turns.rs | 12 +- crates/memoryd/tests/honcho_worker_more.rs | 6 +- crates/memoryd/tests/support/ledger.rs | 20 +++ crates/memoryd/tests/support/turns.rs | 12 +- crates/memoryd/tests/support/worker.rs | 20 ++- docs/implementer-log.md | 1 + 25 files changed, 720 insertions(+), 80 deletions(-) create mode 100644 crates/memoryd/tests/honcho_began_serve.rs create mode 100644 crates/memoryd/tests/honcho_decide_began.rs create mode 100644 crates/memoryd/tests/honcho_ledger_began.rs diff --git a/crates/memoryd/src/honcho/decide.rs b/crates/memoryd/src/honcho/decide.rs index b9af94f..5c15b45 100644 --- a/crates/memoryd/src/honcho/decide.rs +++ b/crates/memoryd/src/honcho/decide.rs @@ -3,12 +3,13 @@ //! and the session's log are given, and nothing is read or written here. use bxcore::sanitize::refuse; -use bxcore::{DataClass, LogRecord, SessionStateReply}; +use bxcore::{DataClass, LogRecord, SessionStateReply, Timestamp}; use crate::honcho::config::is_name; use crate::honcho::ledger::{Action, Workspace}; /// Why a turn goes nowhere: the `why` of its `Action::Skip`. +pub const BEFORE_SYNC: &str = "the turn is from before sync began"; pub const SECRET_CLASS: &str = "the session is secret"; pub const NO_SPEAKER: &str = "the turn has no single speaker"; pub const BAD_SPEAKER: &str = "the speaker is not a Honcho peer name"; @@ -135,8 +136,9 @@ fn labelled_untrusted(records: &[LogRecord]) -> bool { } /// Where turn `turn` of the session whose log is `records` goes. `state` is `brokerd`'s answer -/// for the session; `agent` is `[honcho] agent`. The checks run in this order, and the first that -/// holds decides: the class `secret`; no speaker; a speaker that is not a peer name, or is the +/// for the session; `agent` is `[honcho] agent`; `began` is when sync began (the ledger's +/// `Began`). The checks run in this order, and the first that holds decides: the turn is from +/// before `began`; the class `secret`; no speaker; a speaker that is not a peer name, or is the /// agent's; no reply; a secret in the user's text or the reply. Otherwise the turn goes to the /// untrusted workspace if `brokerd` or any tool result up to the turn's end says untrusted, else /// to the trusted one. @@ -145,6 +147,7 @@ pub fn decide( records: &[LogRecord], turn: u64, agent: &str, + began: Option, ) -> Result { let missing = NoSuchTurn { turn, @@ -159,6 +162,11 @@ pub fn decide( }) }; + if let (Some(began), Some(LogRecord::User { time, .. })) = (began, this.first()) + && *time < began + { + return skip(BEFORE_SYNC); + } if state.class == DataClass::Secret { return skip(SECRET_CLASS); } diff --git a/crates/memoryd/src/honcho/ledger.rs b/crates/memoryd/src/honcho/ledger.rs index 70bca81..5b399a5 100644 --- a/crates/memoryd/src/honcho/ledger.rs +++ b/crates/memoryd/src/honcho/ledger.rs @@ -50,7 +50,11 @@ pub enum Action { text: String, }, /// Delete the conclusion that a sent `Conclude` with this text created. - Unconclude { workspace: Workspace, text: String }, + Unconclude { + workspace: Workspace, + peer: String, + text: String, + }, /// A message from the agent's peer in the fleet workspace. Fleet { session: SessionId, text: String }, /// `retracted: `, from the same peer in the same workspace. @@ -63,6 +67,9 @@ pub enum Action { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum Record { + /// When sync began: always the ledger's first line, written once, by `memoryd` at its first + /// start with `[honcho]`. A turn from before it is not synced. + Began { time: Timestamp }, Decided { time: Timestamp, item: Item, @@ -115,6 +122,10 @@ pub enum LedgerError { Encode(serde_json::Error), AlreadyDecided(Item), NotWaiting(Item), + /// A `Began` after other records, or a second one. + BeganNotFirst, + /// A ledger whose first line is not its `Began`. + NotBegun, } impl std::fmt::Display for LedgerError { @@ -129,6 +140,8 @@ impl std::fmt::Display for LedgerError { LedgerError::NotWaiting(item) => { write!(f, "{}: not waiting to be sent", label(item)) } + LedgerError::BeganNotFirst => write!(f, "sync began, but not on the first line"), + LedgerError::NotBegun => write!(f, "the first line is not when sync began"), } } } @@ -165,6 +178,10 @@ pub struct Ledger { sends: Vec, /// Every failed item, in the order of its `Failed` record. fails: Vec, + /// When sync began: the `Began` record's time. + began: Option, + /// How many records the ledger holds. + records: usize, repaired: bool, } @@ -179,6 +196,8 @@ impl Ledger { entries: HashMap::new(), sends: Vec::new(), fails: Vec::new(), + began: None, + records: 0, repaired: false, }; let Some(bytes) = read(path)? else { @@ -188,7 +207,13 @@ impl Ledger { let lines = bytes.get(..whole).unwrap_or_default(); for (index, line) in lines.split_inclusive(|b| *b == b'\n').enumerate() { let record = parse(path, index + 1, line)?; - if let Err(e) = ledger.check(&record) { + let first_not_began = index == 0 && !matches!(record, Record::Began { .. }); + let checked = if first_not_began { + Err(LedgerError::NotBegun) + } else { + ledger.check(&record) + }; + if let Err(e) = checked { return Err(LedgerError::Damaged { path: path.to_path_buf(), line: index + 1, @@ -219,6 +244,11 @@ impl Ledger { Ok(()) } + /// When sync began, once the ledger has its `Began` record. + pub fn began(&self) -> Option { + self.began + } + /// Whether `item` has its `Decided` record. pub fn is_decided(&self, item: &Item) -> bool { self.entries.contains_key(item) @@ -250,9 +280,14 @@ impl Ledger { } } - /// The latest sent `Conclude` of `text` in `workspace` that no sent `Unconclude` has undone: - /// its item and the conclusion's id. - pub fn conclusion(&self, workspace: Workspace, text: &str) -> Option<(Item, String)> { + /// The latest sent `Conclude` of `text` about `peer` in `workspace` that no sent `Unconclude` + /// of the same has undone: its item and the conclusion's id. + pub fn conclusion( + &self, + workspace: Workspace, + peer: &str, + text: &str, + ) -> Option<(Item, String)> { let mut live: Vec<(Item, String)> = Vec::new(); for item in &self.sends { let Some(entry) = self.entries.get(item) else { @@ -264,17 +299,19 @@ impl Ledger { match &entry.action { Action::Conclude { workspace: w, + peer: p, text: t, .. - } if *w == workspace && t == text => { + } if *w == workspace && p == peer && t == text => { if let Some(id) = ids.first() { live.push((item.clone(), id.clone())); } } Action::Unconclude { workspace: w, + peer: p, text: t, - } if *w == workspace && t == text => { + } if *w == workspace && p == peer && t == text => { live.pop(); } _ => {} @@ -319,6 +356,13 @@ impl Ledger { /// is waiting. fn check(&self, record: &Record) -> Result<(), LedgerError> { match record { + Record::Began { .. } => { + if self.records > 0 { + Err(LedgerError::BeganNotFirst) + } else { + Ok(()) + } + } Record::Decided { item, .. } => { if self.entries.contains_key(item) { Err(LedgerError::AlreadyDecided(item.clone())) @@ -336,7 +380,9 @@ impl Ledger { /// Take a checked record into the state. fn apply(&mut self, record: Record) { + self.records += 1; match record { + Record::Began { time } => self.began = Some(time), Record::Decided { item, action, .. } => { let state = match action { Action::Skip { .. } => State::Skipped, diff --git a/crates/memoryd/src/honcho/mirror.rs b/crates/memoryd/src/honcho/mirror.rs index 6acd916..a593ddf 100644 --- a/crates/memoryd/src/honcho/mirror.rs +++ b/crates/memoryd/src/honcho/mirror.rs @@ -96,6 +96,7 @@ fn identity_actions( .into_iter() .map(|text| Action::Unconclude { workspace: Workspace::Trusted, + peer: peer.to_string(), text, }) .collect(); diff --git a/crates/memoryd/src/honcho/start.rs b/crates/memoryd/src/honcho/start.rs index 40af6a8..f1ffae9 100644 --- a/crates/memoryd/src/honcho/start.rs +++ b/crates/memoryd/src/honcho/start.rs @@ -3,12 +3,12 @@ use std::sync::{Arc, Mutex}; -use bxcore::SessionId; +use bxcore::{SessionId, Timestamp}; use crate::config::Config; use crate::honcho::client::{Clients, Timeouts}; use crate::honcho::config::HonchoConfig; -use crate::honcho::ledger::Ledger; +use crate::honcho::ledger::{Ledger, Record}; use crate::honcho::mirror::Mirror; use crate::honcho::state; use crate::honcho::turns::{LEDGER, TurnSync}; @@ -25,6 +25,20 @@ pub struct Opened { } impl Opened { + /// The first time: sync begins now. The `Began` line is written and synced once `memory/` + /// exists and before any socket is bound, so every turn reported later is after it. + pub fn begin(&self) -> Result<(), StartError> { + let sync = self.sync.lock().unwrap_or_else(|p| p.into_inner()); + let mut ledger = sync.ledger(); + if ledger.began().is_none() { + let began = Record::Began { + time: Timestamp::now(), + }; + ledger.append(began).map_err(StartError::Ledger)?; + } + Ok(()) + } + /// The memory writes' side of the ledger (task 12). pub fn mirror(&self) -> Mirror { let ledger = self diff --git a/crates/memoryd/src/honcho/turns.rs b/crates/memoryd/src/honcho/turns.rs index 75f659b..5780708 100644 --- a/crates/memoryd/src/honcho/turns.rs +++ b/crates/memoryd/src/honcho/turns.rs @@ -218,7 +218,8 @@ impl TurnSync { return Walk::Stop; }; for turn in undecided { - let Ok(action) = decide(&state, &records, turn, &self.agent) else { + let began = self.ledger().began(); + let Ok(action) = decide(&state, &records, turn, &self.agent, began) else { continue; }; let record = Record::Decided { diff --git a/crates/memoryd/src/honcho/worker.rs b/crates/memoryd/src/honcho/worker.rs index af19a3b..19ae37a 100644 --- a/crates/memoryd/src/honcho/worker.rs +++ b/crates/memoryd/src/honcho/worker.rs @@ -345,10 +345,17 @@ impl Worker { text, }, ), - (Item::Edit { .. }, Action::Unconclude { workspace, text }) => ( + ( + Item::Edit { .. }, + Action::Unconclude { + workspace, + peer, + text, + }, + ) => ( workspace, Job::Unconclude { - id: ledger.conclusion(workspace, &text).map(|(_, id)| id), + id: ledger.conclusion(workspace, &peer, &text).map(|(_, id)| id), }, ), (Item::Edit { id, .. }, Action::Fleet { session, text }) => ( diff --git a/crates/memoryd/src/serve.rs b/crates/memoryd/src/serve.rs index 1d30488..2d4e48e 100644 --- a/crates/memoryd/src/serve.rs +++ b/crates/memoryd/src/serve.rs @@ -93,8 +93,8 @@ enum Which { } /// With `[honcho]`, read the ledger through and the keys first. Then create what is missing -/// under `memory/`, open the index and catch it up, bind the three sockets, and start the Honcho -/// worker. The error names what failed. +/// under `memory/` (and, the first time, write when sync began), open the index and catch it up, +/// bind the three sockets, and start the Honcho worker. The error names what failed. pub fn start(config: Config, log: Log) -> Result { let opened = match config.honcho_config()? { Some(honcho) => Some(worker::open(&config, &honcho, &log)?), @@ -102,6 +102,9 @@ pub fn start(config: Config, log: Log) -> Result { }; let store = Store::new(&config.memory_dir()); store.init(&config.memory.soul_starter)?; + if let Some(opened) = &opened { + opened.begin()?; + } let index = Index::open(&config.index_path(), config.index.max_class)?; let (_counts, problems) = index.catch_up(&config.sessions_dir(), &store.path("notes.md"))?; for problem in problems { diff --git a/crates/memoryd/tests/honcho_began_serve.rs b/crates/memoryd/tests/honcho_began_serve.rs new file mode 100644 index 0000000..366c577 --- /dev/null +++ b/crates/memoryd/tests/honcho_began_serve.rs @@ -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 { + 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)); +} diff --git a/crates/memoryd/tests/honcho_decide.rs b/crates/memoryd/tests/honcho_decide.rs index a007db2..a9c2298 100644 --- a/crates/memoryd/tests/honcho_decide.rs +++ b/crates/memoryd/tests/honcho_decide.rs @@ -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")]); diff --git a/crates/memoryd/tests/honcho_decide_began.rs b/crates/memoryd/tests/honcho_decide_began.rs new file mode 100644 index 0000000..a19a440 --- /dev/null +++ b/crates/memoryd/tests/honcho_decide_began.rs @@ -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 { + 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 { .. }) + )); +} diff --git a/crates/memoryd/tests/honcho_edits.rs b/crates/memoryd/tests/honcho_edits.rs index 7013297..90a69ca 100644 --- a/crates/memoryd/tests/honcho_edits.rs +++ b/crates/memoryd/tests/honcho_edits.rs @@ -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(); diff --git a/crates/memoryd/tests/honcho_ledger.rs b/crates/memoryd/tests/honcho_ledger.rs index 790453b..e100923 100644 --- a/crates/memoryd/tests/honcho_ledger.rs +++ b/crates/memoryd/tests/honcho_ledger.rs @@ -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] diff --git a/crates/memoryd/tests/honcho_ledger_began.rs b/crates/memoryd/tests/honcho_ledger_began.rs new file mode 100644 index 0000000..31a3f8a --- /dev/null +++ b/crates/memoryd/tests/honcho_ledger_began.rs @@ -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 + ); +} diff --git a/crates/memoryd/tests/honcho_ledger_file.rs b/crates/memoryd/tests/honcho_ledger_file.rs index 3c80e55..a592e71 100644 --- a/crates/memoryd/tests/honcho_ledger_file.rs +++ b/crates/memoryd/tests/honcho_ledger_file.rs @@ -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::() + ); 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(); diff --git a/crates/memoryd/tests/honcho_mirror.rs b/crates/memoryd/tests/honcho_mirror.rs index d0367ec..9821b27 100644 --- a/crates/memoryd/tests/honcho_mirror.rs +++ b/crates/memoryd/tests/honcho_mirror.rs @@ -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() } diff --git a/crates/memoryd/tests/honcho_mirror_serve.rs b/crates/memoryd/tests/honcho_mirror_serve.rs index 478fa00..2ac24c0 100644 --- a/crates/memoryd/tests/honcho_mirror_serve.rs +++ b/crates/memoryd/tests/honcho_mirror_serve.rs @@ -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(), } } diff --git a/crates/memoryd/tests/honcho_naming.rs b/crates/memoryd/tests/honcho_naming.rs index c0cc27f..e3d1d73 100644 --- a/crates/memoryd/tests/honcho_naming.rs +++ b/crates/memoryd/tests/honcho_naming.rs @@ -45,6 +45,12 @@ fn hs(name: &str, speakers: &[&str]) -> Option { 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 { diff --git a/crates/memoryd/tests/honcho_serve.rs b/crates/memoryd/tests/honcho_serve.rs index 34e0b99..46d23a9 100644 --- a/crates/memoryd/tests/honcho_serve.rs +++ b/crates/memoryd/tests/honcho_serve.rs @@ -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 diff --git a/crates/memoryd/tests/honcho_sync.rs b/crates/memoryd/tests/honcho_sync.rs index 546ba5e..24c3be2 100644 --- a/crates/memoryd/tests/honcho_sync.rs +++ b/crates/memoryd/tests/honcho_sync.rs @@ -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, diff --git a/crates/memoryd/tests/honcho_turns.rs b/crates/memoryd/tests/honcho_turns.rs index 8e6ba01..13579eb 100644 --- a/crates/memoryd/tests/honcho_turns.rs +++ b/crates/memoryd/tests/honcho_turns.rs @@ -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")); } diff --git a/crates/memoryd/tests/honcho_worker_more.rs b/crates/memoryd/tests/honcho_worker_more.rs index f79ddd0..f7b45f9 100644 --- a/crates/memoryd/tests/honcho_worker_more.rs +++ b/crates/memoryd/tests/honcho_worker_more.rs @@ -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); diff --git a/crates/memoryd/tests/support/ledger.rs b/crates/memoryd/tests/support/ledger.rs index 7ede28d..a05d4f2 100644 --- a/crates/memoryd/tests/support/ledger.rs +++ b/crates/memoryd/tests/support/ledger.rs @@ -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(), diff --git a/crates/memoryd/tests/support/turns.rs b/crates/memoryd/tests/support/turns.rs index c1a66bd..2fd8e9e 100644 --- a/crates/memoryd/tests/support/turns.rs +++ b/crates/memoryd/tests/support/turns.rs @@ -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), } diff --git a/crates/memoryd/tests/support/worker.rs b/crates/memoryd/tests/support/worker.rs index d39869c..42b5b22 100644 --- a/crates/memoryd/tests/support/worker.rs +++ b/crates/memoryd/tests/support/worker.rs @@ -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 { 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() } diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 3d20860..e275061 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5c1/15-memoryd-honcho-began | 2026-09-28 | done | 2 | pass | none | Copied the given tests from `docs/plans/M5c1/files/crates/memoryd/tests/`: the three new `honcho_ledger_began.rs` (5), `honcho_decide_began.rs` (4), `honcho_began_serve.rs` (2) and new versions of `honcho_ledger.rs`, `honcho_ledger_file.rs`, `honcho_decide.rs`, `honcho_turns.rs`, `honcho_naming.rs`, `honcho_serve.rs`, `honcho_mirror.rs`, `honcho_mirror_serve.rs`, `honcho_edits.rs`, `honcho_worker_more.rs`, `honcho_sync.rs` and the support `ledger.rs`, `turns.rs`, `worker.rs` (14 modified, 3 new). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed the 14 named targets not compiling: E0061 (`decide` 5 args, `conclusion` 3), E0432 (`BEFORE_SYNC`), E0559 (`Unconclude` no `peer`), E0599 (no `began`/`begin`/`BeganNotFirst`/`Began`); every other target compiled. Made the edits exactly as the task gives: L1-L13 in `ledger.rs` (`Unconclude.peer`; `Record::Began`; the `BeganNotFirst`/`NotBegun` errors and their two Display lines; the `began`/`records` fields and init, the `first_not_began` check in `open`, the new `began()`, `conclusion` by peer, `check` refusing a second `Began`, `apply` counting records and setting `began`); D1-D5 in `decide.rs` (`Timestamp` import, `BEFORE_SYNC`, the doc, the `began` parameter, the first check on the `User` record's time); T1 (`turns.rs` passing `ledger().began()`); M1 (`mirror.rs` `Unconclude`'s `peer`); W1 (`worker.rs` `conclusion` with `peer`); A1-A2 (`start.rs` imports and `Opened::begin`, writing `Began` once when `began().is_none()`); S1-S2 (`serve.rs` doc and `opened.begin()` after `store.init`, before the sockets). `cargo check -p memoryd` clean after all edits; `cargo test -p memoryd` every suite passes; the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1345 tests (1344 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately; the +13 over baseline is exactly the 11 in the three new files, 1 more in `honcho_mirror.rs`, 1 more in `honcho_edits.rs`). One `loopd` `pointers` test failed once in a manual full-workspace run under load (`6 passed; 1 failed`) but passes in isolation and in a clean run without my change, and both `make gate` runs printed `gate: ok`: it is a load-dependent flake, not this task (loopd is untouched, the edit is all in memoryd). | ornith-1.5-35b-a3b | | M5c1/14-bxctl-memory-sync | 2026-09-28 | done | 1 | pass | none | Copied the two given tests from `docs/plans/M5c1/files/`: `crates/memoryd/tests/honcho_sync.rs` (from `honcho_sync.rs-task14`) and `crates/bxctl/tests/cli_memory_sync.rs`, and the skeleton `crates/memoryd/src/honcho/report.rs`. Step 2 showed exactly the named failures: `honcho_sync` with 5 (E0432 for `memoryd::honcho::report`, E0432 for `...::ledger::Failure`/`label`, two E0599 for `failures`, one E0599 for the missing `Sync`); `cli_memory_sync` with 6 (all the missing `Sync`). Made X1 (`MemoryCommand::Sync`), B1-B3 (`memory sync` parsed as `Ask::Admin(MemoryCommand::Sync, String::new())`, the module doc), U1 (the usage line), L1-L9 (`Failure { item, time, status, kind }`; `State::Failed { time, status, kind }`; the `fails: Vec` field, kept in `apply` on every `Failed` record; `failures()` oldest first via `filter_map`; renamed private `describe` to public `label`); R1 (`use crate::honcho::report`; `Mirror::report` under the ledger's lock); H1 (`pub mod report;`); S1/S2 (`Sync` answered with `mirror.report()` or `NOT_CONFIGURED`). Copied the skeleton. The task's U1 code block rendered every line with one extra leading space, but the actual `USAGE` literal uses two spaces before a command and six before its explanation (as `cli::usage_explains_the_speaker_flag` enforces), so I matched those. `cargo check -p memoryd` clean after filling the body: `counts()` for the first line, then when `failures()` is non-empty push `"\nfailed, oldest first:"` and each `format!("\n {} {} {}", time.to_rfc3339(), label(&item), kind)` with ` (HTTP {status})` appended when `status` is `Some`. `cargo test` all pass: `honcho_sync` 4, `honcho_ledger` 12, `honcho_ledger_file` 12, `honcho_worker` 12, `cli_memory_sync` 4, every other suite unchanged. `grep -n 'MemoryCommand::Sync'` prints two lines, `git diff --stat -- crates/loopd crates/brokerd crates/gatewayd Cargo.toml Cargo.lock` prints nothing. `make gate` printed `gate: ok` with 1332 tests (1331 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/13-memoryd-honcho-edits | 2026-09-28 | done | 1 | pass | none | Copied the given files from `docs/plans/M5c1/files/`: the test `crates/memoryd/tests/honcho_edits.rs` (from `honcho_edits.rs-task13`), new versions of `crates/memoryd/tests/honcho_worker_more.rs` (from `honcho_worker_more.rs-task13`) and `tests/support/honcho.rs` (from `support/honcho.rs`), and the skeletons `crates/memoryd/src/honcho/edits.rs` and `crates/memoryd/src/honcho/start.rs` (given whole). Step 2 showed exactly the named failure: `honcho_edits` with one E0432 (`unresolved import memoryd::honcho::edits`); every other target compiled; `honcho_worker_more` 10, `honcho_client` 13, `honcho_worker` 12 passed. Made H1 (`pub mod edits;` after `pub mod decide;` in `honcho/mod.rs`), H2 (`pub mod start;` after `pub mod naming;`), C1 (`owner: String` field in `Clients`), C2 (`owner: config.owner.clone()` in `Clients::new`) and C3 (`ensure_memory_session`: ensure workspace, the agent's peer, the owner's, then the `_memory` session with both peers `observe_me(false) observe_others(false)`); copied the two skeletons. `cargo check -p memoryd` gave 22 warnings (six unused imports, sixteen unused variables, exactly the task named). Filled its five bodies in the order of the file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: `memory_session` (`format!("{}{MEMORY_SUFFIX}", session.as_str())`); `conclude` (`memory_session`, `ensure_memory_session`, `ConclusionCreate::new(neutralise(text), peer, peer).with_session_id(name)` in `ConclusionBatchCreate::new(vec![..])`, `create_conclusions` -> the ids); `unconclude` (`Some(id)` -> `delete_conclusion` then `vec![id]`, `None` -> `Vec::new()`); `fleet_batch` (`Map` metadata `agent`/`boxmaker_session`/`edit`, `parts(neutralise(text))` each `MessageCreate::new(part, agent)` with the metadata); `fleet` (`HonchoSession { name: session id, speakers: [] }`, `ensure_session(Fleet, ...)`, `add_messages` -> the ids). `cargo check` clean. Made W1 (imports: drop `Config`/`Timeouts`/`HonchoConfig`/`mirror`/`state`/`LEDGER`/`read_log`/`StartError`, add `edits`, re-export `start::{Opened, open, spawn}`); W2 (`Job` enum `Turn`/`Conclude`/`Unconclude`/`Fleet`, `Next` holds `item`/`workspace`/`job`); W3 (`label` naming a turn or a part); W4 and W5 (the two log lines use `label(&next.item)`); W6 (`next` matching every `(Item, Action)` pair into a `Job`, `send` dispatching and `send_turn` split out); W7 (the start code removed, it moves to `start.rs`). `cargo check -p memoryd` clean, `worker.rs` 486 lines. Step 6 all pass: `honcho_edits` 10, `honcho_worker_more` 10, `honcho_worker` 12, `honcho_client` 13, `honcho_keys` 3, `honcho_mirror_serve` 6, `honcho_serve` 6. Step 7: `fn open`/`fn spawn` both in `start.rs`, no `Action::Retract` in `worker.rs`, the other-crates `git diff --stat` empty. `cargo fmt --all`; `make gate` printed `gate: ok` with 1324 tests (1313 in `cargo test --workspace`, plus the 10 in `honcho_edits.rs` and the 1 ignored `end_to_end` the gate runs separately). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/12-memoryd-honcho-mirror | 2026-09-28 | done | 3 | pass | none | Copied the two given tests from `docs/plans/M5c1/files/crates/memoryd/tests/`: `honcho_mirror.rs` (from `honcho_mirror.rs-task12`) and `honcho_mirror_serve.rs` (from `honcho_mirror_serve.rs-task12`), and the skeleton `crates/memoryd/src/honcho/mirror.rs`. Step 2 showed exactly the named failures: `honcho_mirror` with one E0432 (`unresolved import memoryd::honcho::mirror`); `honcho_mirror_serve` 1 pass (`without_honcho_nothing_is_decided`) and 5 fail. Made H1 (`pub mod mirror;` after `pub mod ledger;` in `honcho/mod.rs`) and copied the skeleton; `cargo check` gave 24 warnings (unused `Kind` and `Workspace`, and the twenty unused variables the task named). Filled its seven bodies in the order of the file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: `diff` (`minus(before, after), minus(after, before)`); `minus` (a `left` of `b`'s refs, keep a `text` when `left.iter().position` is `None`, else `swap_remove` that occurrence then drop it); `notes_of` (`flat_map` `notes::parse` over `record.notes`); `session_of` (walk the `undoes` chain bound by `records.len()`, return `SessionId::new` when the session is not `OWNER`, else follow `undoes` and `None` when it or the id is missing); `conclusion` (`Conclude` in the trusted workspace with the session, or `Skip` with `NO_SESSION`); `identity_actions` (`moved` notes' texts filtered out of the diff's dropped side as `Unconclude`, the added side each `conclusion` about the owner for `user.md` else the agent); `note_actions` (`Skip` `flagged: ` when flagged, else each `noted` note, `Fleet` when untrusted with a session else `Skip` without one, else a conclusion about the agent). The `before` block for W2 (`use crate::honcho::naming::{HonchoSession, honcho_session}`) was rustfmt-ordered `{honcho_session, HonchoSession}` in the tree, so I anchored on that actual line. Made T1-T8 (the ledger's own `Arc>` in `TurnSync`, `ledger()` returning a `MutexGuard`, the new `shared_ledger()`, and every `self.ledger` call going through it), W1-W9 (the worker takes `self.ledger` and a `sessions` field of its own, `Opened` gains `owner` and `mirror()`), and S1-S7 (`Memoryd` gains `mirror: Option`, `start` builds it from `opened.mirror()`, and `memory` calls `self.mirror(Some(&id))` after `journal::record`, `admin` calls `self.mirror(None)` after an undo or accept). `cargo check` clean with no warning; `honcho_mirror` 8, `honcho_mirror_serve` 6, and every earlier suite pass. `make gate` printed `gate: ok` with 1314 tests (1300 before plus the 14 new). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |