diff --git a/crates/memoryd/src/honcho/ledger.rs b/crates/memoryd/src/honcho/ledger.rs new file mode 100644 index 0000000..2ca1467 --- /dev/null +++ b/crates/memoryd/src/honcho/ledger.rs @@ -0,0 +1,377 @@ +//! The Honcho ledger, `memory/honcho.jsonl`: one JSON line per event, appended and never rewritten +//! (M5c spec, section 5). A turn or a memory write is `decided` once, then `sent` or `failed` once. +//! What is decided and neither sent nor failed is the outbox; there is no other queue. `memoryd` +//! reads the ledger through at start, so nothing is decided or sent twice. + +use std::collections::HashMap; +use std::fs::OpenOptions; +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; + +use bxcore::{SessionId, Timestamp}; +use serde::{Deserialize, Serialize}; + +/// The runbook entry for a ledger that does not read through. +pub const RUNBOOK: &str = "see docs/runbook.md#honcho-ledger-damaged"; + +/// One of this agent's three workspaces. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Workspace { + Trusted, + Untrusted, + Fleet, +} + +/// What is sent: a finished turn of a session, or one part of a journalled memory edit. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum Item { + Turn { session: SessionId, turn: u64 }, + Edit { id: String, part: u32 }, +} + +/// Where an item goes, decided once. A turn's text is read from the session log when it is sent, +/// never stored here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum Action { + /// The turn's two messages, the speaker's and the agent's reply. + Turn { + workspace: Workspace, + speaker: String, + }, + /// A conclusion about `peer`, stamped with the Honcho session of `session`. + Conclude { + workspace: Workspace, + peer: String, + session: SessionId, + text: String, + }, + /// Delete the conclusion that a sent `Conclude` with this text created. + Unconclude { workspace: Workspace, 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. + Retract { session: SessionId, text: String }, + /// Nothing is sent, and why. + Skip { why: String }, +} + +/// One line of the ledger. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum Record { + Decided { + time: Timestamp, + item: Item, + action: Action, + }, + /// Honcho's ids for what the send created: a `Conclude` has exactly one. + Sent { + time: Timestamp, + item: Item, + ids: Vec, + }, + /// Honcho refused the send: its status, if it answered, and the kind of error. + Failed { + time: Timestamp, + item: Item, + status: Option, + kind: String, + }, +} + +/// How many items are in each state. `decided` counts every item, `Skip` included, so it is +/// always `sent + waiting + failed + skipped`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Counts { + pub decided: usize, + pub sent: usize, + pub waiting: usize, + pub failed: usize, + pub skipped: usize, +} + +#[derive(Debug)] +pub enum LedgerError { + Io(PathBuf, std::io::Error), + /// A complete line (numbered from 1) that does not parse, or breaks the ledger's rules. + Damaged { + path: PathBuf, + line: usize, + why: String, + }, + Encode(serde_json::Error), + AlreadyDecided(Item), + NotWaiting(Item), +} + +impl std::fmt::Display for LedgerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LedgerError::Io(path, err) => write!(f, "{}: {err}", path.display()), + LedgerError::Damaged { path, line, why } => { + write!(f, "{}:{line}: {why}; {RUNBOOK}", path.display()) + } + LedgerError::Encode(err) => write!(f, "a ledger record could not be encoded: {err}"), + LedgerError::AlreadyDecided(item) => write!(f, "{}: decided already", describe(item)), + LedgerError::NotWaiting(item) => { + write!(f, "{}: not waiting to be sent", describe(item)) + } + } + } +} + +impl std::error::Error for LedgerError {} + +/// Where a decided item stands. +#[derive(Debug, Clone, PartialEq, Eq)] +enum State { + Skipped, + Waiting, + Sent(Vec), + Failed, +} + +#[derive(Debug, Clone)] +struct Entry { + action: Action, + state: State, +} + +/// The ledger, read through: the file's path and the state its records add up to. +#[derive(Debug)] +pub struct Ledger { + path: PathBuf, + /// Every decided item, in the order of its `Decided` record. + order: Vec, + entries: HashMap, + /// Every sent item, in the order of its `Sent` record. + sends: Vec, + repaired: bool, +} + +impl Ledger { + /// Read the ledger at `path` through. A missing file is an empty ledger, and nothing is + /// created. A last line without its newline, cut off by a crash, is dropped and cut from the + /// file; any other line that does not read is `Damaged`, and the file is left as it is. + pub fn open(path: &Path) -> Result { + let mut ledger = Ledger { + path: path.to_path_buf(), + order: Vec::new(), + entries: HashMap::new(), + sends: Vec::new(), + repaired: false, + }; + let Some(bytes) = read(path)? else { + return Ok(ledger); + }; + let whole = complete(&bytes); + 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) { + return Err(LedgerError::Damaged { + path: path.to_path_buf(), + line: index + 1, + why: e.to_string(), + }); + } + ledger.apply(record); + } + if whole < bytes.len() { + cut(path, whole)?; + ledger.repaired = true; + } + Ok(ledger) + } + + /// Whether `open` dropped a cut-off last line. + pub fn repaired(&self) -> bool { + self.repaired + } + + /// Append `record` to the file and sync it, and only then take it into the state. A record + /// the rules refuse, or a failed write, changes nothing in memory. + pub fn append(&mut self, record: Record) -> Result<(), LedgerError> { + self.check(&record)?; + let line = serde_json::to_string(&record).map_err(LedgerError::Encode)?; + self.write_line(&line)?; + self.apply(record); + Ok(()) + } + + /// Whether `item` has its `Decided` record. + pub fn is_decided(&self, item: &Item) -> bool { + self.entries.contains_key(item) + } + + /// The items waiting to be sent, with their actions, in the order they were decided. + pub fn outbox(&self) -> Vec<(Item, Action)> { + self.order + .iter() + .filter_map(|item| { + let entry = self.entries.get(item)?; + (entry.state == State::Waiting).then(|| (item.clone(), entry.action.clone())) + }) + .collect() + } + + /// The ids of `item`'s `Sent` record, if it has one. + pub fn sent_ids(&self, item: &Item) -> Option<&[String]> { + match &self.entries.get(item)?.state { + State::Sent(ids) => Some(ids.as_slice()), + _ => None, + } + } + + /// 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)> { + let mut live: Vec<(Item, String)> = Vec::new(); + for item in &self.sends { + let Some(entry) = self.entries.get(item) else { + continue; + }; + let State::Sent(ids) = &entry.state else { + continue; + }; + match &entry.action { + Action::Conclude { + workspace: w, + text: t, + .. + } if *w == workspace && t == text => { + if let Some(id) = ids.first() { + live.push((item.clone(), id.clone())); + } + } + Action::Unconclude { + workspace: w, + text: t, + } if *w == workspace && t == text => { + live.pop(); + } + _ => {} + } + } + live.pop() + } + + /// How many items are in each state. + pub fn counts(&self) -> Counts { + let mut counts = Counts::default(); + for entry in self.entries.values() { + counts.decided += 1; + match &entry.state { + State::Skipped => counts.skipped += 1, + State::Waiting => counts.waiting += 1, + State::Sent(_) => counts.sent += 1, + State::Failed => counts.failed += 1, + } + } + counts + } + + /// The ledger's rules: an item is decided once; it is sent or failed once, and only while it + /// is waiting. + fn check(&self, record: &Record) -> Result<(), LedgerError> { + match record { + Record::Decided { item, .. } => { + if self.entries.contains_key(item) { + Err(LedgerError::AlreadyDecided(item.clone())) + } else { + Ok(()) + } + } + Record::Sent { item, .. } | Record::Failed { item, .. } => match self.entries.get(item) + { + Some(entry) if entry.state == State::Waiting => Ok(()), + _ => Err(LedgerError::NotWaiting(item.clone())), + }, + } + } + + /// Take a checked record into the state. + fn apply(&mut self, record: Record) { + match record { + Record::Decided { item, action, .. } => { + let state = match action { + Action::Skip { .. } => State::Skipped, + _ => State::Waiting, + }; + self.order.push(item.clone()); + self.entries.insert(item, Entry { action, state }); + } + Record::Sent { item, ids, .. } => { + if let Some(entry) = self.entries.get_mut(&item) { + entry.state = State::Sent(ids); + } + self.sends.push(item); + } + Record::Failed { item, .. } => { + if let Some(entry) = self.entries.get_mut(&item) { + entry.state = State::Failed; + } + } + } + } + + /// Append `line` and a newline to the file in one write, then sync the data. + fn write_line(&self, line: &str) -> Result<(), LedgerError> { + let io = |e| LedgerError::Io(self.path.clone(), e); + let mut file = OpenOptions::new() + .append(true) + .create(true) + .mode(0o600) + .open(&self.path) + .map_err(io)?; + file.write_all(format!("{line}\n").as_bytes()).map_err(io)?; + file.sync_data().map_err(io) + } +} + +/// The file's bytes, or `None` when there is no file. +fn read(path: &Path) -> Result>, LedgerError> { + match std::fs::read(path) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(LedgerError::Io(path.to_path_buf(), e)), + } +} + +/// How many bytes the complete lines take: up to and including the last newline. +fn complete(bytes: &[u8]) -> usize { + match bytes.iter().rposition(|b| *b == b'\n') { + Some(last) => last + 1, + None => 0, + } +} + +/// Line `number` (from 1), with its newline, as a record. +fn parse(path: &Path, number: usize, line: &[u8]) -> Result { + serde_json::from_slice(line).map_err(|e| LedgerError::Damaged { + path: path.to_path_buf(), + line: number, + why: e.to_string(), + }) +} + +/// Cut the file to its first `keep` bytes, and sync it. +fn cut(path: &Path, keep: usize) -> Result<(), LedgerError> { + let io = |e| LedgerError::Io(path.to_path_buf(), e); + let len = u64::try_from(keep).map_err(|e| io(std::io::Error::other(e)))?; + let file = OpenOptions::new().write(true).open(path).map_err(io)?; + file.set_len(len).map_err(io)?; + file.sync_data().map_err(io) +} + +/// An item as the error messages name it. +fn describe(item: &Item) -> String { + match item { + Item::Turn { session, turn } => format!("turn {turn} of session {}", session.as_str()), + Item::Edit { id, part } => format!("part {part} of edit {id}"), + } +} diff --git a/crates/memoryd/src/honcho/mod.rs b/crates/memoryd/src/honcho/mod.rs index c9c063e..0af89a5 100644 --- a/crates/memoryd/src/honcho/mod.rs +++ b/crates/memoryd/src/honcho/mod.rs @@ -1,3 +1,4 @@ //! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec). pub mod config; +pub mod ledger; diff --git a/crates/memoryd/tests/honcho_ledger.rs b/crates/memoryd/tests/honcho_ledger.rs new file mode 100644 index 0000000..790453b --- /dev/null +++ b/crates/memoryd/tests/honcho_ledger.rs @@ -0,0 +1,451 @@ +//! The Honcho ledger, `memory/honcho.jsonl` (M5c spec, section 5): its lines, exactly, and the +//! rules of `append`, the outbox, the sent ids, the live conclusions and the counts. Reading the +//! file back is in `honcho_ledger_file.rs`. Do not edit. + +#[path = "support/ledger.rs"] +mod ledger; +#[path = "support/tmp.rs"] +mod tmp; + +use ledger::{ + Home, conclude, decided, edit, failed, sent, sid, skip, to_trusted, turn, unconclude, +}; +use memoryd::honcho::ledger::{Action, Counts, Item, LedgerError, Record, Workspace}; + +/// One record of each kind, and of each action, with its line exactly. +fn examples() -> Vec<(Record, &'static str)> { + vec![ + ( + decided(&turn("chat-1", 3), to_trusted("kyle")), + r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"action":{"type":"turn","workspace":"trusted","speaker":"kyle"}}"#, + ), + ( + decided( + &turn("chat-2", 1), + Action::Turn { + workspace: Workspace::Untrusted, + speaker: "ana_b".to_string(), + }, + ), + r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"action":{"type":"turn","workspace":"untrusted","speaker":"ana_b"}}"#, + ), + ( + decided( + &edit("2026-09-28-0001", 0), + conclude(Workspace::Trusted, "Kyle takes his coffee black."), + ), + r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"conclude","workspace":"trusted","peer":"kyle","session":"chat-1","text":"Kyle takes his coffee black."}}"#, + ), + ( + decided( + &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."}}"#, + ), + ( + decided( + &edit("2026-09-28-0003", 0), + Action::Fleet { + session: sid("chat-2"), + text: "The build host is \"straylight\".\nIt runs NixOS.".to_string(), + }, + ), + r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0003","part":0},"action":{"type":"fleet","session":"chat-2","text":"The build host is \"straylight\".\nIt runs NixOS."}}"#, + ), + ( + decided( + &edit("2026-09-28-0004", 0), + Action::Retract { + session: sid("chat-2"), + text: "The build host is titan.".to_string(), + }, + ), + r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0004","part":0},"action":{"type":"retract","session":"chat-2","text":"The build host is titan."}}"#, + ), + ( + decided(&turn("chat-3", 7), skip("the session is secret")), + r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-3","turn":7},"action":{"type":"skip","why":"the session is secret"}}"#, + ), + ( + sent(&turn("chat-1", 3), &["msg-a1", "msg-a2"]), + r#"{"type":"sent","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"ids":["msg-a1","msg-a2"]}"#, + ), + ( + failed(&edit("2026-09-28-0001", 0), Some(422)), + r#"{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"status":422,"kind":"refused"}"#, + ), + ( + failed(&turn("chat-2", 1), None), + r#"{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"status":null,"kind":"refused"}"#, + ), + ] +} + +#[test] +fn each_record_is_one_line_exactly_as_given() { + for (record, text) in examples() { + assert_eq!(serde_json::to_string(&record).unwrap(), text); + assert_eq!( + serde_json::from_str::(text).unwrap(), + record, + "{text}" + ); + } + for (workspace, text) in [ + (Workspace::Trusted, "\"trusted\""), + (Workspace::Untrusted, "\"untrusted\""), + (Workspace::Fleet, "\"fleet\""), + ] { + assert_eq!(serde_json::to_string(&workspace).unwrap(), text); + assert_eq!(serde_json::from_str::(text).unwrap(), workspace); + } +} + +#[test] +fn append_writes_the_line_and_a_newline() { + let home = Home::new("ledger-append"); + let mut ledger = home.open(); + let (first, second) = (examples().remove(0), examples().remove(7)); + ledger.append(first.0).unwrap(); + assert_eq!(home.text(), format!("{}\n", first.1)); + ledger.append(second.0).unwrap(); + assert_eq!(home.text(), format!("{}\n{}\n", first.1, second.1)); +} + +#[test] +fn an_unknown_field_or_value_is_refused() { + for (_, text) in examples() { + let mut broken = vec![format!("{},\"extra\":1}}", &text[..text.len() - 1])]; + broken.push(text.replacen("\"item\":{", "\"item\":{\"extra\":1,", 1)); + if text.contains("\"action\":{") { + broken.push(text.replacen("\"action\":{", "\"action\":{\"extra\":1,", 1)); + } + for line in broken { + let err = serde_json::from_str::(&line).unwrap_err(); + assert!( + err.to_string().contains("unknown field `extra`"), + "{line}: {err}" + ); + } + } + let (_, text) = examples().remove(0); + for (from, to) in [ + ("\"trusted\"", "\"shared\""), + ("\"type\":\"decided\"", "\"type\":\"queued\""), + ( + "\"type\":\"turn\",\"session\"", + "\"type\":\"call\",\"session\"", + ), + ("\"session\":\"chat-1\"", "\"session\":\"Chat 1\""), + ( + "\"time\":\"2026-09-28T10:00:00.000Z\"", + "\"time\":\"today\"", + ), + ] { + assert_eq!(text.matches(from).count(), 1, "{from}"); + let line = text.replace(from, to); + assert!(serde_json::from_str::(&line).is_err(), "{line}"); + } +} + +#[test] +fn the_outbox_is_what_is_decided_and_not_skipped_sent_or_failed() { + let home = Home::new("ledger-outbox"); + let mut ledger = home.open(); + let (a, b, c, d, e) = ( + turn("chat-1", 1), + turn("chat-1", 2), + edit("2026-09-28-0001", 0), + edit("2026-09-28-0002", 0), + turn("chat-1", 3), + ); + ledger.append(decided(&a, to_trusted("kyle"))).unwrap(); + ledger.append(decided(&b, skip("no speaker"))).unwrap(); + ledger + .append(decided(&c, conclude(Workspace::Trusted, "one"))) + .unwrap(); + ledger + .append(decided(&d, conclude(Workspace::Trusted, "two"))) + .unwrap(); + ledger.append(decided(&e, to_trusted("ana_b"))).unwrap(); + ledger.append(sent(&c, &["c-1"])).unwrap(); + ledger.append(failed(&d, Some(400))).unwrap(); + assert_eq!( + ledger.outbox(), + vec![ + (a.clone(), to_trusted("kyle")), + (e.clone(), to_trusted("ana_b")) + ] + ); + for item in [&a, &b, &c, &d, &e] { + assert!(ledger.is_decided(item), "{item:?}"); + } + assert!(!ledger.is_decided(&turn("chat-1", 4))); + assert!(!ledger.is_decided(&edit("2026-09-28-0001", 1))); +} + +#[test] +fn the_outbox_keeps_the_order_of_decision() { + let home = Home::new("ledger-order"); + let mut ledger = home.open(); + let items = [ + turn("chat-b", 2), + turn("chat-a", 9), + edit("2026-09-28-0002", 0), + turn("chat-b", 1), + edit("2026-09-28-0001", 3), + edit("2026-09-28-0001", 0), + ]; + for item in &items { + ledger.append(decided(item, skip("placeholder"))).unwrap(); + } + assert!(ledger.outbox().is_empty()); + let home = Home::new("ledger-order-2"); + let mut ledger = home.open(); + for item in &items { + ledger.append(decided(item, to_trusted("kyle"))).unwrap(); + } + let order: Vec = ledger.outbox().into_iter().map(|(item, _)| item).collect(); + assert_eq!(order, items.to_vec()); + ledger.append(sent(&items[1], &["m"])).unwrap(); + let order: Vec = ledger.outbox().into_iter().map(|(item, _)| item).collect(); + let mut left = items.to_vec(); + left.remove(1); + assert_eq!(order, left); +} + +#[test] +fn a_second_decision_is_refused_and_writes_nothing() { + let home = Home::new("ledger-twice"); + let mut ledger = home.open(); + let item = turn("chat-1", 1); + ledger.append(decided(&item, to_trusted("kyle"))).unwrap(); + let before = home.text(); + for action in [to_trusted("kyle"), skip("again")] { + let err = ledger.append(decided(&item, action)).unwrap_err(); + assert!( + matches!(&err, LedgerError::AlreadyDecided(i) if *i == item), + "{err:?}" + ); + assert_eq!(err.to_string(), "turn 1 of session chat-1: decided already"); + } + assert_eq!(home.text(), before); + assert_eq!(ledger.outbox(), vec![(item, to_trusted("kyle"))]); + assert_eq!(ledger.counts().decided, 1); +} + +#[test] +fn sent_or_failed_before_the_decision_is_refused() { + let home = Home::new("ledger-undecided"); + let mut ledger = home.open(); + let item = edit("2026-09-28-0001", 2); + for record in [sent(&item, &["x"]), failed(&item, Some(404))] { + let err = ledger.append(record).unwrap_err(); + assert!( + matches!(&err, LedgerError::NotWaiting(i) if *i == item), + "{err:?}" + ); + assert_eq!( + err.to_string(), + "part 2 of edit 2026-09-28-0001: not waiting to be sent" + ); + } + assert!(!home.path.exists(), "a refused append wrote the file"); + assert_eq!(ledger.counts(), Counts::default()); + assert_eq!(ledger.sent_ids(&item), None); +} + +#[test] +fn an_item_is_sent_or_failed_once_and_never_when_skipped() { + let home = Home::new("ledger-once"); + let mut ledger = home.open(); + let (s, f, k) = (turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3)); + ledger.append(decided(&s, to_trusted("kyle"))).unwrap(); + ledger.append(decided(&f, to_trusted("kyle"))).unwrap(); + ledger.append(decided(&k, skip("secret"))).unwrap(); + ledger.append(sent(&s, &["m1", "m2"])).unwrap(); + ledger.append(failed(&f, None)).unwrap(); + let before = home.text(); + for record in [ + sent(&s, &["m3"]), + failed(&s, Some(500)), + sent(&f, &["m4"]), + failed(&f, Some(400)), + sent(&k, &["m5"]), + failed(&k, None), + ] { + let err = ledger.append(record.clone()).unwrap_err(); + assert!( + matches!(err, LedgerError::NotWaiting(_)), + "{record:?}: {err:?}" + ); + } + assert_eq!(home.text(), before); + assert_eq!( + ledger.sent_ids(&s), + Some(&["m1".to_string(), "m2".to_string()][..]) + ); +} + +#[test] +fn sent_ids_are_those_of_the_sent_record() { + let home = Home::new("ledger-ids"); + let mut ledger = home.open(); + let (waiting, done, refused, skipped) = ( + turn("chat-1", 1), + turn("chat-1", 2), + turn("chat-1", 3), + turn("chat-1", 4), + ); + for item in [&waiting, &done, &refused] { + ledger.append(decided(item, to_trusted("kyle"))).unwrap(); + } + ledger + .append(decided(&skipped, skip("no speaker"))) + .unwrap(); + ledger.append(sent(&done, &["m-1", "m-2"])).unwrap(); + ledger.append(failed(&refused, Some(413))).unwrap(); + assert_eq!( + ledger.sent_ids(&done), + Some(&["m-1".to_string(), "m-2".to_string()][..]) + ); + for item in [&waiting, &refused, &skipped, &turn("chat-9", 1)] { + assert_eq!(ledger.sent_ids(item), None, "{item:?}"); + } +} + +#[test] +fn a_conclusion_is_the_latest_one_not_undone() { + let home = Home::new("ledger-latest"); + let mut ledger = home.open(); + let text = "Kyle lives in Oakland."; + let (first, second, undo1, undo2) = ( + edit("2026-09-28-0001", 0), + edit("2026-09-28-0002", 0), + edit("2026-09-28-0003", 0), + edit("2026-09-28-0004", 0), + ); + ledger + .append(decided(&first, conclude(Workspace::Trusted, text))) + .unwrap(); + assert_eq!(ledger.conclusion(Workspace::Trusted, 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), + found("c-1", &first) + ); + ledger + .append(decided(&second, conclude(Workspace::Trusted, text))) + .unwrap(); + ledger.append(sent(&second, &["c-2"])).unwrap(); + assert_eq!( + ledger.conclusion(Workspace::Trusted, text), + found("c-2", &second) + ); + ledger + .append(decided(&undo1, unconclude(Workspace::Trusted, text))) + .unwrap(); + assert_eq!( + ledger.conclusion(Workspace::Trusted, text), + found("c-2", &second) + ); + ledger.append(sent(&undo1, &[])).unwrap(); + assert_eq!( + ledger.conclusion(Workspace::Trusted, 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); + // 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] { + ledger + .append(decided(item, conclude(Workspace::Trusted, "y"))) + .unwrap(); + } + ledger.append(sent(&late, &["c-late"])).unwrap(); + ledger.append(sent(&early, &["c-early"])).unwrap(); + assert_eq!( + ledger.conclusion(Workspace::Trusted, "y"), + found("c-early", &early) + ); +} + +#[test] +fn a_conclusion_is_found_only_by_its_workspace_and_text_once_sent() { + let home = Home::new("ledger-where"); + let mut ledger = home.open(); + let (sent_one, unsent, empty, other, undo_elsewhere, undo_unsent) = ( + edit("2026-09-28-0001", 0), + edit("2026-09-28-0002", 0), + edit("2026-09-28-0003", 0), + edit("2026-09-28-0004", 0), + edit("2026-09-28-0005", 0), + edit("2026-09-28-0006", 0), + ); + let actions = [ + (&sent_one, conclude(Workspace::Trusted, "a")), + (&unsent, conclude(Workspace::Trusted, "b")), + (&empty, conclude(Workspace::Trusted, "c")), + (&other, conclude(Workspace::Untrusted, "a")), + (&undo_elsewhere, unconclude(Workspace::Untrusted, "a")), + (&undo_unsent, unconclude(Workspace::Trusted, "a")), + ]; + for (item, action) in actions { + ledger.append(decided(item, action)).unwrap(); + } + ledger.append(sent(&sent_one, &["c-a"])).unwrap(); + ledger.append(sent(&empty, &[])).unwrap(); + 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); + ledger.append(sent(&undo_unsent, &[])).unwrap(); + assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), None); +} + +#[test] +fn the_counts_add_up() { + let home = Home::new("ledger-counts"); + let mut ledger = home.open(); + assert_eq!(ledger.counts(), Counts::default()); + let items: Vec = (1..=9).map(|n| turn("chat-1", n)).collect(); + for (n, item) in items.iter().enumerate() { + let action = if n < 2 { + skip("secret") + } else { + to_trusted("kyle") + }; + ledger.append(decided(item, action)).unwrap(); + } + for item in &items[2..5] { + ledger.append(sent(item, &["m"])).unwrap(); + } + ledger.append(failed(&items[5], Some(400))).unwrap(); + let counts = ledger.counts(); + assert_eq!( + counts, + Counts { + decided: 9, + sent: 3, + waiting: 3, + failed: 1, + skipped: 2, + } + ); + assert_eq!( + counts.decided, + counts.sent + counts.waiting + counts.failed + counts.skipped + ); + assert_eq!(ledger.outbox().len(), counts.waiting); +} diff --git a/crates/memoryd/tests/honcho_ledger_file.rs b/crates/memoryd/tests/honcho_ledger_file.rs new file mode 100644 index 0000000..3c80e55 --- /dev/null +++ b/crates/memoryd/tests/honcho_ledger_file.rs @@ -0,0 +1,303 @@ +//! 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. + +#[path = "support/ledger.rs"] +mod ledger; +#[path = "support/tmp.rs"] +mod tmp; + +use std::os::unix::fs::PermissionsExt; + +use ledger::{ + Home, conclude, decided, edit, failed, line, sent, skip, to_trusted, turn, unconclude, +}; +use memoryd::honcho::ledger::{Counts, Item, Ledger, LedgerError, Workspace}; + +const POINTER: &str = "; see docs/runbook.md#honcho-ledger-damaged"; + +/// `open`'s error for the file of `home`, which must be `Damaged`: its line and its display. +fn damaged(home: &Home) -> (usize, String) { + let err = Ledger::open(&home.path).unwrap_err(); + let shown = err.to_string(); + match err { + LedgerError::Damaged { path, line, .. } => { + assert_eq!(path, home.path); + (line, shown) + } + other => panic!("not Damaged: {other:?}"), + } +} + +#[test] +fn a_missing_file_is_an_empty_ledger_and_nothing_is_created() { + let home = Home::new("ledger-missing"); + let ledger = home.open(); + assert_eq!(ledger.counts(), Counts::default()); + assert!(ledger.outbox().is_empty()); + assert!(!ledger.repaired()); + assert!(!home.path.exists()); + let nowhere = home.dir.path().join("memory").join("honcho.jsonl"); + let ledger = Ledger::open(&nowhere).unwrap(); + assert_eq!(ledger.counts(), Counts::default()); + assert!(!nowhere.parent().unwrap().exists()); +} + +#[test] +fn an_empty_file_is_an_empty_ledger() { + let home = Home::new("ledger-empty"); + home.write(""); + let ledger = home.open(); + assert_eq!(ledger.counts(), Counts::default()); + assert!(!ledger.repaired()); + assert_eq!(home.text(), ""); +} + +#[test] +fn reopening_reads_the_ledger_through() { + let home = Home::new("ledger-reopen"); + let mut ledger = home.open(); + let (a, b, c, d, e) = ( + turn("chat-1", 1), + turn("chat-1", 2), + edit("2026-09-28-0001", 0), + edit("2026-09-28-0002", 0), + turn("chat-2", 1), + ); + ledger.append(decided(&b, to_trusted("kyle"))).unwrap(); + ledger.append(decided(&a, skip("no speaker"))).unwrap(); + ledger + .append(decided(&c, conclude(Workspace::Trusted, "x"))) + .unwrap(); + ledger + .append(decided(&d, unconclude(Workspace::Trusted, "x"))) + .unwrap(); + ledger.append(decided(&e, to_trusted("ana_b"))).unwrap(); + ledger.append(sent(&c, &["c-1"])).unwrap(); + ledger.append(failed(&d, Some(404))).unwrap(); + let again = home.open(); + assert!(!again.repaired()); + assert_eq!(again.outbox(), ledger.outbox()); + assert_eq!(again.counts(), ledger.counts()); + assert_eq!(again.sent_ids(&c), Some(&["c-1".to_string()][..])); + assert_eq!( + again.conclusion(Workspace::Trusted, "x"), + Some((c.clone(), "c-1".to_string())) + ); + for item in [&a, &b, &c, &d, &e] { + assert!(again.is_decided(item), "{item:?}"); + } + let mut again = again; + let err = again.append(decided(&b, skip("again"))).unwrap_err(); + assert!(matches!(err, LedgerError::AlreadyDecided(_)), "{err:?}"); + let err = again.append(sent(&c, &["c-2"])).unwrap_err(); + assert!(matches!(err, LedgerError::NotWaiting(_)), "{err:?}"); + again.append(sent(&b, &["m-1", "m-2"])).unwrap(); + let third = home.open(); + assert_eq!(third.outbox(), vec![(e, to_trusted("ana_b"))]); + assert_eq!( + third.counts(), + Counts { + decided: 5, + sent: 2, + waiting: 1, + failed: 1, + skipped: 1, + } + ); +} + +#[test] +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!( + "{}{}", + line(&decided(&a, to_trusted("kyle"))), + line(&decided(&b, to_trusted("kyle"))) + ); + let third = line(&sent(&a, &["m-1"])); + // Cut in the middle, and one byte short of the newline: a complete record without its + // newline is still a cut-off line. + for cut in [third.len() / 2, third.len() - 1] { + let home = Home::new("ledger-torn"); + home.write(&format!("{whole}{}", &third[..cut])); + let mut ledger = home.open(); + assert!(ledger.repaired()); + assert_eq!(home.text(), whole); + assert_eq!(ledger.outbox().len(), 2, "the cut-off send counted"); + assert_eq!(ledger.sent_ids(&a), None); + ledger.append(decided(&c, to_trusted("kyle"))).unwrap(); + ledger.append(sent(&a, &["m-2"])).unwrap(); + let again = home.open(); + assert!(!again.repaired()); + assert_eq!(again.counts().decided, 3); + assert_eq!(again.sent_ids(&a), Some(&["m-2".to_string()][..])); + } +} + +#[test] +fn a_cut_off_first_line_leaves_an_empty_file() { + let home = Home::new("ledger-torn-first"); + let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle"))); + home.write(&first[..10]); + let ledger = home.open(); + assert!(ledger.repaired()); + assert_eq!(ledger.counts(), Counts::default()); + assert_eq!(std::fs::metadata(&home.path).unwrap().len(), 0); +} + +#[test] +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 second = line(&decided(&a, conclude(Workspace::Trusted, "Kyle's café"))); + let at = second.find('é').unwrap() + 1; + let mut bytes = first.clone().into_bytes(); + bytes.extend_from_slice(&second.as_bytes()[..at]); + std::fs::write(&home.path, &bytes).unwrap(); + let ledger = home.open(); + assert!(ledger.repaired()); + assert_eq!(home.text(), first); + assert!(!ledger.is_decided(&a)); +} + +#[test] +fn a_damaged_line_stops_the_ledger_with_its_number_and_the_pointer() { + let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle"))); + let third = line(&decided(&turn("chat-1", 2), to_trusted("kyle"))); + let extra = first.replacen("\"speaker\"", "\"extra\":1,\"speaker\"", 1); + for (second, why) in [ + ("{\"type\":\"decided\"\n".to_string(), "EOF while parsing"), + ("\n".to_string(), "EOF while parsing"), + ("not json\n".to_string(), "expected"), + (extra, "unknown field `extra`"), + ] { + for tail in ["", "{\"type\":\"se"] { + let home = Home::new("ledger-damaged"); + let text = format!("{first}{second}{third}{tail}"); + home.write(&text); + let (number, shown) = damaged(&home); + assert_eq!(number, 2, "{second}"); + let start = format!("{}:2: ", home.path.display()); + assert!(shown.starts_with(&start), "{shown}"); + assert!(shown.contains(why), "{shown}"); + assert!(shown.ends_with(POINTER), "{shown}"); + assert_eq!(home.text(), text, "a damaged ledger was changed"); + } + } +} + +#[test] +fn a_line_that_breaks_the_rules_is_damage() { + let (a, b) = (turn("chat-1", 1), edit("2026-09-28-0001", 2)); + let cases = [ + ( + vec![sent(&a, &["m"])], + 1, + "turn 1 of session chat-1: not waiting to be sent", + ), + ( + vec![decided(&a, to_trusted("kyle")), decided(&a, skip("again"))], + 2, + "turn 1 of session chat-1: decided already", + ), + ( + vec![decided(&b, skip("flagged")), failed(&b, Some(400))], + 2, + "part 2 of edit 2026-09-28-0001: not waiting to be sent", + ), + ( + vec![ + decided(&a, to_trusted("kyle")), + sent(&a, &["m"]), + decided(&b, to_trusted("kyle")), + failed(&a, None), + ], + 4, + "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(); + home.write(&text); + let (got, shown) = damaged(&home); + assert_eq!(got, number, "{text}"); + let expected = format!("{}:{number}: {why}{POINTER}", home.path.display()); + assert_eq!(shown, expected); + assert_eq!(home.text(), text); + } +} + +#[test] +fn the_file_is_made_0600() { + let home = Home::new("ledger-mode"); + let mut ledger = home.open(); + ledger + .append(decided(&turn("chat-1", 1), to_trusted("kyle"))) + .unwrap(); + let mode = std::fs::metadata(&home.path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "{mode:o}"); +} + +#[test] +fn a_missing_directory_is_an_io_error_and_changes_nothing() { + let home = Home::new("ledger-nodir"); + let path = home.dir.path().join("memory").join("honcho.jsonl"); + let mut ledger = Ledger::open(&path).unwrap(); + let item = turn("chat-1", 1); + let err = ledger + .append(decided(&item, to_trusted("kyle"))) + .unwrap_err(); + assert!( + matches!(&err, LedgerError::Io(p, _) if *p == path), + "{err:?}" + ); + assert!( + err.to_string() + .starts_with(&format!("{}: ", path.display())) + ); + assert!(!ledger.is_decided(&item)); + assert_eq!(ledger.counts(), Counts::default()); + assert!(!path.parent().unwrap().exists(), "the directory was made"); +} + +#[test] +fn a_failed_write_changes_nothing_in_memory() { + let home = Home::new("ledger-failed-write"); + let mut ledger = home.open(); + let (a, b) = (turn("chat-1", 1), turn("chat-1", 2)); + ledger.append(decided(&a, to_trusted("kyle"))).unwrap(); + let kept = home.text(); + // The ledger's path turns into a directory: every write to it fails. + std::fs::remove_file(&home.path).unwrap(); + std::fs::create_dir(&home.path).unwrap(); + let records = [decided(&b, to_trusted("kyle")), sent(&a, &["m-1"])]; + for record in records.clone() { + let err = ledger.append(record).unwrap_err(); + assert!(matches!(err, LedgerError::Io(..)), "{err:?}"); + } + assert!(!ledger.is_decided(&b)); + assert_eq!(ledger.sent_ids(&a), None); + assert_eq!(ledger.outbox(), vec![(a.clone(), to_trusted("kyle"))]); + std::fs::remove_dir(&home.path).unwrap(); + home.write(&kept); + for record in records { + ledger.append(record).unwrap(); + } + let outbox: Vec = ledger.outbox().into_iter().map(|(i, _)| i).collect(); + assert_eq!(outbox, vec![b]); + assert_eq!(home.open().counts().sent, 1); +} + +#[test] +fn a_ledger_that_cannot_be_read_is_an_io_error() { + let home = Home::new("ledger-unreadable"); + std::fs::create_dir(&home.path).unwrap(); + let err = Ledger::open(&home.path).unwrap_err(); + assert!( + matches!(&err, LedgerError::Io(p, _) if *p == home.path), + "{err:?}" + ); +} diff --git a/crates/memoryd/tests/support/ledger.rs b/crates/memoryd/tests/support/ledger.rs new file mode 100644 index 0000000..7ede28d --- /dev/null +++ b/crates/memoryd/tests/support/ledger.rs @@ -0,0 +1,123 @@ +//! Records for the Honcho ledger's tests, and a ledger in a directory of its own. Do not edit. + +#![allow(dead_code)] // each test file uses a different part of this module + +use std::path::PathBuf; + +use bxcore::{SessionId, Timestamp}; +use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace}; + +use crate::tmp::TempDir; + +/// A ledger file that does not exist yet, in a directory removed on drop. +pub struct Home { + pub path: PathBuf, + pub dir: TempDir, +} + +impl Home { + pub fn new(tag: &str) -> Home { + let dir = TempDir::new(tag); + Home { + path: dir.path().join("honcho.jsonl"), + dir, + } + } + + pub fn open(&self) -> Ledger { + Ledger::open(&self.path).unwrap() + } + + /// 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() + } + + /// Writes `text` as the whole file. + pub fn write(&self, text: &str) { + std::fs::write(&self.path, text).unwrap(); + } +} + +pub const TIME: &str = "2026-09-28T10:00:00.000Z"; + +pub fn time() -> Timestamp { + Timestamp::parse(TIME).unwrap() +} + +pub fn sid(s: &str) -> SessionId { + SessionId::new(s).unwrap() +} + +pub fn turn(session: &str, turn: u64) -> Item { + Item::Turn { + session: sid(session), + turn, + } +} + +pub fn edit(id: &str, part: u32) -> Item { + Item::Edit { + id: id.to_string(), + part, + } +} + +pub fn to_trusted(speaker: &str) -> Action { + Action::Turn { + workspace: Workspace::Trusted, + speaker: speaker.to_string(), + } +} + +pub fn skip(why: &str) -> Action { + Action::Skip { + why: why.to_string(), + } +} + +pub fn conclude(workspace: Workspace, text: &str) -> Action { + Action::Conclude { + workspace, + peer: "kyle".to_string(), + session: sid("chat-1"), + text: text.to_string(), + } +} + +pub fn unconclude(workspace: Workspace, text: &str) -> Action { + Action::Unconclude { + workspace, + text: text.to_string(), + } +} + +pub fn decided(item: &Item, action: Action) -> Record { + Record::Decided { + time: time(), + item: item.clone(), + action, + } +} + +pub fn sent(item: &Item, ids: &[&str]) -> Record { + Record::Sent { + time: time(), + item: item.clone(), + ids: ids.iter().map(|s| s.to_string()).collect(), + } +} + +pub fn failed(item: &Item, status: Option) -> Record { + Record::Failed { + time: time(), + item: item.clone(), + status, + kind: "refused".to_string(), + } +} + +/// `record`'s line, as `append` writes it. +pub fn line(record: &Record) -> String { + format!("{}\n", serde_json::to_string(record).unwrap()) +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index dfb54f8..1c77500 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/07-memoryd-honcho-ledger | 2026-09-28 | done | 1 | pass | none | Copied the three given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_ledger.rs`, `honcho_ledger_file.rs` and the support `crates/memoryd/tests/support/ledger.rs`. Step 2 showed exactly the named failures: two test targets (`honcho_ledger` and `honcho_ledger_file`) with two E0432 each (`unresolved import memoryd::honcho::ledger`), one in each test file and one in `support/ledger.rs`; every other target compiled. Made H1 (`pub mod ledger;` after `pub mod config;` in `honcho/mod.rs`) and copied the skeleton `crates/memoryd/src/honcho/ledger.rs`; `cargo check` gave 20 warnings from the unfilled bodies. Step 3 `cargo test -p memoryd --test honcho_ledger` gave 2 pass (`each_record_is_one_line_exactly_as_given`, `an_unknown_field_or_value_is_refused`) and 10 fail with `not yet implemented`. Filled its twelve bodies in the order of the file, each from its comment and `cargo check` after: `is_decided` (`contains_key`); `outbox` (walk `order` with `.iter().filter_map`, keeping `(item, action)` when `state == Waiting`); `sent_ids` (match `State::Sent(ids)` -> `ids.as_slice()`); `conclusion` (walk `sends` in order, pushing a sent `Conclude`'s first id and popping on a matching sent `Unconclude`, returning `live.pop()` so only workspace and text match and a `Sent` with no ids pushes nothing); `counts` (one per entry, matching `&entry.state` since `State` is not `Copy`); `check` (`AlreadyDecided` for a second `Decided`, `NotWaiting` unless a `Sent`/`Failed` finds its entry `Waiting`); `apply` (`Skip` -> `Skipped` else `Waiting`, push to `order` and `entries`; `Sent` sets `Sent(ids)` and pushes to `sends`, `Failed` sets `Failed` without pushing); `write_line` (append, create, mode 0600, one `write_all` then `sync_data`); then `read` (NotFound -> None, else Io), `complete` (rposition of `\n`), `parse` (serde error -> Damaged), `cut` (`set_len` then `sync_data`). Removed the `mut` in `cut` that clippy flagged (`set_len` and `sync_data` take `&self`); after the twelfth `cargo check` was clean and `cargo fmt` left the file. Both test files passed 12 and 12; appended `runbook-honcho-ledger-damaged.md` (`grep -c '^## honcho-ledger-damaged$'` printed 1). Step 7: `sync_data` 2, `write_all` 1, one runbook pointer line in `src`, no `create_dir`, no diff in the other crates. `make gate` printed `gate: ok` with 1212 tests (1211 in `cargo test --workspace`, plus the ignored `end_to_end` the gate runs with `BOXMAKER_BROKERD` set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/06-memoryd-honcho-config | 2026-09-28 | done | 1 | pass | none | Copied the three given tests from `docs/plans/M5c1/files/crates/memoryd/tests/`: `honcho_config.rs`, `honcho_reasons.rs`, `honcho_start.rs`. Step 2 showed exactly the named failures: `honcho_config` with eight (E0433 cannot find `honcho` in `memoryd`, E0432 unresolved import `bxcore::secrets`, two E0599 for `honcho_config`, three for `state_socket`, one for the `Honcho` variant) and `honcho_reasons` with two (the same E0433 and E0432); `honcho_start` compiled. Made K1 (`bxcore` gains `features = ["secrets"]`) and L1 (`pub mod honcho;` after `pub mod files;`), created `crates/memoryd/src/honcho/mod.rs` with the given one-line module, and copied the skeleton `crates/memoryd/src/honcho/config.rs`. Filled its seven bodies in the given order, each from its comment and `cargo check` after: `is_name` (not empty, byte length `<= MAX_NAME`, every byte `is_ascii_alphanumeric()` or `_` or `-`, bytes not chars so `é` and U+0660 fail); `check_url` (strip `https://` then `http://`, `split_once(':')` into host and port, a `host_ok` helper for the non-empty ASCII-alnum/`.`/`-` host, a port that is non-empty, all digits and parses to `u16` `> 0` via `ok().is_some_and`, the `URL_REASON` which never quotes the URL); `check_own_workspaces` (the three pairs in order, first equal pair is R5 with its name); `trust_of` (match on `as_deref`, `None`/`Some("untrusted")` is `Untrusted`, `Some("trusted")` is `Trusted`, else R9 with the written text); `class_of` (same shape, `Private`/`Public`/`Secret`, else R10); `read_workspace` (check agent then workspace names, R6 the agent is this agent, R7 the agent is listed in `earlier`, R8 the workspace is one of the three, then the `ReadWorkspace` with `trust_of`/`class_of`); `check_keys` (a name other than the three is a read key only when `strip_prefix(READ_KEY)` names an agent in `read`, else R11 with the suffix or R12 without the prefix, then `spec.source()` with R13). Made C1-C8 in `config.rs` (module comment, imports with `BTreeMap`/`SecretSpec`/`honcho`, `Sockets.state`, `Config.honcho` and `.secrets`, `ConfigError::Honcho` and its Display arm, `load` calls `honcho_config` before returning, `honcho_config` and `state_socket`), and M1-M2 in `main.rs` (imports and the pointer matched by error kind on the `Config::load` arm); the pointer edit first landed on the `instance::lock` arm and I moved it to the `Config::load` arm, the diff being exactly M1-M2. Appended `runbook-honcho-config-invalid.md` to `docs/runbook.md` (`grep -c '^## honcho-config-invalid$'` printed 1) and made D1 in `docs/dependencies.md`. Step 7: `cargo build -p memoryd` builds, the runbook grep prints one line, the `secrets::load`/`read_checked` grep prints nothing, the other-crates diff is empty. `cargo test -p memoryd` every suite passes (config 4, honcho_config 8, honcho_reasons 5, honcho_start 4). `make gate` printed `gate: ok` with 1188 tests (1187 in `cargo test --workspace` plus the ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD`; 1171 before this task plus the 17 new). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/05-brokerd-state-socket | 2026-09-28 | done | 2 | pass | none | Copied five files from `docs/plans/M5c1/files/`: `state_socket.rs` and `serve_state.rs` (new) and new versions of `config.rs`, `serve.rs` and `tests/fixtures/config/full.toml` (all now set and check `state`). Step 2 showed exactly the named failures: `config` with four E0599 (`no method named state_socket`) and `state_socket` with one E0432 (`unresolved import brokerd::state_socket`); the new `serve.rs` compiled. `cargo build --tests` reported those two targets only. Made the edits exactly as the task gives: C1 `Sockets` gains `state: PathBuf` with the doc comment, C2 `Config::state_socket` after `admin_socket` returning the default `run/memory-broker/state.sock` when empty; L1 `pub mod state_socket;` after `pub mod state;`. Created `crates/brokerd/src/state_socket.rs` with the given body: `read_request`, match `Message::SessionState(question)` (no `SessionState` import), `StateStore::new(&broker.cfg().state_dir())`, `Ok` -> `SessionStateReply { class: state.taint, untrusted: state.untrusted }`, `Err` -> log `brokerd: {e}` and send `internal` with `e.to_string()`, `forbid` for any other kind; `send` the final frame. V1-V11 in `serve.rs`: doc comment, `use crate::state_socket`, `Started` gains `state: UnixListener`, `state.sock` bound first then tool and admin, `Which::State`, the `run` destructure, the second `accept_loop` with `tx` moved, the `accept-state` name, and `state_socket::handle` in `serve_one`; M1-M3 in `main.rs`: doc comment, `state_path = cfg.state_socket()`, the `serving session state on ` notice. R1-R3 in `docs/runbook.md`: `accept-broker (or accept-admin, accept-state)`, the `state.sock` line in `socket-forbidden`, and `pgrep -a 'loopd|bxctl|memoryd'`. After the edits `cargo check -p brokerd` is clean with no warning; `config` 7, `state_socket` 8, `serve_state` 5, `instance` 2, `serve` 8, `serve_pointers` 10 pass. Step 7: `grep -n 'listen(&cfg'` prints three lines with `state_socket` first, `grep -n '^use' state_socket.rs` prints four and the `bxcore` import carries `ErrorCode`, `Message`, `SessionStateReply`, `WireError` with no `SessionState`, `git diff --stat -- crates/brokerd/src` lists exactly config.rs/lib.rs/main.rs/serve.rs, the other-crates diff is empty. `make gate` printed `gate: ok` with 1171 tests (1158 before this task plus the 13 new: eight in `state_socket.rs`, five in `serve_state.rs`; the new `config.rs` and `serve.rs` kept their counts, 7 and 8). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/04-bxctl-speaker | 2026-09-28 | done | 1 | pass | none | Copied four files from `docs/plans/M5c1/files/`: `chat_speaker.rs` (new) and new versions of `chat.rs`, `chat_approvals.rs` and `cli.rs` (did not copy `chat.rs-task01`). Step 2 showed exactly the named failures: four non-compiling test targets (`chat`, `chat_approvals`, `chat_speaker`, `cli`) with E0061 for `run_turn` (5 args) and `stream_turn` (6 args), E0282 on the closure in `chat.rs`, and E0560/E0609 for the missing `speaker` field. Made the edits exactly as the task gives: C1 the `--speaker NAME` flag added to the chat line of `USAGE` with one explaining line (six-space indent); C2 `ChatOptions` gains `speaker: Option`; C3 `default` sets `speaker: None`; C4 the `--speaker` arm in `parse_chat` refuses a second one and falls through to `i += 2`; C5 `parse_speaker` (empty, or any whitespace/control char -> `UsageError`, else the value as typed). R1 `run_turn` gains `speaker: Option<&str>` after `resume` with the doc comment, R2 `speaker.map(str::to_string)` on the `Turn`, R3 `stream_turn` gains the parameter and passes it. M1-M3 all three `stream_turn` calls in `main.rs` pass `opts.speaker.as_deref()`. `main` unchanged (a `UsageError` already prints the usage text and exits 2). After the edits `cargo check -p bxctl` is clean with no warning; `cli` 15 passed, `chat_speaker` 7 passed, every bxctl suite passes. Step 7: `grep -rn 'speaker: None' crates/bxctl/src` prints one line (the default), `git diff --stat -- crates/bxctl/src` lists exactly chat.rs/cli.rs/main.rs, the other-crates diff is empty. `make gate` printed `gate: ok` with 1158 tests (1148 before plus the 10 new: three in `cli.rs`, seven in `chat_speaker.rs`; `chat.rs` and `chat_approvals.rs` kept their counts). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | diff --git a/docs/runbook.md b/docs/runbook.md index b477ba1..84482a7 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -923,3 +923,41 @@ the same in every agent's file. To turn Honcho off, remove `[honcho]`, its `[[ho tables and every `[secrets.honcho_…]`. **Check.** `memoryd serve --config ` prints `memoryd: serving`. + +## honcho-ledger-damaged + +**What you see.** `memoryd` logs a line naming `memory/honcho.jsonl`, a line number and a reason, +then this entry: + +```text +/srv/bx/memory/honcho.jsonl:12: turn 3 of session chat-1: decided already; see docs/runbook.md#honcho-ledger-damaged +``` + +**Why.** The ledger is `memoryd`'s record of what it sends to Honcho: one line when a turn or a +memory write is decided (where it goes, or that it goes nowhere), and one when it is sent or +refused (M5c spec, section 5). What is decided and not yet sent is the outbox. `memoryd` reads the +ledger through at start so that it sends nothing twice and nothing to a workspace it was not +decided for. A line it cannot read, or one that breaks the ledger's rules, could do either, so it +does not guess: it does not start its Honcho sync, and nothing is decided or sent to Honcho until +the ledger reads through. The rules: an item is decided once (`decided already`), and it is sent +or refused once, only after it is decided and only when it was not decided to go nowhere (`not +waiting to be sent`). A last line cut off by a crash is not damage: `memoryd` drops it and says so +in its log. + +**Confirm.** Look at the line named, and at every line of the same item: + +```sh +f="$BOXMAKER_HOME/memory/honcho.jsonl" +sed -n 'p' "$f" +grep -n '"session":"","turn":}' "$f" # a turn; for an edit: '"id":"","part":}' +``` + +**Fix.** Keep a copy first: `cp -a "$f" "$f.damaged-$(date +%s)"`. The ledger is written only by +`memoryd`, so a damaged line was changed by hand or by the disk. If you know what the line should +say, correct it. Otherwise remove it (`sed -i 'd' "$f"`), knowing what that costs: a removed +`sent` or `failed` line means the item is sent again (a turn's messages appear twice in Honcho, a +conclusion is made twice); a removed `decided` line means the item is decided again, and any +later `sent` or `failed` line of the same item then breaks the rules, so remove those too. Honcho +cannot delete a message, so a turn sent twice stays twice. + +**Check.** `memoryd` starts, and its log has no line with this entry.