A shared status, kept by the worker and the walk and read by the report under its own leaf lock, holds the workspaces whose key was refused, when an outage of Honcho began, and when brokerd stopped answering. The report adds a line for each while it lasts, and is unchanged when all is well. The runbook's Honcho entries and broker-state-unavailable now say what sync shows. Tuning and Wake move to honcho/wake.rs (re-exported from worker) to keep worker.rs under 500 lines. honcho_sync.rs's serve test now allows the outage line: the write wakes the worker, which finds Honcho unreachable. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
245 lines
7.2 KiB
Rust
245 lines
7.2 KiB
Rust
//! 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();
|
|
l.append(Record::Began { time: at(0) }).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. The write wakes the worker, so once it
|
|
// has tried, the report also shows the outage (review of M5c-1, findings 3 and 4).
|
|
let shown = sync(&config);
|
|
let mut lines = shown.lines();
|
|
assert_eq!(
|
|
lines.next(),
|
|
Some("honcho sync: 1 decided: 0 sent, 1 waiting, 0 failed, 0 skipped")
|
|
);
|
|
if let Some(outage) = lines.next() {
|
|
assert!(outage.starts_with("honcho unreachable since "), "{shown}");
|
|
assert!(outage.ends_with(": sends wait and are retried"), "{shown}");
|
|
}
|
|
assert_eq!(lines.next(), None, "{shown}");
|
|
}
|
|
|
|
#[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);
|
|
}
|