memoryd: Honcho concatenates every epoch of a session
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
@@ -52,7 +52,8 @@ pub struct TurnSync {
|
||||
status: Shared,
|
||||
}
|
||||
|
||||
/// The path of `session`'s log under `sessions`: epoch 0, the only one `loopd` writes.
|
||||
/// 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 {
|
||||
sessions.join(session.as_str()).join("0.jsonl")
|
||||
}
|
||||
@@ -115,6 +116,41 @@ pub fn read_records(path: &Path) -> Result<Vec<LogRecord>, LogError> {
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Whether a turn has ended with an answer: its last `Assistant` record asks for no tool.
|
||||
pub fn finished(turn: &[LogRecord]) -> bool {
|
||||
let last = turn
|
||||
@@ -178,7 +214,7 @@ impl TurnSync {
|
||||
}
|
||||
}
|
||||
for session in sessions {
|
||||
if let Ok(records) = read_log(&log_path(&self.sessions, &session)) {
|
||||
if let Ok(records) = read_session(&self.sessions, &session) {
|
||||
self.reported.insert(
|
||||
session.as_str().to_string(),
|
||||
(session.clone(), turns(&records)),
|
||||
@@ -271,7 +307,7 @@ impl TurnSync {
|
||||
|
||||
/// `session`'s log, read. A log that does not read is logged once, until it reads again.
|
||||
fn records(&mut self, session: &SessionId, log: &dyn Fn(&str)) -> Option<Vec<LogRecord>> {
|
||||
match read_log(&log_path(&self.sessions, session)) {
|
||||
match read_session(&self.sessions, session).map_err(|e| e.to_string()) {
|
||||
Ok(records) => {
|
||||
self.unreadable.remove(session.as_str());
|
||||
Some(records)
|
||||
|
||||
@@ -13,11 +13,12 @@ use std::time::Duration;
|
||||
use bxcore::{SessionId, SessionStateReply, Timestamp};
|
||||
|
||||
use crate::honcho::client::{Clients, HonchoError, classify};
|
||||
use crate::honcho::decide::{final_reply, turn_records, user_text};
|
||||
use crate::honcho::edits;
|
||||
use crate::honcho::ledger::{self, Action, Item, Ledger, Record, Workspace};
|
||||
use crate::honcho::naming::{HonchoSession, honcho_session};
|
||||
use crate::honcho::status::{self, Shared};
|
||||
use crate::honcho::turns::{TurnSync, log_path};
|
||||
use crate::honcho::turns::{LogError, TurnSync, log_path, read_session};
|
||||
use crate::serve::Log;
|
||||
|
||||
use crate::honcho::messages::ids;
|
||||
@@ -338,11 +339,19 @@ impl Worker {
|
||||
honcho: Option<&HonchoSession>,
|
||||
) -> Result<Vec<String>, Unsent> {
|
||||
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"))?;
|
||||
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"))?;
|
||||
let honcho = honcho.ok_or(Unsent::Local("log"))?;
|
||||
let messages = batch(&text, &reply, speaker, &self.agent, session, turn);
|
||||
let messages = batch(text, reply, speaker, &self.agent, session, turn);
|
||||
if messages.len() > MAX_BATCH {
|
||||
return Err(Unsent::Local("too-long"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Honcho reads every epoch file of a session, in order. Do not edit.
|
||||
|
||||
#[path = "support/honcho.rs"]
|
||||
mod honcho;
|
||||
#[path = "support/logs.rs"]
|
||||
mod logs;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
#[path = "support/turns.rs"]
|
||||
mod turns;
|
||||
#[path = "support/worker.rs"]
|
||||
mod worker;
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use bxcore::{Epoch, LogRecord, SessionId, Timestamp};
|
||||
use honcho::FakeHoncho;
|
||||
use logs::{assistant, write_log};
|
||||
use memoryd::honcho::decide::{turn_records, turns};
|
||||
use memoryd::honcho::ledger::{Action, Item, Ledger, Record, Workspace};
|
||||
use memoryd::honcho::turns::{LOG_RUNBOOK, TurnSync, read_session};
|
||||
use memoryd::honcho::worker::Step;
|
||||
use tmp::TempDir;
|
||||
use turns::{clean, sid, spoken};
|
||||
use worker::{FAST, Home as WorkerHome};
|
||||
|
||||
const LEDGER: &str = "honcho.jsonl";
|
||||
|
||||
fn epoch_end(summary: &str) -> LogRecord {
|
||||
LogRecord::EpochEnd {
|
||||
time: Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap(),
|
||||
next: Epoch(1),
|
||||
summary: summary.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_epoch(home: &std::path::Path, session: &str, epoch: u32, records: &[LogRecord]) {
|
||||
let dir = home.join("sessions").join(session);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(format!("{epoch}.jsonl"));
|
||||
let text: String = records
|
||||
.iter()
|
||||
.map(|r| format!("{}\n", serde_json::to_string(r).unwrap()))
|
||||
.collect();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
struct World {
|
||||
sync: TurnSync,
|
||||
answer: RefCell<Result<bxcore::SessionStateReply, String>>,
|
||||
dir: TempDir,
|
||||
}
|
||||
|
||||
impl World {
|
||||
fn new(tag: &str) -> World {
|
||||
let dir = TempDir::new(tag);
|
||||
std::fs::create_dir_all(dir.path().join("memory")).unwrap();
|
||||
let mut ledger = Ledger::open(&dir.path().join("memory").join(LEDGER)).unwrap();
|
||||
ledger
|
||||
.append(Record::Began {
|
||||
time: Timestamp::parse("2026-09-28T09:00:00.000Z").unwrap(),
|
||||
})
|
||||
.unwrap();
|
||||
World {
|
||||
sync: TurnSync::new(ledger, "boxmaker0", &dir.path().join("sessions")),
|
||||
answer: RefCell::new(Ok(clean())),
|
||||
dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn updated(&mut self, session: &str, turn: u64) {
|
||||
let ask = |s: &SessionId| {
|
||||
let _ = s;
|
||||
self.answer.borrow().clone()
|
||||
};
|
||||
let log = |_line: &str| {};
|
||||
let now = Timestamp::parse("2026-09-28T10:00:00.000Z").unwrap();
|
||||
self.sync.updated(&sid(session), turn, &ask, now, &log);
|
||||
}
|
||||
|
||||
fn action(&self, session: &str, turn: u64) -> Option<Action> {
|
||||
let item = Item::Turn {
|
||||
session: sid(session),
|
||||
turn,
|
||||
};
|
||||
let path = self.dir.path().join("memory").join(LEDGER);
|
||||
let text = std::fs::read_to_string(path).unwrap_or_default();
|
||||
let key = serde_json::to_string(&item).unwrap();
|
||||
let line = text.lines().find(|l| l.contains(&key))?;
|
||||
let value: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
Some(serde_json::from_value(value["action"].clone()).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
fn to(workspace: Workspace, speaker: &str) -> Action {
|
||||
Action::Turn {
|
||||
workspace,
|
||||
speaker: speaker.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concatenated_logs_count_three_turns() {
|
||||
let dir = TempDir::new("honcho-epochs-count");
|
||||
let home = dir.path();
|
||||
let epoch0 = vec![
|
||||
spoken("one", Some("kyle")),
|
||||
assistant(2, Some("ok"), &[]),
|
||||
spoken("two", Some("kyle")),
|
||||
assistant(3, Some("ok"), &[]),
|
||||
epoch_end("sum"),
|
||||
];
|
||||
write_log(home, "mm-a", &epoch0);
|
||||
write_epoch(
|
||||
home,
|
||||
"mm-a",
|
||||
1,
|
||||
&[spoken("three", Some("kyle")), assistant(4, Some("ok"), &[])],
|
||||
);
|
||||
let records = read_session(&home.join("sessions"), &sid("mm-a")).unwrap();
|
||||
assert_eq!(turns(&records), 3);
|
||||
let turn3 = turn_records(&records, 3).unwrap();
|
||||
assert!(
|
||||
matches!(turn3[0], LogRecord::User { ref content, .. } if content == "three"),
|
||||
"{turn3:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_0_alone_still_reads() {
|
||||
let dir = TempDir::new("honcho-epochs-zero");
|
||||
let home = dir.path();
|
||||
write_log(home, "mm-a", &[spoken("only", Some("kyle"))]);
|
||||
let records = read_session(&home.join("sessions"), &sid("mm-a")).unwrap();
|
||||
assert_eq!(turns(&records), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_later_epoch_is_an_error_naming_that_file() {
|
||||
let dir = TempDir::new("honcho-epochs-damaged");
|
||||
let home = dir.path();
|
||||
write_log(home, "mm-a", &[spoken("one", Some("kyle"))]);
|
||||
let path = home.join("sessions/mm-a/1.jsonl");
|
||||
std::fs::write(&path, "{\"type\":\"zz\"}\n").unwrap();
|
||||
let err = read_session(&home.join("sessions"), &sid("mm-a")).unwrap_err();
|
||||
assert!(err.to_string().contains("1.jsonl"), "{err}");
|
||||
assert!(
|
||||
err.to_string().contains(LOG_RUNBOOK) || err.to_string().contains("session-log-damaged"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_sync_decides_a_turn_that_lives_in_epoch_1() {
|
||||
let mut w = World::new("honcho-epochs-sync");
|
||||
write_log(
|
||||
w.dir.path(),
|
||||
"mm-a",
|
||||
&[
|
||||
spoken("one", Some("kyle")),
|
||||
assistant(2, Some("ok"), &[]),
|
||||
spoken("two", Some("kyle")),
|
||||
assistant(3, Some("ok"), &[]),
|
||||
epoch_end("sum"),
|
||||
],
|
||||
);
|
||||
write_epoch(
|
||||
w.dir.path(),
|
||||
"mm-a",
|
||||
1,
|
||||
&[spoken("three", Some("kyle")), assistant(4, Some("ok"), &[])],
|
||||
);
|
||||
w.updated("mm-a", 3);
|
||||
assert_eq!(w.action("mm-a", 3), Some(to(Workspace::Trusted, "kyle")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_session_directory_is_empty() {
|
||||
let dir = TempDir::new("honcho-epochs-missing");
|
||||
let records = read_session(&dir.path().join("sessions"), &sid("mm-none")).unwrap();
|
||||
assert!(records.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_turn_reads_a_turn_that_lives_in_epoch_1() {
|
||||
let fake = FakeHoncho::start();
|
||||
let home = WorkerHome::new("honcho-epochs-send");
|
||||
write_log(
|
||||
home.dir.path(),
|
||||
"mm-a",
|
||||
&[
|
||||
spoken("one", Some("kyle")),
|
||||
assistant(2, Some("ok"), &[]),
|
||||
spoken("two", Some("kyle")),
|
||||
assistant(3, Some("ok"), &[]),
|
||||
epoch_end("sum"),
|
||||
],
|
||||
);
|
||||
write_epoch(
|
||||
home.dir.path(),
|
||||
"mm-a",
|
||||
1,
|
||||
&[spoken("three", Some("kyle")), assistant(4, Some("ok"), &[])],
|
||||
);
|
||||
home.report("mm-a", 3);
|
||||
let mut w = home.worker(&fake, FAST);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
assert_eq!(w.step(), Step::Done);
|
||||
let posts: Vec<_> = fake
|
||||
.requests()
|
||||
.into_iter()
|
||||
.filter(|r| r.path.ends_with("/messages"))
|
||||
.collect();
|
||||
assert_eq!(posts.len(), 3, "{posts:?}");
|
||||
let body = posts[2].body.as_ref().expect("message batch");
|
||||
let contents: Vec<&str> = body["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| m["content"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(contents, ["three", "ok"]);
|
||||
assert_eq!(body["messages"][0]["metadata"]["turn"], 3);
|
||||
}
|
||||
Reference in New Issue
Block a user