diff --git a/crates/loopd/src/channel.rs b/crates/loopd/src/channel.rs index 8a076d6..5eb43b5 100644 --- a/crates/loopd/src/channel.rs +++ b/crates/loopd/src/channel.rs @@ -178,25 +178,32 @@ pub fn handle(stream: UnixStream, ctx: Arc) { } }; - let outcome = run_turn(&mut session, &runtime, &turn.content, &mut |event| { - let _ = write_frame( - &mut stream, - &Envelope { - v: PROTOCOL_VERSION, - id: request.id, - r#final: false, - msg: Message::TurnEvent(event.clone()), - }, - ); - }); + let outcome = run_turn( + &mut session, + &runtime, + &turn.content, + turn.speaker.as_deref(), + &mut |event| { + let _ = write_frame( + &mut stream, + &Envelope { + v: PROTOCOL_VERSION, + id: request.id, + r#final: false, + msg: Message::TurnEvent(event.clone()), + }, + ); + }, + ); // 5. Release the session before the final frame, so the next turn does not find it busy. drop(held); // A finished turn's records are for `memoryd` to index; best effort, since `memoryd` catches - // up at its next start. + // up at its next start. The number is this turn's: its `User` record is the last one counted. + // A failed turn is not told, as before, though its logged message still counts. if outcome.is_ok() - && let Err(e) = ctx.memory.updated(&turn.session) + && let Err(e) = ctx.memory.updated(&turn.session, session.turns()) { eprintln!( "loopd: memoryd was not told about {}: {e}", diff --git a/crates/loopd/src/memory.rs b/crates/loopd/src/memory.rs index 0130981..ec4cc95 100644 --- a/crates/loopd/src/memory.rs +++ b/crates/loopd/src/memory.rs @@ -22,8 +22,9 @@ pub const MEMORY_WAIT: Duration = Duration::from_secs(10); pub trait MemorySource: Send + Sync { /// The block for a new session. `Err` ends in `UNAVAILABLE`: no session starts without it. fn block(&self) -> Result; - /// The session has new records; best effort, a failure is only logged by the caller. - fn updated(&self, session: &SessionId) -> Result<(), String>; + /// The session's turn number `turn` has ended, and its records are in the log; best effort, + /// a failure is only logged by the caller. + fn updated(&self, session: &SessionId, turn: u64) -> Result<(), String>; } /// No memory: for tests and the self-test, never for `loopd serve`. @@ -33,7 +34,7 @@ impl MemorySource for NoMemory { fn block(&self) -> Result { Ok(String::new()) } - fn updated(&self, _session: &SessionId) -> Result<(), String> { + fn updated(&self, _session: &SessionId, _turn: u64) -> Result<(), String> { Ok(()) } } @@ -91,11 +92,10 @@ impl MemorySource for MemoryPort { } } - fn updated(&self, session: &SessionId) -> Result<(), String> { + fn updated(&self, session: &SessionId, turn: u64) -> 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, + turn, }))? { Message::Ok(_) => Ok(()), Message::Error(e) => Err(format!("memoryd: {}", e.detail)), diff --git a/crates/loopd/src/session.rs b/crates/loopd/src/session.rs index 2dd0603..87c061f 100644 --- a/crates/loopd/src/session.rs +++ b/crates/loopd/src/session.rs @@ -208,6 +208,19 @@ impl Session { }) } + /// How many turns the session has had: its `User` records, one per turn that reached the + /// log, a failed turn's too. Counted from `records`, which `open` reads back whole, so the + /// count goes on after a reopen or a restart; right after a turn it is that turn's number, + /// the first being 1. Epoch 0 is a session's only log until compaction is built. + pub fn turns(&self) -> u64 { + let users = self + .records + .iter() + .filter(|record| matches!(record, LogRecord::User { .. })) + .count(); + u64::try_from(users).unwrap_or(u64::MAX) + } + pub fn last_usage(&self) -> Option { for record in self.records.iter().rev() { if let LogRecord::Usage { diff --git a/crates/loopd/src/turn.rs b/crates/loopd/src/turn.rs index 5c84b59..94721a0 100644 --- a/crates/loopd/src/turn.rs +++ b/crates/loopd/src/turn.rs @@ -113,19 +113,23 @@ fn map_event(event: &ChatEvent, on_event: &mut dyn FnMut(&TurnEvent)) { } } +/// One turn of `session`: `content` is the user's message, and `speaker` who spoke it, when one +/// person did. The speaker is logged on the `User` record, for `memoryd`, and never sent to the +/// model: the request is the same with a speaker and without one (M5c spec, section 11). pub fn run_turn( session: &mut Session, rt: &Runtime<'_>, content: &str, + speaker: Option<&str>, on_event: &mut dyn FnMut(&TurnEvent), ) -> Result { session .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, + // As it came: not checked and not neutralised, since it never reaches the model. + // `None` is not written, so the record's bytes are as before M5c. + speaker: speaker.map(str::to_string), }) .map_err(TurnError::Session)?; diff --git a/crates/loopd/tests/end_to_end.rs b/crates/loopd/tests/end_to_end.rs index 3625cf5..2d29c25 100644 --- a/crates/loopd/tests/end_to_end.rs +++ b/crates/loopd/tests/end_to_end.rs @@ -121,6 +121,7 @@ fn a_call_no_grant_allows_reaches_the_model_as_a_denial_and_is_audited() { &mut session, &runtime, "what is this host called?", + None, &mut |e| events.push(e.clone()), ); assert!( diff --git a/crates/loopd/tests/memory.rs b/crates/loopd/tests/memory.rs index 2e4b5f4..27aae1f 100644 --- a/crates/loopd/tests/memory.rs +++ b/crates/loopd/tests/memory.rs @@ -33,7 +33,7 @@ impl MemorySource for Shared { fn block(&self) -> Result { self.0.block.lock().unwrap().clone() } - fn updated(&self, session: &SessionId) -> Result<(), String> { + fn updated(&self, session: &SessionId, _turn: u64) -> Result<(), String> { self.0 .updated .lock() diff --git a/crates/loopd/tests/speaker_turn.rs b/crates/loopd/tests/speaker_turn.rs new file mode 100644 index 0000000..543ce7b --- /dev/null +++ b/crates/loopd/tests/speaker_turn.rs @@ -0,0 +1,309 @@ +//! A turn's speaker and its number (M5c spec, sections 5 and 11). `loopd` logs the speaker on the +//! turn's `User` record as it came, and never puts it in a request: the model sees the same bytes +//! with a speaker and without one. After a finished turn it tells `memoryd` the turn's number: the +//! count of `User` records in the session's log, so the first turn is 1, a failed turn that was +//! logged counts too, and the count goes on after a reopen or a restart. Do not edit. + +mod support; +#[path = "support/tags.rs"] +mod tags; + +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +use bxcore::{ + Empty, Envelope, LogRecord, Message, PROTOCOL_VERSION, SessionId, SessionUpdated, Timestamp, + Turn, read_frame, write_frame, +}; +use loopd::baseline::Baseline; +use loopd::channel::{Context, serve}; +use loopd::llama::Client; +use loopd::memory::{MemoryPort, MemorySource}; +use loopd::session::Session; +use loopd::tools::Registry; +use support::{FakeServer, Home, Reply, ScriptedPort}; +use tags::{holds_a_tag, same}; + +const CHAT: &str = "/v1/chat/completions"; + +/// A `memoryd` stand-in that records each `(session, turn)` it is told about. +#[derive(Default)] +struct Recorder(Mutex>); + +impl Recorder { + fn told(&self) -> Vec<(String, u64)> { + self.0.lock().unwrap().clone() + } +} + +/// Lets the test keep its `Arc` while the context owns a `Box`. +struct Shared(Arc); + +impl MemorySource for Shared { + fn block(&self) -> Result { + Ok(String::new()) + } + fn updated(&self, session: &SessionId, turn: u64) -> Result<(), String> { + let entry = (session.as_str().to_string(), turn); + self.0.0.lock().unwrap().push(entry); + Ok(()) + } +} + +/// A fake inference server and a home that one `loopd` or several, one after the other, serve +/// from. +struct Setup { + server: FakeServer, + // Declared last so it is dropped last: fields drop in order, and the home must outlive + // everything that may still write into it (tip T44). + home: Home, +} + +impl Setup { + /// The server answers the completions in `chat` in order; the last one repeats. + fn new(chat: Vec) -> Setup { + let home = Home::new(); + let server = FakeServer::start(); + server.route(CHAT, chat); + Setup { server, home } + } + + /// Starts a channel server on the home, as a new `loopd` process would: its own context, + /// nothing carried over but the files. `name` names its socket, under the home; the returned + /// recorder hears what this `loopd` tells `memoryd`. + fn loopd(&self, name: &str) -> (PathBuf, Arc) { + let mut cfg = self.home.config(&self.server.socket); + cfg.channel.socket = self.home.dir.join(name); + let socket = cfg.channel_socket(); + let listener = UnixListener::bind(&socket).unwrap(); + let memory = Arc::new(Recorder::default()); + let ctx = Arc::new(Context::new( + cfg.clone(), + Client::new(cfg), + Box::new(ScriptedPort::new(vec![])), + Registry::m2b(), + Box::new(Shared(Arc::clone(&memory))), + )); + thread::spawn(move || serve(listener, ctx)); + (socket, memory) + } + + /// The `User` records of a session's log, in order. + fn users(&self, session: &str) -> Vec { + let records = self.home.records(session); + let is_user = |r: &LogRecord| matches!(r, LogRecord::User { .. }); + records.into_iter().filter(is_user).collect() + } + + /// The bodies of the completion requests so far, as sent, in order. + fn bodies(&self) -> Vec { + let requests = self.server.requests_to(CHAT); + let body = |r: &support::Recorded| String::from_utf8(r.body.clone()).unwrap(); + requests.iter().map(body).collect() + } +} + +/// Sends one turn and returns true if it ended with `turn_done`, false if with an error. +fn turn(socket: &Path, session: &str, resume: bool, speaker: Option<&str>) -> bool { + let mut stream = UnixStream::connect(socket).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .unwrap(); + let msg = Message::Turn(Turn { + session: SessionId::new(session).unwrap(), + content: "hello".to_string(), + resume, + speaker: speaker.map(str::to_string), + }); + let env = Envelope { + v: PROTOCOL_VERSION, + id: 7, + r#final: true, + msg, + }; + write_frame(&mut stream, &env).unwrap(); + loop { + let frame = read_frame(&mut stream).unwrap(); + if !frame.r#final { + continue; + } + match frame.msg { + Message::TurnDone(_) => return true, + Message::Error(_) => return false, + other => panic!("the turn ended with neither turn_done nor error: {other:?}"), + } + } +} + +fn told(session: &str, turn: u64) -> (String, u64) { + (session.to_string(), turn) +} + +/// The speaker of a `User` record, or a panic for any other record. +fn speaker(record: &LogRecord) -> Option { + match record { + LogRecord::User { speaker, .. } => speaker.clone(), + other => panic!("not a user record: {other:?}"), + } +} + +#[test] +fn the_speaker_is_logged_on_the_user_record_as_it_came() { + let s = Setup::new(vec![Reply::fixture("plain")]); + let (socket, _) = s.loopd("loop.sock"); + // Not a valid Honcho peer name, and one that holds one of the model's tags: `loopd` neither + // checks nor neutralises a speaker, since it never reaches the model. + let tagged = format!("x{}y", bxcore::sanitize::TAGS[0].text); + assert!(turn(&socket, "a", false, Some("first.last"))); + assert!(turn(&socket, "a", true, None)); + assert!(turn(&socket, "a", true, Some(&tagged))); + let users = s.users("a"); + assert_eq!(users.len(), 3, "one user record per turn"); + assert_eq!(speaker(&users[0]), Some("first.last".to_string())); + assert_eq!(speaker(&users[1]), None, "a turn without a speaker"); + same(&speaker(&users[2]), &Some(tagged), "the third speaker"); + let log = s.home.read("sessions/a/0.jsonl"); + assert_eq!( + log.matches("\"speaker\":").count(), + 2, + "no speaker key for the turn without one" + ); +} + +#[test] +fn the_requests_are_the_same_with_and_without_a_speaker() { + let s = Setup::new(vec![Reply::fixture("plain")]); + let (socket, _) = s.loopd("loop.sock"); + let tagged = format!("x{}y", bxcore::sanitize::TAGS[0].text); + assert!(turn(&socket, "with", false, Some("Zelda_Q"))); + assert!(turn(&socket, "with", true, Some(&tagged))); + assert!(turn(&socket, "without", false, None)); + assert!(turn(&socket, "without", true, None)); + let bodies = s.bodies(); + assert_eq!(bodies.len(), 4, "one request per turn"); + // Each session's second request replays its first turn from the log: a speaker there would + // change the replayed bytes too. + assert!(bodies[0] == bodies[2], "the first requests differ"); + assert!(bodies[1] == bodies[3], "the second requests differ"); + for (n, body) in bodies.iter().enumerate() { + assert!(!body.contains("Zelda_Q"), "request {n} holds the speaker"); + assert!(!holds_a_tag(body), "request {n} holds the tagged speaker"); + } +} + +#[test] +fn the_turn_number_starts_at_one_in_each_session() { + let s = Setup::new(vec![Reply::fixture("plain")]); + let (socket, memory) = s.loopd("loop.sock"); + assert!(turn(&socket, "a", false, None)); + assert!(turn(&socket, "a", true, Some("kyle"))); + assert!(turn(&socket, "b", false, None)); + assert!(turn(&socket, "a", true, None)); + assert_eq!( + memory.told(), + [told("a", 1), told("a", 2), told("b", 1), told("a", 3)] + ); +} + +#[test] +fn a_turn_with_tool_calls_is_one_turn() { + // The first turn asks for a tool, then answers: two assistant records, one user record. + let s = Setup::new(vec![Reply::fixture("tool_call"), Reply::fixture("plain")]); + let (socket, memory) = s.loopd("loop.sock"); + assert!(turn(&socket, "a", false, None)); + assert!(turn(&socket, "a", true, None)); + assert_eq!(memory.told(), [told("a", 1), told("a", 2)]); +} + +#[test] +fn a_failed_turn_is_not_told_but_is_counted() { + let s = Setup::new(vec![ + Reply::fixture("plain"), + Reply::fixture("bad_request"), + Reply::fixture("plain"), + ]); + let (socket, memory) = s.loopd("loop.sock"); + assert!(turn(&socket, "a", false, None)); + assert!(!turn(&socket, "a", true, None), "the second turn fails"); + assert!(!turn(&socket, "nobody", true, None), "no such session"); + assert!(turn(&socket, "a", true, None)); + assert_eq!(s.users("a").len(), 3, "the failed turn's message is logged"); + assert_eq!(memory.told(), [told("a", 1), told("a", 3)]); +} + +#[test] +fn a_restarted_loopd_goes_on_counting_from_the_log() { + let s = Setup::new(vec![Reply::fixture("plain")]); + let (first, before) = s.loopd("first.sock"); + assert!(turn(&first, "a", false, None)); + assert!(turn(&first, "a", true, None)); + let (second, after) = s.loopd("second.sock"); + assert!(turn(&second, "a", true, None)); + assert!(turn(&second, "b", false, None)); + assert_eq!(before.told(), [told("a", 1), told("a", 2)]); + assert_eq!(after.told(), [told("a", 3), told("b", 1)]); +} + +#[test] +fn session_turns_counts_the_user_records_of_the_log() { + let home = Home::new(); + let cfg = home.config(Path::new("/tmp/unused.sock")); + let baseline = Baseline::assemble(&cfg, &Registry::m2b()).unwrap(); + let id = SessionId::new("a").unwrap(); + let mut session = Session::create(&home.dir, id.clone(), baseline, 0).unwrap(); + assert_eq!(session.turns(), 0, "a new session has had no turn"); + let user = |speaker: Option<&str>| LogRecord::User { + time: Timestamp::now(), + content: "hello".to_string(), + speaker: speaker.map(str::to_string), + }; + let assistant = || LogRecord::Assistant { + time: Timestamp::now(), + content: Some("hi".to_string()), + reasoning_content: None, + tool_calls: vec![], + }; + session.append(user(None)).unwrap(); + session.append(assistant()).unwrap(); + assert_eq!(session.turns(), 1); + session.append(user(Some("kyle"))).unwrap(); + session.append(assistant()).unwrap(); + session.append(assistant()).unwrap(); + assert_eq!(session.turns(), 2); + drop(session); + let session = Session::open(&home.dir, id).unwrap(); + assert_eq!(session.turns(), 2, "counted again from the log"); +} + +#[test] +fn the_memory_port_sends_the_turn_number() { + let home = Home::new(); + let socket = home.dir.join("memory.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let env = read_frame(&mut stream).unwrap(); + let reply = Envelope { + v: PROTOCOL_VERSION, + id: env.id, + r#final: true, + msg: Message::Ok(Empty {}), + }; + write_frame(&mut stream, &reply).unwrap(); + let _ = tx.send(env.msg); + }); + let port = MemoryPort::new(socket, Duration::from_secs(5)); + let id = SessionId::new("a").unwrap(); + assert_eq!(port.updated(&id, 7), Ok(())); + let sent = rx.recv_timeout(Duration::from_secs(10)).unwrap(); + let want = Message::SessionUpdated(SessionUpdated { + session: id, + turn: 7, + }); + assert_eq!(sent, want); +} diff --git a/crates/loopd/tests/support/turn.rs b/crates/loopd/tests/support/turn.rs index b128cf1..e65b458 100644 --- a/crates/loopd/tests/support/turn.rs +++ b/crates/loopd/tests/support/turn.rs @@ -63,7 +63,7 @@ impl Setup { text: &str, ) -> (Result, Vec) { let mut events = Vec::new(); - let result = run_turn(session, &self.runtime(), text, &mut |e| { + let result = run_turn(session, &self.runtime(), text, None, &mut |e| { events.push(e.clone()) }); (result, events) diff --git a/crates/loopd/tests/turn_broker.rs b/crates/loopd/tests/turn_broker.rs index 66e9672..c916408 100644 --- a/crates/loopd/tests/turn_broker.rs +++ b/crates/loopd/tests/turn_broker.rs @@ -243,8 +243,9 @@ fn a_port_that_returns_a_pending_frame_as_its_answer_is_a_failure_not_a_decision registry: &s.registry, }; let mut events = Vec::new(); - let result = - loopd::turn::run_turn(&mut session, &runtime, "x", &mut |e| events.push(e.clone())); + let result = loopd::turn::run_turn(&mut session, &runtime, "x", None, &mut |e| { + events.push(e.clone()) + }); assert!(result.is_ok(), "{result:?}"); match &session.records()[4] { LogRecord::ToolResult { diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 1d63098..d2d118a 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/02-loopd-speaker-turn | 2026-09-28 | done | 2 | pass | none | Copied five files from `docs/plans/M5c1/files/`: `speaker_turn.rs` (new) and new versions of `memory.rs`, `turn_broker.rs`, `end_to_end.rs` and `support/turn.rs` (skipped the older `memory.rs-task01`). Step 2 showed exactly the named failures: eight non-compiling test targets (end_to_end, limits, memory, neutralise, neutralise_replay, speaker_turn, turn, turn_broker) with E0061 for `run_turn` and `updated`, E0282 on the closure, E0050 and E0599 for `turns`. Made the edits exactly as the task gives: S1 `Session::turns` counting `User` records in `records` via `matches!` and `u64::try_from(...).unwrap_or(u64::MAX)`, placed before `last_usage`; M1 the trait `updated` gains `turn: u64`, M2 `NoMemory` and M3 `MemoryPort` pass it (the `turn: 0` placeholder and its comment dropped for `turn`); T1 `run_turn` gains `speaker: Option<&str>` after `content` with the doc comment, T2 logs `speaker: speaker.map(str::to_string)` (as it came, not checked, not neutralised, `None` not written); C1 passes `turn.speaker.as_deref()` to `run_turn`, C2 passes `session.turns()` to `updated` (the comment now says the number is this turn's, a failed turn not told but its logged message still counts). `baseline::messages` untouched, the request is the same with and without a speaker. Step 7 all pass: the grep for `turn: 0`/`speaker: None` prints nothing, `git diff --stat -- crates/loopd/src` lists exactly channel.rs/memory.rs/session.rs/turn.rs (baseline.rs absent), the other-crates diff is empty. After the edits `cargo check -p loopd` is clean, `speaker_turn` 8 passed, every loopd suite passes. `make gate` printed `gate: ok` with 1138 tests (1130 before plus the 8 of `speaker_turn.rs`). No new dependency, so `Cargo.lock` is unchanged. | ornith-1.5-35b-a3b | | 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 |