Given tests: Action::Turn (not To), send_turn of epoch 1, clippy on
compact_summary, jsonl-without-baseline, live turn after compact sends
the carry. send_turn maps Damaged to Local("log") as read_turn did.
Gate counts 1484 → 1508. Mutations recorded in the README.
Co-Authored-By: Grok 4.6 <noreply@x.ai>
9.2 KiB
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 (W1–W3) |
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 testcrates/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/LogErroralready exist inturns.rs.read_sessionreturnsResult<Vec<LogRecord>, LogError>so a damaged line still names its file andLOG_RUNBOOK.- Listing. Under
sessions/<id>/, every file named<n>.jsonlwherenparses asu32, sorted byn. A missing session directory is empty, not an error (same as a missing0.jsonltoday). log_pathstays.../0.jsonl. Do not change its body.readablein the worker still names that path when the concatenated read succeeds, so existing recovery log lines stay stable.send_turntoday callsread_turn(&log_path(...), turn), which only sees epoch 0. After this task it loadsread_session, thenturn_records/user_text/final_replyfromcrate::honcho::decide(alreadypub).read_turnmaps a damaged line toOk(None)(the turn is marked failed, kindlog). Keep that:LogError::DamagedisUnsent::Local("log"); an I/O error staysUnsent::Unreadableand is retried.turns.rsafter 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.rsturn_records(&[LogRecord], u64) -> Option<&[LogRecord]>decide.rsuser_text(&[LogRecord]) -> (&str, Option<&str>)decide.rsfinal_reply(&[LogRecord]) -> Option<&str>decide.rsstr::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
/// 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
/// 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.
/// 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
if let Ok(records) = read_log(&log_path(&self.sessions, &session)) {
becomes
if let Ok(records) = read_session(&self.sessions, &session) {
H3. In records, the line
match read_log(&log_path(&self.sessions, session)) {
becomes
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
use crate::honcho::turns::{TurnSync, log_path};
becomes
use crate::honcho::decide::{final_reply, turn_records, user_text};
use crate::honcho::turns::{LogError, TurnSync, log_path, read_session};
W2. The body of send_turn, the lines
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
let path = log_path(&self.sessions, session);
let records = match read_session(&self.sessions, session) {
Ok(records) => {
self.readable(&path);
records
}
Err(LogError::Damaged(_)) => return Err(Unsent::Local("log")),
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"))?;
W3. The batch call, the line
let messages = batch(&text, &reply, speaker, &self.agent, session, turn);
becomes
let messages = batch(text, reply, speaker, &self.agent, session, turn);
text and reply are already &str; clippy denies the extra borrows (needless_borrow).
The rest of send_turn is unchanged (honcho.ok_or, MAX_BATCH, add_messages).
Steps
-
1. Copy the given test.
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 targethoncho_epochsdoes not compile (E0432unresolved importmemoryd::honcho::turns::read_session). Every other target compiles. -
3. The edits. H1, H1b, H2, H3, W1, W2, W3.
cargo check -p memorydprints no error and no warning.wc -l crates/memoryd/src/honcho/turns.rsandwc -l crates/memoryd/src/honcho/worker.rsare each ≤ 500. -
4. The tests.
cargo test -p memoryd --test honcho_epochs --test honcho_turns --test honcho_worker: every test passes, 6 inhoncho_epochsand the others as before. -
5. Check.
git diff --statlists exactlycrates/memoryd/src/honcho/turns.rsandcrates/memoryd/src/honcho/worker.rs. -
6. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1484 (1478 before, plus 6). -
7. Log and commit. Task cell
M5d1/04-memoryd-honcho-epochs. Stage withgit add crates/memoryd docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1484 tests.git diff HEAD~1 --statlists only the twohonchosources,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 1484.
- You think
log_pathshould return every file. It must still return0.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; send_turn posts turn 3 from epoch 1 (three worker steps, oldest first);
a missing session directory is empty.