memoryd: the Honcho ledger, decided, sent and failed, read through at start

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 11:33:49 -07:00
co-authored by ornith-1.5-35b-a3b
parent 129a9eadc3
commit 8aaeb74f36
7 changed files with 1294 additions and 0 deletions
+377
View File
@@ -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: <text>`, 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<String>,
},
/// Honcho refused the send: its status, if it answered, and the kind of error.
Failed {
time: Timestamp,
item: Item,
status: Option<u16>,
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<String>),
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<Item>,
entries: HashMap<Item, Entry>,
/// Every sent item, in the order of its `Sent` record.
sends: Vec<Item>,
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<Ledger, LedgerError> {
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<Option<Vec<u8>>, 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<Record, LedgerError> {
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}"),
}
}
+1
View File
@@ -1,3 +1,4 @@
//! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec). //! Honcho, the memory this agent shares with the fleet, reached through `roncho` (M5c spec).
pub mod config; pub mod config;
pub mod ledger;
+451
View File
@@ -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::<Record>(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::<Workspace>(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::<Record>(&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::<Record>(&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<Item> = ledger.outbox().into_iter().map(|(item, _)| item).collect();
assert_eq!(order, items.to_vec());
ledger.append(sent(&items[1], &["m"])).unwrap();
let order: Vec<Item> = 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<Item> = (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);
}
+303
View File
@@ -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<Item> = 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:?}"
);
}
+123
View File
@@ -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<u16>) -> 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())
}
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | | 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/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 <path>` 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/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 <path>` 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<String>`; 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 | | 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<String>`; 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 |
+38
View File
@@ -923,3 +923,41 @@ the same in every agent's file. To turn Honcho off, remove `[honcho]`, its `[[ho
tables and every `[secrets.honcho_…]`. tables and every `[secrets.honcho_…]`.
**Check.** `memoryd serve --config <path>` prints `memoryd: serving`. **Check.** `memoryd serve --config <path>` 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 '<line>p' "$f"
grep -n '"session":"<id>","turn":<n>}' "$f" # a turn; for an edit: '"id":"<id>","part":<n>}'
```
**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 '<line>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.