loopd: write the next epoch from a compaction summary

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 00:25:04 -07:00
co-authored by ornith-1.5-35b-a3b
parent c8b0a01c41
commit fe8e3e9354
4 changed files with 284 additions and 2 deletions
+45 -2
View File
@@ -2,12 +2,14 @@
//! (M5d spec, section 3). Task 07 writes the next files; task 08 calls this after a turn. //! (M5d spec, section 3). Task 07 writes the next files; task 08 calls this after a turn.
use bxcore::sanitize::neutralise; use bxcore::sanitize::neutralise;
use bxcore::{LogRecord, Usage}; use bxcore::{Epoch, LogRecord, Timestamp, Usage};
use crate::baseline::messages_with_carry; use crate::baseline::{Baseline, messages_with_carry};
use crate::config::Config; use crate::config::Config;
use crate::llama::{ChatMessage, ChatRequest, Client}; use crate::llama::{ChatMessage, ChatRequest, Client};
use crate::memory::MemorySource;
use crate::session::Session; use crate::session::Session;
use crate::tools::Registry;
use crate::turn::is_context_full; use crate::turn::is_context_full;
/// Why a summary call or a next-epoch write failed. Every variant's `Display` ends with the /// Why a summary call or a next-epoch write failed. Every variant's `Display` ends with the
@@ -90,3 +92,44 @@ pub fn summarise(cfg: &Config, client: &Client, session: &Session) -> Result<Str
}), }),
} }
} }
/// Append `EpochEnd` (unless it is already last), rebuild the baseline from `memory`, and
/// switch `session` onto the next epoch. When the current log already ends with `EpochEnd`,
/// that record's summary is the carry and the argument is ignored.
pub fn commit(
session: &mut Session,
cfg: &Config,
registry: &Registry,
memory: &dyn MemorySource,
summary: String,
) -> Result<(), CompactError> {
let (next, summary) = match session.records().last() {
Some(LogRecord::EpochEnd { next, summary, .. }) => (*next, summary.clone()),
_ => {
let next = Epoch(session.epoch().0.saturating_add(1));
session
.append(LogRecord::EpochEnd {
time: Timestamp::now(),
next,
summary: summary.clone(),
})
.map_err(|e| CompactError::Failed {
detail: e.to_string(),
})?;
(next, summary)
}
};
let block = memory
.block()
.map_err(|e| CompactError::Failed { detail: e })?;
let baseline =
Baseline::assemble_with(cfg, registry, &block).map_err(|e| CompactError::Failed {
detail: e.to_string(),
})?;
session
.begin_epoch(next, baseline, summary)
.map_err(|e| CompactError::Failed {
detail: e.to_string(),
})?;
Ok(())
}
+37
View File
@@ -261,6 +261,43 @@ impl Session {
fn log_path(&self) -> PathBuf { fn log_path(&self) -> PathBuf {
self.dir.join(format!("{}.jsonl", self.epoch.0)) self.dir.join(format!("{}.jsonl", self.epoch.0))
} }
/// Write `<n>.baseline.json` and `<n>.jsonl` with `SessionStart`, and switch this session
/// onto that epoch. The live slot and `next_call` stay. Callers pass the carry that
/// `messages_with_carry` will insert.
pub(crate) fn begin_epoch(
&mut self,
next: Epoch,
baseline: Baseline,
carry: String,
) -> Result<(), SessionError> {
let n = next.0;
let baseline_path = self.dir.join(format!("{n}.baseline.json"));
let json = baseline.to_json().map_err(SessionError::Encode)?;
std::fs::write(&baseline_path, json)
.map_err(|e| SessionError::Io(baseline_path.clone(), e))?;
let log_path = self.dir.join(format!("{n}.jsonl"));
let log = OpenOptions::new()
.create_new(true)
.append(true)
.open(&log_path)
.map_err(|e| SessionError::Io(log_path.clone(), e))?;
self.prior_users = self.prior_users.saturating_add(count_users(&self.records));
self.prior_untrusted = self.prior_untrusted || any_untrusted(&self.records);
self.epoch = next;
self.baseline = baseline;
self.carry = Some(carry);
self.records = Vec::new();
self.log = log;
let hash = self.baseline.hash().map_err(SessionError::Baseline)?;
self.persist(LogRecord::SessionStart {
time: Timestamp::now(),
session: self.id.clone(),
epoch: next,
slot: self.slot,
baseline: hash,
})
}
} }
/// Epoch numbers that have both `<n>.jsonl` and `<n>.baseline.json`, sorted. /// Epoch numbers that have both `<n>.jsonl` and `<n>.baseline.json`, sorted.
+201
View File
@@ -0,0 +1,201 @@
//! Tests for writing the next epoch. Do not edit.
mod support;
use bxcore::{Epoch, LogRecord, SessionId};
use loopd::baseline::Baseline;
use loopd::compact;
use loopd::memory::{MemorySource, NoMemory};
use loopd::session::Session;
use loopd::tools::Registry;
use support::Home;
struct Block(&'static str);
impl MemorySource for Block {
fn block(&self) -> Result<String, String> {
Ok(self.0.to_string())
}
fn updated(&self, _session: &SessionId, _turn: u64) -> Result<(), String> {
Ok(())
}
}
fn id(s: &str) -> SessionId {
SessionId::new(s).unwrap()
}
fn session(home: &Home) -> (loopd::config::Config, Session) {
let cfg = home.config(std::path::Path::new("/tmp/unused.sock"));
let baseline = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
let s = Session::create(&home.dir, id("a"), baseline, cfg.slots.main).unwrap();
(cfg, s)
}
#[test]
fn commit_writes_epoch_end_a_new_baseline_and_a_new_log() {
let home = Home::new();
let (cfg, mut s) = session(&home);
s.append(LogRecord::User {
time: bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap(),
content: "hi".to_string(),
speaker: None,
})
.unwrap();
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"the sum".to_string(),
)
.unwrap();
assert_eq!(s.epoch(), Epoch(1));
assert_eq!(s.carry(), Some("the sum"));
assert_eq!(s.slot(), cfg.slots.main);
assert_eq!(s.turns(), 1, "the old user still counts");
assert_eq!(
s.records()
.iter()
.filter(|r| matches!(r, LogRecord::User { .. }))
.count(),
0
);
let old = home.records("a");
assert!(
matches!(old.last(), Some(LogRecord::EpochEnd { next, summary, .. }) if *next == Epoch(1) && summary == "the sum")
);
assert!(home.dir.join("sessions/a/1.baseline.json").is_file());
let new_log = home.read("sessions/a/1.jsonl");
assert!(new_log.contains("\"epoch\":1"), "{new_log}");
}
#[test]
fn the_new_baseline_takes_a_fresh_memory_block() {
let home = Home::new();
let (cfg, mut s) = session(&home);
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&Block("I take my tea strong."),
"sum".to_string(),
)
.unwrap();
assert!(
s.baseline().system.contains("I take my tea strong."),
"{}",
s.baseline().system
);
let on_disk = home.read("sessions/a/1.baseline.json");
assert!(on_disk.contains("I take my tea strong."), "{on_disk}");
}
#[test]
fn reopen_loads_the_new_epoch() {
let home = Home::new();
let (cfg, mut s) = session(&home);
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"carry".to_string(),
)
.unwrap();
drop(s);
let s = Session::open(&home.dir, id("a")).unwrap();
assert_eq!(s.epoch(), Epoch(1));
assert_eq!(s.carry(), Some("carry"));
}
#[test]
fn a_second_commit_does_not_write_another_epoch_end_on_the_old_log() {
let home = Home::new();
let (cfg, mut s) = session(&home);
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"first".to_string(),
)
.unwrap();
let ends = home
.records("a")
.iter()
.filter(|r| matches!(r, LogRecord::EpochEnd { .. }))
.count();
assert_eq!(ends, 1);
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"second".to_string(),
)
.unwrap();
assert_eq!(s.epoch(), Epoch(2));
let ends = home
.records("a")
.iter()
.filter(|r| matches!(r, LogRecord::EpochEnd { .. }))
.count();
assert_eq!(ends, 1, "epoch 0 still has one EpochEnd");
let mid = home.read("sessions/a/1.jsonl");
assert!(mid.contains("\"type\":\"epoch_end\""), "{mid}");
}
#[test]
fn resume_after_epoch_end_without_next_files_does_not_write_a_second_end() {
let home = Home::new();
let (cfg, mut s) = session(&home);
s.append(LogRecord::EpochEnd {
time: bxcore::Timestamp::now(),
next: Epoch(1),
summary: "already".to_string(),
})
.unwrap();
drop(s);
let mut s = Session::open(&home.dir, id("a")).unwrap();
assert_eq!(s.epoch(), Epoch(0));
compact::commit(
&mut s,
&cfg,
&Registry::m2b(),
&NoMemory,
"ignored".to_string(),
)
.unwrap();
let ends = home
.records("a")
.iter()
.filter(|r| matches!(r, LogRecord::EpochEnd { .. }))
.count();
assert_eq!(ends, 1);
assert_eq!(s.epoch(), Epoch(1));
assert_eq!(
s.carry(),
Some("already"),
"the summary already in EpochEnd is the carry"
);
}
#[test]
fn the_new_session_start_keeps_the_live_slot() {
let home = Home::new();
let cfg = home.config(std::path::Path::new("/tmp/unused.sock"));
let baseline = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
let mut s = Session::create(&home.dir, id("a"), baseline, 7).unwrap();
compact::commit(&mut s, &cfg, &Registry::m2b(), &NoMemory, "s".to_string()).unwrap();
assert_eq!(s.slot(), 7);
match &s.records()[0] {
LogRecord::SessionStart { slot, epoch, .. } => {
assert_eq!(*slot, 7);
assert_eq!(*epoch, Epoch(1));
}
other => panic!("{other:?}"),
}
}
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| M5d1/07-loopd-compact-advance | 2026-10-03 | done | 2 | pass | none | Copied the given test `crates/loopd/tests/compact_advance.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `compact_advance` with E0425 (`cannot find function commit in module compact`); every other target compiled. Made S1 (`session.rs`: `begin_epoch` after `log_path`, in that order — writes `<n>.baseline.json` with `to_json`, `create_new` the `<n>.jsonl`, accumulates `prior_users`/`prior_untrusted` from the old `records` before clearing them, sets `epoch`/`baseline`/`carry`/empty `records`/the new `File`, then `persist` `SessionStart` with the live `slot`; does not reset `next_call` or `slot`). Made C1 (`compact.rs`: extended the `use crate::` group to `Baseline`/`MemorySource`/`Registry` and the bxcore group to `Epoch`/`Timestamp`, then added `commit` after `summarise` — skips the append and takes `next`/summary from a trailing `EpochEnd`, else appends `EpochEnd` with `Epoch(epoch.0.saturating_add(1))`, reads `memory.block()` as `Failed`, `Baseline::assemble_with(cfg, registry, &block)` as `Failed`, then `begin_epoch`). `cargo check -p loopd` clean, no error or warning. `wc -l crates/loopd/src/session.rs` prints 438 and `crates/loopd/src/compact.rs` prints 134, each ≤ 500. `cargo test -p loopd --test compact_advance --test session_epochs --test compact_summary` passes: 6 in `compact_advance`, 9 in `session_epochs`, 6 in `compact_summary`. `git diff --stat` lists only `session.rs` and `compact.rs`; `git status --short` shows `compact_advance.rs` as new. `make gate` printed `gate: ok` with 1502 tests (1496 before, plus the 6 of `compact_advance.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M5d1/05-loopd-compact-due | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/compact_due.rs` and the starter `config/compact.md` from `docs/plans/M5d1/files/`. Created `crates/loopd/src/compact.rs`: `due` does `cache_n.saturating_add(prompt_n) as f64 >= threshold * (n_ctx as f64)`; `fallback_summary` `filter_map`s `LogRecord::User { content, .. }` to `&str`, reverses, joins `"\n\n"`, then `cap_chars`; `cap_chars` is the given `chars().take(n)`. Added `pub mod compact;` after `pub mod channel;` in `lib.rs` (rustfmt order, accepted). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test compact_due` passes all 6. `git diff --stat` lists only `lib.rs` and `compact.rs`; `git status --short` shows `compact_due.rs` and `compact.md` as new. `make gate` printed `gate: ok` with 1490 tests (1484 before, plus the 6 of `compact_due.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d1/05-loopd-compact-due | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/compact_due.rs` and the starter `config/compact.md` from `docs/plans/M5d1/files/`. Created `crates/loopd/src/compact.rs`: `due` does `cache_n.saturating_add(prompt_n) as f64 >= threshold * (n_ctx as f64)`; `fallback_summary` `filter_map`s `LogRecord::User { content, .. }` to `&str`, reverses, joins `"\n\n"`, then `cap_chars`; `cap_chars` is the given `chars().take(n)`. Added `pub mod compact;` after `pub mod channel;` in `lib.rs` (rustfmt order, accepted). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test compact_due` passes all 6. `git diff --stat` lists only `lib.rs` and `compact.rs`; `git status --short` shows `compact_due.rs` and `compact.md` as new. `make gate` printed `gate: ok` with 1490 tests (1484 before, plus the 6 of `compact_due.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M5d1/04-memoryd-honcho-epochs | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_epochs.rs` (new). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed exactly the named failure: `honcho_epochs` with one E0432 (`unresolved import memoryd::honcho::turns::read_session`); every other target compiled. Made H1 (`turns.rs`: `log_path` doc now names the sibling epoch files and that `read_session` concatenates them), H1b (inserted `epoch_logs` after `read_records`: lists `<n>.jsonl` under `sessions/<id>/`, `n` parsed as `u32`, sorted by `n`, a missing directory empty; then `read_session` concatenates `read_records` over each), H2 (`resume` reads `read_session` instead of `read_log(&log_path(...))`), H3 (`records` matches `read_session(...).map_err(|e| e.to_string())`, keeping the logged line shape); W1 (`worker.rs`: added `use ...decide::{final_reply, turn_records, user_text}` and `use ...turns::{LogError, TurnSync, log_path, read_session}`), W2 (`send_turn` loads `read_session`, `self.readable(&path)` on Ok, `LogError::Damaged(_)` -> `Unsent::Local("log")`, other error -> `Unsent::Unreadable(e.to_string())`, then `turn_records`/`user_text`/`final_reply` from `decide`), W3 (`batch` called with `text`/`reply` by value, no borrow). `read_turn` no longer used in the worker body but stays re-exported. `cargo check -p memoryd` clean, no error or warning. `wc -l` prints 369 for `turns.rs` and 453 for `worker.rs`, each ≤ 500. `cargo test -p memoryd --test honcho_epochs --test honcho_turns --test honcho_worker` passes: 6 in `honcho_epochs`, 16 in `honcho_turns`, 12 in `honcho_worker`. `git diff --stat` lists only `turns.rs` and `worker.rs`; `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1484 tests (1478 before, plus the 6 of `honcho_epochs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d1/04-memoryd-honcho-epochs | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_epochs.rs` (new). Step 2 (`cargo build --tests --keep-going -p memoryd`) showed exactly the named failure: `honcho_epochs` with one E0432 (`unresolved import memoryd::honcho::turns::read_session`); every other target compiled. Made H1 (`turns.rs`: `log_path` doc now names the sibling epoch files and that `read_session` concatenates them), H1b (inserted `epoch_logs` after `read_records`: lists `<n>.jsonl` under `sessions/<id>/`, `n` parsed as `u32`, sorted by `n`, a missing directory empty; then `read_session` concatenates `read_records` over each), H2 (`resume` reads `read_session` instead of `read_log(&log_path(...))`), H3 (`records` matches `read_session(...).map_err(|e| e.to_string())`, keeping the logged line shape); W1 (`worker.rs`: added `use ...decide::{final_reply, turn_records, user_text}` and `use ...turns::{LogError, TurnSync, log_path, read_session}`), W2 (`send_turn` loads `read_session`, `self.readable(&path)` on Ok, `LogError::Damaged(_)` -> `Unsent::Local("log")`, other error -> `Unsent::Unreadable(e.to_string())`, then `turn_records`/`user_text`/`final_reply` from `decide`), W3 (`batch` called with `text`/`reply` by value, no borrow). `read_turn` no longer used in the worker body but stays re-exported. `cargo check -p memoryd` clean, no error or warning. `wc -l` prints 369 for `turns.rs` and 453 for `worker.rs`, each ≤ 500. `cargo test -p memoryd --test honcho_epochs --test honcho_turns --test honcho_worker` passes: 6 in `honcho_epochs`, 16 in `honcho_turns`, 12 in `honcho_worker`. `git diff --stat` lists only `turns.rs` and `worker.rs`; `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1484 tests (1478 before, plus the 6 of `honcho_epochs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |
| M5d1/03-loopd-carry-messages | 2026-10-02 | done | 2 | pass | none | Copied the given test `crates/loopd/tests/carry_messages.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `carry_messages` with one E0432 (`unresolved import loopd::baseline::{CARRY_HEADING, messages_with_carry}`); every other target compiled. Made B1 (`baseline.rs`: `CARRY_HEADING` = `concat!("Earlier ", "turns, summarised:")`, `messages` becomes `messages_with_carry(baseline, records, None)`, new `messages_with_carry` inserts the heading plus a blank line plus the summary as one `ChatMessage::User` after the system prompt and before walking `records`; the rest of the `match` untouched); T1 (`turn.rs`: `use crate::baseline::messages_with_carry`); T2 (`run_turn` passes `session.carry()`). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test carry_messages --test baseline --test turn` passes: 5 in `carry_messages`, 6 in `baseline`, 6 in `turn`. `git diff --stat` lists only `baseline.rs` and `turn.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1478 tests (1473 before, plus the 5 of `carry_messages.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d1/03-loopd-carry-messages | 2026-10-02 | done | 2 | pass | none | Copied the given test `crates/loopd/tests/carry_messages.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `carry_messages` with one E0432 (`unresolved import loopd::baseline::{CARRY_HEADING, messages_with_carry}`); every other target compiled. Made B1 (`baseline.rs`: `CARRY_HEADING` = `concat!("Earlier ", "turns, summarised:")`, `messages` becomes `messages_with_carry(baseline, records, None)`, new `messages_with_carry` inserts the heading plus a blank line plus the summary as one `ChatMessage::User` after the system prompt and before walking `records`; the rest of the `match` untouched); T1 (`turn.rs`: `use crate::baseline::messages_with_carry`); T2 (`run_turn` passes `session.carry()`). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test carry_messages --test baseline --test turn` passes: 5 in `carry_messages`, 6 in `baseline`, 6 in `turn`. `git diff --stat` lists only `baseline.rs` and `turn.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1478 tests (1473 before, plus the 5 of `carry_messages.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |