Files
boxmaker/docs/plans/M5d1/04-memoryd-honcho-epochs.md
T
kyleandGrok 4.6 3726ae585f docs: M5d-1 and M5d-2 implementation plans for Ornith
Eight closed tasks for epochs and compaction, then ten for the scheduler,
ToolSummary, and tiers. Given tests first. Gate 1456 → 1507 → 1572.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-02 21:54:37 -07:00

233 lines
8.7 KiB
Markdown

# M5d-1 task 04: Honcho reads every epoch file
**Branch:** `m5d1`. Check it: `git branch --show-current` prints `m5d1` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `memoryd: Honcho concatenates every epoch of a session`
**Log row:** the Task cell is `M5d1/04-memoryd-honcho-epochs`.
## Goal
`memoryd` keys a turn as `Item::Turn { session, turn }` with no epoch (spec 3.2). After
compaction, turn 3 may live in `1.jsonl`. `log_path` still names `0.jsonl` (existing tests pin
that path). A new `read_session` concatenates every `<n>.jsonl` in epoch order. `TurnSync`
walks that concatenation, and the worker's `send_turn` does too, so a turn in epoch 1 is
decided and sent.
`turn_records` / `turns` do not change: they already count `User` records on a slice.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/memoryd/src/honcho/turns.rs` | `epoch_logs`; `read_session`; `records` and `resume` use it |
| `crates/memoryd/src/honcho/worker.rs` | `send_turn` reads the concatenated log |
| `crates/memoryd/tests/honcho_epochs.rs` | new, given |
| everything else | **no change**: not `log_path`'s return value, not `decide.rs` |
## Files
- Copy, from `docs/plans/M5d1/files/` (the same path under it): the new test
`crates/memoryd/tests/honcho_epochs.rs`.
- Modify: `crates/memoryd/src/honcho/turns.rs`, `crates/memoryd/src/honcho/worker.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **`read_records` / `LogError`** already exist in `turns.rs`. `read_session` returns
`Result<Vec<LogRecord>, LogError>` so a damaged line still names its file and
`LOG_RUNBOOK`.
- **Listing.** Under `sessions/<id>/`, every file named `<n>.jsonl` where `n` parses as `u32`,
sorted by `n`. A missing session directory is empty, not an error (same as a missing
`0.jsonl` today).
- **`log_path` stays** `.../0.jsonl`. Do not change its body. `readable` in the worker still
names that path when the concatenated read succeeds, so existing recovery log lines stay
stable.
- **`send_turn` today** calls `read_turn(&log_path(...), turn)`, which only sees epoch 0. After
this task it loads `read_session`, then `turn_records` / `user_text` / `final_reply` from
`crate::honcho::decide` (already `pub`).
- `turns.rs` after this task stays under 500 lines (it is 332 now).
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `read_records(&Path) -> Result<Vec<LogRecord>, LogError>` | one file, already in `turns.rs` |
| `turn_records(&[LogRecord], u64) -> Option<&[LogRecord]>` | `decide.rs` |
| `user_text(&[LogRecord]) -> (&str, Option<&str>)` | `decide.rs` |
| `final_reply(&[LogRecord]) -> Option<&str>` | `decide.rs` |
| `str::strip_suffix(".jsonl")`, `str::parse::<u32>` | epoch numbers |
| `Unsent::Unreadable(String)` | worker, `e.to_string()` |
## The edits, exactly
### `crates/memoryd/src/honcho/turns.rs`
**H1.** The doc comment on `log_path`, the lines
```rust
/// The path of `session`'s log under `sessions`: epoch 0, the only one `loopd` writes.
pub fn log_path(sessions: &Path, session: &SessionId) -> PathBuf {
```
become
```rust
/// The path of `session`'s epoch-0 log under `sessions`. Later epochs are sibling files;
/// `read_session` concatenates them. Existing tests pin this path.
pub fn log_path(sessions: &Path, session: &SessionId) -> PathBuf {
```
**H1b.** After `read_records` (the function that ends `Ok(records)` / `}`), insert these two
functions. They sit below `LogError` and `read_records`, so both names resolve.
```rust
/// Every `<n>.jsonl` of `session`, sorted by `n`. A missing directory is empty.
pub fn epoch_logs(sessions: &Path, session: &SessionId) -> Result<Vec<PathBuf>, LogError> {
let dir = sessions.join(session.as_str());
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(LogError::Io(format!("{}: {e}", dir.display()))),
};
let mut found: Vec<(u32, PathBuf)> = Vec::new();
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(e) => return Err(LogError::Io(format!("{}: {e}", dir.display()))),
};
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Some(stem) = name.strip_suffix(".jsonl") else { continue };
let Ok(n) = stem.parse::<u32>() else { continue };
found.push((n, entry.path()));
}
found.sort_by_key(|(n, _)| *n);
Ok(found.into_iter().map(|(_, p)| p).collect())
}
/// Every epoch's records, in epoch order. A missing session has none.
pub fn read_session(
sessions: &Path,
session: &SessionId,
) -> Result<Vec<LogRecord>, LogError> {
let mut out = Vec::new();
for path in epoch_logs(sessions, session)? {
out.extend(read_records(&path)?);
}
Ok(out)
}
```
The next function after this insert is `finished`. Do not change `finished`.
**H2.** In `resume`, the line
```rust
if let Ok(records) = read_log(&log_path(&self.sessions, &session)) {
```
becomes
```rust
if let Ok(records) = read_session(&self.sessions, &session) {
```
**H3.** In `records`, the line
```rust
match read_log(&log_path(&self.sessions, session)) {
```
becomes
```rust
match read_session(&self.sessions, session).map_err(|e| e.to_string()) {
```
`read_log` already mapped `LogError` through `Display`; this keeps the logged line the same
shape (`memoryd: {why}; {LOG_RUNBOOK}`).
### `crates/memoryd/src/honcho/worker.rs`
**W1.** The `use` line
```rust
use crate::honcho::turns::{TurnSync, log_path};
```
becomes
```rust
use crate::honcho::decide::{final_reply, turn_records, user_text};
use crate::honcho::turns::{TurnSync, log_path, read_session};
```
**W2.** The body of `send_turn`, the lines
```rust
let path = log_path(&self.sessions, session);
let read = read_turn(&path, turn).map_err(Unsent::Unreadable)?;
self.readable(&path);
let (text, reply) = read.ok_or(Unsent::Local("log"))?;
```
become
```rust
let path = log_path(&self.sessions, session);
let records = match read_session(&self.sessions, session) {
Ok(records) => {
self.readable(&path);
records
}
Err(e) => return Err(Unsent::Unreadable(e.to_string())),
};
let slice = turn_records(&records, turn).ok_or(Unsent::Local("log"))?;
let (text, _) = user_text(slice);
let reply = final_reply(slice).ok_or(Unsent::Local("log"))?;
```
The rest of `send_turn` is unchanged (`honcho.ok_or`, `batch`, `MAX_BATCH`, `add_messages`).
`text` is `&str` from `user_text`; `reply` is `&str` from `final_reply`. `batch` already takes
`&str`.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M5d1/files/crates/memoryd/tests/honcho_epochs.rs crates/memoryd/tests/
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p memoryd`, timeout 600000 ms: test
target `honcho_epochs` does not compile (`E0432` unresolved import
`memoryd::honcho::turns::read_session`). Every other target compiles.
- [ ] **3. The edits.** H1, H1b, H2, H3, W1, W2. `cargo check -p memoryd`
prints no error and no warning. `wc -l crates/memoryd/src/honcho/turns.rs` and
`wc -l crates/memoryd/src/honcho/worker.rs` are each ≤ 500.
- [ ] **4. The tests.** `cargo test -p memoryd --test honcho_epochs --test honcho_turns --test
honcho_worker`: every test passes, 5 in `honcho_epochs` and the others as before.
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/memoryd/src/honcho/turns.rs` and
`crates/memoryd/src/honcho/worker.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1483** (1478 before, plus 5).
- [ ] **7. Log and commit.** Task cell `M5d1/04-memoryd-honcho-epochs`. Stage with
`git add crates/memoryd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1483 tests.
- `git diff HEAD~1 --stat` lists only the two `honcho` sources, `honcho_epochs.rs`, and the log.
## Stop and report if
- The branch check fails; a "before" block is not found exactly once; step 2 differs; a file
exceeds 500 lines; a test fails; the gate count is not 1483.
- You think `log_path` should return every file. It must still return `0.jsonl`.
## What the given tests pin
Two users in epoch 0 and one in epoch 1 is `turns() == 3` and `turn_records(..., 3)` is the
epoch-1 user; epoch 0 alone still reads; a damaged `1.jsonl` names that file; `TurnSync` decides
turn 3 from epoch 1; a missing session directory is empty.