diff --git a/crates/loopd/src/compact.rs b/crates/loopd/src/compact.rs index b1fab36..f6e8ca3 100644 --- a/crates/loopd/src/compact.rs +++ b/crates/loopd/src/compact.rs @@ -2,12 +2,14 @@ //! (M5d spec, section 3). Task 07 writes the next files; task 08 calls this after a turn. 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::llama::{ChatMessage, ChatRequest, Client}; +use crate::memory::MemorySource; use crate::session::Session; +use crate::tools::Registry; use crate::turn::is_context_full; /// 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 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(()) +} diff --git a/crates/loopd/src/session.rs b/crates/loopd/src/session.rs index 23d91df..9be08aa 100644 --- a/crates/loopd/src/session.rs +++ b/crates/loopd/src/session.rs @@ -261,6 +261,43 @@ impl Session { fn log_path(&self) -> PathBuf { self.dir.join(format!("{}.jsonl", self.epoch.0)) } + + /// Write `.baseline.json` and `.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 `.jsonl` and `.baseline.json`, sorted. diff --git a/crates/loopd/tests/compact_advance.rs b/crates/loopd/tests/compact_advance.rs new file mode 100644 index 0000000..dc7bd7a --- /dev/null +++ b/crates/loopd/tests/compact_advance.rs @@ -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 { + 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:?}"), + } +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 1fe2b72..47ac34d 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | 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 `.baseline.json` with `to_json`, `create_new` the `.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/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 `.jsonl` under `sessions//`, `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 |