M5c-1 plan: task 14, bxctl memory sync

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 09:38:50 -07:00
co-authored by Claude Opus 5.5
parent 687d07a9a7
commit 44d597524b
4 changed files with 983 additions and 0 deletions
+623
View File
@@ -0,0 +1,623 @@
# M5c-1 task 14: `bxctl memory sync` shows what `memoryd` sends to Honcho
**Branch:** `m5c1` (run `git switch m5c1`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `bxctl memory sync: the Honcho ledger's counts and failed items`
**Log row:** the Task cell is `M5c1/14-bxctl-memory-sync`.
## Goal
The owner's view of the Honcho ledger (M5c spec, section 5): `bxctl memory sync` asks `memoryd`
on its admin socket, as `memory log`, `undo`, `pending`, `accept` and `reject` do, and prints the
answer as it comes.
- **The command.** A new `bxcore::MemoryCommand::Sync` (`"sync"` on the wire, with an empty
`arg`), sent in the `MemoryAdmin` message those commands use, and answered, like them, with a
`MemoryAdminReply { text }` that `memoryd` writes and `bxctl` prints followed by a newline.
- **The report**, exactly, from the ledger: one line of counts, then, if any item failed, a heading
and one line per failed item, oldest first (the order of the `Failed` records), all of them:
```text
honcho sync: 7 decided: 2 sent, 2 waiting, 2 failed, 1 skipped
failed, oldest first:
2026-09-28T10:02:00.000Z turn 4 of session mm-a log
2026-09-28T10:03:00.000Z part 0 of edit 2026-09-28-0001 status (HTTP 422)
```
The first line is `format!("honcho sync: {} decided: {} sent, {} waiting, {} failed, {}
skipped", c.decided, c.sent, c.waiting, c.failed, c.skipped)`; each failed item is two spaces,
its record's time (`Timestamp::to_rfc3339`), two spaces, its label (`turn N of session S`,
`part N of edit ID`), two spaces, its kind, and ` (HTTP <status>)` when it has a status. No
item's text is ever printed.
- **Without `[honcho]`**: `honcho sync: not configured (memoryd.toml has no [honcho])`. Not an
error: `bxctl` prints it and exits 0.
- **`memoryd` down**: as for every memory command, `cannot reach memoryd at <socket>: <error>`
on stderr, exit 1 (`bxctl`'s existing `run`).
The report is read under the ledger's own lock (task 12), inside the admin call's index lock:
index → ledger, the one order. The worker's state (a workspace whose key was refused, an outage
in progress) is its own, behind no lock the report can take, so it is not in the report; the log
has both.
## Interfaces
- `bxcore::MemoryCommand` gains `Sync` (edit X1).
- In `crates/memoryd/src/honcho/ledger.rs`: `pub struct Failure { pub item: Item, pub time:
Timestamp, pub status: Option<u16>, pub kind: String }` (Debug, Clone, PartialEq, Eq);
`Ledger::failures(&self) -> Vec<Failure>`, oldest first; `pub fn label(item: &Item) -> String`
(the old private `describe`, renamed and made public). The private `State::Failed` keeps the
record's time, status and kind (edits L1 to L9).
- In `crates/memoryd/src/honcho/report.rs` (new): `pub const NOT_CONFIGURED: &str` and
`pub fn report(ledger: &Ledger) -> String`.
- `Mirror::report(&self) -> String` (edits R1, R2), and `memoryd`'s `admin` answers `Sync`
with it, or with `NOT_CONFIGURED` (edits S1, S2).
- `bxctl`: `memory sync` parses to `Ask::Admin(MemoryCommand::Sync, String::new())` (edits B1 to
B3); the usage text names it (edit U1).
## Every place that changes
This is the complete list.
| Place | What happens there |
|---|---|
| `crates/bxcore/src/memory.rs` | `MemoryCommand::Sync` (edit X1) |
| `crates/bxctl/src/memory.rs`, `cli.rs` | `memory sync` parsed; the usage text (edits B1 to B3, U1) |
| `crates/memoryd/src/honcho/ledger.rs` | failures kept, in order; `Failure`, `failures`, `label` (edits L1 to L9) |
| `crates/memoryd/src/honcho/report.rs` | new: a skeleton you copy, then fill 1 body |
| `crates/memoryd/src/honcho/mirror.rs`, `mod.rs`, `serve.rs` | `Mirror::report`, `pub mod report;`, the `Sync` answer (edits R1, R2, H1, S1, S2) |
| everything else | **no change** |
## Files
- Copy, from `docs/plans/M5c1/files/` (the same path under it): the new tests
`crates/memoryd/tests/honcho_sync.rs` and `crates/bxctl/tests/cli_memory_sync.rs`, and the
skeleton `crates/memoryd/src/honcho/report.rs`.
- Modify: the files of the edits, `docs/implementer-log.md`.
- Do not touch: any other source file, any other test, any `Cargo.toml`, and in the skeleton
anything but its `todo!()` body.
## Facts you need
- **A new `MemoryCommand` variant** changes no existing message: `MemoryAdmin`'s fixture uses
`undo`, and the wire test that names each command loops over a list. Every `match` on
`MemoryCommand` is in `memoryd`'s `admin`, which edit S2 extends.
- **`Ledger::failures`** reads the new `fails: Vec<Item>` (each failed item, in the order of its
`Failed` record) and the time, status and kind each entry's state now keeps; a restart reads the
same back from the file.
- **The given tests.**
- `honcho_sync.rs` (4 tests, `memoryd`): the report of a ledger with every state, exactly, and
of an empty one; the failures in order, and the same after a reopen; `label`; `memoryd`
answering `Sync` (the counts before and after a memory write) and, without `[honcho]`,
`NOT_CONFIGURED`.
- `cli_memory_sync.rs` (4 tests, `bxctl`): parsing (and `memory sync now` refused), the usage
line; the wire name `sync`; the answer printed as it came, with what was sent; `memoryd`
down.
- The calls the body uses: `ledger.counts() -> Counts`; `ledger.failures() -> Vec<Failure>`;
`Timestamp::to_rfc3339(&self) -> String`; `label(&Item) -> String`; `String::push_str`.
## The edits, exactly
Nothing else in these files changes. Each quoted "before" block occurs exactly once in its file
when its turn comes (make them in order).
### `crates/bxcore/src/memory.rs`
**X1.** The lines
```rust
Accept,
Reject,
}
```
become
```rust
Accept,
Reject,
/// What the Honcho ledger holds: its counts and failed items (M5c spec, section 5).
Sync,
}
```
### `crates/bxctl/src/memory.rs`
**B1.** The lines
```rust
//! The owner's memory commands over `memoryd`'s owner socket: `reindex`, and `memory log`,
//! `undo`, `pending`, `accept`, `reject` (M5a spec, section 10).
use std::io::Write;
```
become
```rust
//! The owner's memory commands over `memoryd`'s owner socket: `reindex`, and `memory log`,
//! `undo`, `pending`, `accept`, `reject` (M5a spec, section 10), and `sync` (M5c spec, section 5).
use std::io::Write;
```
**B2.** The lines
```rust
}
/// `bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id>`, each with an optional
/// `--memory-socket PATH`.
pub fn parse_memory(args: &[String], home: &Path) -> Result<Command, UsageError> {
let (socket, rest) = socket_flag(args, home)?;
```
become
```rust
}
/// `bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> | sync`, each with an
/// optional `--memory-socket PATH`.
pub fn parse_memory(args: &[String], home: &Path) -> Result<Command, UsageError> {
let (socket, rest) = socket_flag(args, home)?;
```
**B3.** The lines
```rust
["accept", id] => Ask::Admin(MemoryCommand::Accept, id.to_string()),
["reject", id] => Ask::Admin(MemoryCommand::Reject, id.to_string()),
_ => return Err(UsageError),
};
```
become
```rust
["accept", id] => Ask::Admin(MemoryCommand::Accept, id.to_string()),
["reject", id] => Ask::Admin(MemoryCommand::Reject, id.to_string()),
["sync"] => Ask::Admin(MemoryCommand::Sync, String::new()),
_ => return Err(UsageError),
};
```
### `crates/bxctl/src/cli.rs`
**U1.** The lines
```rust
Rebuild memoryd's index from the session logs and notes.
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> [--memory-socket PATH]
List memory edits, undo the latest edit of a file, or answer writes waiting for review.
bxctl sanitize check [TEXT]
```
become
```rust
Rebuild memoryd's index from the session logs and notes.
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> | sync [--memory-socket PATH]
List memory edits, undo the latest edit of a file, or answer writes waiting for review.
sync shows what memoryd sends to Honcho: counts, and each failed item by its label.
bxctl sanitize check [TEXT]
```
### `crates/memoryd/src/honcho/ledger.rs`
**L1.** The lines
```rust
}
#[derive(Debug)]
pub enum LedgerError {
```
become
```rust
}
/// A failed item, as its `Failed` record says.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Failure {
pub item: Item,
pub time: Timestamp,
pub status: Option<u16>,
pub kind: String,
}
#[derive(Debug)]
pub enum LedgerError {
```
**L2.** The lines
```rust
}
LedgerError::Encode(err) => write!(f, "a ledger record could not be encoded: {err}"),
LedgerError::AlreadyDecided(item) => write!(f, "{}: decided already", describe(item)),
LedgerError::NotWaiting(item) => {
write!(f, "{}: not waiting to be sent", describe(item))
}
}
```
become
```rust
}
LedgerError::Encode(err) => write!(f, "a ledger record could not be encoded: {err}"),
LedgerError::AlreadyDecided(item) => write!(f, "{}: decided already", label(item)),
LedgerError::NotWaiting(item) => {
write!(f, "{}: not waiting to be sent", label(item))
}
}
```
**L3.** The lines
```rust
Waiting,
Sent(Vec<String>),
Failed,
}
```
become
```rust
Waiting,
Sent(Vec<String>),
Failed {
time: Timestamp,
status: Option<u16>,
kind: String,
},
}
```
**L4.** The lines
```rust
/// Every sent item, in the order of its `Sent` record.
sends: Vec<Item>,
repaired: bool,
}
```
become
```rust
/// Every sent item, in the order of its `Sent` record.
sends: Vec<Item>,
/// Every failed item, in the order of its `Failed` record.
fails: Vec<Item>,
repaired: bool,
}
```
**L5.** The lines
```rust
entries: HashMap::new(),
sends: Vec::new(),
repaired: false,
};
```
become
```rust
entries: HashMap::new(),
sends: Vec::new(),
fails: Vec::new(),
repaired: false,
};
```
**L6.** The lines
```rust
}
/// How many items are in each state.
pub fn counts(&self) -> Counts {
```
become
```rust
}
/// Every failed item, oldest first (the order of the `Failed` records), with its record's
/// time, status and kind.
pub fn failures(&self) -> Vec<Failure> {
self.fails
.iter()
.filter_map(|item| match &self.entries.get(item)?.state {
State::Failed { time, status, kind } => Some(Failure {
item: item.clone(),
time: *time,
status: *status,
kind: kind.clone(),
}),
_ => None,
})
.collect()
}
/// How many items are in each state.
pub fn counts(&self) -> Counts {
```
**L7.** The lines
```rust
State::Waiting => counts.waiting += 1,
State::Sent(_) => counts.sent += 1,
State::Failed => counts.failed += 1,
}
}
```
become
```rust
State::Waiting => counts.waiting += 1,
State::Sent(_) => counts.sent += 1,
State::Failed { .. } => counts.failed += 1,
}
}
```
**L8.** The lines
```rust
self.sends.push(item);
}
Record::Failed { item, .. } => {
if let Some(entry) = self.entries.get_mut(&item) {
entry.state = State::Failed;
}
}
}
```
become
```rust
self.sends.push(item);
}
Record::Failed {
time,
item,
status,
kind,
} => {
if let Some(entry) = self.entries.get_mut(&item) {
entry.state = State::Failed { time, status, kind };
}
self.fails.push(item);
}
}
```
**L9.** The lines
```rust
}
/// An item as the error messages name it.
fn describe(item: &Item) -> String {
match item {
Item::Turn { session, turn } => format!("turn {turn} of session {}", session.as_str()),
```
become
```rust
}
/// An item as the error messages and `bxctl memory sync` name it: `turn 3 of session mm-a`,
/// `part 0 of edit 2026-09-28-0001`.
pub fn label(item: &Item) -> String {
match item {
Item::Turn { session, turn } => format!("turn {turn} of session {}", session.as_str()),
```
### `crates/memoryd/src/honcho/mirror.rs`
**R1.** The lines
```rust
use crate::files::Store;
use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace};
use crate::journal;
use crate::notes::{self, Kind};
```
become
```rust
use crate::files::Store;
use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace};
use crate::honcho::report;
use crate::journal;
use crate::notes::{self, Kind};
```
**R2.** The lines
```rust
}
/// Decide the journalled edit `id` (the latest when `None`), called under the index lock
/// right after it was journalled, so the ledger has the journal's order. Its parts are
```
become
```rust
}
/// `bxctl memory sync`'s report of the ledger, under the ledger's lock alone.
pub fn report(&self) -> String {
report::report(&self.ledger.lock().unwrap_or_else(|p| p.into_inner()))
}
/// Decide the journalled edit `id` (the latest when `None`), called under the index lock
/// right after it was journalled, so the ledger has the journal's order. Its parts are
```
### `crates/memoryd/src/honcho/mod.rs`
**H1.** The lines
```rust
pub mod mirror;
pub mod naming;
pub mod start;
pub mod state;
```
become
```rust
pub mod mirror;
pub mod naming;
pub mod report;
pub mod start;
pub mod state;
```
### `crates/memoryd/src/serve.rs`
**S1.** The lines
```rust
use crate::honcho::ledger::{self, LedgerError};
use crate::honcho::mirror::Mirror;
use crate::honcho::state;
use crate::honcho::turns::TurnSync;
```
become
```rust
use crate::honcho::ledger::{self, LedgerError};
use crate::honcho::mirror::Mirror;
use crate::honcho::report;
use crate::honcho::state;
use crate::honcho::turns::TurnSync;
```
**S2.** The lines
```rust
MemoryCommand::Accept => review::accept(&self.store, arg, now),
MemoryCommand::Reject => review::reject(&self.store, arg, now),
};
if result.is_ok() && matches!(command, MemoryCommand::Undo | MemoryCommand::Accept) {
```
become
```rust
MemoryCommand::Accept => review::accept(&self.store, arg, now),
MemoryCommand::Reject => review::reject(&self.store, arg, now),
MemoryCommand::Sync => Ok(match &self.mirror {
Some(mirror) => mirror.report(),
None => report::NOT_CONFIGURED.to_string(),
}),
};
if result.is_ok() && matches!(command, MemoryCommand::Undo | MemoryCommand::Accept) {
```
## Steps
- [ ] **1. Copy the given tests.** From the repository root, two commands:
```sh
cp docs/plans/M5c1/files/crates/memoryd/tests/honcho_sync.rs crates/memoryd/tests/
cp docs/plans/M5c1/files/crates/bxctl/tests/cli_memory_sync.rs crates/bxctl/tests/
```
- [ ] **2. See them fail.** `cargo build --tests --keep-going -p memoryd -p bxctl`, with a shell
timeout of 600000 ms: two test targets do not compile. `honcho_sync`, 5 errors: `E0432`
(``unresolved import `memoryd::honcho::report` ``), `E0432` (``unresolved imports
`memoryd::honcho::ledger::Failure`, `memoryd::honcho::ledger::label` ``), two `E0599` (``no
method named `failures` ``) and one `E0599` (``no variant, associated function, or constant
named `Sync` found for enum `MemoryCommand` ``). `cli_memory_sync`, 6 errors, all that last
`E0599`. Every other target compiles.
- [ ] **3. The edits.** Make every edit below, in order (X1, B1 to B3, U1, L1 to L9, R1, R2, H1,
S1, S2), then copy the skeleton:
```sh
cp docs/plans/M5c1/files/crates/memoryd/src/honcho/report.rs crates/memoryd/src/honcho/
```
`cargo check -p memoryd -p bxctl`: no error, and 2 warnings, from the unfilled body:
``unused import: `label` `` and ``unused variable: `ledger` ``. And
`cargo test -p bxctl --test cli_memory_sync` passes its 4 tests.
- [ ] **4. Fill the body of `report`** from its comment and "The report" above. Delete its
instruction comment and keep the doc comment. `cargo check -p memoryd` prints no warning.
- [ ] **5. The tests.** With a shell timeout of 600000 ms, every test passes (4 and 4, and the
others unchanged):
```sh
cargo test -p memoryd --test honcho_sync --test honcho_ledger --test honcho_ledger_file \
--test honcho_worker && cargo test -p bxctl --test cli_memory_sync --test cli_memory --test cli
```
- [ ] **6. Check.** Two commands:
- `grep -n 'MemoryCommand::Sync' crates/memoryd/src/serve.rs crates/bxctl/src/memory.rs`
prints two lines.
- `git diff --stat -- crates/loopd crates/brokerd crates/gatewayd Cargo.toml Cargo.lock` prints
nothing.
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
Expected last line: `gate: ok`, with 1332 tests: 1324 before this task, plus 8 new ones (4 in
`honcho_sync.rs`, 4 in `cli_memory_sync.rs`).
- [ ] **8. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
`M5c1/14-bxctl-memory-sync`. `git status --short` lists the seven edited files and your log as
modified, and `report.rs` and the two test files as new. Stage with
`git add crates/bxcore crates/bxctl crates/memoryd docs/implementer-log.md Cargo.lock`, then
commit as `AGENTS.md` says. `Cargo.lock` does not change in this task; the `git add` is
harmless.
## Done when
- `make gate` prints `gate: ok` with 1332 tests.
- `git diff HEAD~1 --stat` lists only the files of step 8.
## Stop and report if
- A "before" block is not in its file exactly as shown when its turn comes, or is there more
than once.
- Step 2 or 3 shows a different result: other test targets, errors of another kind, other
warnings.
- `cargo check` reports an error after an edit or the body, and the edit or the comment does not
tell you how to fix it. Quote the error, and stop.
- After step 4 a test fails although the body follows its comment and each edit is as shown. Do
not change a test: stop, and give the failing tests' names and their messages.
- The fix seems to need a change anywhere else: another crate, the worker, a new message type.
It does not; stop and say why you think so.
- A test outside the new files fails in the gate: check once whether it fails without your
change, then stop and report both results. (`gatewayd`'s `gateway_sock` test
`brokerd_closing_first_ends_it_as_no_longer_pending` fails now and then on a loaded machine,
without this task's change; if it is the one, run the gate once more.)
- The gate passes with a count other than 1332.
## What the given tests pin
Each of these changes to the finished code makes at least one given test fail (14 tried): the
counts in another order; the heading with no failure; the status or the kind left out; the
failures newest first, or in the order of decision; a failure's time or status lost, or not
recorded at all; an edit's label; "not configured" with `[honcho]`, or `sync` answered as an
error; `memory sync` not parsed, or sent as `log`.
@@ -0,0 +1,108 @@
//! `bxctl memory sync` (M5c spec, section 5): parsed, sent to `memoryd` as the `sync` command,
//! its answer printed as it comes, and `memoryd` down reported as for every memory command. Do
//! not edit.
#[path = "support/temp.rs"]
mod temp;
use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use bxcore::{
Envelope, MemoryAdmin, MemoryAdminReply, MemoryCommand, Message, read_frame, write_frame,
};
use bxctl::cli::{Command, USAGE, UsageError, parse};
use bxctl::memory::{Ask, run};
fn args(line: &str) -> Vec<String> {
line.split_whitespace().map(str::to_string).collect()
}
/// A fake `memoryd` answering one request with `text`; hands back what it was sent.
fn fake(socket: &Path, text: &str) -> mpsc::Receiver<Message> {
let listener = UnixListener::bind(socket).unwrap();
let (tx, rx) = mpsc::channel();
let answer = Message::MemoryAdminReply(MemoryAdminReply {
text: text.to_string(),
});
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let env = read_frame(&mut stream).unwrap();
tx.send(env.msg).unwrap();
let reply = Envelope {
v: 1,
id: env.id,
r#final: true,
msg: answer,
};
write_frame(&mut stream, &reply).unwrap();
});
rx
}
#[test]
fn sync_parses() {
let home = PathBuf::from("/srv/bx");
assert_eq!(
parse(&args("memory sync"), &home),
Ok(Command::Memory {
socket: PathBuf::from("/srv/bx/run/owner-memory/admin.sock"),
ask: Ask::Admin(MemoryCommand::Sync, String::new()),
})
);
assert_eq!(
parse(&args("memory sync --memory-socket /r/m.sock"), &home),
Ok(Command::Memory {
socket: PathBuf::from("/r/m.sock"),
ask: Ask::Admin(MemoryCommand::Sync, String::new()),
})
);
assert_eq!(parse(&args("memory sync now"), &home), Err(UsageError));
assert!(
USAGE.contains("| reject <id> | sync [--memory-socket PATH]\n"),
"{USAGE}"
);
}
#[test]
fn the_command_is_named_sync_on_the_wire() {
assert_eq!(
serde_json::to_string(&MemoryCommand::Sync).unwrap(),
"\"sync\""
);
}
#[test]
fn the_answer_is_printed_as_it_comes() {
let dir = temp::TempDir::new("bx-mem-sync");
let socket = dir.join("admin.sock");
let report = "honcho sync: 3 decided: 1 sent, 1 waiting, 1 failed, 0 skipped\n\
failed, oldest first:\n \
2026-09-28T10:00:00.000Z turn 3 of session mm-a status (HTTP 422)";
let seen = fake(&socket, report);
let mut out = Vec::new();
let ask = Ask::Admin(MemoryCommand::Sync, String::new());
run(&socket, &ask, Duration::from_secs(5), &mut out).unwrap();
assert_eq!(String::from_utf8(out).unwrap(), format!("{report}\n"));
assert_eq!(
seen.recv_timeout(Duration::from_secs(5)).unwrap(),
Message::MemoryAdmin(MemoryAdmin {
command: MemoryCommand::Sync,
arg: String::new(),
})
);
}
#[test]
fn memoryd_down_is_reported_as_for_every_memory_command() {
let dir = temp::TempDir::new("bx-mem-sync-down");
let ask = Ask::Admin(MemoryCommand::Sync, String::new());
let socket = dir.join("nobody.sock");
let err = run(&socket, &ask, Duration::from_secs(1), &mut Vec::new()).unwrap_err();
assert!(
err.starts_with(&format!("cannot reach memoryd at {}: ", socket.display())),
"{err}"
);
}
@@ -0,0 +1,17 @@
//! What `bxctl memory sync` prints (M5c spec, section 5): how many items the ledger holds in each
//! state, and every failed item, oldest first, by its label, never by its text.
use crate::honcho::ledger::{Ledger, label};
/// The answer without `[honcho]`: not an error.
pub const NOT_CONFIGURED: &str = "honcho sync: not configured (memoryd.toml has no [honcho])";
/// The report of `ledger`: one line of counts, then, when any item failed, a heading and one line
/// per failed item: its time, its label, its kind, and its HTTP status when it had one.
pub fn report(ledger: &Ledger) -> String {
// `let c = ledger.counts();` and `out` the first line (see "The report"). With
// `ledger.failures()` not empty, push `"\nfailed, oldest first:"`; then for each `f`, push
// `format!("\n {} {} {}", f.time.to_rfc3339(), label(&f.item), f.kind)`, and when
// `f.status` is `Some(status)`, `format!(" (HTTP {status})")`. Return `out`.
todo!()
}
@@ -0,0 +1,235 @@
//! What `bxctl memory sync` prints (M5c spec, section 5): the ledger's counts, and each failed
//! item, oldest first, by its label, its kind and its status, exactly; the same after a restart;
//! and `memoryd` answering it, with `[honcho]` and without. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::time::Duration;
use bxcore::{
CallId, DataClass, Envelope, MemoryAdmin, MemoryCall, MemoryCommand, Message, SessionId,
Timestamp, ToolRequest, read_frame, write_frame,
};
use memoryd::config::Config;
use memoryd::honcho::ledger::{Action, Failure, Item, Ledger, Record, Workspace, label};
use memoryd::honcho::report::{NOT_CONFIGURED, report};
use memoryd::serve;
use tmp::TempDir;
fn at(minute: u32) -> Timestamp {
Timestamp::parse(&format!("2026-09-28T10:{minute:02}:00.000Z")).unwrap()
}
fn turn(n: u64) -> Item {
Item::Turn {
session: SessionId::new("mm-a").unwrap(),
turn: n,
}
}
fn edit(id: &str) -> Item {
Item::Edit {
id: id.to_string(),
part: 0,
}
}
fn to_trusted() -> Action {
Action::Turn {
workspace: Workspace::Trusted,
speaker: "kyle".to_string(),
}
}
/// A ledger with every state: 2 sent, 1 waiting, 2 failed (the later-decided one failing first),
/// 1 skipped.
fn mixed(dir: &TempDir) -> Ledger {
let mut l = Ledger::open(&dir.path().join("honcho.jsonl")).unwrap();
let decided = |item: Item, action: Action| Record::Decided {
time: at(0),
item,
action,
};
let records = vec![
decided(turn(1), to_trusted()),
decided(turn(2), to_trusted()),
decided(
edit("2026-09-28-0001"),
Action::Conclude {
workspace: Workspace::Trusted,
peer: "kyle".to_string(),
session: SessionId::new("mm-a").unwrap(),
text: "a private fact".to_string(),
},
),
decided(
turn(3),
Action::Skip {
why: "the turn has no single speaker".to_string(),
},
),
decided(turn(4), to_trusted()),
decided(turn(5), to_trusted()),
decided(turn(6), to_trusted()),
Record::Sent {
time: at(1),
item: turn(1),
ids: vec!["m1".to_string()],
},
Record::Failed {
time: at(2),
item: turn(4),
status: None,
kind: "log".to_string(),
},
Record::Failed {
time: at(3),
item: edit("2026-09-28-0001"),
status: Some(422),
kind: "status".to_string(),
},
Record::Sent {
time: at(4),
item: turn(2),
ids: vec!["m2".to_string()],
},
];
for r in records {
l.append(r).unwrap();
}
l
}
const MIXED: &str = "honcho sync: 7 decided: 2 sent, 2 waiting, 2 failed, 1 skipped
failed, oldest first:
2026-09-28T10:02:00.000Z turn 4 of session mm-a log
2026-09-28T10:03:00.000Z part 0 of edit 2026-09-28-0001 status (HTTP 422)";
#[test]
fn the_report_is_exactly_this() {
let dir = TempDir::new("sync-report");
let l = mixed(&dir);
assert_eq!(report(&l), MIXED);
assert!(!report(&l).contains("private"), "no text is printed");
let empty = Ledger::open(&dir.path().join("none.jsonl")).unwrap();
assert_eq!(
report(&empty),
"honcho sync: 0 decided: 0 sent, 0 waiting, 0 failed, 0 skipped"
);
assert_eq!(
NOT_CONFIGURED,
"honcho sync: not configured (memoryd.toml has no [honcho])"
);
}
#[test]
fn the_failures_are_oldest_first_and_survive_a_restart() {
let dir = TempDir::new("sync-failures");
let l = mixed(&dir);
let expected = vec![
Failure {
item: turn(4),
time: at(2),
status: None,
kind: "log".to_string(),
},
Failure {
item: edit("2026-09-28-0001"),
time: at(3),
status: Some(422),
kind: "status".to_string(),
},
];
assert_eq!(l.failures(), expected);
let again = Ledger::open(&dir.path().join("honcho.jsonl")).unwrap();
assert_eq!(again.failures(), expected);
assert_eq!(report(&again), MIXED);
assert_eq!(label(&turn(4)), "turn 4 of session mm-a");
assert_eq!(label(&edit("e")), "part 0 of edit e");
}
const SECTION: &str = "[honcho]
url = \"http://127.0.0.1:9\"
agent = \"boxmaker0\"
owner = \"kyle\"
trusted = \"boxmaker0-trusted\"
untrusted = \"boxmaker0-untrusted\"
fleet = \"fleet\"
";
fn ask(socket: &std::path::Path, msg: Message) -> Message {
let mut stream = UnixStream::connect(socket).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let env = Envelope {
v: 1,
id: 5,
r#final: true,
msg,
};
write_frame(&mut stream, &env).unwrap();
read_frame(&mut stream).unwrap().msg
}
fn sync(config: &Config) -> String {
let msg = Message::MemoryAdmin(MemoryAdmin {
command: MemoryCommand::Sync,
arg: String::new(),
});
match ask(&config.admin_socket(), msg) {
Message::MemoryAdminReply(reply) => reply.text,
other => panic!("{other:?}"),
}
}
fn serve(dir: &TempDir, honcho: bool) -> Config {
let soul = dir.write("etc/soul.md", "You are Boxmaker.\n");
let text = format!(
"[paths]\nhome = \"{}\"\n[memory]\nsoul_starter = \"{}\"\n{}",
dir.path().display(),
soul.display(),
if honcho { SECTION } else { "" }
);
let config = Config::parse(&text).unwrap();
let log: serve::Log = Arc::new(|_: &str| {});
let running = serve::start(config.clone(), log).unwrap();
std::thread::spawn(move || running.run());
config
}
#[test]
fn memoryd_answers_with_the_report() {
let dir = TempDir::new("sync-serve");
let config = serve(&dir, true);
assert_eq!(
sync(&config),
"honcho sync: 0 decided: 0 sent, 0 waiting, 0 failed, 0 skipped"
);
let call = Message::MemoryCall(MemoryCall {
request: ToolRequest {
session: SessionId::new("mm-a").unwrap(),
call: CallId(1),
tool: "memory".to_string(),
arguments: r#"{"action":"add","file":"user","text":"Prefers UTC."}"#.to_string(),
},
taint: DataClass::Private,
untrusted: false,
});
ask(&config.broker_socket(), call);
// Honcho is not reachable here: the conclusion waits.
assert_eq!(
sync(&config),
"honcho sync: 1 decided: 0 sent, 1 waiting, 0 failed, 0 skipped"
);
}
#[test]
fn without_honcho_it_is_not_configured() {
let dir = TempDir::new("sync-off");
let config = serve(&dir, false);
assert_eq!(sync(&config), NOT_CONFIGURED);
}