Files
boxmaker/docs/plans/M5d1/07-loopd-compact-advance.md
T
kyleandGrok 4.6 c3b05cf003 docs: M5d-1 checklist fixes before hand-over
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>
2026-10-02 22:36:51 -07:00

269 lines
11 KiB
Markdown

# M5d-1 task 07: writing the next epoch
**Branch:** `m5d1`. Check it: `git branch --show-current` prints `m5d1` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: write the next epoch from a compaction summary`
**Log row:** the Task cell is `M5d1/07-loopd-compact-advance`.
## Goal
Given a summary string, `commit` starts the next epoch (spec 3.5):
1. Append `EpochEnd { next, summary }` to the current log, unless that log already ends with an
`EpochEnd` (a compact that stopped after the end record and before the next files).
2. Ask `memoryd` for a fresh block; write `<n>.baseline.json` from `assemble_with` (so identity
edits applied in the old epoch land here, and `registry.core_schemas()` is the new tools
array).
3. Create `<n>.jsonl` with `SessionStart { epoch: n, slot: the session's live slot, baseline:
hash of the new file }`.
4. The in-memory `Session` switches: new baseline, records that are only that `SessionStart`,
`carry = Some(summary)`, `prior_users` / `prior_untrusted` accumulated from the old records.
A crash after step 1 and before the next files exist reopens epoch 0 with `EpochEnd` last (task
02). The next `commit` resumes: it uses the summary already in that `EpochEnd` as the carry and
does not write a second `EpochEnd`. The summary argument is ignored in that case.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/session.rs` | `begin_epoch` (edit S1) |
| `crates/loopd/src/compact.rs` | `commit` (edit C1) |
| `crates/loopd/tests/compact_advance.rs` | new, given |
| everything else | **no change**: not `turn.rs`, not `channel.rs` |
## Files
- Copy, from `docs/plans/M5d1/files/` (the same path under it): the new test
`crates/loopd/tests/compact_advance.rs`.
- Modify: `crates/loopd/src/session.rs`, `crates/loopd/src/compact.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **`begin_epoch` is `pub(crate)`** on `Session` so `compact.rs` can switch the private fields.
Tests call `compact::commit`, never `begin_epoch`.
- **Order inside `begin_epoch`.** Write `<n>.baseline.json` (overwrite is fine on resume),
`create_new` the log file, then set `epoch` / `baseline` / `carry` / empty `records` / the new
`File`, **then** `persist` `SessionStart`. `log_path` uses `self.epoch`, so `epoch` must be
`next` before that persist. Accumulate `prior_users` and `prior_untrusted` from the **old**
`records` before clearing them. Do not reset `next_call` or `slot`.
- **Resume.** If `session.records().last()` is `EpochEnd { next, summary }`, skip the append;
`next` and `summary` (cloned) are what `begin_epoch` gets. Otherwise append `EpochEnd` with
`next = Epoch(session.epoch().0.saturating_add(1))` and the summary argument, then
`begin_epoch`.
- **Memory.** `memory.block().map_err(|e| CompactError::Failed { detail: e })?`. Then
`Baseline::assemble_with(cfg, registry, &block)`. `assemble_with` already calls
`registry.core_schemas()`, so a new core tool lands at this boundary. Map assemble and session
errors to `Failed { detail: e.to_string() }`.
- **`NoMemory.block()`** is `Ok("")`. The given test `the_new_baseline_takes_a_fresh_memory_block`
uses a `MemorySource` that returns a distinctive sentence.
- **`session.rs` stays ≤ 500 lines.** After task 02 it is about 430; `begin_epoch` is about 40.
- **`compact.rs` stays ≤ 500 lines.** After this task it is about 180.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `Baseline::assemble_with` | new system + memory block + `registry.core_schemas()` |
| `Baseline::to_json` / `hash` | the file and `SessionStart.baseline` |
| `OpenOptions::create_new(true).append(true)` | the new log, as `create` does |
| `count_users` / `any_untrusted` | task 02, module-private in `session.rs` |
| `Session::persist` | private; `begin_epoch` is in the same impl |
| `MemorySource::block` | fresh text; `Err` is `Failed` |
| `Timestamp::now` | `EpochEnd` and `SessionStart` |
| `Epoch(n.saturating_add(1))` | the next number |
## The edits, exactly
Each quoted "before" block occurs exactly once after task 06 (session.rs is as task 02 left it).
### `crates/loopd/src/session.rs`
**S1.** `log_path`, the lines
```rust
fn log_path(&self) -> PathBuf {
self.dir.join(format!("{}.jsonl", self.epoch.0))
}
}
```
become
```rust
fn log_path(&self) -> PathBuf {
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,
})
}
}
```
The helpers after the `impl` (`complete_epochs` and the rest) stay where they are. Do not move
them.
### `crates/loopd/src/compact.rs`
**C1.** After `summarise` (the function that ends the `match client.chat_with_retry` / `}`), add
the `use` items this function needs by extending the existing `use crate::` group, then add
`commit`. The current crate uses (after task 06) are:
```rust
use crate::baseline::messages_with_carry;
use crate::config::Config;
use crate::llama::{ChatMessage, ChatRequest, Client};
use crate::session::Session;
use crate::turn::is_context_full;
```
They become
```rust
use crate::baseline::{messages_with_carry, Baseline};
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;
```
And the bxcore use
```rust
use bxcore::{LogRecord, Usage};
```
becomes
```rust
use bxcore::{Epoch, LogRecord, Timestamp, Usage};
```
Then this function, whole, after `summarise`:
```rust
/// 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(())
}
```
If rustfmt splits the `map_err` closures, accept that.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M5d1/files/crates/loopd/tests/compact_advance.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test
target `compact_advance` does not compile (`E0599` no function `commit`). Every other target
compiles.
- [ ] **3. The edits.** S1, then C1. `cargo check -p loopd` prints no error and no warning.
`wc -l crates/loopd/src/session.rs` and `wc -l crates/loopd/src/compact.rs` each print a
number ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test compact_advance --test session_epochs --test
compact_summary`: every test passes, 6 in `compact_advance`, 9 in `session_epochs`, 6 in
`compact_summary`.
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/loopd/src/session.rs` and
`crates/loopd/src/compact.rs`. `git status --short` shows `compact_advance.rs` as new.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1502** (1496 before, plus 6).
- [ ] **7. Log and commit.** Task cell `M5d1/07-loopd-compact-advance`. Stage with
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1502 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/session.rs`,
`crates/loopd/src/compact.rs`, `crates/loopd/tests/compact_advance.rs` and the log.
## Stop and report if
- The branch check fails; a "before" block is not found exactly once; step 2 differs; `cargo
check` warns; a test fails; the gate count is not 1502; a file exceeds 500 lines; you write a
second `EpochEnd` on resume; you reset `slot` or `next_call`; you log the summary as a
`LogRecord::User`.
## What the given tests pin
`commit` writes `EpochEnd` on the old log, a new baseline file, and a new log whose
`SessionStart` has the next epoch; the new baseline contains a fresh memory block; reopen loads
the new epoch and its carry; a second commit leaves epoch 0 with one `EpochEnd` and writes the
next on epoch 1; resume after `EpochEnd` without next files uses that summary as carry and does
not write a second end; the new `SessionStart` keeps the live slot (including a non-default 7).