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>
269 lines
11 KiB
Markdown
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).
|