memoryd: a Honcho client per workspace, and its workspace, peers and sessions made sure of

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-28 12:13:48 -07:00
co-authored by ornith-1.5-35b-a3b
parent 28df2a0e3b
commit aabfbc2f0a
39 changed files with 2324 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
//! This agent's three Honcho workspaces through `roncho`'s blocking client (M5c spec, section 6):
//! one client per workspace, with its own key, and the steps that make sure a workspace, a peer
//! and a session exist before anything is written to them. Nothing here keeps or logs a response
//! body or a key: an error is its kind and its status.
use std::collections::{HashMap, HashSet};
use std::ffi::OsString;
use std::time::Duration;
use bxcore::secrets;
use roncho::blocking::{Client, Error};
use roncho::{PeerCreate, SessionCreate, SessionPeerConfig, WorkspaceCreate};
use serde_json::{Map, Value, json};
use crate::honcho::config::{FLEET_KEY, HonchoConfig, TRUSTED_KEY, UNTRUSTED_KEY};
use crate::honcho::ledger::Workspace;
use crate::honcho::naming::HonchoSession;
/// How long a connection may take, and a read may go without a byte.
pub const CONNECT: Duration = Duration::from_secs(5);
pub const READ: Duration = Duration::from_secs(30);
/// A chat's read: a non-streamed chat sends nothing until it is done (M5c-2's `ask_memory`).
pub const CHAT_READ: Duration = Duration::from_secs(180);
/// The timeouts a client is built with. Tests shorten them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timeouts {
pub connect: Duration,
pub read: Duration,
}
impl Timeouts {
pub const WRITES: Timeouts = Timeouts {
connect: CONNECT,
read: READ,
};
}
/// Why a call to Honcho failed: its kind and status only, never the response's body.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HonchoError {
/// No answer that counts: no connection, a timeout, a closed socket, or a 5xx. Try again later.
Unreachable {
kind: &'static str,
status: Option<u16>,
},
/// Honcho answered, and will answer the same again: a 4xx, a redirect, or a body that is not
/// what `roncho` expects.
Refused {
kind: &'static str,
status: Option<u16>,
},
/// The workspace's key was refused (401 or 403).
KeyRefused { status: u16 },
}
impl HonchoError {
pub fn kind(&self) -> &'static str {
match self {
HonchoError::Unreachable { kind, .. } | HonchoError::Refused { kind, .. } => kind,
HonchoError::KeyRefused { .. } => "key-refused",
}
}
pub fn status(&self) -> Option<u16> {
match self {
HonchoError::Unreachable { status, .. } | HonchoError::Refused { status, .. } => {
*status
}
HonchoError::KeyRefused { status } => Some(*status),
}
}
}
impl std::fmt::Display for HonchoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let what = match self {
HonchoError::Unreachable { .. } => "Honcho is unreachable",
HonchoError::Refused { .. } => "Honcho refused the request",
HonchoError::KeyRefused { .. } => "Honcho refused the key",
};
match self.status() {
Some(status) => write!(f, "{what} ({}, HTTP {status})", self.kind()),
None => write!(f, "{what} ({})", self.kind()),
}
}
}
impl std::error::Error for HonchoError {}
/// `roncho`'s error as a `HonchoError`. Its text is dropped: a `Status` or `Decode` error carries
/// up to 4 KiB of the response, which can echo memory text (a 422 echoes the whole input).
pub fn classify(error: &Error) -> HonchoError {
let unreachable = |kind, status| HonchoError::Unreachable { kind, status };
let refused = |kind, status| HonchoError::Refused { kind, status };
match error {
Error::Connect(_) => unreachable("connect", None),
Error::Timeout => unreachable("timeout", None),
Error::Closed => unreachable("closed", None),
Error::Io(_) => unreachable("io", None),
Error::Status { status, .. } if *status >= 500 => unreachable("server", Some(*status)),
Error::Status { status, .. } => refused("status", Some(*status)),
Error::Unauthorized { status } => HonchoError::KeyRefused { status: *status },
Error::Redirect { status } => refused("redirect", Some(*status)),
Error::ActiveSessions { .. } => refused("active-sessions", Some(409)),
Error::TooLarge => refused("too-large", None),
Error::Decode(_) => refused("decode", None),
Error::Stream(_) => refused("stream", None),
Error::Config(_) => refused("config", None),
_ => refused("other", None),
}
}
/// What has been made sure of in one workspace during this run.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Ensured {
Workspace,
Peer(String),
Session(String),
}
/// The three clients, and what they have made sure of since `memoryd` started. After a restart
/// everything is made sure of again: each step is get-or-create, so that costs a request, no more.
#[derive(Debug)]
pub struct Clients {
trusted: Client,
untrusted: Client,
fleet: Client,
agent: String,
ensured: HashSet<(Workspace, Ensured)>,
}
/// The fleet workspace's configuration: nothing reasons over the fleet's notes (M5c spec,
/// section 6). All three are set, since Honcho checks each on its own.
pub fn fleet_configuration() -> Map<String, Value> {
let mut map = Map::new();
for feature in ["reasoning", "summary", "dream"] {
map.insert(feature.to_string(), json!({ "enabled": false }));
}
map
}
/// A session's peers: the agent's, unobserved, and each speaker's, observed; no peer forms a
/// picture of the others.
pub fn session_peers(agent: &str, speakers: &[String]) -> HashMap<String, SessionPeerConfig> {
let mut peers = HashMap::new();
peers.insert(
agent.to_string(),
SessionPeerConfig::new()
.observe_me(false)
.observe_others(false),
);
for speaker in speakers {
peers.insert(
speaker.clone(),
SessionPeerConfig::new()
.observe_me(true)
.observe_others(false),
);
}
peers
}
/// A create that found the thing there (409) did what it was for.
fn created<T>(result: Result<T, Error>) -> Result<(), HonchoError> {
match result {
Ok(_) => Ok(()),
Err(e) => match classify(&e) {
HonchoError::Refused {
status: Some(409), ..
} => Ok(()),
other => Err(other),
},
}
}
/// One workspace's client: no `roncho.toml` read, the timeouts given, the key when there is one.
fn client(
url: &str,
workspace: &str,
key: Option<&secrets::Secret>,
timeouts: Timeouts,
) -> Result<Client, String> {
let mut builder = Client::builder()
.without_config_file()
.base_url(url)
.workspace_id(workspace)
.connect_timeout(timeouts.connect)
.read_timeout(timeouts.read);
if let Some(key) = key {
builder = builder.api_key(key.expose());
}
builder
.build()
.map_err(|e| format!("the Honcho client for {workspace}: {e}"))
}
/// The key named `name` in `[secrets]`, if one is configured, read now.
fn key(
config: &HonchoConfig,
name: &str,
env: &dyn Fn(&str) -> Option<OsString>,
warnings: &mut Vec<String>,
) -> Result<Option<secrets::Secret>, String> {
let Some(source) = config.keys.get(name) else {
return Ok(None);
};
let loaded = secrets::load("memoryd", name, source, env).map_err(|e| e.to_string())?;
warnings.extend(loaded.warning);
Ok(Some(loaded.secret))
}
impl Clients {
/// A client for each of the three workspaces, each with its key when one is configured. The
/// warnings are the key files' plaintext warnings, for the caller to log. An error names the
/// key or the workspace, never a key's value.
pub fn new(
config: &HonchoConfig,
env: &dyn Fn(&str) -> Option<OsString>,
timeouts: Timeouts,
) -> Result<(Clients, Vec<String>), String> {
let mut warnings = Vec::new();
let trusted_key = key(config, TRUSTED_KEY, env, &mut warnings)?;
let untrusted_key = key(config, UNTRUSTED_KEY, env, &mut warnings)?;
let fleet_key = key(config, FLEET_KEY, env, &mut warnings)?;
let clients = Clients {
trusted: client(&config.url, &config.trusted, trusted_key.as_ref(), timeouts)?,
untrusted: client(
&config.url,
&config.untrusted,
untrusted_key.as_ref(),
timeouts,
)?,
fleet: client(&config.url, &config.fleet, fleet_key.as_ref(), timeouts)?,
agent: config.agent.clone(),
ensured: HashSet::new(),
};
Ok((clients, warnings))
}
/// The client of `workspace`.
pub fn client(&self, workspace: Workspace) -> &Client {
match workspace {
Workspace::Trusted => &self.trusted,
Workspace::Untrusted => &self.untrusted,
Workspace::Fleet => &self.fleet,
}
}
/// Make sure `workspace` exists; the fleet one is made with `fleet_configuration`, the other
/// two with Honcho's defaults.
pub fn ensure_workspace(&mut self, workspace: Workspace) -> Result<(), HonchoError> {
let key = (workspace, Ensured::Workspace);
if self.ensured.contains(&key) {
return Ok(());
}
let client = self.client(workspace);
let mut create = WorkspaceCreate::new(client.workspace_id());
if workspace == Workspace::Fleet {
create = create.with_configuration(fleet_configuration());
}
created(client.get_or_create_workspace(&create))?;
self.ensured.insert(key);
Ok(())
}
/// Make sure peer `peer` exists in `workspace`. The agent's own peer is made unobserved
/// (`observe_me` false): Honcho draws no conclusions from the agent's own output.
pub fn ensure_peer(&mut self, workspace: Workspace, peer: &str) -> Result<(), HonchoError> {
let key = (workspace, Ensured::Peer(peer.to_string()));
if self.ensured.contains(&key) {
return Ok(());
}
let mut create = PeerCreate::new(peer);
if peer == self.agent {
let mut configuration = Map::new();
configuration.insert("observe_me".to_string(), json!(false));
create = create.with_configuration(configuration);
}
created(self.client(workspace).peer(&create))?;
self.ensured.insert(key);
Ok(())
}
/// Make sure `session` exists in `workspace`, with the agent's peer and its speakers' peers,
/// after the workspace and every peer. A session must exist before a message is added to
/// it: Honcho makes a missing one on the fly, with no peers configured.
pub fn ensure_session(
&mut self,
workspace: Workspace,
session: &HonchoSession,
) -> Result<(), HonchoError> {
self.ensure_workspace(workspace)?;
let agent = self.agent.clone();
self.ensure_peer(workspace, &agent)?;
for speaker in &session.speakers {
self.ensure_peer(workspace, speaker)?;
}
let key = (workspace, Ensured::Session(session.name.clone()));
if self.ensured.contains(&key) {
return Ok(());
}
let peers = session_peers(&agent, &session.speakers);
let create = SessionCreate::new(session.name.as_str()).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.
pub fn delete_conclusion(&self, workspace: Workspace, id: &str) -> Result<(), HonchoError> {
match self.client(workspace).delete_conclusion(id) {
Ok(()) => Ok(()),
Err(e) => match classify(&e) {
HonchoError::Refused {
status: Some(404), ..
} => Ok(()),
other => Err(other),
},
}
}
}
+7
View File
@@ -208,6 +208,13 @@ impl Ledger {
self.entries.contains_key(item) self.entries.contains_key(item)
} }
/// Every decided item with its action, `Skip` included, in the order they were decided.
pub fn decisions(&self) -> impl Iterator<Item = (&Item, &Action)> + '_ {
self.order
.iter()
.filter_map(|item| Some((item, &self.entries.get(item)?.action)))
}
/// The items waiting to be sent, with their actions, in the order they were decided. /// The items waiting to be sent, with their actions, in the order they were decided.
pub fn outbox(&self) -> Vec<(Item, Action)> { pub fn outbox(&self) -> Vec<(Item, Action)> {
self.order self.order
+2
View File
@@ -1,7 +1,9 @@
//! 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 client;
pub mod config; pub mod config;
pub mod decide; pub mod decide;
pub mod ledger; pub mod ledger;
pub mod naming;
pub mod state; pub mod state;
pub mod turns; pub mod turns;
+61
View File
@@ -0,0 +1,61 @@
//! Which Honcho session a turn goes to (M5c spec, section 3). A Boxmaker session maps to the Honcho
//! session of its own id; a speaker new to it starts the next one, `<id>-2`, `<id>-3` and so on,
//! holding every speaker so far, since a Honcho session's peers are fixed when it is made. The
//! numbering is read from the ledger alone, so a restart numbers the same way.
use bxcore::SessionId;
use crate::honcho::ledger::{Action, Item};
/// A Honcho session: its name, and the speakers' peers it holds besides the agent's.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HonchoSession {
pub name: String,
pub speakers: Vec<String>,
}
/// The name of the Honcho session for the `count`-th speaker of `session`: the id itself for the
/// first (and for none), then `<id>-<count>`.
pub fn session_name(session: &SessionId, count: usize) -> String {
if count <= 1 {
session.as_str().to_string()
} else {
format!("{}-{count}", session.as_str())
}
}
/// The Honcho session of turn `turn` of `session`, from the ledger's decisions in their order:
/// the speakers of that session's `Turn` actions (in any workspace) up to and including this
/// turn, each counted once, in the order they first spoke. `None` when the turn has no `Turn`
/// action: undecided, or decided to go nowhere.
pub fn honcho_session<'a, I>(decisions: I, session: &SessionId, turn: u64) -> Option<HonchoSession>
where
I: IntoIterator<Item = (&'a Item, &'a Action)>,
{
let mut speakers: Vec<String> = Vec::new();
for (item, action) in decisions {
let Item::Turn {
session: s,
turn: t,
} = item
else {
continue;
};
let Action::Turn { speaker, .. } = action else {
continue;
};
if s != session {
continue;
}
if !speakers.contains(speaker) {
speakers.push(speaker.clone());
}
if *t == turn {
return Some(HonchoSession {
name: session_name(session, speakers.len()),
speakers,
});
}
}
None
}
@@ -0,0 +1,42 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces",
"body": {
"id": "boxmaker-plan-probe",
"configuration": {
"dream": {
"enabled": false
},
"reasoning": {
"enabled": false
},
"summary": {
"enabled": false
}
}
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {
"reasoning": {
"enabled": false
},
"summary": {
"enabled": false
},
"dream": {
"enabled": false
}
},
"created_at": "2026-09-28T05:55:49.212118Z"
}
}
}
@@ -0,0 +1,42 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces",
"body": {
"id": "boxmaker-plan-probe",
"configuration": {
"dream": {
"enabled": false
},
"reasoning": {
"enabled": false
},
"summary": {
"enabled": false
}
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {
"reasoning": {
"enabled": false
},
"summary": {
"enabled": false
},
"dream": {
"enabled": false
}
},
"created_at": "2026-09-28T05:55:49.212118Z"
}
}
}
@@ -0,0 +1,22 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/peers",
"body": {
"id": "kyle"
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "kyle",
"workspace_id": "boxmaker-plan-probe",
"created_at": "2026-09-28T05:55:56.303995Z",
"metadata": {},
"configuration": {}
}
}
}
@@ -0,0 +1,27 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/peers",
"body": {
"id": "boxmaker0",
"configuration": {
"observe_me": false
}
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "boxmaker0",
"workspace_id": "boxmaker-plan-probe",
"created_at": "2026-09-28T05:55:56.326330Z",
"metadata": {},
"configuration": {
"observe_me": false
}
}
}
}
@@ -0,0 +1,27 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/peers",
"body": {
"id": "boxmaker0",
"configuration": {
"observe_me": false
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "boxmaker0",
"workspace_id": "boxmaker-plan-probe",
"created_at": "2026-09-28T05:55:56.326330Z",
"metadata": {},
"configuration": {
"observe_me": false
}
}
}
}
@@ -0,0 +1,33 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions",
"body": {
"id": "s-probe-1",
"peers": {
"kyle": {
"observe_me": true,
"observe_others": false
},
"boxmaker0": {
"observe_me": false,
"observe_others": false
}
}
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "s-probe-1",
"is_active": true,
"workspace_id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {},
"created_at": "2026-09-28T05:56:01.274224Z"
}
}
}
@@ -0,0 +1,33 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions",
"body": {
"id": "s-probe-1",
"peers": {
"kyle": {
"observe_me": true,
"observe_others": false
},
"boxmaker0": {
"observe_me": false,
"observe_others": false
}
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"id": "s-probe-1",
"is_active": true,
"workspace_id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {},
"created_at": "2026-09-28T05:56:01.274224Z"
}
}
}
@@ -0,0 +1,64 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-probe-1/messages",
"body": {
"messages": [
{
"content": "I prefer tea to coffee in the afternoon.",
"peer_id": "kyle",
"metadata": {
"agent": "boxmaker0",
"boxmaker_session": "s-probe-1",
"turn": 1
}
},
{
"content": "Noted: tea in the afternoon.",
"peer_id": "boxmaker0",
"metadata": {
"agent": "boxmaker0",
"boxmaker_session": "s-probe-1",
"turn": 1
}
}
]
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": [
{
"id": "7HqDWzXcKAdw9tnElCNFg",
"content": "I prefer tea to coffee in the afternoon.",
"peer_id": "kyle",
"session_id": "s-probe-1",
"metadata": {
"agent": "boxmaker0",
"boxmaker_session": "s-probe-1",
"turn": 1
},
"created_at": "2026-09-28T05:56:08.742363Z",
"workspace_id": "boxmaker-plan-probe",
"token_count": 9
},
{
"id": "mjsyhwuGsXTh1_zG_tWRx",
"content": "Noted: tea in the afternoon.",
"peer_id": "boxmaker0",
"session_id": "s-probe-1",
"metadata": {
"agent": "boxmaker0",
"boxmaker_session": "s-probe-1",
"turn": 1
},
"created_at": "2026-09-28T05:56:08.742363Z",
"workspace_id": "boxmaker-plan-probe",
"token_count": 8
}
]
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,52 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions",
"body": {
"conclusions": [
{
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1"
},
{
"content": "Kyle prefers tea in the afternoon.",
"observer_id": "kyle",
"observed_id": "kyle",
"session_id": "s-probe-1"
}
]
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": [
{
"id": "YUBMqIqHhuJprV4o-inrg",
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
},
{
"id": "JuoCFtoo5lwFL7O4ySZ6f",
"content": "Kyle prefers tea in the afternoon.",
"observer_id": "kyle",
"observed_id": "kyle",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
}
]
}
}
@@ -0,0 +1,32 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/query",
"body": {
"query": "builds",
"filters": {
"observed_id": "boxmaker0",
"observer_id": "boxmaker0"
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": [
{
"id": "YUBMqIqHhuJprV4o-inrg",
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
}
]
}
}
@@ -0,0 +1,43 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/list",
"body": {}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [
{
"id": "YUBMqIqHhuJprV4o-inrg",
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
},
{
"id": "JuoCFtoo5lwFL7O4ySZ6f",
"content": "Kyle prefers tea in the afternoon.",
"observer_id": "kyle",
"observed_id": "kyle",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
}
],
"total": 2,
"page": 1,
"size": 50,
"pages": 1
}
}
}
@@ -0,0 +1,37 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/list",
"body": {
"filters": {
"observed_id": "boxmaker0",
"observer_id": "boxmaker0"
}
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [
{
"id": "YUBMqIqHhuJprV4o-inrg",
"content": "The owner's builds run on straylight.",
"observer_id": "boxmaker0",
"observed_id": "boxmaker0",
"session_id": "s-probe-1",
"level": "explicit",
"source_ids": null,
"times_derived": 1,
"created_at": "2026-09-28T05:56:22.130594Z"
}
],
"total": 1,
"page": 1,
"size": 50,
"pages": 1
}
}
}
@@ -0,0 +1,14 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/YUBMqIqHhuJprV4o-inrg",
"body": null
},
"response": {
"status": 204,
"headers": {
"content-type": "application/json"
},
"body": ""
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/conclusions/YUBMqIqHhuJprV4o-inrg",
"body": null
},
"response": {
"status": 404,
"headers": {
"content-type": "application/json"
},
"body": {
"detail": "Document YUBMqIqHhuJprV4o-inrg not found or does not belong to workspace boxmaker-plan-probe"
}
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/chat",
"body": {
"query": "What does the owner drink in the afternoon?",
"reasoning_level": "minimal"
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"content": "Based on what I found in memory, the owner in this workspace is **kyle**.\n\nThe relevant exchange (session s-probe-1, 2026-09-28):\n\n- **kyle**: \"I prefer tea to coffee in the afternoon.\"\n- **boxmaker0**: \"Noted: tea in the afternoon.\"\n\nSo the owner (kyle) drinks **tea** in the afternoon — specifically, tea rather than coffee.\n\nOne caveat: the second message from kyle in that conversation is a long run of repeated \"a\"s rather than a substantive statement, so there's no additional afternoon-drink detail beyond the tea preference.",
"evidence": null
}
}
}
@@ -0,0 +1,32 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-missing/messages",
"body": {
"messages": [
{
"content": "probe",
"peer_id": "kyle"
}
]
}
},
"response": {
"status": 201,
"headers": {
"content-type": "application/json"
},
"body": [
{
"id": "HaXlV5eTh7GTEuID4JXir",
"content": "probe",
"peer_id": "kyle",
"session_id": "s-missing",
"metadata": {},
"created_at": "2026-09-28T05:57:01.015107Z",
"workspace_id": "boxmaker-plan-probe",
"token_count": 1
}
]
}
}
@@ -0,0 +1,31 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/peers",
"body": {
"id": "bad peer"
}
},
"response": {
"status": 422,
"headers": {
"content-type": "application/json"
},
"body": {
"detail": [
{
"type": "string_pattern_mismatch",
"loc": [
"body",
"id"
],
"msg": "String should match pattern '^[a-zA-Z0-9_-]+$'",
"input": "bad peer",
"ctx": {
"pattern": "^[a-zA-Z0-9_-]+$"
}
}
]
}
}
}
@@ -0,0 +1,20 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-never/messages/list",
"body": {}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [],
"total": 0,
"page": 1,
"size": 50,
"pages": 0
}
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-never/search",
"body": {
"query": "tea"
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": []
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-never",
"body": null
},
"response": {
"status": 404,
"headers": {
"content-type": "application/json"
},
"body": {
"detail": "Session s-never not found in workspace boxmaker-plan-probe"
}
}
}
@@ -0,0 +1,37 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/list",
"body": {}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [
{
"id": "s-probe-1",
"is_active": true,
"workspace_id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {},
"created_at": "2026-09-28T05:56:01.274224Z"
},
{
"id": "s-missing",
"is_active": true,
"workspace_id": "boxmaker-plan-probe",
"metadata": {},
"configuration": {},
"created_at": "2026-09-28T05:57:00.994684Z"
}
],
"total": 2,
"page": 1,
"size": 50,
"pages": 1
}
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-probe-1",
"body": null
},
"response": {
"status": 202,
"headers": {
"content-type": "application/json"
},
"body": {
"message": "Session deleted successfully"
}
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/s-missing",
"body": null
},
"response": {
"status": 202,
"headers": {
"content-type": "application/json"
},
"body": {
"message": "Session deleted successfully"
}
}
}
@@ -0,0 +1,20 @@
{
"request": {
"method": "POST",
"path": "/v3/workspaces/boxmaker-plan-probe/sessions/list",
"body": {}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"items": [],
"total": 0,
"page": 1,
"size": 50,
"pages": 0
}
}
}
@@ -0,0 +1,16 @@
{
"request": {
"method": "DELETE",
"path": "/v3/workspaces/boxmaker-plan-probe",
"body": null
},
"response": {
"status": 202,
"headers": {
"content-type": "application/json"
},
"body": {
"message": "Workspace deletion accepted"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
# Honcho responses, recorded
Real requests and responses from the owner's Honcho (3.2.0, rift), recorded on 2026-09-27 by the
design model for M5c-1, in a throwaway workspace, `boxmaker-plan-probe`, created for it and deleted
afterwards. Each request is exactly what `roncho` 1.0.0's blocking client sends. Each file is
`{"request": {"method", "path", "body"}, "response": {"status", "headers", "body"}}`. The fake
Honcho server in `memoryd`'s tests answers from them. Do not edit.
+439
View File
@@ -0,0 +1,439 @@
//! `memoryd`'s Honcho clients (M5c spec, sections 3 and 6), against a fake Honcho that answers
//! from the responses recorded from rift: the workspace, the peers and the session made before
//! anything is written, each once per run, with the fleet's settings and the agent unobserved;
//! the errors by kind and status, never a body. The keys are in `honcho_keys.rs`. Do not edit.
#[path = "support/honcho.rs"]
mod honcho;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::time::Duration;
use honcho::{FakeHoncho, Mode, Request};
use memoryd::honcho::client::{
CHAT_READ, CONNECT, Clients, HonchoError, READ, Timeouts, classify, fleet_configuration,
};
use memoryd::honcho::config::HonchoConfig;
use memoryd::honcho::ledger::Workspace;
use memoryd::honcho::naming::HonchoSession;
use serde_json::{Value, json};
const FAST: Timeouts = Timeouts {
connect: Duration::from_secs(5),
read: Duration::from_millis(300),
};
fn config(url: &str) -> HonchoConfig {
HonchoConfig {
url: url.to_string(),
agent: "boxmaker0".to_string(),
owner: "kyle".to_string(),
trusted: "boxmaker0-trusted".to_string(),
untrusted: "boxmaker0-untrusted".to_string(),
fleet: "fleet".to_string(),
read: Vec::new(),
keys: BTreeMap::new(),
}
}
fn no_env(_: &str) -> Option<OsString> {
None
}
fn clients(fake: &FakeHoncho) -> Clients {
Clients::new(&config(&fake.url), &no_env, FAST).unwrap().0
}
fn session(name: &str, speakers: &[&str]) -> HonchoSession {
HonchoSession {
name: name.to_string(),
speakers: speakers.iter().map(|s| s.to_string()).collect(),
}
}
/// A request as (method, path, body).
fn seen(requests: &[Request]) -> Vec<(String, String, Value)> {
requests
.iter()
.map(|r| {
(
r.method.clone(),
r.path.clone(),
r.body.clone().unwrap_or(Value::Null),
)
})
.collect()
}
fn post(path: &str, body: Value) -> (String, String, Value) {
("POST".to_string(), path.to_string(), body)
}
#[test]
fn the_timeouts() {
assert_eq!(CONNECT, Duration::from_secs(5));
assert_eq!(READ, Duration::from_secs(30));
assert_eq!(CHAT_READ, Duration::from_secs(180));
assert_eq!(
Timeouts::WRITES,
Timeouts {
connect: CONNECT,
read: READ
}
);
}
#[test]
fn each_client_has_its_workspace_and_timeouts() {
let fake = FakeHoncho::start();
let c = clients(&fake);
for (workspace, name) in [
(Workspace::Trusted, "boxmaker0-trusted"),
(Workspace::Untrusted, "boxmaker0-untrusted"),
(Workspace::Fleet, "fleet"),
] {
let client = c.client(workspace);
assert_eq!(client.workspace_id(), name);
// `roncho`'s `Debug` shows the timeouts, and whether there is a key, never the key.
let shown = format!("{client:?}");
assert!(shown.contains("connect_timeout: 5s"), "{shown}");
assert!(shown.contains("read_timeout: 300ms"), "{shown}");
assert!(shown.contains("api_key_present: false"), "{shown}");
}
}
#[test]
fn the_fleets_configuration_is_the_recorded_one() {
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/honcho/01-workspace-create.json"
);
let recorded: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(
Value::Object(fleet_configuration()),
recorded["request"]["body"]["configuration"]
);
}
#[test]
fn a_session_is_made_after_its_workspace_and_peers() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
let w = "/v3/workspaces/boxmaker0-trusted";
assert_eq!(
seen(&fake.requests()),
vec![
post("/v3/workspaces", json!({ "id": "boxmaker0-trusted" })),
post(
&format!("{w}/peers"),
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
),
post(&format!("{w}/peers"), json!({ "id": "kyle" })),
post(
&format!("{w}/sessions"),
json!({ "id": "chat-1", "peers": {
"boxmaker0": { "observe_me": false, "observe_others": false },
"kyle": { "observe_me": true, "observe_others": false },
} })
),
]
);
}
#[test]
fn what_was_made_is_not_asked_for_again_in_this_run() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
fake.clear();
c.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
assert!(fake.requests().is_empty(), "{:?}", fake.requests());
// A new speaker: only its peer and the next session.
c.ensure_session(Workspace::Trusted, &session("chat-1-2", &["kyle", "ana_b"]))
.unwrap();
let w = "/v3/workspaces/boxmaker0-trusted";
assert_eq!(
seen(&fake.requests()),
vec![
post(&format!("{w}/peers"), json!({ "id": "ana_b" })),
post(
&format!("{w}/sessions"),
json!({ "id": "chat-1-2", "peers": {
"boxmaker0": { "observe_me": false, "observe_others": false },
"kyle": { "observe_me": true, "observe_others": false },
"ana_b": { "observe_me": true, "observe_others": false },
} })
),
]
);
// Another workspace is made sure of on its own.
fake.clear();
c.ensure_session(Workspace::Untrusted, &session("chat-1", &["kyle"]))
.unwrap();
assert_eq!(fake.requests().len(), 4);
assert!(
fake.requests()
.iter()
.skip(1)
.all(|r| r.path.starts_with("/v3/workspaces/boxmaker0-untrusted/"))
);
}
#[test]
fn after_a_restart_everything_is_made_sure_of_again() {
let fake = FakeHoncho::start();
clients(&fake)
.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
fake.clear();
// Honcho answers 200 now: it has them all. That is success too.
clients(&fake)
.ensure_session(Workspace::Trusted, &session("chat-1", &["kyle"]))
.unwrap();
assert_eq!(fake.requests().len(), 4);
}
#[test]
fn the_fleet_is_made_with_its_settings_and_the_agent_alone() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
c.ensure_session(Workspace::Fleet, &session("chat-1", &[]))
.unwrap();
assert_eq!(
seen(&fake.requests()),
vec![
post(
"/v3/workspaces",
json!({ "id": "fleet", "configuration": {
"reasoning": { "enabled": false },
"summary": { "enabled": false },
"dream": { "enabled": false },
} })
),
post(
"/v3/workspaces/fleet/peers",
json!({ "id": "boxmaker0", "configuration": { "observe_me": false } })
),
post(
"/v3/workspaces/fleet/sessions",
json!({ "id": "chat-1", "peers": {
"boxmaker0": { "observe_me": false, "observe_others": false },
} })
),
]
);
}
#[test]
fn a_409_on_create_means_it_is_there() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
fake.set(Mode::Status(409));
c.ensure_workspace(Workspace::Trusted).unwrap();
c.ensure_peer(Workspace::Trusted, "kyle").unwrap();
fake.clear();
c.ensure_workspace(Workspace::Trusted).unwrap();
assert!(fake.requests().is_empty());
}
#[test]
fn errors_by_kind_and_status() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
let cases = [
(
500,
HonchoError::Unreachable {
kind: "server",
status: Some(500),
},
),
(
503,
HonchoError::Unreachable {
kind: "server",
status: Some(503),
},
),
(401, HonchoError::KeyRefused { status: 401 }),
(403, HonchoError::KeyRefused { status: 403 }),
(
400,
HonchoError::Refused {
kind: "status",
status: Some(400),
},
),
(
404,
HonchoError::Refused {
kind: "status",
status: Some(404),
},
),
(
422,
HonchoError::Refused {
kind: "status",
status: Some(422),
},
),
];
for (status, expected) in cases {
fake.set(Mode::Status(status));
assert_eq!(
c.ensure_workspace(Workspace::Trusted),
Err(expected.clone())
);
assert_eq!(c.ensure_peer(Workspace::Fleet, "kyle"), Err(expected));
}
// Nothing failed is remembered: the next call asks again, and succeeds.
fake.set(Mode::Fixtures);
fake.clear();
c.ensure_workspace(Workspace::Trusted).unwrap();
assert_eq!(fake.requests().len(), 1);
fake.set(Mode::Hang);
assert_eq!(
c.ensure_peer(Workspace::Trusted, "ana_b"),
Err(HonchoError::Unreachable {
kind: "timeout",
status: None
})
);
}
#[test]
fn nothing_listening_is_unreachable() {
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
};
let (mut c, _) =
Clients::new(&config(&format!("http://127.0.0.1:{port}")), &no_env, FAST).unwrap();
assert_eq!(
c.ensure_workspace(Workspace::Trusted),
Err(HonchoError::Unreachable {
kind: "connect",
status: None
})
);
}
#[test]
fn an_error_never_holds_the_response() {
let fake = FakeHoncho::start();
let mut c = clients(&fake);
fake.set(Mode::Status(422));
let err = c
.ensure_peer(Workspace::Trusted, "private-note-text")
.unwrap_err();
let body = fake.requests()[0].body.clone().unwrap();
assert_eq!(body["id"], "private-note-text", "the fake did not echo it");
for shown in [err.to_string(), format!("{err:?}")] {
assert!(!shown.contains("private-note-text"), "{shown}");
}
assert_eq!(
err.to_string(),
"Honcho refused the request (status, HTTP 422)"
);
assert_eq!((err.kind(), err.status()), ("status", Some(422)));
let lines = [
HonchoError::Unreachable {
kind: "timeout",
status: None,
}
.to_string(),
HonchoError::KeyRefused { status: 401 }.to_string(),
];
assert_eq!(lines[0], "Honcho is unreachable (timeout)");
assert_eq!(lines[1], "Honcho refused the key (key-refused, HTTP 401)");
}
#[test]
fn every_roncho_error_is_classified() {
use roncho::blocking::Error;
let io = || std::io::Error::other("x");
let cases = [
(Error::Connect(io()), "connect", None, true),
(Error::Timeout, "timeout", None, true),
(Error::Closed, "closed", None, true),
(Error::Io(io()), "io", None, true),
(
Error::Status {
status: 502,
body: "b".into(),
},
"server",
Some(502),
true,
),
(
Error::Status {
status: 499,
body: "b".into(),
},
"status",
Some(499),
false,
),
(
Error::Redirect { status: 301 },
"redirect",
Some(301),
false,
),
(
Error::ActiveSessions { body: "b".into() },
"active-sessions",
Some(409),
false,
),
(Error::TooLarge, "too-large", None, false),
(Error::Decode("b".into()), "decode", None, false),
(Error::Stream("b".into()), "stream", None, false),
(Error::Config("b".into()), "config", None, false),
];
for (error, kind, status, unreachable) in cases {
let got = classify(&error);
assert_eq!((got.kind(), got.status()), (kind, status), "{error:?}");
assert_eq!(
matches!(got, HonchoError::Unreachable { .. }),
unreachable,
"{error:?}"
);
}
let got = classify(&Error::Unauthorized { status: 403 });
assert_eq!(got, HonchoError::KeyRefused { status: 403 });
}
#[test]
fn a_conclusion_deleted_or_gone_is_done() {
let fake = FakeHoncho::start();
let c = clients(&fake);
c.delete_conclusion(Workspace::Trusted, "YUBMqIqHhuJprV4o-inrg")
.unwrap();
c.delete_conclusion(Workspace::Trusted, "YUBMqIqHhuJprV4o-inrg")
.unwrap();
let requests = fake.requests();
assert_eq!(requests.len(), 2);
assert!(requests.iter().all(|r| r.method == "DELETE"
&& r.path == "/v3/workspaces/boxmaker0-trusted/conclusions/YUBMqIqHhuJprV4o-inrg"));
fake.set(Mode::Status(500));
assert_eq!(
c.delete_conclusion(Workspace::Trusted, "x"),
Err(HonchoError::Unreachable {
kind: "server",
status: Some(500)
})
);
fake.set(Mode::Status(403));
assert_eq!(
c.delete_conclusion(Workspace::Trusted, "x"),
Err(HonchoError::KeyRefused { status: 403 })
);
}
+171
View File
@@ -0,0 +1,171 @@
//! The keys of `memoryd`'s Honcho clients (M5c spec, section 10): each read with
//! `bxcore::secrets`, sent as a bearer token to its own workspace only, and never shown by a
//! client, an error or a warning; a key that cannot be read is an error naming it. Against a fake
//! Honcho. Do not edit.
#[path = "support/honcho.rs"]
mod honcho;
#[path = "support/tmp.rs"]
mod tmp;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::os::unix::fs::PermissionsExt;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use bxcore::secrets::SecretSource;
use honcho::{FakeHoncho, Mode};
use memoryd::honcho::client::{Clients, Timeouts};
use memoryd::honcho::config::{FLEET_KEY, HonchoConfig, TRUSTED_KEY};
use memoryd::honcho::ledger::Workspace;
use tmp::TempDir;
const FAST: Timeouts = Timeouts {
connect: Duration::from_secs(5),
read: Duration::from_millis(300),
};
fn config(url: &str) -> HonchoConfig {
HonchoConfig {
url: url.to_string(),
agent: "boxmaker0".to_string(),
owner: "kyle".to_string(),
trusted: "boxmaker0-trusted".to_string(),
untrusted: "boxmaker0-untrusted".to_string(),
fleet: "fleet".to_string(),
read: Vec::new(),
keys: BTreeMap::new(),
}
}
fn no_env(_: &str) -> Option<OsString> {
None
}
/// Key values built from pieces, so that no file holds one whole.
fn env_key() -> String {
["hk", "trusted", "5e1f0a"].join("-")
}
fn file_key() -> String {
["hk", "fleet", "c27d94"].join("-")
}
#[test]
fn each_key_goes_to_its_own_workspace_and_is_never_shown() {
let dir = TempDir::new("honcho-keys");
let file = dir.write("fleet.key", &format!("{}\n", file_key()));
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
let fake = FakeHoncho::start();
let mut config = config(&fake.url);
config.keys.insert(
TRUSTED_KEY.to_string(),
SecretSource::Env("HONCHO_TRUSTED".to_string()),
);
config
.keys
.insert(FLEET_KEY.to_string(), SecretSource::File(file.clone()));
let env = |name: &str| (name == "HONCHO_TRUSTED").then(|| OsString::from(env_key()));
let (mut c, warnings) = Clients::new(&config, &env, FAST).unwrap();
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("honcho_fleet"), "{}", warnings[0]);
c.ensure_workspace(Workspace::Trusted).unwrap();
c.ensure_workspace(Workspace::Untrusted).unwrap();
c.ensure_workspace(Workspace::Fleet).unwrap();
let auth: Vec<Option<String>> = fake
.requests()
.iter()
.map(|r| r.header("authorization").map(str::to_string))
.collect();
assert_eq!(
auth,
vec![
Some(format!("Bearer {}", env_key())),
None,
Some(format!("Bearer {}", file_key())),
]
);
// Nothing shows a key: the clients, their errors, the warnings.
fake.set(Mode::Status(401));
let err = c.ensure_peer(Workspace::Trusted, "kyle").unwrap_err();
let shown = [
format!("{c:?}"),
format!("{err:?}"),
err.to_string(),
warnings.join("\n"),
];
for text in shown {
assert!(!text.contains(&env_key()), "{text}");
assert!(!text.contains(&file_key()), "{text}");
}
}
#[test]
fn a_key_that_cannot_be_read_is_an_error_naming_it() {
let fake = FakeHoncho::start();
let mut config = config(&fake.url);
config.keys.insert(
TRUSTED_KEY.to_string(),
SecretSource::Env("HONCHO_TRUSTED".to_string()),
);
let err = Clients::new(&config, &no_env, FAST).unwrap_err();
assert!(err.contains("honcho_trusted"), "{err}");
assert!(
err.contains("see docs/runbook.md#secret-unavailable"),
"{err}"
);
}
/// Set in the process `a_roncho_toml_in_home_is_not_read` starts, and only there.
const CHILD: &str = "MEMORYD_TEST_RONCHO_HOME";
#[test]
fn a_roncho_toml_in_home_is_not_read() {
// `roncho` reads `$HOME/.config/roncho/roncho.toml` unless told not to, and takes its
// `api_key`. HOME is the process's, so the check runs in a process of its own: this test
// binary, running only the test below.
let dir = TempDir::new("honcho-home");
let key = ["hk", "from", "roncho", "toml"].join("-");
dir.write(
".config/roncho/roncho.toml",
&format!("api_key = \"{key}\"\n"),
);
let mut child = Command::new(std::env::current_exe().unwrap())
.args(["--exact", "in_a_home_with_a_roncho_toml", "--ignored"])
.env("HOME", dir.path())
.env_remove("XDG_CONFIG_HOME")
.env(CHILD, "1")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(20);
let mut status = None;
while Instant::now() < deadline {
if let Ok(Some(done)) = child.try_wait() {
status = Some(done);
break;
}
std::thread::sleep(Duration::from_millis(20));
}
let _ = child.kill();
let output = child.wait_with_output().unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(status.is_some_and(|s| s.success()), "{stdout}");
assert!(stdout.contains("1 passed"), "{stdout}");
}
#[test]
#[ignore = "run by a_roncho_toml_in_home_is_not_read, in a process of its own"]
fn in_a_home_with_a_roncho_toml() {
if std::env::var_os(CHILD).is_none() {
return;
}
let fake = FakeHoncho::start();
let (mut c, _) = Clients::new(&config(&fake.url), &no_env, FAST).unwrap();
c.ensure_workspace(Workspace::Trusted).unwrap();
let requests = fake.requests();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].header("authorization"), None);
}
+150
View File
@@ -0,0 +1,150 @@
//! Which Honcho session a turn goes to (M5c spec, section 3): the Boxmaker session's id, then
//! `<id>-2`, `<id>-3` for each new speaker, holding every speaker so far; numbered from the
//! ledger's decisions alone, so a restart numbers the same. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use bxcore::{SessionId, Timestamp};
use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace};
use memoryd::honcho::naming::{HonchoSession, honcho_session, session_name};
use tmp::TempDir;
fn sid(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
fn turn(session: &str, turn: u64) -> Item {
Item::Turn {
session: sid(session),
turn,
}
}
fn spoke(workspace: Workspace, speaker: &str) -> Action {
Action::Turn {
workspace,
speaker: speaker.to_string(),
}
}
fn skip() -> Action {
Action::Skip {
why: "the turn has no single speaker".to_string(),
}
}
fn hs(name: &str, speakers: &[&str]) -> Option<HonchoSession> {
Some(HonchoSession {
name: name.to_string(),
speakers: speakers.iter().map(|s| s.to_string()).collect(),
})
}
/// A ledger with these decisions, in this order.
fn ledger(dir: &TempDir, decisions: &[(Item, Action)]) -> Ledger {
let path = dir.path().join("honcho.jsonl");
let mut ledger = Ledger::open(&path).unwrap();
for (item, action) in decisions {
ledger
.append(Record::Decided {
time: Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap(),
item: item.clone(),
action: action.clone(),
})
.unwrap();
}
ledger
}
#[test]
fn session_names() {
assert_eq!(session_name(&sid("chat-1"), 0), "chat-1");
assert_eq!(session_name(&sid("chat-1"), 1), "chat-1");
assert_eq!(session_name(&sid("chat-1"), 2), "chat-1-2");
assert_eq!(session_name(&sid("chat-1"), 12), "chat-1-12");
}
#[test]
fn each_new_speaker_starts_the_next_session() {
let dir = TempDir::new("naming-order");
let decisions = [
(turn("chat-1", 1), spoke(Workspace::Trusted, "kyle")),
(turn("chat-2", 1), spoke(Workspace::Trusted, "zed")),
(turn("chat-1", 2), spoke(Workspace::Trusted, "kyle")),
(turn("chat-1", 3), spoke(Workspace::Untrusted, "ana_b")),
(turn("chat-1", 4), skip()),
(turn("chat-1", 5), spoke(Workspace::Trusted, "kyle")),
(turn("chat-1", 6), spoke(Workspace::Untrusted, "bob")),
(turn("chat-1", 7), spoke(Workspace::Trusted, "ana_b")),
];
let l = ledger(&dir, &decisions);
let at = |t: u64| honcho_session(l.decisions(), &sid("chat-1"), t);
assert_eq!(at(1), hs("chat-1", &["kyle"]));
assert_eq!(at(2), hs("chat-1", &["kyle"]));
// A new speaker in any workspace counts.
assert_eq!(at(3), hs("chat-1-2", &["kyle", "ana_b"]));
assert_eq!(at(4), None, "a skipped turn goes nowhere");
assert_eq!(at(5), hs("chat-1-2", &["kyle", "ana_b"]));
assert_eq!(at(6), hs("chat-1-3", &["kyle", "ana_b", "bob"]));
assert_eq!(at(7), hs("chat-1-3", &["kyle", "ana_b", "bob"]));
assert_eq!(at(8), None, "an undecided turn");
// Another session's speakers do not count.
assert_eq!(
honcho_session(l.decisions(), &sid("chat-2"), 1),
hs("chat-2", &["zed"])
);
}
#[test]
fn the_order_is_the_order_of_decision() {
let dir = TempDir::new("naming-decided");
// Turn 2 was decided first (turn 1 waited for brokerd): its speaker is the first.
let decisions = [
(turn("chat-1", 2), spoke(Workspace::Trusted, "ana_b")),
(turn("chat-1", 1), spoke(Workspace::Trusted, "kyle")),
];
let l = ledger(&dir, &decisions);
assert_eq!(
honcho_session(l.decisions(), &sid("chat-1"), 2),
hs("chat-1", &["ana_b"])
);
assert_eq!(
honcho_session(l.decisions(), &sid("chat-1"), 1),
hs("chat-1-2", &["ana_b", "kyle"])
);
}
#[test]
fn a_restart_numbers_the_same() {
let dir = TempDir::new("naming-restart");
let decisions = [
(turn("chat-1", 1), spoke(Workspace::Trusted, "kyle")),
(turn("chat-1", 2), skip()),
(turn("chat-1", 3), spoke(Workspace::Trusted, "ana_b")),
];
let before: Vec<Option<HonchoSession>> = {
let l = ledger(&dir, &decisions);
(1..=3)
.map(|t| honcho_session(l.decisions(), &sid("chat-1"), t))
.collect()
};
let l = Ledger::open(&dir.path().join("honcho.jsonl")).unwrap();
let after: Vec<Option<HonchoSession>> = (1..=3)
.map(|t| honcho_session(l.decisions(), &sid("chat-1"), t))
.collect();
assert_eq!(before, after);
assert_eq!(after[2], hs("chat-1-2", &["kyle", "ana_b"]));
}
#[test]
fn decisions_are_every_decided_item_in_order() {
let dir = TempDir::new("naming-decisions");
let decisions = [
(turn("chat-1", 2), skip()),
(turn("chat-1", 1), spoke(Workspace::Trusted, "kyle")),
];
let l = ledger(&dir, &decisions);
let got: Vec<(Item, Action)> = l.decisions().map(|(i, a)| (i.clone(), a.clone())).collect();
assert_eq!(got, decisions.to_vec());
}
+260
View File
@@ -0,0 +1,260 @@
//! 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
//! 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
//! deadline. Do not edit.
#![allow(dead_code)] // each test file uses a different part of this module
use std::collections::HashSet;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use serde_json::Value;
/// One request as the fake read it.
#[derive(Debug, Clone, PartialEq)]
pub struct Request {
pub method: String,
pub path: String,
/// Header names in lower case.
pub headers: Vec<(String, String)>,
pub body: Option<Value>,
}
impl Request {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
}
/// How the fake answers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// From the fixtures.
Fixtures,
/// Every request with this status. A 422 echoes the request's body, as Honcho does.
Status(u16),
/// Read the request, then answer nothing until the fake is dropped (at most 30 s).
Hang,
}
struct Shared {
mode: Mutex<Mode>,
requests: Mutex<Vec<Request>>,
/// What was made, by method and path and id: a second create answers 200, a second delete
/// 404.
made: Mutex<HashSet<String>>,
stop: AtomicBool,
}
pub struct FakeHoncho {
pub url: String,
port: u16,
shared: Arc<Shared>,
accept: Option<JoinHandle<()>>,
}
/// A recorded fixture: its response's status and body.
fn fixture(name: &str) -> (u16, String) {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/honcho")
.join(name);
let text = std::fs::read_to_string(&path).unwrap();
let value: Value = serde_json::from_str(&text).unwrap();
let status = u16::try_from(value["response"]["status"].as_u64().unwrap()).unwrap();
let body = match &value["response"]["body"] {
Value::String(s) if s.is_empty() => String::new(),
other => other.to_string(),
};
(status, body)
}
impl FakeHoncho {
pub fn start() -> FakeHoncho {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let shared = Arc::new(Shared {
mode: Mutex::new(Mode::Fixtures),
requests: Mutex::new(Vec::new()),
made: Mutex::new(HashSet::new()),
stop: AtomicBool::new(false),
});
let for_accept = Arc::clone(&shared);
let accept = std::thread::spawn(move || {
for stream in listener.incoming() {
if for_accept.stop.load(Ordering::SeqCst) {
return;
}
let Ok(stream) = stream else { return };
let shared = Arc::clone(&for_accept);
std::thread::spawn(move || serve(stream, &shared));
}
});
FakeHoncho {
url: format!("http://127.0.0.1:{port}"),
port,
shared,
accept: Some(accept),
}
}
pub fn set(&self, mode: Mode) {
*self.shared.mode.lock().unwrap() = mode;
}
/// Every request read so far, in order.
pub fn requests(&self) -> Vec<Request> {
self.shared.requests.lock().unwrap().clone()
}
/// Forget the requests read so far (not what was made).
pub fn clear(&self) {
self.shared.requests.lock().unwrap().clear();
}
/// Waits up to 10 s until at least `n` requests were read; true if they were.
pub fn wait_for(&self, n: usize) -> bool {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if self.requests().len() >= n {
return true;
}
std::thread::sleep(Duration::from_millis(10));
}
false
}
}
impl Drop for FakeHoncho {
fn drop(&mut self) {
self.shared.stop.store(true, Ordering::SeqCst);
let _ = TcpStream::connect(("127.0.0.1", self.port));
if let Some(accept) = self.accept.take() {
let _ = accept.join();
}
}
}
/// Reads one request: the head up to its blank line, then `Content-Length` bytes of body.
fn read_request(stream: &mut TcpStream) -> Option<Request> {
let mut buf = Vec::new();
let mut byte = [0u8; 1];
while !buf.ends_with(b"\r\n\r\n") {
if stream.read(&mut byte).ok()? == 0 {
return None;
}
buf.push(byte[0]);
}
let head = String::from_utf8(buf).ok()?;
let mut lines = head.split("\r\n");
let mut first = lines.next()?.split(' ');
let (method, path) = (first.next()?.to_string(), first.next()?.to_string());
let headers: Vec<(String, String)> = lines
.filter_map(|l| l.split_once(": "))
.map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
.collect();
let length: usize = headers
.iter()
.find(|(k, _)| k == "content-length")
.map_or(Some(0), |(_, v)| v.parse().ok())?;
let mut body = vec![0u8; length];
stream.read_exact(&mut body).ok()?;
let body = if length == 0 {
None
} else {
serde_json::from_slice(&body).ok()
};
Some(Request {
method,
path,
headers,
body,
})
}
/// The fixture that answers `request`, by its method and the shape of its path; the second of a
/// pair when the same thing was made (or deleted) before.
fn answer(request: &Request, made: &Mutex<HashSet<String>>) -> (u16, String) {
let parts: Vec<&str> = request.path.trim_start_matches('/').split('/').collect();
let id = request
.body
.as_ref()
.and_then(|b| b["id"].as_str())
.unwrap_or_default()
.to_string();
let again = !made
.lock()
.unwrap()
.insert(format!("{} {} {id}", request.method, request.path));
let configured = request
.body
.as_ref()
.is_some_and(|b| b.get("configuration").is_some());
match (request.method.as_str(), parts.as_slice()) {
("POST", ["v3", "workspaces"]) if again => fixture("02-workspace-create-again.json"),
("POST", ["v3", "workspaces"]) => fixture("01-workspace-create.json"),
("POST", ["v3", "workspaces", _, "peers"]) if again => {
fixture("05-peer-create-boxmaker0-again.json")
}
("POST", ["v3", "workspaces", _, "peers"]) if configured => {
fixture("04-peer-create-boxmaker0.json")
}
("POST", ["v3", "workspaces", _, "peers"]) => fixture("03-peer-create-kyle.json"),
("POST", ["v3", "workspaces", _, "sessions"]) if again => {
fixture("07-session-create-again.json")
}
("POST", ["v3", "workspaces", _, "sessions"]) => fixture("06-session-create.json"),
("POST", ["v3", "workspaces", _, "sessions", _, "messages"]) => {
fixture("08-messages-add-batch.json")
}
("DELETE", ["v3", "workspaces", _, "conclusions", _]) if again => {
fixture("16-conclusion-delete-again.json")
}
("DELETE", ["v3", "workspaces", _, "conclusions", _]) => {
fixture("15-conclusion-delete.json")
}
_ => (404, r#"{"detail":"Not Found"}"#.to_string()),
}
}
fn serve(mut stream: TcpStream, shared: &Shared) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let Some(request) = read_request(&mut stream) else {
return;
};
shared.requests.lock().unwrap().push(request.clone());
let mode = *shared.mode.lock().unwrap();
let (status, body) = match mode {
Mode::Fixtures => answer(&request, &shared.made),
Mode::Status(422) => {
let input = request.body.clone().unwrap_or(Value::Null);
(
422,
serde_json::json!({ "detail": [{ "input": input }] }).to_string(),
)
}
Mode::Status(status) => (status, r#"{"detail":"refused"}"#.to_string()),
Mode::Hang => {
let deadline = Instant::now() + Duration::from_secs(30);
while !shared.stop.load(Ordering::SeqCst) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
return;
}
};
let head = format!(
"HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body.as_bytes());
}
+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/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/09-memoryd-honcho-turns | 2026-09-28 | done | 1 | pass | none | Copied the five given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_state.rs`, `honcho_turns.rs` (from `honcho_turns.rs-task09`) and `honcho_serve.rs` (from `honcho_serve.rs-task09`) and the support `crates/memoryd/tests/support/brokerd.rs` and `support/turns.rs` (from `turns.rs-task09`). Step 2 showed exactly the named failures: three test targets (`honcho_state`, `honcho_turns`, `honcho_serve`) with one E0432 each (`unresolved import memoryd::honcho::state`, `...::turns` and `memoryd::serve::StartError`); every other target compiled. Made H1 (`pub mod state;` and `pub mod turns;` after `pub mod ledger;` in `honcho/mod.rs`) and copied the two skeletons; `cargo check` gave 19 warnings (four unused imports: `UnixStream`; the five `bxcore` frame items plus `crate::honcho::state` and `turn_records`/`turns`; fourteen unused variables: `session` four, `log` twice, the rest once each; three fields never read: `sessions`, `broker_down`, `unreadable`). Filled its seven bodies in the order of the file, each from its comment and `cargo check` after: `ask` (`state.rs`) — connect, set read and write timeout to `wait`, write an `Envelope { v: PROTOCOL_VERSION, id: 1, r#final: true, msg: Message::SessionState(SessionState { session }) }`, match `read_frame`'s msg: `SessionStateReply` -> Ok, `Error` -> `Err(format!("brokerd: {}", e.detail))`, anything else the wrong-message error, every io error through `socket: <e>`; `log_path` (`sessions.join(id).join("0.jsonl")`); `read_log` (`fs::read`, NotFound -> empty, else Io with the runbook, walk the complete lines up to the last newline so a partial last line is left out, each line parsed and a bad line named with its number and the runbook); `finished` (last `Assistant` with empty `tool_calls`); `records` (`read_log`, remove from `unreadable` on Ok, log a damaged log once on Err); `state` (`ask`, log the outage and its return once each); `decidable` (`reported.min(count)`, hold the log's last turn when it is not finished). After the seventh `cargo check` is clean with no warning; `cargo test -p memoryd --test honcho_state --test honcho_turns` gives all 21 pass. Step 6 all pass: `honcho_serve` 6, `honcho_turns` 16, `honcho_state` 5, `serve` 8, `honcho_start` 4, the existing suites unchanged; the three step-8 greps print as expected and the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1257 tests (1256 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/09-memoryd-honcho-turns | 2026-09-28 | done | 1 | pass | none | Copied the five given files from `docs/plans/M5c1/files/`: the tests `crates/memoryd/tests/honcho_state.rs`, `honcho_turns.rs` (from `honcho_turns.rs-task09`) and `honcho_serve.rs` (from `honcho_serve.rs-task09`) and the support `crates/memoryd/tests/support/brokerd.rs` and `support/turns.rs` (from `turns.rs-task09`). Step 2 showed exactly the named failures: three test targets (`honcho_state`, `honcho_turns`, `honcho_serve`) with one E0432 each (`unresolved import memoryd::honcho::state`, `...::turns` and `memoryd::serve::StartError`); every other target compiled. Made H1 (`pub mod state;` and `pub mod turns;` after `pub mod ledger;` in `honcho/mod.rs`) and copied the two skeletons; `cargo check` gave 19 warnings (four unused imports: `UnixStream`; the five `bxcore` frame items plus `crate::honcho::state` and `turn_records`/`turns`; fourteen unused variables: `session` four, `log` twice, the rest once each; three fields never read: `sessions`, `broker_down`, `unreadable`). Filled its seven bodies in the order of the file, each from its comment and `cargo check` after: `ask` (`state.rs`) — connect, set read and write timeout to `wait`, write an `Envelope { v: PROTOCOL_VERSION, id: 1, r#final: true, msg: Message::SessionState(SessionState { session }) }`, match `read_frame`'s msg: `SessionStateReply` -> Ok, `Error` -> `Err(format!("brokerd: {}", e.detail))`, anything else the wrong-message error, every io error through `socket: <e>`; `log_path` (`sessions.join(id).join("0.jsonl")`); `read_log` (`fs::read`, NotFound -> empty, else Io with the runbook, walk the complete lines up to the last newline so a partial last line is left out, each line parsed and a bad line named with its number and the runbook); `finished` (last `Assistant` with empty `tool_calls`); `records` (`read_log`, remove from `unreadable` on Ok, log a damaged log once on Err); `state` (`ask`, log the outage and its return once each); `decidable` (`reported.min(count)`, hold the log's last turn when it is not finished). After the seventh `cargo check` is clean with no warning; `cargo test -p memoryd --test honcho_state --test honcho_turns` gives all 21 pass. Step 6 all pass: `honcho_serve` 6, `honcho_turns` 16, `honcho_state` 5, `serve` 8, `honcho_start` 4, the existing suites unchanged; the three step-8 greps print as expected and the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1257 tests (1256 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
| M5c1/08-memoryd-honcho-decide | 2026-09-28 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_decide.rs` and the skeleton `crates/memoryd/src/honcho/decide.rs` from `docs/plans/M5c1/files/`. Step 2 showed exactly the named failure: `honcho_decide` with one E0432 (`unresolved import memoryd::honcho::decide`); every other target compiled. Made H1 (`pub mod decide;` after `pub mod config;` in `honcho/mod.rs`) and copied the skeleton; `cargo check` gave 11 warnings (one unused import `bxcore::sanitize::refuse`, ten unused variables: `records` four, `turn` four, `speaker` and `text` once each). Step 3 `cargo test -p memoryd --test honcho_decide` gave 1 pass (`the_skip_reasons_are_these_texts`) and 17 fail with `not yet implemented`. Filled its eight bodies in the order of the file, deleting each instruction comment as it went and keeping the doc comments, each from its comment and `cargo check` after: `turns` (filter `User`, `u64::try_from(count).unwrap_or(u64::MAX)`); `bounds` (walk `enumerate`, when a second `User` follows the tracked turn-th one return `(start, index)`, else count and set `start` at `seen == turn`, then `start.map(|s| (s, len))`); `turn_records` (`bounds` then `get`); `peer_name` (`replace('.', "_")`); `has_secret` (map each char `refuse` refuses on its own to a space via a 4-byte `encode_utf8` buffer, then keep only a `secret-` rule); `user_text` (first record's `User`, `speaker.as_deref()`); `final_reply` (last `Assistant` with empty `tool_calls`, `content.as_deref()`); `labelled_untrusted` (any `ToolResult { untrusted: true }`). After the eighth `cargo check` is clean with no warning; `cargo test` gives all 18 pass. Step 6 all pass: the grep for `fs::`/`std::io`/`Timestamp::now` prints 0, the two `refuse(` lines (87, 94) are both in `has_secret`, and the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1230 tests (1229 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD` set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5c1/08-memoryd-honcho-decide | 2026-09-28 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_decide.rs` and the skeleton `crates/memoryd/src/honcho/decide.rs` from `docs/plans/M5c1/files/`. Step 2 showed exactly the named failure: `honcho_decide` with one E0432 (`unresolved import memoryd::honcho::decide`); every other target compiled. Made H1 (`pub mod decide;` after `pub mod config;` in `honcho/mod.rs`) and copied the skeleton; `cargo check` gave 11 warnings (one unused import `bxcore::sanitize::refuse`, ten unused variables: `records` four, `turn` four, `speaker` and `text` once each). Step 3 `cargo test -p memoryd --test honcho_decide` gave 1 pass (`the_skip_reasons_are_these_texts`) and 17 fail with `not yet implemented`. Filled its eight bodies in the order of the file, deleting each instruction comment as it went and keeping the doc comments, each from its comment and `cargo check` after: `turns` (filter `User`, `u64::try_from(count).unwrap_or(u64::MAX)`); `bounds` (walk `enumerate`, when a second `User` follows the tracked turn-th one return `(start, index)`, else count and set `start` at `seen == turn`, then `start.map(|s| (s, len))`); `turn_records` (`bounds` then `get`); `peer_name` (`replace('.', "_")`); `has_secret` (map each char `refuse` refuses on its own to a space via a 4-byte `encode_utf8` buffer, then keep only a `secret-` rule); `user_text` (first record's `User`, `speaker.as_deref()`); `final_reply` (last `Assistant` with empty `tool_calls`, `content.as_deref()`); `labelled_untrusted` (any `ToolResult { untrusted: true }`). After the eighth `cargo check` is clean with no warning; `cargo test` gives all 18 pass. Step 6 all pass: the grep for `fs::`/`std::io`/`Timestamp::now` prints 0, the two `refuse(` lines (87, 94) are both in `has_secret`, and the other-crates `git diff --stat` is empty. `make gate` printed `gate: ok` with 1230 tests (1229 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD` set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |
| 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/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 |