Eight closed tasks for epochs and compaction, then ten for the scheduler, ToolSummary, and tiers. Given tests first. Gate 1456 → 1507 → 1572. Co-Authored-By: Grok 4.6 <noreply@x.ai>
11 KiB
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):
- Append
EpochEnd { next, summary }to the current log, unless that log already ends with anEpochEnd(a compact that stopped after the end record and before the next files). - Ask
memorydfor a fresh block; write<n>.baseline.jsonfromassemble_with(so identity edits applied in the old epoch land here, andregistry.core_schemas()is the new tools array). - Create
<n>.jsonlwithSessionStart { epoch: n, slot: the session's live slot, baseline: hash of the new file }. - The in-memory
Sessionswitches: new baseline, records that are only thatSessionStart,carry = Some(summary),prior_users/prior_untrustedaccumulated 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 testcrates/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_epochispub(crate)onSessionsocompact.rscan switch the private fields. Tests callcompact::commit, neverbegin_epoch.- Order inside
begin_epoch. Write<n>.baseline.json(overwrite is fine on resume),create_newthe log file, then setepoch/baseline/carry/ emptyrecords/ the newFile, thenpersistSessionStart.log_pathusesself.epoch, soepochmust benextbefore that persist. Accumulateprior_usersandprior_untrustedfrom the oldrecordsbefore clearing them. Do not resetnext_callorslot. - Resume. If
session.records().last()isEpochEnd { next, summary }, skip the append;nextandsummary(cloned) are whatbegin_epochgets. Otherwise appendEpochEndwithnext = Epoch(session.epoch().0.saturating_add(1))and the summary argument, thenbegin_epoch. - Memory.
memory.block().map_err(|e| CompactError::Failed { detail: e })?. ThenBaseline::assemble_with(cfg, registry, &block).assemble_withalready callsregistry.core_schemas(), so a new core tool lands at this boundary. Map assemble and session errors toFailed { detail: e.to_string() }. NoMemory.block()isOk(""). The given testthe_new_baseline_takes_a_fresh_memory_blockuses aMemorySourcethat returns a distinctive sentence.session.rsstays ≤ 500 lines. After task 02 it is about 430;begin_epochis about 40.compact.rsstays ≤ 500 lines. After this task it is about 180.- The items the edits use. This table is complete.
Item What it does Baseline::assemble_withnew system + memory block + registry.core_schemas()Baseline::to_json/hashthe file and SessionStart.baselineOpenOptions::create_new(true).append(true)the new log, as createdoescount_users/any_untrustedtask 02, module-private in session.rsSession::persistprivate; begin_epochis in the same implMemorySource::blockfresh text; ErrisFailedTimestamp::nowEpochEndandSessionStartEpoch(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
fn log_path(&self) -> PathBuf {
self.dir.join(format!("{}.jsonl", self.epoch.0))
}
}
become
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:
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
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
use bxcore::{LogRecord, Usage};
becomes
use bxcore::{Epoch, LogRecord, Timestamp, Usage};
Then this function, whole, after summarise:
/// 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.
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 targetcompact_advancedoes not compile (E0599no functioncommit). Every other target compiles. -
3. The edits. S1, then C1.
cargo check -p loopdprints no error and no warning.wc -l crates/loopd/src/session.rsandwc -l crates/loopd/src/compact.rseach print a number ≤ 500. -
4. The tests.
cargo test -p loopd --test compact_advance --test session_epochs --test compact_summary: every test passes, 6 incompact_advance, 9 insession_epochs, 6 incompact_summary. -
5. Check.
git diff --statlists exactlycrates/loopd/src/session.rsandcrates/loopd/src/compact.rs.git status --shortshowscompact_advance.rsas new. -
6. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1501 (1495 before, plus 6). -
7. Log and commit. Task cell
M5d1/07-loopd-compact-advance. Stage withgit add crates/loopd docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1501 tests.git diff HEAD~1 --statlists onlycrates/loopd/src/session.rs,crates/loopd/src/compact.rs,crates/loopd/tests/compact_advance.rsand the log.
Stop and report if
- The branch check fails; a "before" block is not found exactly once; step 2 differs;
cargo checkwarns; a test fails; the gate count is not 1501; a file exceeds 500 lines; you write a secondEpochEndon resume; you resetslotornext_call; you log the summary as aLogRecord::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).