loopd: open the highest epoch and carry its summary
Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
+193
-60
@@ -1,5 +1,6 @@
|
||||
//! A session on disk: `sessions/<id>/0.baseline.json` and `0.jsonl`. The log is the state —
|
||||
//! create, open, append and resume all live here, and nothing is durable until `append` syncs it.
|
||||
//! A session on disk: `sessions/<id>/<epoch>.baseline.json` and `<epoch>.jsonl`. The log is the
|
||||
//! state — create, open, append and resume all live here, and nothing is durable until `append`
|
||||
//! syncs it. Epoch 0 is the first; later epochs appear after compaction.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::Write;
|
||||
@@ -21,6 +22,9 @@ pub enum SessionError {
|
||||
},
|
||||
Baseline(BaselineError),
|
||||
Encode(serde_json::Error),
|
||||
NoCarry {
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SessionError {
|
||||
@@ -36,6 +40,11 @@ impl std::fmt::Display for SessionError {
|
||||
),
|
||||
SessionError::Baseline(err) => write!(f, "{err}"),
|
||||
SessionError::Encode(err) => write!(f, "{err}"),
|
||||
SessionError::NoCarry { path } => write!(
|
||||
f,
|
||||
"{}: compacted epoch is missing its EpochEnd; see docs/runbook.md#session-log-damaged",
|
||||
path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,6 +70,11 @@ pub struct Session {
|
||||
records: Vec<LogRecord>,
|
||||
log: File,
|
||||
next_call: CallId,
|
||||
epoch: Epoch,
|
||||
slot: u32,
|
||||
carry: Option<String>,
|
||||
prior_users: u64,
|
||||
prior_untrusted: bool,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -99,6 +113,11 @@ impl Session {
|
||||
records: Vec::new(),
|
||||
log,
|
||||
next_call: CallId(1),
|
||||
epoch: Epoch(0),
|
||||
slot,
|
||||
carry: None,
|
||||
prior_users: 0,
|
||||
prior_untrusted: false,
|
||||
};
|
||||
session.persist(LogRecord::SessionStart {
|
||||
time: Timestamp::now(),
|
||||
@@ -112,51 +131,26 @@ impl Session {
|
||||
|
||||
pub fn open(home: &Path, id: SessionId) -> Result<Session, SessionError> {
|
||||
let dir = Session::dir_for(home, &id);
|
||||
let log_path = dir.join("0.jsonl");
|
||||
if !log_path.exists() {
|
||||
let epochs = complete_epochs(&dir)?;
|
||||
let Some(&n) = epochs.last() else {
|
||||
return Err(SessionError::NotFound(id));
|
||||
}
|
||||
let baseline_path = dir.join("0.baseline.json");
|
||||
};
|
||||
let epoch = Epoch(n);
|
||||
let baseline_path = dir.join(format!("{n}.baseline.json"));
|
||||
let baseline = Baseline::load(&baseline_path).map_err(SessionError::Baseline)?;
|
||||
|
||||
let text = std::fs::read_to_string(&log_path)
|
||||
.map_err(|e| SessionError::Io(log_path.clone(), e))?;
|
||||
let mut records = Vec::new();
|
||||
if !text.is_empty() {
|
||||
for (idx, line) in text.split_inclusive('\n').enumerate() {
|
||||
let line_no = idx + 1;
|
||||
if !line.ends_with('\n') {
|
||||
return Err(SessionError::Torn {
|
||||
path: log_path.clone(),
|
||||
line: line_no,
|
||||
why: "last line does not end in a newline".to_string(),
|
||||
});
|
||||
}
|
||||
match serde_json::from_str::<LogRecord>(line) {
|
||||
Ok(record) => records.push(record),
|
||||
Err(why) => {
|
||||
return Err(SessionError::Torn {
|
||||
path: log_path.clone(),
|
||||
line: line_no,
|
||||
why: why.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let log_path = dir.join(format!("{n}.jsonl"));
|
||||
let records = load_records(&log_path)?;
|
||||
let (carry, prior_users, prior_untrusted) = priors(&dir, n)?;
|
||||
let mut highest = max_call(&records);
|
||||
for earlier in &epochs[..epochs.len() - 1] {
|
||||
let path = dir.join(format!("{earlier}.jsonl"));
|
||||
highest = highest.max(max_call(&load_records(&path)?));
|
||||
}
|
||||
|
||||
let mut highest = 0u64;
|
||||
for record in &records {
|
||||
if let LogRecord::ToolResult { call, .. } = record {
|
||||
highest = highest.max(call.0);
|
||||
}
|
||||
}
|
||||
|
||||
let slot = live_slot(&records, 0);
|
||||
let log = OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.map_err(|e| SessionError::Io(log_path.clone(), e))?;
|
||||
|
||||
Ok(Session {
|
||||
id,
|
||||
dir,
|
||||
@@ -164,6 +158,11 @@ impl Session {
|
||||
records,
|
||||
log,
|
||||
next_call: CallId(highest + 1),
|
||||
epoch,
|
||||
slot,
|
||||
carry,
|
||||
prior_users,
|
||||
prior_untrusted,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -183,6 +182,18 @@ impl Session {
|
||||
&self.records
|
||||
}
|
||||
|
||||
pub fn epoch(&self) -> Epoch {
|
||||
self.epoch
|
||||
}
|
||||
|
||||
pub fn slot(&self) -> u32 {
|
||||
self.slot
|
||||
}
|
||||
|
||||
pub fn carry(&self) -> Option<&str> {
|
||||
self.carry.as_deref()
|
||||
}
|
||||
|
||||
pub fn append(&mut self, record: LogRecord) -> Result<(), SessionError> {
|
||||
self.persist(record)
|
||||
}
|
||||
@@ -197,28 +208,13 @@ impl Session {
|
||||
/// grant, or a recalled memory-tainted note, which `brokerd` labels so. The labels are in
|
||||
/// the log, so this holds after the session is reopened, and it never becomes false again.
|
||||
pub fn untrusted(&self) -> bool {
|
||||
self.records.iter().any(|record| {
|
||||
matches!(
|
||||
record,
|
||||
LogRecord::ToolResult {
|
||||
untrusted: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
self.prior_untrusted || any_untrusted(&self.records)
|
||||
}
|
||||
|
||||
/// How many turns the session has had: its `User` records, one per turn that reached the
|
||||
/// log, a failed turn's too. Counted from `records`, which `open` reads back whole, so the
|
||||
/// count goes on after a reopen or a restart; right after a turn it is that turn's number,
|
||||
/// the first being 1. Epoch 0 is a session's only log until compaction is built.
|
||||
/// How many turns the session has had: its `User` records across every epoch, a failed
|
||||
/// turn's too. The first `User` of the session is turn 1 for its life.
|
||||
pub fn turns(&self) -> u64 {
|
||||
let users = self
|
||||
.records
|
||||
.iter()
|
||||
.filter(|record| matches!(record, LogRecord::User { .. }))
|
||||
.count();
|
||||
u64::try_from(users).unwrap_or(u64::MAX)
|
||||
self.prior_users.saturating_add(count_users(&self.records))
|
||||
}
|
||||
|
||||
pub fn last_usage(&self) -> Option<bxcore::Usage> {
|
||||
@@ -263,6 +259,143 @@ impl Session {
|
||||
}
|
||||
|
||||
fn log_path(&self) -> PathBuf {
|
||||
self.dir.join("0.jsonl")
|
||||
self.dir.join(format!("{}.jsonl", self.epoch.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Epoch numbers that have both `<n>.jsonl` and `<n>.baseline.json`, sorted.
|
||||
fn complete_epochs(dir: &Path) -> Result<Vec<u32>, SessionError> {
|
||||
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(SessionError::Io(dir.to_path_buf(), e)),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| SessionError::Io(dir.to_path_buf(), e))?;
|
||||
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 };
|
||||
if dir.join(format!("{n}.baseline.json")).exists() {
|
||||
out.push(n);
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn load_records(path: &Path) -> Result<Vec<LogRecord>, SessionError> {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(SessionError::Io(path.to_path_buf(), e));
|
||||
}
|
||||
Err(e) => return Err(SessionError::Io(path.to_path_buf(), e)),
|
||||
};
|
||||
let mut records = Vec::new();
|
||||
if text.is_empty() {
|
||||
return Ok(records);
|
||||
}
|
||||
for (idx, line) in text.split_inclusive('\n').enumerate() {
|
||||
let line_no = idx + 1;
|
||||
if !line.ends_with('\n') {
|
||||
return Err(SessionError::Torn {
|
||||
path: path.to_path_buf(),
|
||||
line: line_no,
|
||||
why: "last line does not end in a newline".to_string(),
|
||||
});
|
||||
}
|
||||
match serde_json::from_str::<LogRecord>(line) {
|
||||
Ok(record) => records.push(record),
|
||||
Err(why) => {
|
||||
return Err(SessionError::Torn {
|
||||
path: path.to_path_buf(),
|
||||
line: line_no,
|
||||
why: why.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Carry from epoch `n-1`, and user / untrusted counts from every epoch before `n`.
|
||||
fn priors(dir: &Path, n: u32) -> Result<(Option<String>, u64, bool), SessionError> {
|
||||
let mut prior_users = 0u64;
|
||||
let mut prior_untrusted = false;
|
||||
let mut carry = None;
|
||||
if n == 0 {
|
||||
return Ok((None, 0, false));
|
||||
}
|
||||
for earlier in 0..n {
|
||||
let path = dir.join(format!("{earlier}.jsonl"));
|
||||
let records = match load_records(&path) {
|
||||
Ok(r) => r,
|
||||
Err(SessionError::Io(path, e)) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(SessionError::NoCarry { path });
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
prior_users = prior_users.saturating_add(count_users(&records));
|
||||
prior_untrusted = prior_untrusted || any_untrusted(&records);
|
||||
if earlier + 1 == n {
|
||||
carry = carry_from(&records, Epoch(n)).map(str::to_string);
|
||||
if carry.is_none() {
|
||||
return Err(SessionError::NoCarry { path });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((carry, prior_users, prior_untrusted))
|
||||
}
|
||||
|
||||
fn carry_from(records: &[LogRecord], next: Epoch) -> Option<&str> {
|
||||
records.iter().rev().find_map(|record| match record {
|
||||
LogRecord::EpochEnd {
|
||||
next: n, summary, ..
|
||||
} if *n == next => Some(summary.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn count_users(records: &[LogRecord]) -> u64 {
|
||||
let users = records
|
||||
.iter()
|
||||
.filter(|record| matches!(record, LogRecord::User { .. }))
|
||||
.count();
|
||||
u64::try_from(users).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
fn any_untrusted(records: &[LogRecord]) -> bool {
|
||||
records.iter().any(|record| {
|
||||
matches!(
|
||||
record,
|
||||
LogRecord::ToolResult {
|
||||
untrusted: true,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn max_call(records: &[LogRecord]) -> u64 {
|
||||
let mut highest = 0u64;
|
||||
for record in records {
|
||||
if let LogRecord::ToolResult { call, .. } = record {
|
||||
highest = highest.max(call.0);
|
||||
}
|
||||
}
|
||||
highest
|
||||
}
|
||||
|
||||
fn live_slot(records: &[LogRecord], fallback: u32) -> u32 {
|
||||
records
|
||||
.iter()
|
||||
.find_map(|record| match record {
|
||||
LogRecord::SessionStart { slot, .. } => Some(*slot),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Tests for multi-epoch session open, turns, trust and call ids. Do not edit.
|
||||
|
||||
mod support;
|
||||
|
||||
use bxcore::{CallId, DataClass, Epoch, LogRecord, SessionId, Timestamp};
|
||||
use loopd::baseline::Baseline;
|
||||
use loopd::session::{Session, SessionError};
|
||||
use support::Home;
|
||||
|
||||
fn baseline() -> Baseline {
|
||||
Baseline {
|
||||
system: "sys".to_string(),
|
||||
tools: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn later() -> Baseline {
|
||||
Baseline {
|
||||
system: "sys-epoch-1".to_string(),
|
||||
tools: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn id(s: &str) -> SessionId {
|
||||
SessionId::new(s).unwrap()
|
||||
}
|
||||
|
||||
fn ts() -> Timestamp {
|
||||
Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
|
||||
}
|
||||
|
||||
fn user(content: &str) -> LogRecord {
|
||||
LogRecord::User {
|
||||
time: ts(),
|
||||
content: content.to_string(),
|
||||
speaker: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_jsonl(home: &Home, session: &str, epoch: u32, records: &[LogRecord]) {
|
||||
let text: String = records
|
||||
.iter()
|
||||
.map(|r| format!("{}\n", serde_json::to_string(r).unwrap()))
|
||||
.collect();
|
||||
home.write(&format!("sessions/{session}/{epoch}.jsonl"), &text);
|
||||
}
|
||||
|
||||
fn write_baseline(home: &Home, session: &str, epoch: u32, b: &Baseline) {
|
||||
home.write(
|
||||
&format!("sessions/{session}/{epoch}.baseline.json"),
|
||||
&b.to_json().unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
fn start(session: &str, epoch: u32, slot: u32, b: &Baseline) -> LogRecord {
|
||||
LogRecord::SessionStart {
|
||||
time: ts(),
|
||||
session: id(session),
|
||||
epoch: Epoch(epoch),
|
||||
slot,
|
||||
baseline: b.hash().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
fn epoch_end(next: u32, summary: &str) -> LogRecord {
|
||||
LogRecord::EpochEnd {
|
||||
time: ts(),
|
||||
next: Epoch(next),
|
||||
summary: summary.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_result(call: u64, untrusted: bool) -> LogRecord {
|
||||
LogRecord::ToolResult {
|
||||
time: ts(),
|
||||
call: CallId(call),
|
||||
tool_call_id: format!("c{call}"),
|
||||
content: "out".to_string(),
|
||||
class: DataClass::Public,
|
||||
untrusted,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_loads_the_highest_complete_epoch_and_its_carry() {
|
||||
let home = Home::new();
|
||||
let mut s = Session::create(&home.dir, id("a"), baseline(), 3).unwrap();
|
||||
s.append(user("one")).unwrap();
|
||||
s.append(user("two")).unwrap();
|
||||
s.append(epoch_end(1, "carry-text")).unwrap();
|
||||
drop(s);
|
||||
write_baseline(&home, "a", 1, &later());
|
||||
write_jsonl(&home, "a", 1, &[start("a", 1, 3, &later()), user("three")]);
|
||||
|
||||
let s = Session::open(&home.dir, id("a")).unwrap();
|
||||
assert_eq!(s.epoch(), Epoch(1));
|
||||
assert_eq!(s.slot(), 3);
|
||||
assert_eq!(s.carry(), Some("carry-text"));
|
||||
assert_eq!(s.baseline(), &later());
|
||||
assert_eq!(s.records().len(), 2);
|
||||
assert!(matches!(s.records()[1], LogRecord::User { ref content, .. } if content == "three"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_carry_on_epoch_1_is_an_error() {
|
||||
let home = Home::new();
|
||||
let s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
||||
drop(s);
|
||||
write_baseline(&home, "a", 1, &later());
|
||||
write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later())]);
|
||||
|
||||
match Session::open(&home.dir, id("a")) {
|
||||
Err(SessionError::NoCarry { ref path, .. }) => {
|
||||
assert!(path.ends_with("0.jsonl"), "{path:?}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let e: Box<dyn std::error::Error> =
|
||||
Box::new(Session::open(&home.dir, id("a")).map(|_| ()).unwrap_err());
|
||||
assert!(e.to_string().contains("0.jsonl"), "{e}");
|
||||
assert!(
|
||||
e.to_string()
|
||||
.contains("see docs/runbook.md#session-log-damaged"),
|
||||
"{e}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_0_has_no_carry() {
|
||||
let home = Home::new();
|
||||
let s = Session::create(&home.dir, id("a"), baseline(), 7).unwrap();
|
||||
drop(s);
|
||||
let s = Session::open(&home.dir, id("a")).unwrap();
|
||||
assert_eq!(s.epoch(), Epoch(0));
|
||||
assert_eq!(s.carry(), None);
|
||||
assert_eq!(s.slot(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turns_count_users_across_every_epoch() {
|
||||
let home = Home::new();
|
||||
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
||||
s.append(user("one")).unwrap();
|
||||
s.append(user("two")).unwrap();
|
||||
s.append(epoch_end(1, "sum")).unwrap();
|
||||
drop(s);
|
||||
write_baseline(&home, "a", 1, &later());
|
||||
write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later()), user("three")]);
|
||||
let s = Session::open(&home.dir, id("a")).unwrap();
|
||||
assert_eq!(s.turns(), 3);
|
||||
assert_eq!(
|
||||
s.records()
|
||||
.iter()
|
||||
.filter(|r| matches!(r, LogRecord::User { .. }))
|
||||
.count(),
|
||||
1,
|
||||
"records() is the current epoch only"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_reads_every_epoch() {
|
||||
let home = Home::new();
|
||||
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
||||
s.append(tool_result(1, true)).unwrap();
|
||||
s.append(epoch_end(1, "sum")).unwrap();
|
||||
drop(s);
|
||||
write_baseline(&home, "a", 1, &later());
|
||||
write_jsonl(
|
||||
&home,
|
||||
"a",
|
||||
1,
|
||||
&[start("a", 1, 0, &later()), tool_result(2, false)],
|
||||
);
|
||||
let s = Session::open(&home.dir, id("a")).unwrap();
|
||||
assert!(s.untrusted(), "an untrusted result in epoch 0 still taints");
|
||||
assert!(
|
||||
!s.records().iter().any(|r| matches!(
|
||||
r,
|
||||
LogRecord::ToolResult {
|
||||
untrusted: true,
|
||||
..
|
||||
}
|
||||
)),
|
||||
"the current epoch's records look clean"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_ids_continue_across_epochs() {
|
||||
let home = Home::new();
|
||||
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
||||
s.append(tool_result(4, false)).unwrap();
|
||||
s.append(epoch_end(1, "sum")).unwrap();
|
||||
drop(s);
|
||||
write_baseline(&home, "a", 1, &later());
|
||||
write_jsonl(
|
||||
&home,
|
||||
"a",
|
||||
1,
|
||||
&[start("a", 1, 0, &later()), tool_result(2, false)],
|
||||
);
|
||||
let mut s = Session::open(&home.dir, id("a")).unwrap();
|
||||
assert_eq!(
|
||||
s.next_call(),
|
||||
CallId(5),
|
||||
"one more than the highest call in any epoch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_writes_the_current_epoch_file() {
|
||||
let home = Home::new();
|
||||
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
||||
s.append(epoch_end(1, "sum")).unwrap();
|
||||
drop(s);
|
||||
write_baseline(&home, "a", 1, &later());
|
||||
write_jsonl(&home, "a", 1, &[start("a", 1, 0, &later())]);
|
||||
let mut s = Session::open(&home.dir, id("a")).unwrap();
|
||||
s.append(user("after")).unwrap();
|
||||
let text = home.read("sessions/a/1.jsonl");
|
||||
assert!(text.contains("after"), "{text}");
|
||||
let old = home.read("sessions/a/0.jsonl");
|
||||
assert!(!old.contains("after"), "{old}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_incomplete_next_epoch_is_ignored() {
|
||||
let home = Home::new();
|
||||
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
||||
s.append(user("still-here")).unwrap();
|
||||
drop(s);
|
||||
write_baseline(&home, "a", 1, &later());
|
||||
let s = Session::open(&home.dir, id("a")).unwrap();
|
||||
assert_eq!(s.epoch(), Epoch(0));
|
||||
assert_eq!(s.carry(), None);
|
||||
assert_eq!(s.turns(), 1);
|
||||
drop(s);
|
||||
write_jsonl(&home, "a", 2, &[start("a", 2, 0, &later())]);
|
||||
let s = Session::open(&home.dir, id("a")).unwrap();
|
||||
assert_eq!(s.epoch(), Epoch(0), "a log without its baseline is ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn epoch_end_without_the_next_files_stays_on_the_old_epoch() {
|
||||
let home = Home::new();
|
||||
let mut s = Session::create(&home.dir, id("a"), baseline(), 0).unwrap();
|
||||
s.append(user("x")).unwrap();
|
||||
s.append(epoch_end(1, "sum")).unwrap();
|
||||
drop(s);
|
||||
let s = Session::open(&home.dir, id("a")).unwrap();
|
||||
assert_eq!(s.epoch(), Epoch(0));
|
||||
assert_eq!(s.carry(), None);
|
||||
assert_eq!(s.turns(), 1);
|
||||
assert!(
|
||||
matches!(s.records().last(), Some(LogRecord::EpochEnd { next, .. }) if *next == Epoch(1))
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
|
||||
|
||||
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| M5d1/02-loopd-session-epochs | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/session_epochs.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failures: `session_epochs` with E0599 (`no method named epoch` / `carry` / `slot`) and E0433 (`NoCarry`); every other target compiled and the existing `session` tests still compiled. Made S1 (module doc naming `<epoch>.baseline.json` / `<epoch>.jsonl` and epochs after compaction), S2 (`SessionError` gains `NoCarry { path: PathBuf }`), S3 (its Display arm naming the damaged-log runbook pointer), S4 (`Session` gains `epoch`, `slot`, `carry`, `prior_users`, `prior_untrusted` in that order), S5 (`create` inits the five new fields, `slot` from the argument), S6 (whole `open`: `complete_epochs` picks the highest epoch with both files, `records` is the current log, `priors` walks every earlier complete epoch for user/untrusted counts and the epoch-1 carry, `max_call` over every epoch for `next_call`, `live_slot` from the current `SessionStart`), S7 (accessors `epoch` / `slot` / `carry`), S8 (`untrusted` = `prior_untrusted || any_untrusted`, `turns` = `prior_users.saturating_add(count_users)`), S9 (`log_path` to the current epoch's file plus helpers `complete_epochs`, `load_records`, `priors`, `carry_from`, `count_users`, `any_untrusted`, `max_call`, `live_slot`). `cargo check -p loopd` clean, no error or warning. `wc -l crates/loopd/src/session.rs` prints 402. `cargo test -p loopd --test session_epochs --test session` passes: 9 in `session_epochs`, 7 in `session`. `git diff --stat` lists only `session.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1473 tests (1472 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +9 over the 1464 before is exactly the tests of `session_epochs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5d1/01-loopd-compact-config | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/config_compact.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failures: `config_compact` with E0609 (`no field \`compact\` on type \`Config\``), E0599 (`no method named \`compact_prompt\``) and E0433 (`no variant or associated item named \`default\` for \`Compact\``); every other target compiled. Made the edits exactly as the task gives: C1 (new `Compact` struct after `Memory`, `#[derive(Debug, Clone, PartialEq, Deserialize)]` plus `#[serde(deny_unknown_fields, default)]`, fields in order `threshold: f64`, `summary_chars: usize`, `summarise: Vec<String>`, and `Default` = threshold `0.85`, `summary_chars` `4_000`, `summarise` `vec!["http_fetch".to_string()]`); C2 (`Config` gains `#[serde(default)] pub compact: Compact` after `memory`); C3 (`compact_prompt` after `channel_socket`, `self.baseline.system.with_file_name("compact.md")`). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test config_compact --test config` passes: 8 in `config_compact`, 11 in `config`. `git diff --stat` lists only `config.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1464 tests (1463 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +8 over the 1456 before is exactly the tests of `config_compact.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5c2/12-memoryd-serve-ask-memory | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_ask_serve.rs` (new). Step 2 (`cargo test --no-fail-fast -p memoryd --test honcho_ask_serve`) showed exactly the six named failures: every `ask_memory` answered with `memoryd runs only recall and memory`, and the hang test after its 10 s deadline (`the chat was never sent`). Made A1 (`ask.rs`: `NOT_CONFIGURED` before `NO_ANSWER`, the sentence letter for letter), E1 (`use bxcore::AskMemoryArgs;`), E2 (`use crate::honcho::ask;`), E3 (`call` gains the third arm `bxcore::memory::ASK_MEMORY => self.ask_memory(&call)` and the unknown-tool sentence becomes `memoryd runs only recall, memory and ask_memory`), E4 (new `ask_memory`: decode `AskMemoryArgs`, `args.check()?`, the `[honcho]`-less branch returns `NOT_CONFIGURED` labelled private and trusted, else `ask::ask` then `ask::answer`). `cargo check -p memoryd` clean, no error or warning. `cargo test` passes: 6 in `honcho_ask_serve`, 10 in `honcho_ask`, 4 in `honcho_recall_serve`, 4 in `serve_admin`, 8 in `serve`. Step 5 all pass: `git diff --stat` lists only `ask.rs` and `serve.rs`; `wc -l crates/memoryd/src/serve.rs` prints 456. `make gate` printed `gate: ok` with 1454 tests (1453 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +6 over the 1448 before is exactly the tests of `honcho_ask_serve.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
| M5c2/11-memoryd-serve-admin | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/serve_admin.rs` (new) and the new module `crates/memoryd/src/serve/admin.rs` (given whole, `reindex` and `admin` moved unchanged from `serve.rs`, `fn` becoming `pub(super) fn`). Step 1 (`cargo test -p memoryd --test serve_admin`) passed all 4 before the move. Made E1 (dropped `MemoryAdminReply`, `MemoryCommand`, `Reindexed` from the `bxcore` use in `serve.rs`), E2 (dropped `use crate::honcho::report;`), E3 (added `mod admin;` after `use crate::{journal, recall, review};`), E4 (deleted `reindex` and `admin` from the end of `index_notes` to the end of the file). `cargo check -p memoryd` clean, no error or warning. `cargo test -p memoryd` passes: 4 in `serve_admin`, every other suite as before. Step 5 all pass: `wc -l crates/memoryd/src/serve.rs` prints 433; the `diff` of the moved `reindex` (the deleted one in `serve.rs` vs the `pub(super) fn` in `admin.rs`, `pub(super)` normalised) prints nothing; `git diff --stat` lists only `serve.rs` and `git status --short` shows `serve/` and `serve_admin.rs` as new. `make gate` printed `gate: ok` with 1448 tests (1447 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +4 over the 1444 before is exactly the tests of `serve_admin.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
|
||||
|
||||
Reference in New Issue
Block a user