diff --git a/crates/brokerd/src/broker.rs b/crates/brokerd/src/broker.rs index 1c04c39..3a3c3a6 100644 --- a/crates/brokerd/src/broker.rs +++ b/crates/brokerd/src/broker.rs @@ -88,7 +88,7 @@ impl Broker { } } -/// The snake_case wire name of a message: all twenty-six kinds, none with a leading underscore. +/// The snake_case wire name of a message: all twenty-eight kinds, none with a leading underscore. pub fn kind(msg: &Message) -> &'static str { match msg { Message::ToolRequest(_) => "tool_request", @@ -117,6 +117,8 @@ pub fn kind(msg: &Message) -> &'static str { Message::Reindexed(_) => "reindexed", Message::MemoryAdmin(_) => "memory_admin", Message::MemoryAdminReply(_) => "memory_admin_reply", + Message::SessionState(_) => "session_state", + Message::SessionStateReply(_) => "session_state_reply", } } diff --git a/crates/brokerd/tests/broker.rs b/crates/brokerd/tests/broker.rs index 72e87cd..f8f8f58 100644 --- a/crates/brokerd/tests/broker.rs +++ b/crates/brokerd/tests/broker.rs @@ -22,8 +22,8 @@ use brokerd::ledger::NOT_RECORDED; use brokerd::runner::{REFUSING, RunError}; use bxcore::{ ApprovalList, Approve, ApproveResult, AuditEvent, DataClass, DecisionRecord, DenyReason, Empty, - Envelope, ErrorCode, GrantsReport, Message, Refuse, ResultStatus, SessionId, ToolResponse, - Turn, TurnDone, TurnEvent, Usage, WireError, + Envelope, ErrorCode, GrantsReport, Message, Refuse, ResultStatus, SessionId, SessionState, + SessionStateReply, ToolResponse, Turn, TurnDone, TurnEvent, Usage, WireError, }; use client::{Serve, call, next, open}; use rig::{Rig, grant_text, request}; @@ -158,6 +158,7 @@ fn every_kind_but_tool_request() -> Vec { session: SessionId::new("s1").unwrap(), content: String::new(), resume: false, + speaker: None, }), Message::TurnEvent(TurnEvent::Content { text: String::new(), @@ -217,6 +218,24 @@ fn every_other_kind_on_broker_sock_is_forbidden() { ); } +/// A refused kind is logged by its wire name, the new session-state messages too (M5c spec, +/// section 7). +#[test] +fn the_session_state_kinds_have_their_wire_names() { + for msg in [ + Message::SessionState(SessionState { + session: SessionId::new("s1").unwrap(), + }), + Message::SessionStateReply(SessionStateReply { + class: DataClass::Private, + untrusted: false, + }), + ] { + let wire = serde_json::to_value(&msg).unwrap(); + assert_eq!(Some(broker::kind(&msg)), wire["kind"].as_str(), "{wire}"); + } +} + #[test] fn a_frame_that_is_not_json_is_answered_with_bad_message() { let rig = Rig::new("broker-badframe"); diff --git a/crates/bxcore/src/lib.rs b/crates/bxcore/src/lib.rs index f4022e9..0e8b24f 100644 --- a/crates/bxcore/src/lib.rs +++ b/crates/bxcore/src/lib.rs @@ -38,7 +38,8 @@ pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError}; pub use log::{LogRecord, ToolCall, Usage}; pub use memory::{ ConsolidateEntry, EntryAction, MemoryAdmin, MemoryAdminReply, MemoryArgs, MemoryBlockReply, - MemoryCall, MemoryCommand, MemoryFile, RecallArgs, Reindexed, SessionUpdated, + MemoryCall, MemoryCommand, MemoryFile, RecallArgs, Reindexed, SessionState, SessionStateReply, + SessionUpdated, }; pub use wire::{ ApprovalList, Approve, ApproveResult, DenyReason, Empty, Envelope, ErrorCode, GrantProblem, diff --git a/crates/bxcore/src/log.rs b/crates/bxcore/src/log.rs index 6395c9b..b617f62 100644 --- a/crates/bxcore/src/log.rs +++ b/crates/bxcore/src/log.rs @@ -37,6 +37,10 @@ pub enum LogRecord { User { time: Timestamp, content: String, + /// The turn's speaker (M5c spec, section 11). Written only when present, so a log written + /// before M5c reads as it is, and a turn without a speaker is logged as before. + #[serde(default, skip_serializing_if = "Option::is_none")] + speaker: Option, }, Assistant { time: Timestamp, diff --git a/crates/bxcore/src/memory.rs b/crates/bxcore/src/memory.rs index d10464b..a888d99 100644 --- a/crates/bxcore/src/memory.rs +++ b/crates/bxcore/src/memory.rs @@ -228,11 +228,30 @@ pub struct MemoryBlockReply { pub text: String, } -/// `loopd` → `memoryd`: this session's log has new records. +/// `loopd` → `memoryd`: this session's log has new records, those of a turn that has ended. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SessionUpdated { pub session: SessionId, + /// The ended turn's number in the session (M5c spec, section 5). It has no default: a frame + /// without it is refused, never read as turn 0. + pub turn: u64, +} + +/// `memoryd` → `brokerd`, on `state.sock`: the session's state now (M5c spec, section 7). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionState { + pub session: SessionId, +} + +/// `brokerd` → `memoryd`: the session's class, and whether it has read untrusted content. Neither +/// field has a default: a reply without one is refused, never read as a clean session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SessionStateReply { + pub class: DataClass, + pub untrusted: bool, } /// `brokerd` → `memoryd`: a decided `recall` or `memory` call, with the session's state when it diff --git a/crates/bxcore/src/wire.rs b/crates/bxcore/src/wire.rs index 180954f..a011a16 100644 --- a/crates/bxcore/src/wire.rs +++ b/crates/bxcore/src/wire.rs @@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize}; use crate::gateway::{ApprovalOutcome, ApprovalPosted, ApprovalReply, ApprovalRequest}; use crate::memory::{ - MemoryAdmin, MemoryAdminReply, MemoryBlockReply, MemoryCall, Reindexed, SessionUpdated, + MemoryAdmin, MemoryAdminReply, MemoryBlockReply, MemoryCall, Reindexed, SessionState, + SessionStateReply, SessionUpdated, }; use crate::{CallId, DataClass, DecisionRecord, SessionId, Timestamp, Usage}; @@ -51,6 +52,8 @@ pub enum Message { Reindexed(Reindexed), MemoryAdmin(MemoryAdmin), MemoryAdminReply(MemoryAdminReply), + SessionState(SessionState), + SessionStateReply(SessionStateReply), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -126,13 +129,18 @@ pub enum DenyReason { StateUnreadable, } -// JSON: {"kind":"turn","body":{"session":"…","content":"…","resume":false}} +// JSON: {"kind":"turn","body":{"session":"…","content":"…","resume":false,"speaker":"kyle"}} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Turn { pub session: SessionId, pub content: String, pub resume: bool, + /// Who spoke the turn, when one person did (M5c spec, section 11). Written only when present, + /// and a frame without it reads as `None`, the safe side: a turn without a speaker is never + /// synced to Honcho. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub speaker: Option, } // JSON: {"event":"content","text":"…"} — the tag sits beside the fields, snake_case diff --git a/crates/bxcore/tests/fixtures/records/session_speaker.jsonl b/crates/bxcore/tests/fixtures/records/session_speaker.jsonl new file mode 100644 index 0000000..67b2702 --- /dev/null +++ b/crates/bxcore/tests/fixtures/records/session_speaker.jsonl @@ -0,0 +1,5 @@ +{"type":"session_start","time":"2026-09-27T09:00:00.000Z","session":"mm-r0000000000000000000000001","epoch":0,"slot":2,"baseline":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"} +{"type":"user","time":"2026-09-27T09:00:01.000Z","content":"When is the launch?","speaker":"kyle"} +{"type":"assistant","time":"2026-09-27T09:00:04.000Z","content":"On Thursday.","reasoning_content":null,"tool_calls":[]} +{"type":"user","time":"2026-09-27T09:01:00.000Z","content":"And the review?"} +{"type":"assistant","time":"2026-09-27T09:01:03.000Z","content":"On Friday.","reasoning_content":null,"tool_calls":[]} diff --git a/crates/bxcore/tests/fixtures/wire/session_state.json b/crates/bxcore/tests/fixtures/wire/session_state.json new file mode 100644 index 0000000..784939c --- /dev/null +++ b/crates/bxcore/tests/fixtures/wire/session_state.json @@ -0,0 +1 @@ +{"v":1,"id":1,"final":true,"msg":{"kind":"session_state","body":{"session":"mm-r0000000000000000000000001"}}} diff --git a/crates/bxcore/tests/fixtures/wire/session_state_reply.json b/crates/bxcore/tests/fixtures/wire/session_state_reply.json new file mode 100644 index 0000000..d867774 --- /dev/null +++ b/crates/bxcore/tests/fixtures/wire/session_state_reply.json @@ -0,0 +1 @@ +{"v":1,"id":1,"final":true,"msg":{"kind":"session_state_reply","body":{"class":"private","untrusted":true}}} diff --git a/crates/bxcore/tests/fixtures/wire/session_updated.json b/crates/bxcore/tests/fixtures/wire/session_updated.json index d495118..18a787b 100644 --- a/crates/bxcore/tests/fixtures/wire/session_updated.json +++ b/crates/bxcore/tests/fixtures/wire/session_updated.json @@ -1 +1 @@ -{"v":1,"id":1,"final":true,"msg":{"kind":"session_updated","body":{"session":"mm-r0000000000000000000000001"}}} +{"v":1,"id":1,"final":true,"msg":{"kind":"session_updated","body":{"session":"mm-r0000000000000000000000001","turn":3}}} diff --git a/crates/bxcore/tests/fixtures/wire/turn_speaker.json b/crates/bxcore/tests/fixtures/wire/turn_speaker.json new file mode 100644 index 0000000..d0e7271 --- /dev/null +++ b/crates/bxcore/tests/fixtures/wire/turn_speaker.json @@ -0,0 +1 @@ +{"v":1,"id":3,"final":true,"msg":{"kind":"turn","body":{"session":"mm-r0000000000000000000000001","content":"What time is it?","resume":true,"speaker":"kyle"}}} diff --git a/crates/bxcore/tests/memory_wire.rs b/crates/bxcore/tests/memory_wire.rs index 06e0094..dae3b4f 100644 --- a/crates/bxcore/tests/memory_wire.rs +++ b/crates/bxcore/tests/memory_wire.rs @@ -1,9 +1,11 @@ -//! The eight memory messages against byte-exact fixtures, each refusing an unknown key (M5a spec, -//! section 7). Do not edit these or the fixtures. +//! The memory messages against byte-exact fixtures, each refusing an unknown key: the eight of M5a +//! (M5a spec, section 7) and the session-state question of M5c (M5c spec, section 7). Do not edit +//! these or the fixtures. use bxcore::{ CallId, DataClass, Empty, Envelope, MemoryAdmin, MemoryAdminReply, MemoryBlockReply, - MemoryCall, MemoryCommand, Message, Reindexed, SessionId, SessionUpdated, ToolRequest, + MemoryCall, MemoryCommand, Message, Reindexed, SessionId, SessionState, SessionStateReply, + SessionUpdated, ToolRequest, }; const SESSION: &str = "mm-r0000000000000000000000001"; @@ -53,6 +55,7 @@ fn the_eight_messages() { 1, Message::SessionUpdated(SessionUpdated { session: SessionId::new(SESSION).unwrap(), + turn: 3, }), ); check( @@ -96,6 +99,58 @@ fn the_eight_messages() { ); } +/// `turn` has no default: a `session_updated` without it, as a `loopd` from before M5c sent, is +/// refused, never read as turn 0 (M5c spec, section 5). +#[test] +fn a_session_updated_without_turn_is_refused() { + let old = r#"{"v":1,"id":1,"final":true,"msg":{"kind":"session_updated","body":{"session":"mm-r0000000000000000000000001"}}}"#; + let error = serde_json::from_str::(old).unwrap_err(); + assert!( + error.to_string().contains("missing field `turn`"), + "{error}" + ); +} + +/// `memoryd` asks `brokerd` for a session's state on `state.sock` (M5c spec, section 7). +#[test] +fn the_session_state_messages() { + check( + "session_state.json", + 1, + Message::SessionState(SessionState { + session: SessionId::new(SESSION).unwrap(), + }), + ); + check( + "session_state_reply.json", + 1, + Message::SessionStateReply(SessionStateReply { + class: DataClass::Private, + untrusted: true, + }), + ); +} + +/// Neither field of the reply has a default: a reply without one is refused, never read as a +/// clean or `public` session. +#[test] +fn a_session_state_reply_needs_both_fields() { + let text = fixture("session_state_reply.json"); + for (field, without) in [ + ("class", text.replacen(r#""class":"private","#, "", 1)), + ("untrusted", text.replacen(r#","untrusted":true"#, "", 1)), + ] { + assert_ne!(without, text, "{field}: the fixture holds the field"); + let error = serde_json::from_str::(&without).unwrap_err(); + assert!( + error + .to_string() + .contains(&format!("missing field `{field}`")), + "{field}: {error}" + ); + } +} + #[test] fn every_command_has_its_name() { for (command, name) in [ diff --git a/crates/bxcore/tests/records.rs b/crates/bxcore/tests/records.rs index fa79599..f8a1313 100644 --- a/crates/bxcore/tests/records.rs +++ b/crates/bxcore/tests/records.rs @@ -235,6 +235,7 @@ fn session_log_records() { LogRecord::User { time: ts("2026-09-17T08:05:01.000Z"), content: "What is in /etc/hosts?".to_string(), + speaker: None, }, LogRecord::Assistant { time: ts("2026-09-17T08:05:03.000Z"), @@ -275,6 +276,45 @@ fn session_log_records() { check("session.jsonl", &want); } +/// A user record holds its turn's speaker when the turn had one, written last; a record without +/// one has the bytes it had before M5c, so a log written earlier reads as it is and is never +/// rewritten (M5c spec, section 11). +#[test] +fn user_records_with_and_without_a_speaker() { + let want = [ + LogRecord::SessionStart { + time: ts("2026-09-27T09:00:00.000Z"), + session: sid("mm-r0000000000000000000000001"), + epoch: Epoch(0), + slot: 2, + baseline: Hash32::from_hex(SEQ_HEX).unwrap(), + }, + LogRecord::User { + time: ts("2026-09-27T09:00:01.000Z"), + content: "When is the launch?".to_string(), + speaker: Some("kyle".to_string()), + }, + LogRecord::Assistant { + time: ts("2026-09-27T09:00:04.000Z"), + content: Some("On Thursday.".to_string()), + reasoning_content: None, + tool_calls: vec![], + }, + LogRecord::User { + time: ts("2026-09-27T09:01:00.000Z"), + content: "And the review?".to_string(), + speaker: None, + }, + LogRecord::Assistant { + time: ts("2026-09-27T09:01:03.000Z"), + content: Some("On Friday.".to_string()), + reasoning_content: None, + tool_calls: vec![], + }, + ]; + check("session_speaker.jsonl", &want); +} + #[test] fn unknown_fields_and_types_are_rejected() { let user = r#"{"type":"user","time":"2026-09-17T08:05:01.000Z","content":"hi"}"#; diff --git a/crates/bxcore/tests/strict.rs b/crates/bxcore/tests/strict.rs index c71c325..cebf216 100644 --- a/crates/bxcore/tests/strict.rs +++ b/crates/bxcore/tests/strict.rs @@ -92,6 +92,10 @@ fn envelopes_reject_unknown_keys_at_every_depth() { "error_no_such_approval.json", "turn_event_approval_pending.json", "turn_event_tool_denied.json", + "turn_speaker.json", + "session_updated.json", + "session_state.json", + "session_state_reply.json", ] { // Envelope, msg and body: three objects. Some bodies hold more: turn_done a usage // object, approval_list one item, approve_result an outcome, grants_report two problems. @@ -142,6 +146,16 @@ fn usage_log_records_reject_unknown_keys_at_every_depth() { assert_eq!(tried, 8); } +#[test] +fn speaker_log_records_reject_unknown_keys_at_every_depth() { + let mut tried = 0; + for (i, line) in fixture("records/session_speaker.jsonl").lines().enumerate() { + tried += check::(&format!("session_speaker.jsonl:{}", i + 1), line); + } + // Five records, none holding another object. + assert_eq!(tried, 5); +} + #[test] fn grants_reject_unknown_keys_at_every_depth() { let grant: Grant = toml::from_str(&fixture("grant/full.toml")).unwrap(); diff --git a/crates/bxcore/tests/turn_wire.rs b/crates/bxcore/tests/turn_wire.rs index cffce50..d106c47 100644 --- a/crates/bxcore/tests/turn_wire.rs +++ b/crates/bxcore/tests/turn_wire.rs @@ -46,16 +46,46 @@ fn usage() -> Usage { } } +/// A turn without a speaker has the bytes it had before M5c, and a frame without `speaker` reads +/// as `None`: a turn with no speaker is never synced (M5c spec, section 4). #[test] fn turn() { let body = Turn { session: SessionId::new("chat-1789700000-42").unwrap(), content: "What time is it?".to_string(), resume: false, + speaker: None, }; check("turn.json", env(3, true, Message::Turn(body))); } +/// The speaker is written last, when there is one (M5c spec, section 11). +#[test] +fn turn_with_a_speaker() { + let body = Turn { + session: SessionId::new("mm-r0000000000000000000000001").unwrap(), + content: "What time is it?".to_string(), + resume: true, + speaker: Some("kyle".to_string()), + }; + check("turn_speaker.json", env(3, true, Message::Turn(body))); +} + +/// `bxcore` does not check a speaker's name: `memoryd` checks it against Honcho's pattern (M5c +/// spec, section 3). A Mattermost username with a `.` is carried as it is. +#[test] +fn a_speaker_is_carried_as_it_is() { + let body = Turn { + session: SessionId::new("mm-r0000000000000000000000001").unwrap(), + content: "hi".to_string(), + resume: false, + speaker: Some("first.last".to_string()), + }; + let text = serde_json::to_string(&body).unwrap(); + assert!(text.ends_with(r#","speaker":"first.last"}"#), "{text}"); + assert_eq!(serde_json::from_str::(&text).unwrap(), body); +} + #[test] fn turn_events() { check( @@ -262,6 +292,7 @@ fn usage_records() { LogRecord::User { time: Timestamp::parse("2026-09-18T08:05:01.000Z").unwrap(), content: "What time is it?".to_string(), + speaker: None, }, LogRecord::Assistant { time: Timestamp::parse("2026-09-18T08:05:03.000Z").unwrap(), diff --git a/crates/bxctl/src/chat.rs b/crates/bxctl/src/chat.rs index 371de97..49ff3ca 100644 --- a/crates/bxctl/src/chat.rs +++ b/crates/bxctl/src/chat.rs @@ -87,6 +87,8 @@ pub fn run_turn( session: session.clone(), content: content.to_string(), resume, + // Set in M5c-1 task 04, from `--speaker`. Until then no turn has a speaker. + speaker: None, }; write_frame( &mut stream, diff --git a/crates/bxctl/tests/chat.rs b/crates/bxctl/tests/chat.rs index 5687c38..67388fa 100644 --- a/crates/bxctl/tests/chat.rs +++ b/crates/bxctl/tests/chat.rs @@ -174,7 +174,8 @@ fn run_turn_delivers_every_event_in_order_then_the_answer() { Turn { session: id("s1"), content: "what time is it?".to_string(), - resume: false + resume: false, + speaker: None, } ); } diff --git a/crates/gatewayd/src/deliver.rs b/crates/gatewayd/src/deliver.rs index 8ec7548..3062ad6 100644 --- a/crates/gatewayd/src/deliver.rs +++ b/crates/gatewayd/src/deliver.rs @@ -167,6 +167,8 @@ fn one_turn( session: batch.session.clone(), content: batch.text.clone(), resume, + // Set in M5c-1 task 03. Until then no turn has a speaker, and none is synced. + speaker: None, }; if let Err(e) = write_frame( &mut stream, diff --git a/crates/loopd/src/memory.rs b/crates/loopd/src/memory.rs index 9108a54..0130981 100644 --- a/crates/loopd/src/memory.rs +++ b/crates/loopd/src/memory.rs @@ -94,6 +94,8 @@ impl MemorySource for MemoryPort { fn updated(&self, session: &SessionId) -> Result<(), String> { match self.ask(Message::SessionUpdated(SessionUpdated { session: session.clone(), + // Set in M5c-1 task 02. `memoryd` does not read it before M5c-1 task 09. + turn: 0, }))? { Message::Ok(_) => Ok(()), Message::Error(e) => Err(format!("memoryd: {}", e.detail)), diff --git a/crates/loopd/src/turn.rs b/crates/loopd/src/turn.rs index 5e00803..5c84b59 100644 --- a/crates/loopd/src/turn.rs +++ b/crates/loopd/src/turn.rs @@ -123,6 +123,9 @@ pub fn run_turn( .append(LogRecord::User { time: Timestamp::now(), content: neutralise(content).into_owned(), + // Set in M5c-1 task 02, from the turn. `None` is not written: the record's bytes + // are as before. + speaker: None, }) .map_err(TurnError::Session)?; diff --git a/crates/loopd/tests/baseline.rs b/crates/loopd/tests/baseline.rs index 671f910..4e30c20 100644 --- a/crates/loopd/tests/baseline.rs +++ b/crates/loopd/tests/baseline.rs @@ -102,6 +102,7 @@ fn replay_keeps_only_messages_in_order_and_unchanged() { LogRecord::User { time: ts(), content: "hi".to_string(), + speaker: None, }, LogRecord::Assistant { time: ts(), @@ -195,6 +196,7 @@ fn replay_of_a_prefix_is_a_prefix() { 0 => LogRecord::User { time: ts(), content: format!("u{i}"), + speaker: None, }, 1 => LogRecord::Assistant { time: ts(), diff --git a/crates/loopd/tests/channel.rs b/crates/loopd/tests/channel.rs index 15c7f79..228eeb4 100644 --- a/crates/loopd/tests/channel.rs +++ b/crates/loopd/tests/channel.rs @@ -65,6 +65,7 @@ fn turn(socket: &PathBuf, session: &str, content: &str, resume: bool) -> Vec bool { session: SessionId::new("probe").unwrap(), content: "are you there".to_string(), resume: true, + speaker: None, }), }; if write_frame(&mut stream, &turn).is_err() { diff --git a/crates/loopd/tests/memory.rs b/crates/loopd/tests/memory.rs index d47106b..2e4b5f4 100644 --- a/crates/loopd/tests/memory.rs +++ b/crates/loopd/tests/memory.rs @@ -88,6 +88,7 @@ fn turn(l: &Loopd, session: &str, resume: bool) -> Message { session: SessionId::new(session).unwrap(), content: "hello".to_string(), resume, + speaker: None, }); let env = Envelope { v: PROTOCOL_VERSION, diff --git a/crates/loopd/tests/neutralise_replay.rs b/crates/loopd/tests/neutralise_replay.rs index a39d9a6..94a8d7f 100644 --- a/crates/loopd/tests/neutralise_replay.rs +++ b/crates/loopd/tests/neutralise_replay.rs @@ -125,6 +125,7 @@ fn an_old_log_with_a_raw_tag_is_replayed_as_it_is() { LogRecord::User { time: ts(), content: user.clone(), + speaker: None, }, LogRecord::Assistant { time: ts(), diff --git a/crates/loopd/tests/session.rs b/crates/loopd/tests/session.rs index ff883bf..63d3b89 100644 --- a/crates/loopd/tests/session.rs +++ b/crates/loopd/tests/session.rs @@ -62,6 +62,7 @@ fn append_is_visible_on_disk_at_once_and_after_reopen() { s.append(LogRecord::User { time: ts(), content: "hi\nthere \"quoted\" caf\u{e9}".to_string(), + speaker: None, }) .unwrap(); assert_eq!( @@ -104,6 +105,7 @@ fn a_torn_log_is_refused_with_the_line_number() { s.append(LogRecord::User { time: ts(), content: "hi".to_string(), + speaker: None, }) .unwrap(); drop(s); @@ -173,6 +175,7 @@ fn last_usage_is_the_latest_usage_record() { s.append(LogRecord::User { time: ts(), content: "x".to_string(), + speaker: None, }) .unwrap(); s.append(LogRecord::Usage { diff --git a/crates/loopd/tests/turn_done_untrusted.rs b/crates/loopd/tests/turn_done_untrusted.rs index b26e342..1b288fe 100644 --- a/crates/loopd/tests/turn_done_untrusted.rs +++ b/crates/loopd/tests/turn_done_untrusted.rs @@ -74,6 +74,7 @@ fn untrusted(socket: &Path, session: &str, content: &str, resume: bool) -> bool session: SessionId::new(session).unwrap(), content: content.to_string(), resume, + speaker: None, }); let turn = Envelope { v: PROTOCOL_VERSION, diff --git a/crates/memoryd/src/index.rs b/crates/memoryd/src/index.rs index 9ca1804..14edd7c 100644 --- a/crates/memoryd/src/index.rs +++ b/crates/memoryd/src/index.rs @@ -221,7 +221,7 @@ impl Index { return Ok(()); } match record { - LogRecord::User { time, content } if !content.trim().is_empty() => { + LogRecord::User { time, content, .. } if !content.trim().is_empty() => { let src = Source { time: time.to_rfc3339(), role: "owner".to_string(), diff --git a/crates/memoryd/tests/serve.rs b/crates/memoryd/tests/serve.rs index f2db954..331eec4 100644 --- a/crates/memoryd/tests/serve.rs +++ b/crates/memoryd/tests/serve.rs @@ -143,6 +143,7 @@ fn recall_finds_old_sessions_updated_ones_and_notes() { append(&log, &user(2, "the launch is Thursday")); let update = Message::SessionUpdated(SessionUpdated { session: SessionId::new("mm-b").unwrap(), + turn: 1, }); assert_eq!(ask(&r.config.loop_socket(), update), Message::Ok(Empty {})); call( diff --git a/crates/memoryd/tests/support/logs.rs b/crates/memoryd/tests/support/logs.rs index a0764a7..910b462 100644 --- a/crates/memoryd/tests/support/logs.rs +++ b/crates/memoryd/tests/support/logs.rs @@ -14,6 +14,7 @@ pub fn user(minute: u32, content: &str) -> LogRecord { LogRecord::User { time: at(minute), content: content.to_string(), + speaker: None, } } diff --git a/docs/implementer-log.md b/docs/implementer-log.md index e45eea5..1d63098 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5c1/01-wire-speaker-state | 2026-09-28 | done | 1 | pass | none | Changed the types in `bxcore` only, as the task says: `Turn` and `LogRecord::User` gain `speaker: Option` (optional, `serde(default, skip_serializing_if = "Option::is_none")`, so a frame or log line without it reads `None` and `None` is not written); `SessionUpdated` gains `turn: u64` (required, no default), plus the two new messages `SessionState { session }` and `SessionStateReply { class, untrusted }` (both fields required). Copied the 20 given files from `docs/plans/M5c1/files/` (4 new fixtures, 16 replaced test files, the loopd `memory.rs` and bxctl `chat.rs` being the `-task01` versions). Step 2 showed exactly the named failures: `strict` `envelopes_reject_unknown_keys_at_every_depth` on `turn_speaker.json` and `speaker_log_records_reject_unknown_keys_at_every_depth` on `session_speaker.jsonl:2`, and 15 non-compiling test targets with E0559/E0560/E0432/E0599. Made every edit exactly as the task gives (W1-W3, L1, M1, X1 in bxcore; B1-B2 in brokerd; G1, C1, T1, U1, I1 in gatewayd, bxctl, loopd twice, memoryd), the placeholders `speaker: None` / `turn: 0` left in place. After the edits `cargo test -p bxcore` every suite passes (turn_wire 9, memory_wire 5, records 4, strict 6) and `cargo test -p brokerd -p gatewayd -p bxctl -p loopd -p memoryd` every suite passes. Step 5 checks all pass: `grep -c 'serde(default' crates/bxcore/src/wire.rs` prints 2, `git diff --stat -- crates/gatewayd/tests crates/inferproxy crates/toolkit` prints nothing, `git diff --stat -- crates/*/src` lists exactly the ten source files. `make gate` printed `gate: ok` with 1130 tests (1129 in `cargo test --workspace`, plus the 1 ignored `end_to_end` the gate runs separately with `BOXMAKER_BROKERD` set; the first gate run's `end_to_end` was the only flake and it passes once the env var is set). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5b/14-runbook | 2026-09-27 | done | 1 | pass | none | Only `docs/runbook.md` changed. R1 in `memory-write-refused` under **Fix.**: the two-line sentence about narrowing the rule replaced with the same sentence pointing at [`sanitizer-rules`](#sanitizer-rules) instead of "the M5b spec's section 6.5". R2 in `memory-write-flagged` under **Fix.**: the two-line "narrow it as the M5b spec's section 6.5 says" replaced with the same sentence pointing at [`sanitizer-rules`](#sanitizer-rules). Verified before appending: `grep -c "section 6.5"` printed 0. Appended `docs/plans/M5b/files/runbook-sanitizer-rules.md` verbatim with `{ echo; cat ... } >> docs/runbook.md`; `grep -c '^## sanitizer-rules$'` printed 1 and `grep -c '(#sanitizer-rules)'` printed 2. `cargo fmt --all`; `make gate` printed `gate: ok` with 1118 tests (1117 in `cargo test --workspace`, plus the ignored `end_to_end` the gate runs). No code, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5b/13-instance-lock-rest | 2026-09-27 | done | 2 | pass | none | Made the other three daemons take the instance lock, `bxcore::instance` (task 12), the same way `loopd` and `inferproxy` do: before they touch their sockets each locks `/run/.lock` and holds it until the process ends. Copied the three new `instance.rs` tests and a new version of `crates/brokerd/tests/serve.rs` (the old second-instance test was gone) from `docs/plans/M5b/files/`. Step 2 showed exactly the named failures: brokerd `instance` failed on `the lock's line, and nothing else` (the second stopped at the audit lock with `brokerd is already running`) and `the lock file, mode 0600` (None vs Some(384)); `serve` 8 passed; memoryd and gatewayd `instance` each failed the same two shapes, each about 10 s. Made the edits exactly as the task gives: B1 the `use bxcore::instance;` import in brokerd main.rs, B2 the lock after the config loads and before `serve::start`; D1 the import in memoryd main.rs, D2 the lock after the config loads and before `serve::start`; G1 the combined `use bxcore::{instance, secrets};` import in gatewayd main.rs, G2 the `3b.` comment in `serve`, G3 the lock after the secret is read and before the state directory is made. `let _instance` binds each `InstanceLock` so it is not dropped at once. `cargo check` clean for each daemon; brokerd `instance` 2 / `serve` 8, memoryd `instance` 2, gatewayd `instance` 2 / `main` 5 pass. `grep -rn 'instance::lock(' crates/*/src` prints exactly five lines, one in each of brokerd, gatewayd, inferproxy, loopd, memoryd; `grep -rn 'runbook.md#already-running' crates/*/src` prints one line in bxcore; `git diff --stat -- crates/bxcore '*Cargo.toml'` prints nothing. `cargo fmt --all`; `make gate` printed `gate: ok` with 1118 tests (1113 before, minus 1 in the new brokerd `serve.rs`, plus 2 in each of the three new `instance.rs`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | M5b/12-instance-lock | 2026-09-27 | done | 1 | pass | none | Wrote the lock that keeps a daemon to one instance (M5b spec, section 9a) and made `loopd` and `inferproxy` take it. Step 2 showed exactly the named failures: the bxcore test did not compile (one E0432 `unresolved import bxcore::instance`); the loopd test failed its 3 tests (`a_second_loopd_on_the_same_home_is_refused...` left None right Some(1) because the second copy started and served, `after_the_first_is_killed_a_new_loopd_removes_the_stale_socket_and_serves` the lock file mode None vs Some(384), `a_run_directory_that_cannot_be_written_stops_loopd_naming_the_error` ran its self-test and failed later on the socket directory); the inferproxy test failed the same three shapes (`the second inferproxy...` left None right Some(1), `the lock file, beside the socket` None vs Some(384), `wanted the line` for the read-only directory). Copied the skeleton `crates/bxcore/src/instance.rs` (fill 2 functions) and the tests `bxcore/tests/instance.rs`, `loopd/tests/instance.rs`, `inferproxy/tests/instance.rs` from `docs/plans/M5b/files/`; appended `runbook-already-running.md` to `docs/runbook.md`. Made the edits in order: L1 the doc comment naming `instance` in lib.rs; L2 `pub mod instance;` after `pub mod ids;`. Filled `LockError::line` (a `match` on `self`, each arm `format!` with the path shown via `path.display()` and `{RUNBOOK}` inline) then `lock` (make the parent dir 0700 when `path.parent()` is `Some`, open the file 0600 with `create(true).truncate(false)`, `try_lock()` mapping `WouldBlock` to `Held` and other errors to `Io`). `cargo check -p bxcore` clean; `instance` 8 passed. M1 the `use bxcore::instance;` import in loopd main.rs; M2 the lock in `run_serve` after the config loads and before the socket is removed and the self-test, bound to `_instance`. `cargo check -p loopd` clean; loopd `instance` 3 passed. P1 the `use bxcore::instance;` import in inferproxy main.rs; P2 the lock beside the `--listen` socket (`.lock`) after the args are parsed and before the stale socket is removed, bound to `_instance`. `cargo check -p inferproxy` clean; inferproxy `instance` 3 passed. `cargo fmt --all`; `make gate` printed `gate: ok` with 1113 tests (1112 in `cargo test --workspace`, plus the gate's separate loopd `end_to_end` ignored test). `grep -rn 'runbook.md#already-running' crates/*/src` prints exactly one line, the `RUNBOOK` constant; `grep -c '^## already-running$' docs/runbook.md` printed 1; `git diff --stat -- '*Cargo.toml'` printed nothing. No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b |