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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user