memoryd: the Honcho worker sends conclusions, their deletions and fleet notes

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 13:11:51 -07:00
co-authored by ornith-1.5-35b-a3b
parent 4ebfc36c1a
commit fb05b26462
9 changed files with 790 additions and 121 deletions
+32
View File
@@ -127,6 +127,7 @@ pub struct Clients {
untrusted: Client, untrusted: Client,
fleet: Client, fleet: Client,
agent: String, agent: String,
owner: String,
ensured: HashSet<(Workspace, Ensured)>, ensured: HashSet<(Workspace, Ensured)>,
} }
@@ -233,6 +234,7 @@ impl Clients {
)?, )?,
fleet: client(&config.url, &config.fleet, fleet_key.as_ref(), timeouts)?, fleet: client(&config.url, &config.fleet, fleet_key.as_ref(), timeouts)?,
agent: config.agent.clone(), agent: config.agent.clone(),
owner: config.owner.clone(),
ensured: HashSet::new(), ensured: HashSet::new(),
}; };
Ok((clients, warnings)) Ok((clients, warnings))
@@ -307,6 +309,36 @@ impl Clients {
Ok(()) Ok(())
} }
/// Make sure memory session `name` (a Boxmaker session's `<id>_memory`) exists in
/// `workspace`, after the workspace, the agent's peer and the owner's. It holds conclusions,
/// no messages, so neither peer observes (M5c spec, section 4).
pub fn ensure_memory_session(
&mut self,
workspace: Workspace,
name: &str,
) -> Result<(), HonchoError> {
self.ensure_workspace(workspace)?;
let (agent, owner) = (self.agent.clone(), self.owner.clone());
self.ensure_peer(workspace, &agent)?;
self.ensure_peer(workspace, &owner)?;
let key = (workspace, Ensured::Session(name.to_string()));
if self.ensured.contains(&key) {
return Ok(());
}
let unobserved = || {
SessionPeerConfig::new()
.observe_me(false)
.observe_others(false)
};
let mut peers = HashMap::new();
peers.insert(agent, unobserved());
peers.insert(owner, unobserved());
let create = SessionCreate::new(name).with_peers(peers);
created(self.client(workspace).session(&create))?;
self.ensured.insert(key);
Ok(())
}
/// Delete conclusion `id` in `workspace`. Gone already (404) counts as done. /// Delete conclusion `id` in `workspace`. Gone already (404) counts as done.
pub fn delete_conclusion(&self, workspace: Workspace, id: &str) -> Result<(), HonchoError> { pub fn delete_conclusion(&self, workspace: Workspace, id: &str) -> Result<(), HonchoError> {
match self.client(workspace).delete_conclusion(id) { match self.client(workspace).delete_conclusion(id) {
+92
View File
@@ -0,0 +1,92 @@
//! Memory writes sent to Honcho (M5c spec, sections 4 and 5), for the worker: a conclusion made
//! in the Boxmaker session's memory session, `<id>_memory`; a conclusion deleted by the id its
//! making was given; an untrusted note added to the fleet workspace as a message from the agent.
//! The text is neutralised here, as it leaves.
use bxcore::SessionId;
use bxcore::sanitize::neutralise;
use roncho::{ConclusionBatchCreate, ConclusionCreate, MessageCreate};
use serde_json::{Map, json};
use crate::honcho::client::{Clients, HonchoError, classify};
use crate::honcho::ledger::Workspace;
use crate::honcho::naming::HonchoSession;
use crate::honcho::worker::parts;
/// What names a Boxmaker session's memory session: `_` is in no Boxmaker id, so the name is no
/// other session's.
pub const MEMORY_SUFFIX: &str = "_memory";
/// The memory session of `session`: `<id>_memory`.
pub fn memory_session(session: &SessionId) -> String {
format!("{}{MEMORY_SUFFIX}", session.as_str())
}
/// Make conclusion `text` about `peer` (its observer and the observed) in `workspace`, stamped
/// with `session`'s memory session, which is made sure of first. Its one id.
pub fn conclude(
clients: &mut Clients,
workspace: Workspace,
peer: &str,
session: &SessionId,
text: &str,
) -> Result<Vec<String>, HonchoError> {
let name = memory_session(session);
clients.ensure_memory_session(workspace, &name)?;
let conclusion = ConclusionCreate::new(neutralise(text), peer, peer).with_session_id(name);
let batch = ConclusionBatchCreate::new(vec![conclusion]);
match clients.client(workspace).create_conclusions(&batch) {
Ok(made) => Ok(made.into_iter().map(|c| c.id).collect()),
Err(e) => Err(classify(&e)),
}
}
/// Delete conclusion `id` in `workspace`; with none to delete, nothing is asked. The ids of what
/// was deleted.
pub fn unconclude(
clients: &Clients,
workspace: Workspace,
id: Option<&str>,
) -> Result<Vec<String>, HonchoError> {
match id {
Some(id) => {
clients.delete_conclusion(workspace, id)?;
Ok(vec![id.to_string()])
}
None => Ok(Vec::new()),
}
}
/// An untrusted note's messages for the fleet workspace: from the agent's peer, neutralised, in
/// parts of at most `MAX_PART` characters, each with the note's metadata.
pub fn fleet_batch(text: &str, agent: &str, session: &SessionId, edit: &str) -> Vec<MessageCreate> {
let mut metadata = Map::new();
metadata.insert("agent".to_string(), json!(agent));
metadata.insert("boxmaker_session".to_string(), json!(session.as_str()));
metadata.insert("edit".to_string(), json!(edit));
parts(&neutralise(text))
.into_iter()
.map(|part| MessageCreate::new(part, agent).with_metadata(metadata.clone()))
.collect()
}
/// Add `messages` to `session`'s Honcho session in the fleet workspace, which holds only the
/// agent's peer, made sure of first. The ids of the messages.
pub fn fleet(
clients: &mut Clients,
session: &SessionId,
messages: &[MessageCreate],
) -> Result<Vec<String>, HonchoError> {
let honcho = HonchoSession {
name: session.as_str().to_string(),
speakers: Vec::new(),
};
clients.ensure_session(Workspace::Fleet, &honcho)?;
match clients
.client(Workspace::Fleet)
.add_messages(&honcho.name, messages)
{
Ok(made) => Ok(made.into_iter().map(|m| m.id).collect()),
Err(e) => Err(classify(&e)),
}
}
+2
View File
@@ -3,9 +3,11 @@
pub mod client; pub mod client;
pub mod config; pub mod config;
pub mod decide; pub mod decide;
pub mod edits;
pub mod ledger; pub mod ledger;
pub mod mirror; pub mod mirror;
pub mod naming; pub mod naming;
pub mod start;
pub mod state; pub mod state;
pub mod turns; pub mod turns;
pub mod worker; pub mod worker;
+85
View File
@@ -0,0 +1,85 @@
//! `memoryd`'s Honcho side at start (M5c spec, sections 5 and 10): the ledger read through, the
//! sessions that wait again, the clients with their keys, and the worker's thread.
use std::sync::{Arc, Mutex};
use bxcore::SessionId;
use crate::config::Config;
use crate::honcho::client::{Clients, Timeouts};
use crate::honcho::config::HonchoConfig;
use crate::honcho::ledger::Ledger;
use crate::honcho::mirror::Mirror;
use crate::honcho::state;
use crate::honcho::turns::{LEDGER, TurnSync};
use crate::honcho::worker::{AskBox, Tuning, Wake, Worker};
use crate::serve::{Log, StartError};
/// With `[honcho]`, at start: the ledger read through, the sessions that wait again, and the
/// clients with their keys read. Nothing is bound or started yet.
pub struct Opened {
pub sync: Arc<Mutex<TurnSync>>,
clients: Clients,
agent: String,
owner: String,
}
impl Opened {
/// The memory writes' side of the ledger (task 12).
pub fn mirror(&self) -> Mirror {
let ledger = self
.sync
.lock()
.unwrap_or_else(|p| p.into_inner())
.shared_ledger();
Mirror::new(ledger, &self.agent, &self.owner)
}
}
/// Open the ledger (a cut-off last line is logged), resume the waiting sessions, and build the
/// clients (their key files' warnings are logged). A key that cannot be read is `Secret`.
pub fn open(config: &Config, honcho: &HonchoConfig, log: &Log) -> Result<Opened, StartError> {
let path = config.memory_dir().join(LEDGER);
let ledger = Ledger::open(&path).map_err(StartError::Ledger)?;
if ledger.repaired() {
(log)(&format!(
"memoryd: {}: a last line cut off by a crash was dropped",
path.display()
));
}
let mut sync = TurnSync::new(ledger, &honcho.agent, &config.sessions_dir());
sync.resume();
let env = |name: &str| std::env::var_os(name);
let (clients, warnings) =
Clients::new(honcho, &env, Timeouts::WRITES).map_err(StartError::Secret)?;
for warning in warnings {
(log)(&warning);
}
Ok(Opened {
sync: Arc::new(Mutex::new(sync)),
clients,
agent: honcho.agent.clone(),
owner: honcho.owner.clone(),
})
}
/// Start the worker's thread, asking `brokerd` on `config`'s state socket.
pub fn spawn(opened: Opened, config: &Config, wake: Arc<Wake>, log: Log) -> Result<(), String> {
let socket = config.state_socket();
let ask: AskBox =
Box::new(move |session: &SessionId| state::ask(&socket, session, state::WAIT));
let worker = Worker::new(
opened.sync,
opened.clients,
wake,
ask,
log,
Tuning::DEFAULT,
&opened.agent,
);
std::thread::Builder::new()
.name("honcho".to_string())
.spawn(move || worker.run())
.map(|_| ())
.map_err(|e| format!("cannot start the Honcho worker: {e}"))
}
+135 -110
View File
@@ -15,16 +15,15 @@ use bxcore::{SessionId, SessionStateReply, Timestamp};
use roncho::MessageCreate; use roncho::MessageCreate;
use serde_json::{Map, json}; use serde_json::{Map, json};
use crate::config::Config; use crate::honcho::client::{Clients, HonchoError, classify};
use crate::honcho::client::{Clients, HonchoError, Timeouts, classify};
use crate::honcho::config::HonchoConfig;
use crate::honcho::decide::{final_reply, turn_records, user_text}; use crate::honcho::decide::{final_reply, turn_records, user_text};
use crate::honcho::edits;
use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace}; use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace};
use crate::honcho::mirror::Mirror;
use crate::honcho::naming::{HonchoSession, honcho_session}; use crate::honcho::naming::{HonchoSession, honcho_session};
use crate::honcho::state; use crate::honcho::turns::{TurnSync, log_path, read_log};
use crate::honcho::turns::{LEDGER, TurnSync, log_path, read_log}; use crate::serve::Log;
use crate::serve::{Log, StartError};
pub use crate::honcho::start::{Opened, open, spawn};
/// The most characters Honcho takes in one message (recorded: 25,000 is taken, 25,001 refused). /// The most characters Honcho takes in one message (recorded: 25,000 is taken, 25,001 refused).
pub const MAX_PART: usize = 25_000; pub const MAX_PART: usize = 25_000;
@@ -103,14 +102,36 @@ enum Unsent {
Local(&'static str), Local(&'static str),
} }
/// A turn to send: its item, workspace, speaker and Honcho session. /// What to send for one waiting item (M5c spec, section 5).
struct Next { enum Job {
item: Item, /// A turn: its session and number, its speaker, and its Honcho session.
Turn {
session: SessionId, session: SessionId,
turn: u64, turn: u64,
workspace: Workspace,
speaker: String, speaker: String,
honcho: Option<HonchoSession>, honcho: Option<HonchoSession>,
},
/// A conclusion about `peer`, stamped with `session`'s memory session.
Conclude {
peer: String,
session: SessionId,
text: String,
},
/// Delete the conclusion with this id; `None` when no sent conclusion is live.
Unconclude { id: Option<String> },
/// An untrusted note: a fleet message from the agent.
Fleet {
session: SessionId,
edit: String,
text: String,
},
}
/// The oldest waiting item: what it is, where it goes, and what to send.
struct Next {
item: Item,
workspace: Workspace,
job: Job,
} }
pub struct Worker { pub struct Worker {
@@ -173,6 +194,14 @@ pub fn batch(
messages messages
} }
/// An item as the log lines name it: `turn 3 of session mm-a`, `part 0 of edit 2026-09-28-0001`.
fn label(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}"),
}
}
/// The ids of the messages Honcho made, in order. /// The ids of the messages Honcho made, in order.
fn ids(messages: &[roncho::Message]) -> Vec<String> { fn ids(messages: &[roncho::Message]) -> Vec<String> {
messages.iter().map(|m| m.id.clone()).collect() messages.iter().map(|m| m.id.clone()).collect()
@@ -255,9 +284,8 @@ impl Worker {
Err(Unsent::Honcho(e)) => { Err(Unsent::Honcho(e)) => {
self.recovered(); self.recovered();
(self.log)(&format!( (self.log)(&format!(
"memoryd: turn {} of session {} was not sent: {e}; it is marked failed; {SEND_REFUSED}", "memoryd: {} was not sent: {e}; it is marked failed; {SEND_REFUSED}",
next.turn, label(&next.item)
next.session.as_str()
)); ));
self.record(Record::Failed { self.record(Record::Failed {
time: Timestamp::now(), time: Timestamp::now(),
@@ -273,9 +301,8 @@ impl Worker {
SEND_REFUSED SEND_REFUSED
}; };
(self.log)(&format!( (self.log)(&format!(
"memoryd: turn {} of session {} cannot be sent ({kind}); it is marked failed; {pointer}", "memoryd: {} cannot be sent ({kind}); it is marked failed; {pointer}",
next.turn, label(&next.item)
next.session.as_str()
)); ));
self.record(Record::Failed { self.record(Record::Failed {
time: Timestamp::now(), time: Timestamp::now(),
@@ -287,50 +314,117 @@ impl Worker {
} }
} }
/// The oldest waiting turn whose workspace's key was not refused. Other actions wait. /// The oldest waiting item whose workspace's key was not refused. A retraction, which
/// nothing decides any more, waits.
fn next(&self) -> Option<Next> { fn next(&self) -> Option<Next> {
let ledger = self.ledger.lock().unwrap_or_else(|p| p.into_inner()); let ledger = self.ledger.lock().unwrap_or_else(|p| p.into_inner());
ledger.outbox().into_iter().find_map(|(item, action)| { ledger.outbox().into_iter().find_map(|(item, action)| {
let Item::Turn { session, turn } = &item else { let (workspace, job) = match (&item, action) {
return None; (Item::Turn { session, turn }, Action::Turn { workspace, speaker }) => (
}; workspace,
let Action::Turn { workspace, speaker } = action else { Job::Turn {
return None; honcho: honcho_session(ledger.decisions(), session, *turn),
session: session.clone(),
turn: *turn,
speaker,
},
),
(
Item::Edit { .. },
Action::Conclude {
workspace,
peer,
session,
text,
},
) => (
workspace,
Job::Conclude {
peer,
session,
text,
},
),
(Item::Edit { .. }, Action::Unconclude { workspace, text }) => (
workspace,
Job::Unconclude {
id: ledger.conclusion(workspace, &text).map(|(_, id)| id),
},
),
(Item::Edit { id, .. }, Action::Fleet { session, text }) => (
Workspace::Fleet,
Job::Fleet {
session,
edit: id.clone(),
text,
},
),
_ => return None,
}; };
if self.refused.contains(&workspace) { if self.refused.contains(&workspace) {
return None; return None;
} }
Some(Next { Some(Next {
honcho: honcho_session(ledger.decisions(), session, *turn),
session: session.clone(),
turn: *turn,
item: item.clone(), item: item.clone(),
workspace, workspace,
speaker, job,
}) })
}) })
} }
/// Read the turn again, make sure of its Honcho session, and add its messages as one batch. /// Send one item, by what it is.
fn send(&mut self, next: &Next) -> Result<Vec<String>, Unsent> { fn send(&mut self, next: &Next) -> Result<Vec<String>, Unsent> {
let (text, reply) = read_turn(&log_path(&self.sessions, &next.session), next.turn) let honcho = |e| Unsent::Honcho(e);
.ok_or(Unsent::Local("log"))?; match &next.job {
let honcho = next.honcho.as_ref().ok_or(Unsent::Local("log"))?; Job::Turn {
let messages = batch( session,
&text, turn,
&reply, speaker,
&next.speaker, honcho: name,
&self.agent, } => self.send_turn(next.workspace, session, *turn, speaker, name.as_ref()),
&next.session, Job::Conclude {
next.turn, peer,
); session,
text,
} => edits::conclude(&mut self.clients, next.workspace, peer, session, text)
.map_err(honcho),
Job::Unconclude { id } => {
edits::unconclude(&self.clients, next.workspace, id.as_deref()).map_err(honcho)
}
Job::Fleet {
session,
edit,
text,
} => {
let messages = edits::fleet_batch(text, &self.agent, session, edit);
if messages.len() > MAX_BATCH {
return Err(Unsent::Local("too-long"));
}
edits::fleet(&mut self.clients, session, &messages).map_err(honcho)
}
}
}
/// Read the turn again, make sure of its Honcho session, and add its messages as one batch.
fn send_turn(
&mut self,
workspace: Workspace,
session: &SessionId,
turn: u64,
speaker: &str,
honcho: Option<&HonchoSession>,
) -> Result<Vec<String>, Unsent> {
let (text, reply) =
read_turn(&log_path(&self.sessions, session), turn).ok_or(Unsent::Local("log"))?;
let honcho = honcho.ok_or(Unsent::Local("log"))?;
let messages = batch(&text, &reply, speaker, &self.agent, session, turn);
if messages.len() > MAX_BATCH { if messages.len() > MAX_BATCH {
return Err(Unsent::Local("too-long")); return Err(Unsent::Local("too-long"));
} }
self.clients self.clients
.ensure_session(next.workspace, honcho) .ensure_session(workspace, honcho)
.map_err(Unsent::Honcho)?; .map_err(Unsent::Honcho)?;
let client = self.clients.client(next.workspace); let client = self.clients.client(workspace);
match client.add_messages(&honcho.name, &messages) { match client.add_messages(&honcho.name, &messages) {
Ok(made) => Ok(ids(&made)), Ok(made) => Ok(ids(&made)),
Err(e) => Err(Unsent::Honcho(classify(&e))), Err(e) => Err(Unsent::Honcho(classify(&e))),
@@ -390,72 +484,3 @@ impl Worker {
} }
} }
} }
/// With `[honcho]`, at start: the ledger read through, the sessions that wait again, and the
/// clients with their keys read. Nothing is bound or started yet.
pub struct Opened {
pub sync: Arc<Mutex<TurnSync>>,
clients: Clients,
agent: String,
owner: String,
}
impl Opened {
/// The memory writes' side of the ledger (task 12).
pub fn mirror(&self) -> Mirror {
let ledger = self
.sync
.lock()
.unwrap_or_else(|p| p.into_inner())
.shared_ledger();
Mirror::new(ledger, &self.agent, &self.owner)
}
}
/// Open the ledger (a cut-off last line is logged), resume the waiting sessions, and build the
/// clients (their key files' warnings are logged). A key that cannot be read is `Secret`.
pub fn open(config: &Config, honcho: &HonchoConfig, log: &Log) -> Result<Opened, StartError> {
let path = config.memory_dir().join(LEDGER);
let ledger = Ledger::open(&path).map_err(StartError::Ledger)?;
if ledger.repaired() {
(log)(&format!(
"memoryd: {}: a last line cut off by a crash was dropped",
path.display()
));
}
let mut sync = TurnSync::new(ledger, &honcho.agent, &config.sessions_dir());
sync.resume();
let env = |name: &str| std::env::var_os(name);
let (clients, warnings) =
Clients::new(honcho, &env, Timeouts::WRITES).map_err(StartError::Secret)?;
for warning in warnings {
(log)(&warning);
}
Ok(Opened {
sync: Arc::new(Mutex::new(sync)),
clients,
agent: honcho.agent.clone(),
owner: honcho.owner.clone(),
})
}
/// Start the worker's thread, asking `brokerd` on `config`'s state socket.
pub fn spawn(opened: Opened, config: &Config, wake: Arc<Wake>, log: Log) -> Result<(), String> {
let socket = config.state_socket();
let ask: AskBox =
Box::new(move |session: &SessionId| state::ask(&socket, session, state::WAIT));
let worker = Worker::new(
opened.sync,
opened.clients,
wake,
ask,
log,
Tuning::DEFAULT,
&opened.agent,
);
std::thread::Builder::new()
.name("honcho".to_string())
.spawn(move || worker.run())
.map(|_| ())
.map_err(|e| format!("cannot start the Honcho worker: {e}"))
}
+411
View File
@@ -0,0 +1,411 @@
//! The worker sending memory writes (M5c spec, sections 4 and 5), stepped by hand against the fake
//! Honcho: a conclusion made in the session's `<id>_memory` session with the agent's and the
//! owner's peers; one deleted by the id its making got, or nothing to delete; a 404 on delete as
//! done; an untrusted note as a fleet message from the agent; the text neutralised; one outbox
//! across turns and edits; errors as for turns; a restart sending nothing again. Do not edit.
#[path = "support/honcho.rs"]
mod honcho;
#[path = "support/logs.rs"]
mod logs;
#[path = "support/tmp.rs"]
mod tmp;
#[path = "support/turns.rs"]
mod turns;
#[path = "support/worker.rs"]
mod worker;
use bxcore::sanitize::TAGS;
use bxcore::{SessionId, Timestamp};
use honcho::{FakeHoncho, Mode, Request};
use logs::assistant;
use memoryd::honcho::edits::{MEMORY_SUFFIX, memory_session};
use memoryd::honcho::ledger::{Action, Item, Record, Workspace};
use memoryd::honcho::worker::{MAX_PART, Step};
use serde_json::{Value, json};
use turns::spoken;
use worker::{FAST, Home};
/// The id fixture 11 gives the first conclusion it makes.
const CONCLUSION: &str = "YUBMqIqHhuJprV4o-inrg";
fn sid(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
fn edit(id: &str, part: u32) -> Item {
Item::Edit {
id: id.to_string(),
part,
}
}
fn conclude(peer: &str, text: &str) -> Action {
Action::Conclude {
workspace: Workspace::Trusted,
peer: peer.to_string(),
session: sid("mm-a"),
text: text.to_string(),
}
}
fn unconclude(text: &str) -> Action {
Action::Unconclude {
workspace: Workspace::Trusted,
text: text.to_string(),
}
}
fn fleet(text: &str) -> Action {
Action::Fleet {
session: sid("mm-c"),
text: text.to_string(),
}
}
/// Decide `action` for `item`, as task 12 does when an edit is journalled.
fn decide(home: &Home, item: Item, action: Action) {
let sync = home.sync.lock().unwrap();
sync.ledger()
.append(Record::Decided {
time: Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap(),
item,
action,
})
.unwrap();
}
fn sent_ids(home: &Home, item: &Item) -> Option<Vec<String>> {
home.records().into_iter().find_map(|r| match r {
Record::Sent { item: i, ids, .. } if i == *item => Some(ids),
_ => None,
})
}
fn posts(fake: &FakeHoncho, suffix: &str) -> Vec<Request> {
fake.requests()
.into_iter()
.filter(|r| r.path.ends_with(suffix))
.collect()
}
#[test]
fn the_memory_session_is_named_for_the_boxmaker_session() {
assert_eq!(MEMORY_SUFFIX, "_memory");
assert_eq!(memory_session(&sid("mm-a")), "mm-a_memory");
}
#[test]
fn a_conclusion_goes_into_the_sessions_memory_session() {
let fake = FakeHoncho::start();
let home = Home::new("edits-conclude");
decide(&home, edit("e1", 0), conclude("kyle", "Prefers UTC."));
let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Done);
let w_path = "/v3/workspaces/boxmaker0-trusted";
let seen: Vec<(String, Value)> = fake
.requests()
.into_iter()
.map(|r| (r.path, r.body.unwrap_or(Value::Null)))
.collect();
assert_eq!(
seen,
vec![
(
"/v3/workspaces".to_string(),
json!({ "id": "boxmaker0-trusted" })
),
(
format!("{w_path}/peers"),
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
),
(format!("{w_path}/peers"), json!({ "id": "kyle" })),
(
format!("{w_path}/sessions"),
json!({ "id": "mm-a_memory", "peers": {
"boxmaker0": { "observe_me": false, "observe_others": false },
"kyle": { "observe_me": false, "observe_others": false },
} })
),
(
format!("{w_path}/conclusions"),
json!({ "conclusions": [{
"content": "Prefers UTC.",
"observer_id": "kyle",
"observed_id": "kyle",
"session_id": "mm-a_memory",
}] })
),
]
);
assert_eq!(
sent_ids(&home, &edit("e1", 0)),
Some(vec![CONCLUSION.to_string()])
);
let sync = home.sync.lock().unwrap();
assert_eq!(
sync.ledger().conclusion(Workspace::Trusted, "Prefers UTC."),
Some((edit("e1", 0), CONCLUSION.to_string()))
);
}
#[test]
fn a_replace_deletes_then_makes_and_an_undo_reverses_it() {
let fake = FakeHoncho::start();
let home = Home::new("edits-replace");
decide(&home, edit("e1", 0), conclude("kyle", "old"));
decide(&home, edit("e2", 0), unconclude("old"));
decide(&home, edit("e2", 1), conclude("kyle", "new"));
// The owner's undo of e2: the new deleted, the old made again.
decide(&home, edit("e3", 0), unconclude("new"));
decide(&home, edit("e3", 1), conclude("kyle", "old"));
let mut w = home.worker(&fake, FAST);
for _ in 0..5 {
assert_eq!(w.step(), Step::Done);
}
assert_eq!(w.step(), Step::Idle);
let order: Vec<(String, String)> = fake
.requests()
.into_iter()
.filter(|r| r.path.contains("/conclusions"))
.map(|r| {
let text = r.body.map_or(String::new(), |b| {
b["conclusions"][0]["content"].as_str().unwrap().to_string()
});
(r.method, text)
})
.collect();
let post = |t: &str| ("POST".to_string(), t.to_string());
let delete = ("DELETE".to_string(), String::new());
assert_eq!(
order,
vec![
post("old"),
delete.clone(),
post("new"),
delete,
post("old")
]
);
// Each deletion names the id its conclusion got, and records it.
let deletes = fake
.requests()
.into_iter()
.filter(|r| r.method == "DELETE")
.map(|r| r.path)
.collect::<Vec<_>>();
let path = format!("/v3/workspaces/boxmaker0-trusted/conclusions/{CONCLUSION}");
assert_eq!(deletes, vec![path.clone(), path]);
// The memory session, its workspace and peers are made sure of once in this run.
assert_eq!(posts(&fake, "/sessions").len(), 1);
assert_eq!(posts(&fake, "/peers").len(), 2);
assert_eq!(
sent_ids(&home, &edit("e2", 0)),
Some(vec![CONCLUSION.to_string()])
);
let sync = home.sync.lock().unwrap();
assert_eq!(sync.ledger().conclusion(Workspace::Trusted, "new"), None);
assert_eq!(
sync.ledger().conclusion(Workspace::Trusted, "old"),
Some((edit("e3", 1), CONCLUSION.to_string()))
);
}
#[test]
fn a_deletion_with_nothing_to_delete_is_sent_with_no_id() {
let fake = FakeHoncho::start();
let home = Home::new("edits-nothing");
decide(&home, edit("e1", 0), unconclude("never made"));
// A conclusion that failed is not live either.
decide(&home, edit("e2", 0), conclude("kyle", "refused"));
decide(&home, edit("e3", 0), unconclude("refused"));
let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Done);
assert!(fake.requests().is_empty());
assert_eq!(sent_ids(&home, &edit("e1", 0)), Some(Vec::new()));
fake.set(Mode::Status(422));
assert_eq!(w.step(), Step::Done);
fake.set(Mode::Fixtures);
fake.clear();
assert_eq!(w.step(), Step::Done);
assert!(fake.requests().is_empty(), "{:?}", fake.requests());
assert_eq!(sent_ids(&home, &edit("e3", 0)), Some(Vec::new()));
assert_eq!(
home.lines().len(),
1,
"only the refusal: {:?}",
home.lines()
);
}
#[test]
fn a_404_on_delete_is_done() {
let fake = FakeHoncho::start();
let home = Home::new("edits-404");
decide(&home, edit("e1", 0), conclude("kyle", "x"));
decide(&home, edit("e2", 0), unconclude("x"));
let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Done);
fake.set(Mode::Status(404));
assert_eq!(w.step(), Step::Done);
assert_eq!(
sent_ids(&home, &edit("e2", 0)),
Some(vec![CONCLUSION.to_string()])
);
assert!(home.lines().is_empty(), "{:?}", home.lines());
}
#[test]
fn an_untrusted_note_is_a_fleet_message_from_the_agent() {
let fake = FakeHoncho::start();
let home = Home::new("edits-fleet");
decide(&home, edit("e1", 0), fleet("The page says 3.2."));
let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Done);
let seen: Vec<(String, Value)> = fake
.requests()
.into_iter()
.map(|r| (r.path, r.body.unwrap_or(Value::Null)))
.collect();
assert_eq!(seen.len(), 4);
assert_eq!(seen[0].1["configuration"]["reasoning"]["enabled"], false);
assert_eq!(seen[2].0, "/v3/workspaces/fleet/sessions");
assert_eq!(
seen[2].1,
json!({ "id": "mm-c", "peers": {
"boxmaker0": { "observe_me": false, "observe_others": false },
} })
);
assert_eq!(
seen[3],
(
"/v3/workspaces/fleet/sessions/mm-c/messages".to_string(),
json!({ "messages": [{
"content": "The page says 3.2.",
"peer_id": "boxmaker0",
"metadata": { "agent": "boxmaker0", "boxmaker_session": "mm-c", "edit": "e1" },
}] })
)
);
assert_eq!(sent_ids(&home, &edit("e1", 0)).map(|i| i.len()), Some(1));
// A long note in parts, in one batch.
decide(&home, edit("e2", 0), fleet(&"n".repeat(MAX_PART + 1)));
assert_eq!(w.step(), Step::Done);
let last = posts(&fake, "/messages").pop().unwrap().body.unwrap();
assert_eq!(last["messages"].as_array().unwrap().len(), 2);
}
#[test]
fn a_note_too_long_for_one_batch_is_marked_failed() {
let fake = FakeHoncho::start();
let home = Home::new("edits-long");
decide(&home, edit("e1", 0), fleet(&"n".repeat(MAX_PART * 100 + 1)));
let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Done);
assert!(matches!(
home.records().last(),
Some(Record::Failed { status: None, kind, .. }) if kind == "too-long"
));
assert!(fake.requests().is_empty());
}
#[test]
fn the_text_is_neutralised() {
let fake = FakeHoncho::start();
let home = Home::new("edits-neutral");
decide(
&home,
edit("e1", 0),
conclude("boxmaker0", &format!("a{}b", TAGS[0].text)),
);
decide(&home, edit("e2", 0), fleet(&format!("c{}d", TAGS[1].text)));
let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Done);
assert_eq!(w.step(), Step::Done);
let concluded = posts(&fake, "/conclusions")[0].body.clone().unwrap();
let content = concluded["conclusions"][0]["content"].as_str().unwrap();
assert!(
!content.contains(TAGS[0].text) && content.contains("&lt;"),
"{content}"
);
let message = posts(&fake, "/messages")[0].body.clone().unwrap();
let content = message["messages"][0]["content"].as_str().unwrap();
assert!(
!content.contains(TAGS[1].text) && content.contains("&lt;"),
"{content}"
);
}
#[test]
fn one_outbox_in_order_across_turns_and_edits() {
let fake = FakeHoncho::start();
let mut home = Home::new("edits-order");
home.log(
"mm-a",
&[
vec![spoken("one", Some("kyle")), assistant(2, Some("a"), &[])],
vec![spoken("two", Some("kyle")), assistant(2, Some("b"), &[])],
],
);
home.report("mm-a", 1);
decide(&home, edit("e1", 0), conclude("kyle", "Prefers UTC."));
home.report("mm-a", 2);
let mut w = home.worker(&fake, FAST);
for _ in 0..3 {
assert_eq!(w.step(), Step::Done);
}
let order: Vec<String> = fake
.requests()
.into_iter()
.filter(|r| r.path.ends_with("/messages") || r.path.ends_with("/conclusions"))
.map(|r| r.path)
.collect();
assert_eq!(
order,
vec![
"/v3/workspaces/boxmaker0-trusted/sessions/mm-a/messages".to_string(),
"/v3/workspaces/boxmaker0-trusted/conclusions".to_string(),
"/v3/workspaces/boxmaker0-trusted/sessions/mm-a/messages".to_string(),
]
);
// A restart sends nothing again.
home.restart();
let fake2 = FakeHoncho::start();
let mut w = home.worker(&fake2, FAST);
assert_eq!(w.step(), Step::Idle);
assert!(fake2.requests().is_empty());
}
#[test]
fn errors_as_for_turns() {
let fake = FakeHoncho::start();
let home = Home::new("edits-errors");
decide(&home, edit("e1", 0), conclude("kyle", "private-fact"));
decide(&home, edit("e2", 0), fleet("private-note"));
decide(&home, edit("e3", 0), conclude("kyle", "second"));
let mut w = home.worker(&fake, FAST);
fake.set(Mode::Status(503));
assert!(matches!(w.step(), Step::Backoff(_)));
fake.set(Mode::Status(422));
assert_eq!(w.step(), Step::Done);
assert!(matches!(
home.records().last(),
Some(Record::Failed { status: Some(422), kind, .. }) if kind == "status"
));
fake.set(Mode::Status(401));
assert_eq!(w.step(), Step::Done, "the fleet's key refused");
fake.set(Mode::Fixtures);
assert_eq!(w.step(), Step::Done, "the trusted workspace goes on");
assert!(sent_ids(&home, &edit("e3", 0)).is_some());
assert_eq!(w.step(), Step::Idle, "the fleet note waits");
let lines = home.lines();
assert_eq!(lines.len(), 4, "{lines:?}");
assert!(
lines[2].starts_with("memoryd: part 0 of edit e1 was not sent: "),
"{lines:?}"
);
assert!(lines[3].contains("workspace fleet"), "{lines:?}");
for line in lines {
assert!(!line.contains("private"), "{line}");
}
}
+9 -7
View File
@@ -1,9 +1,9 @@
//! The Honcho worker's other duties (M5c spec, section 5): with nothing to send it decides what //! The Honcho worker's other duties (M5c spec, section 5): with nothing to send it decides what
//! `brokerd` could not answer for; after a restart nothing is sent again, and a session with a //! `brokerd` could not answer for; after a restart nothing is sent again, and a session with a
//! decided turn waits again (a session without one does not); a running turn is never decided; //! decided turn waits again (a session without one does not); a running turn is never decided;
//! actions of later tasks wait; a ledger that cannot be written stops it; no log line holds a //! a retraction waits (nothing decides one); a ledger that cannot be written stops it; no log
//! turn's text. Then `memoryd` itself: a reported turn reaches Honcho, woken at once, and a key //! line holds a turn's text. Then `memoryd` itself: a reported turn reaches Honcho, woken at
//! that cannot be read stops the start. Do not edit. //! once, and a key that cannot be read stops the start. Do not edit.
#[path = "support/brokerd.rs"] #[path = "support/brokerd.rs"]
mod brokerd; mod brokerd;
@@ -150,15 +150,17 @@ fn a_restart_never_decides_a_running_turn() {
} }
#[test] #[test]
fn actions_of_later_tasks_wait() { fn a_retraction_waits() {
// Nothing decides a retraction any more (M5c spec, section 4, 2026-09-28): one in the
// ledger is neither sent nor failed.
let fake = FakeHoncho::start(); let fake = FakeHoncho::start();
let mut home = Home::new("worker-later"); let mut home = Home::new("worker-later");
let conclude = 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":"mm-a","text":"a fact"}}"#; let retract = r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"retract","session":"mm-a","text":"a note"}}"#;
std::fs::write(home.ledger_path(), format!("{conclude}\n")).unwrap(); std::fs::write(home.ledger_path(), format!("{retract}\n")).unwrap();
home.restart(); home.restart();
let mut w = home.worker(&fake, FAST); let mut w = home.worker(&fake, FAST);
assert_eq!(w.step(), Step::Idle); assert_eq!(w.step(), Step::Idle);
assert_eq!(home.records().len(), 1, "the conclusion was failed or sent"); assert_eq!(home.records().len(), 1, "the retraction was failed or sent");
assert!(fake.requests().is_empty()); assert!(fake.requests().is_empty());
} }
+22 -3
View File
@@ -1,6 +1,7 @@
//! A fake Honcho for tests, on 127.0.0.1 and a port of its own: it answers each request from the //! A fake Honcho for tests, on 127.0.0.1 and a port of its own: it answers each request from the
//! responses recorded from rift (`tests/fixtures/honcho/`), by method and path, and records every //! responses recorded from rift (`tests/fixtures/honcho/`), by method and path (a batch of
//! request, body included. It can be set to answer every request with a status instead, or to //! messages or conclusions answered with as many as it asked for, up to the two recorded), and
//! records every request, body included. It can be set to answer every request with a status instead, or to
//! answer nothing. Stopped, and its held connections released, when dropped; every wait has a //! answer nothing. Stopped, and its held connections released, when dropped; every wait has a
//! deadline. Do not edit. //! deadline. Do not edit.
@@ -214,8 +215,13 @@ fn answer(request: &Request, made: &Mutex<HashSet<String>>) -> (u16, String) {
} }
("POST", ["v3", "workspaces", _, "sessions"]) => fixture("06-session-create.json"), ("POST", ["v3", "workspaces", _, "sessions"]) => fixture("06-session-create.json"),
("POST", ["v3", "workspaces", _, "sessions", _, "messages"]) => { ("POST", ["v3", "workspaces", _, "sessions", _, "messages"]) => {
fixture("08-messages-add-batch.json") first(fixture("08-messages-add-batch.json"), request, "messages")
} }
("POST", ["v3", "workspaces", _, "conclusions"]) => first(
fixture("11-conclusions-create-batch.json"),
request,
"conclusions",
),
("DELETE", ["v3", "workspaces", _, "conclusions", _]) if again => { ("DELETE", ["v3", "workspaces", _, "conclusions", _]) if again => {
fixture("16-conclusion-delete-again.json") fixture("16-conclusion-delete-again.json")
} }
@@ -226,6 +232,19 @@ fn answer(request: &Request, made: &Mutex<HashSet<String>>) -> (u16, String) {
} }
} }
/// A recorded batch answer cut to as many items as the request's `field` holds (the recorded ones
/// hold two), so that one message or conclusion gets one id.
fn first(recorded: (u16, String), request: &Request, field: &str) -> (u16, String) {
let asked = request
.body
.as_ref()
.and_then(|b| b[field].as_array())
.map_or(0, Vec::len);
let mut items: Vec<Value> = serde_json::from_str(&recorded.1).unwrap();
items.truncate(asked);
(recorded.0, Value::Array(items).to_string())
}
fn serve(mut stream: TcpStream, shared: &Shared) { fn serve(mut stream: TcpStream, shared: &Shared) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let Some(request) = read_request(&mut stream) else { let Some(request) = read_request(&mut stream) else {
+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/13-memoryd-honcho-edits | 2026-09-28 | done | 1 | pass | none | Copied the given files from `docs/plans/M5c1/files/`: the test `crates/memoryd/tests/honcho_edits.rs` (from `honcho_edits.rs-task13`), new versions of `crates/memoryd/tests/honcho_worker_more.rs` (from `honcho_worker_more.rs-task13`) and `tests/support/honcho.rs` (from `support/honcho.rs`), and the skeletons `crates/memoryd/src/honcho/edits.rs` and `crates/memoryd/src/honcho/start.rs` (given whole). Step 2 showed exactly the named failure: `honcho_edits` with one E0432 (`unresolved import memoryd::honcho::edits`); every other target compiled; `honcho_worker_more` 10, `honcho_client` 13, `honcho_worker` 12 passed. Made H1 (`pub mod edits;` after `pub mod decide;` in `honcho/mod.rs`), H2 (`pub mod start;` after `pub mod naming;`), C1 (`owner: String` field in `Clients`), C2 (`owner: config.owner.clone()` in `Clients::new`) and C3 (`ensure_memory_session`: ensure workspace, the agent's peer, the owner's, then the `<id>_memory` session with both peers `observe_me(false) observe_others(false)`); copied the two skeletons. `cargo check -p memoryd` gave 22 warnings (six unused imports, sixteen unused variables, exactly the task named). Filled its five bodies in the order of the file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: `memory_session` (`format!("{}{MEMORY_SUFFIX}", session.as_str())`); `conclude` (`memory_session`, `ensure_memory_session`, `ConclusionCreate::new(neutralise(text), peer, peer).with_session_id(name)` in `ConclusionBatchCreate::new(vec![..])`, `create_conclusions` -> the ids); `unconclude` (`Some(id)` -> `delete_conclusion` then `vec![id]`, `None` -> `Vec::new()`); `fleet_batch` (`Map` metadata `agent`/`boxmaker_session`/`edit`, `parts(neutralise(text))` each `MessageCreate::new(part, agent)` with the metadata); `fleet` (`HonchoSession { name: session id, speakers: [] }`, `ensure_session(Fleet, ...)`, `add_messages` -> the ids). `cargo check` clean. Made W1 (imports: drop `Config`/`Timeouts`/`HonchoConfig`/`mirror`/`state`/`LEDGER`/`read_log`/`StartError`, add `edits`, re-export `start::{Opened, open, spawn}`); W2 (`Job` enum `Turn`/`Conclude`/`Unconclude`/`Fleet`, `Next` holds `item`/`workspace`/`job`); W3 (`label` naming a turn or a part); W4 and W5 (the two log lines use `label(&next.item)`); W6 (`next` matching every `(Item, Action)` pair into a `Job`, `send` dispatching and `send_turn` split out); W7 (the start code removed, it moves to `start.rs`). `cargo check -p memoryd` clean, `worker.rs` 486 lines. Step 6 all pass: `honcho_edits` 10, `honcho_worker_more` 10, `honcho_worker` 12, `honcho_client` 13, `honcho_keys` 3, `honcho_mirror_serve` 6, `honcho_serve` 6. Step 7: `fn open`/`fn spawn` both in `start.rs`, no `Action::Retract` in `worker.rs`, the other-crates `git diff --stat` empty. `cargo fmt --all`; `make gate` printed `gate: ok` with 1324 tests (1313 in `cargo test --workspace`, plus the 10 in `honcho_edits.rs` and the 1 ignored `end_to_end` the gate runs separately). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
| M5c1/12-memoryd-honcho-mirror | 2026-09-28 | done | 3 | pass | none | Copied the two given tests from `docs/plans/M5c1/files/crates/memoryd/tests/`: `honcho_mirror.rs` (from `honcho_mirror.rs-task12`) and `honcho_mirror_serve.rs` (from `honcho_mirror_serve.rs-task12`), and the skeleton `crates/memoryd/src/honcho/mirror.rs`. Step 2 showed exactly the named failures: `honcho_mirror` with one E0432 (`unresolved import memoryd::honcho::mirror`); `honcho_mirror_serve` 1 pass (`without_honcho_nothing_is_decided`) and 5 fail. Made H1 (`pub mod mirror;` after `pub mod ledger;` in `honcho/mod.rs`) and copied the skeleton; `cargo check` gave 24 warnings (unused `Kind` and `Workspace`, and the twenty unused variables the task named). Filled its seven bodies in the order of the file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: `diff` (`minus(before, after), minus(after, before)`); `minus` (a `left` of `b`'s refs, keep a `text` when `left.iter().position` is `None`, else `swap_remove` that occurrence then drop it); `notes_of` (`flat_map` `notes::parse` over `record.notes`); `session_of` (walk the `undoes` chain bound by `records.len()`, return `SessionId::new` when the session is not `OWNER`, else follow `undoes` and `None` when it or the id is missing); `conclusion` (`Conclude` in the trusted workspace with the session, or `Skip` with `NO_SESSION`); `identity_actions` (`moved` notes' texts filtered out of the diff's dropped side as `Unconclude`, the added side each `conclusion` about the owner for `user.md` else the agent); `note_actions` (`Skip` `flagged: <rule>` when flagged, else each `noted` note, `Fleet` when untrusted with a session else `Skip` without one, else a conclusion about the agent). The `before` block for W2 (`use crate::honcho::naming::{HonchoSession, honcho_session}`) was rustfmt-ordered `{honcho_session, HonchoSession}` in the tree, so I anchored on that actual line. Made T1-T8 (the ledger's own `Arc<Mutex<Ledger>>` in `TurnSync`, `ledger()` returning a `MutexGuard`, the new `shared_ledger()`, and every `self.ledger` call going through it), W1-W9 (the worker takes `self.ledger` and a `sessions` field of its own, `Opened` gains `owner` and `mirror()`), and S1-S7 (`Memoryd` gains `mirror: Option<Mirror>`, `start` builds it from `opened.mirror()`, and `memory` calls `self.mirror(Some(&id))` after `journal::record`, `admin` calls `self.mirror(None)` after an undo or accept). `cargo check` clean with no warning; `honcho_mirror` 8, `honcho_mirror_serve` 6, and every earlier suite pass. `make gate` printed `gate: ok` with 1314 tests (1300 before plus the 14 new). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/12-memoryd-honcho-mirror | 2026-09-28 | done | 3 | pass | none | Copied the two given tests from `docs/plans/M5c1/files/crates/memoryd/tests/`: `honcho_mirror.rs` (from `honcho_mirror.rs-task12`) and `honcho_mirror_serve.rs` (from `honcho_mirror_serve.rs-task12`), and the skeleton `crates/memoryd/src/honcho/mirror.rs`. Step 2 showed exactly the named failures: `honcho_mirror` with one E0432 (`unresolved import memoryd::honcho::mirror`); `honcho_mirror_serve` 1 pass (`without_honcho_nothing_is_decided`) and 5 fail. Made H1 (`pub mod mirror;` after `pub mod ledger;` in `honcho/mod.rs`) and copied the skeleton; `cargo check` gave 24 warnings (unused `Kind` and `Workspace`, and the twenty unused variables the task named). Filled its seven bodies in the order of the file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: `diff` (`minus(before, after), minus(after, before)`); `minus` (a `left` of `b`'s refs, keep a `text` when `left.iter().position` is `None`, else `swap_remove` that occurrence then drop it); `notes_of` (`flat_map` `notes::parse` over `record.notes`); `session_of` (walk the `undoes` chain bound by `records.len()`, return `SessionId::new` when the session is not `OWNER`, else follow `undoes` and `None` when it or the id is missing); `conclusion` (`Conclude` in the trusted workspace with the session, or `Skip` with `NO_SESSION`); `identity_actions` (`moved` notes' texts filtered out of the diff's dropped side as `Unconclude`, the added side each `conclusion` about the owner for `user.md` else the agent); `note_actions` (`Skip` `flagged: <rule>` when flagged, else each `noted` note, `Fleet` when untrusted with a session else `Skip` without one, else a conclusion about the agent). The `before` block for W2 (`use crate::honcho::naming::{HonchoSession, honcho_session}`) was rustfmt-ordered `{honcho_session, HonchoSession}` in the tree, so I anchored on that actual line. Made T1-T8 (the ledger's own `Arc<Mutex<Ledger>>` in `TurnSync`, `ledger()` returning a `MutexGuard`, the new `shared_ledger()`, and every `self.ledger` call going through it), W1-W9 (the worker takes `self.ledger` and a `sessions` field of its own, `Opened` gains `owner` and `mirror()`), and S1-S7 (`Memoryd` gains `mirror: Option<Mirror>`, `start` builds it from `opened.mirror()`, and `memory` calls `self.mirror(Some(&id))` after `journal::record`, `admin` calls `self.mirror(None)` after an undo or accept). `cargo check` clean with no warning; `honcho_mirror` 8, `honcho_mirror_serve` 6, and every earlier suite pass. `make gate` printed `gate: ok` with 1314 tests (1300 before plus the 14 new). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
| M5c1/11-memoryd-honcho-worker | 2026-09-28 | done | 1 | pass | none | Copied the four given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_worker.rs` and `honcho_worker_more.rs` (from `honcho_worker_more.rs-task11`), a new version of `crates/memoryd/tests/honcho_serve.rs` (from `honcho_serve.rs-task11`, replacing the tree's) and the support `crates/memoryd/tests/support/worker.rs` (from `worker.rs-task11`), then the skeleton `crates/memoryd/src/honcho/worker.rs`. Step 2 showed exactly the named failures: `honcho_worker` with three (E0432 for `memoryd::honcho::worker` in the test and in `support/worker.rs`, E0599 for the missing `resume` on `TurnSync`); `honcho_worker_more` with four (the same three plus E0599 for the missing `Secret` on `StartError`); every other target compiled and `honcho_serve` passed its 6 already. Made D1/D2 (`user_text`/`final_reply` `pub` in `decide.rs`), H1 (`pub mod worker;` after `pub mod turns;` in `honcho/mod.rs`), T1 (`sessions` in `impl TurnSync`), T2 (`resume` walking `decisions` deduped by id then reading each log, and `append` delegating to `ledger.append` before `updated`'s doc), T3 (`updated` calling `self.retry` then the new `retry`), S1 (imports: drop `Ledger`, add `worker::{self, Wake}`), S2 (`Memoryd` holds `Arc<Mutex<TurnSync>>` and a `wake`), S3 (`StartError::Secret(String)`), S4 (the `Display` arm), S5 (`start` calling `worker::open`), S6 (`wake` built and `worker::spawn` bound after the sockets), S7 (`wake` in the `Memoryd`), S8 (`decide_turns` dropping the lock then `wake.notify()`), M1 (`main.rs` matching both `Ledger` and `Secret`). Filled its eleven bodies in the order of the file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: `notify` (lock, set flag, `notify_one`); `wait` (lock, `wait_timeout_while(..., |woken| !*woken)`, clear the flag); `parts` (collect `chars`, `chunks(MAX_PART)`); `read_turn` (`read_log` ok, `turn_records`, `user_text`, `final_reply`, both `to_string`); `batch` (a `Map` with `agent`/`boxmaker_session`/`turn` in order, then for each of `(text,speaker)`/`(reply,agent)` the neutralised `parts`, each a `MessageCreate` with the metadata); `ids` (`m.id.clone()`); `next` (lock, `outbox().find_map`, `Item::Turn` and `Action::Turn` by `let .. else`, skip when `refused.contains(workspace)`, build `Next` with `honcho_session`); `unreachable` (the `outage` match, log the start once, double-and-cap the wait, `Backoff`); `recovered` (`outage.take()` ends it, log once, `retry`); `retry` (lock, `sync.retry`); `record` (`append`, `Ok` -> `Done`, `Err` logs with `ledger::RUNBOOK`, sets `stopped`, `Stopped`). After the last body `cargo check -p memoryd` is clean with no warning. Step 5 all pass: `honcho_worker` 12, `honcho_worker_more` 10, `honcho_serve` 6, and every unchanged suite. Step 6 all pass: the `thread::spawn`/`Builder::new` grep prints one line in `spawn`, the three runbook-pointer greps print the three constants, and the other-crates `git diff --stat` is empty. Appended the three runbook fragments (`runbook-honcho-unreachable`, `-send-refused`, `-key-refused`) with `{ echo; cat ... } >>`; `grep -c '^## honcho-'` printed 5. `make gate` printed `gate: ok` with 1300 tests (1299 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/11-memoryd-honcho-worker | 2026-09-28 | done | 1 | pass | none | Copied the four given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_worker.rs` and `honcho_worker_more.rs` (from `honcho_worker_more.rs-task11`), a new version of `crates/memoryd/tests/honcho_serve.rs` (from `honcho_serve.rs-task11`, replacing the tree's) and the support `crates/memoryd/tests/support/worker.rs` (from `worker.rs-task11`), then the skeleton `crates/memoryd/src/honcho/worker.rs`. Step 2 showed exactly the named failures: `honcho_worker` with three (E0432 for `memoryd::honcho::worker` in the test and in `support/worker.rs`, E0599 for the missing `resume` on `TurnSync`); `honcho_worker_more` with four (the same three plus E0599 for the missing `Secret` on `StartError`); every other target compiled and `honcho_serve` passed its 6 already. Made D1/D2 (`user_text`/`final_reply` `pub` in `decide.rs`), H1 (`pub mod worker;` after `pub mod turns;` in `honcho/mod.rs`), T1 (`sessions` in `impl TurnSync`), T2 (`resume` walking `decisions` deduped by id then reading each log, and `append` delegating to `ledger.append` before `updated`'s doc), T3 (`updated` calling `self.retry` then the new `retry`), S1 (imports: drop `Ledger`, add `worker::{self, Wake}`), S2 (`Memoryd` holds `Arc<Mutex<TurnSync>>` and a `wake`), S3 (`StartError::Secret(String)`), S4 (the `Display` arm), S5 (`start` calling `worker::open`), S6 (`wake` built and `worker::spawn` bound after the sockets), S7 (`wake` in the `Memoryd`), S8 (`decide_turns` dropping the lock then `wake.notify()`), M1 (`main.rs` matching both `Ledger` and `Secret`). Filled its eleven bodies in the order of the file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: `notify` (lock, set flag, `notify_one`); `wait` (lock, `wait_timeout_while(..., |woken| !*woken)`, clear the flag); `parts` (collect `chars`, `chunks(MAX_PART)`); `read_turn` (`read_log` ok, `turn_records`, `user_text`, `final_reply`, both `to_string`); `batch` (a `Map` with `agent`/`boxmaker_session`/`turn` in order, then for each of `(text,speaker)`/`(reply,agent)` the neutralised `parts`, each a `MessageCreate` with the metadata); `ids` (`m.id.clone()`); `next` (lock, `outbox().find_map`, `Item::Turn` and `Action::Turn` by `let .. else`, skip when `refused.contains(workspace)`, build `Next` with `honcho_session`); `unreachable` (the `outage` match, log the start once, double-and-cap the wait, `Backoff`); `recovered` (`outage.take()` ends it, log once, `retry`); `retry` (lock, `sync.retry`); `record` (`append`, `Ok` -> `Done`, `Err` logs with `ledger::RUNBOOK`, sets `stopped`, `Stopped`). After the last body `cargo check -p memoryd` is clean with no warning. Step 5 all pass: `honcho_worker` 12, `honcho_worker_more` 10, `honcho_serve` 6, and every unchanged suite. Step 6 all pass: the `thread::spawn`/`Builder::new` grep prints one line in `spawn`, the three runbook-pointer greps print the three constants, and the other-crates `git diff --stat` is empty. Appended the three runbook fragments (`runbook-honcho-unreachable`, `-send-refused`, `-key-refused`) with `{ echo; cat ... } >>`; `grep -c '^## honcho-'` printed 5. `make gate` printed `gate: ok` with 1300 tests (1299 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
| M5c1/10-memoryd-honcho-client | 2026-09-28 | done | 1 | pass | none | Copied the given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_client.rs`, `honcho_keys.rs` and `honcho_naming.rs` (from `honcho_naming.rs-task10`) and the support `crates/memoryd/tests/support/honcho.rs` (from `honcho.rs-task10`), and the directory `crates/memoryd/tests/fixtures/honcho/` (30 files, 29 JSON and a README). Step 2 showed exactly the named failures: three test targets (`honcho_client`, `honcho_keys`, `honcho_naming`) with E0432 for the `memoryd::honcho::client` and `...::naming` imports and E0599 for the missing `decisions` method on `Ledger`; every other target compiled. Made H1 (`pub mod client;` and `pub mod naming;` in `honcho/mod.rs`, the former first so the imports resolve and the latter last to keep the alphabetical order) and L1 (the `decisions` method before `outbox`, walking `order` with `.iter().filter_map` returning `(item, &self.entries.get(item)?.action)` for every decided item, `Skip` included), then copied the two skeletons; `cargo check` gave 26 warnings (unused `json`; unused `PeerCreate` and `WorkspaceCreate`; unused variables `workspace` five, `session` twice, the rest once each; the three `Clients` fields never read; the `Ensured::Workspace` and `Peer` variants never constructed). Filled its thirteen bodies in the order of each file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: in `client.rs` `kind` (one arm for the two `{ kind, .. }` variants, `"key-refused"` for `KeyRefused`), `status` (the two `{ status, .. }` arms, `Some(*status)` for `KeyRefused`), `fleet_configuration` (`Map::new`, insert each of `reasoning`, `summary`, `dream` -> `{ "enabled": false }`), `session_peers` (agent `observe_me(false)`, each speaker `observe_me(true)`), `created` (`Ok` and a `Refused` 409 are success), the free `client` (chain `.without_config_file()`, `.base_url`, `.workspace_id`, the two timeouts, `.api_key` only when there is a key, `.build()`), `key` (`config.keys.get(name)` else `None`, `secrets::load`, extend the warnings, return the secret), `Clients::client` (match the workspace to its field), `ensure_workspace` (the fleet one with `fleet_configuration`, the other two with defaults, remembered), `ensure_peer` (the agent's peer with `observe_me(false)`, remembered), `delete_conclusion` (Ok and a 404 are done); in `naming.rs` `session_name` (the id for `count <= 1`, `<id>-<count>` otherwise) and `honcho_session` (walk `decisions`, keep each `Turn` of the session's speakers each counted once in decision order, return when the turn-th is reached, else `None`). After the last `cargo check` is clean with no warning; `cargo test -p memoryd --test honcho_client --test honcho_keys --test honcho_naming` gives 13, 3 (one ignored, run by the third) and 5 pass. Step 6 all pass: `without_config_file` counts once, `expose()` appears once in the free `client`, the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1278 tests (1257 before this task, plus the 21 new: 13, 3 and 5). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/10-memoryd-honcho-client | 2026-09-28 | done | 1 | pass | none | Copied the given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_client.rs`, `honcho_keys.rs` and `honcho_naming.rs` (from `honcho_naming.rs-task10`) and the support `crates/memoryd/tests/support/honcho.rs` (from `honcho.rs-task10`), and the directory `crates/memoryd/tests/fixtures/honcho/` (30 files, 29 JSON and a README). Step 2 showed exactly the named failures: three test targets (`honcho_client`, `honcho_keys`, `honcho_naming`) with E0432 for the `memoryd::honcho::client` and `...::naming` imports and E0599 for the missing `decisions` method on `Ledger`; every other target compiled. Made H1 (`pub mod client;` and `pub mod naming;` in `honcho/mod.rs`, the former first so the imports resolve and the latter last to keep the alphabetical order) and L1 (the `decisions` method before `outbox`, walking `order` with `.iter().filter_map` returning `(item, &self.entries.get(item)?.action)` for every decided item, `Skip` included), then copied the two skeletons; `cargo check` gave 26 warnings (unused `json`; unused `PeerCreate` and `WorkspaceCreate`; unused variables `workspace` five, `session` twice, the rest once each; the three `Clients` fields never read; the `Ensured::Workspace` and `Peer` variants never constructed). Filled its thirteen bodies in the order of each file, deleting each instruction comment and keeping the doc comments, each from its comment and `cargo check` after: in `client.rs` `kind` (one arm for the two `{ kind, .. }` variants, `"key-refused"` for `KeyRefused`), `status` (the two `{ status, .. }` arms, `Some(*status)` for `KeyRefused`), `fleet_configuration` (`Map::new`, insert each of `reasoning`, `summary`, `dream` -> `{ "enabled": false }`), `session_peers` (agent `observe_me(false)`, each speaker `observe_me(true)`), `created` (`Ok` and a `Refused` 409 are success), the free `client` (chain `.without_config_file()`, `.base_url`, `.workspace_id`, the two timeouts, `.api_key` only when there is a key, `.build()`), `key` (`config.keys.get(name)` else `None`, `secrets::load`, extend the warnings, return the secret), `Clients::client` (match the workspace to its field), `ensure_workspace` (the fleet one with `fleet_configuration`, the other two with defaults, remembered), `ensure_peer` (the agent's peer with `observe_me(false)`, remembered), `delete_conclusion` (Ok and a 404 are done); in `naming.rs` `session_name` (the id for `count <= 1`, `<id>-<count>` otherwise) and `honcho_session` (walk `decisions`, keep each `Turn` of the session's speakers each counted once in decision order, return when the turn-th is reached, else `None`). After the last `cargo check` is clean with no warning; `cargo test -p memoryd --test honcho_client --test honcho_keys --test honcho_naming` gives 13, 3 (one ignored, run by the third) and 5 pass. Step 6 all pass: `without_config_file` counts once, `expose()` appears once in the free `client`, the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1278 tests (1257 before this task, plus the 21 new: 13, 3 and 5). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |