Files
boxmaker/docs/plans/M5d2/03-loopd-heartbeat-config.md
T
kyleandGrok 4.6 3726ae585f docs: M5d-1 and M5d-2 implementation plans for Ornith
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>
2026-10-02 21:54:37 -07:00

219 lines
7.9 KiB
Markdown

# M5d-2 task 03: `[heartbeat]` and unknown `[compact] summarise` names
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: [heartbeat] table and summarise name check`
**Log row:** the Task cell is `M5d2/03-loopd-heartbeat-config`.
## Goal
`[heartbeat]` on `loopd.toml` (spec 4.1, 5): `every_s` (default `0`, disables) and `prompt`
(default `heartbeat.md`, next to the config file). Unknown keys are errors.
Unknown names in `[compact] summarise` are a config error (spec 4.3). `Config::check_summarise`
takes `&Registry`. `loopd` calls it after `load` with `Registry::m5c()`. `parse` still succeeds
with an unknown name: the given tests call `check_summarise` themselves.
The starter `config/heartbeat.md` is copied like `config/system.md`. Nothing ticks yet.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/config.rs` | `Heartbeat`; field; `load` joins the prompt; `heartbeat_prompt`; `check_summarise` |
| `crates/loopd/src/main.rs` | after each `Config::load`, `check_summarise(&Registry::m5c())` |
| `config/heartbeat.md` | new, given starter |
| `crates/loopd/tests/config_heartbeat.rs` | new, given |
| everything else | **no change** |
## Files
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test
`crates/loopd/tests/config_heartbeat.rs` and the starter `config/heartbeat.md`.
- Modify: `crates/loopd/src/config.rs`, `crates/loopd/src/main.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **`every_s` is `u64`.** `0` means off. No range check.
- **`prompt` after `load`.** Join it to the config file's parent, the same way `load` already
joins `baseline.system`. `heartbeat_prompt()` returns `self.heartbeat.prompt.clone()`. After
`parse` (no file), the path stays relative.
- **`check_summarise`.** For each name in `self.compact.summarise`, `registry.get(name)` must be
`Some`. Empty list is allowed. The error `String` contains both `summarise` and the unknown
name. `Registry::get` is already `pub`. `http_fetch` and `clock` are in `m5c()`.
- **`main.rs`.** Both `run_selftest` and `run_serve` load a config. After a successful `load`,
if `check_summarise` fails, print `loopd: {e}` and return exit 1, as for a load error.
- **`#[serde(deny_unknown_fields, default)]`** on `Heartbeat`, like `Compact`.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `Registry::get` | known tool names |
| `Registry::m5c` | what `loopd serve` uses |
| `Path::join` | prompt beside the config file |
## The edits, exactly
Each quoted "before" block occurs exactly once after M5d-1 (compact is already on `Config`).
### `crates/loopd/src/config.rs`
**H1.** After the `Compact` `Default` impl (the closing `}` of that impl, then the `Config`
struct's `memory` / `compact` fields). After
```rust
#[serde(default)]
pub compact: Compact,
}
```
become
```rust
#[serde(default)]
pub compact: Compact,
#[serde(default)]
pub heartbeat: Heartbeat,
}
```
**H2.** After the `Compact` `Default` impl, add `Heartbeat` (before the `Config` struct if that
is where `Compact` sits; keep `Heartbeat` next to `Compact`). The new type:
```rust
/// `[heartbeat]`: how often the standing `heartbeat` session runs (M5d spec, section 4.1).
/// `every_s` 0 disables it.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Heartbeat {
pub every_s: u64,
pub prompt: std::path::PathBuf,
}
impl Default for Heartbeat {
fn default() -> Self {
Self {
every_s: 0,
prompt: std::path::PathBuf::from("heartbeat.md"),
}
}
}
```
If `Compact` sits just above `Config`, put `Heartbeat` immediately after `Compact`'s `Default`
impl and **then** do H1. If a "before" block is not found exactly once, stop.
**H3.** In `load`, the lines
```rust
if let Some(parent) = path.parent() {
config.baseline.system = parent.join(&config.baseline.system);
}
Ok(config)
```
become
```rust
if let Some(parent) = path.parent() {
config.baseline.system = parent.join(&config.baseline.system);
config.heartbeat.prompt = parent.join(&config.heartbeat.prompt);
}
Ok(config)
```
**H4.** After `compact_prompt` (the lines)
```rust
pub fn compact_prompt(&self) -> std::path::PathBuf {
self.baseline.system.with_file_name("compact.md")
}
}
```
become
```rust
pub fn compact_prompt(&self) -> std::path::PathBuf {
self.baseline.system.with_file_name("compact.md")
}
/// The heartbeat prompt, already joined to the config file's directory by `load`.
pub fn heartbeat_prompt(&self) -> std::path::PathBuf {
self.heartbeat.prompt.clone()
}
/// Unknown `[compact] summarise` names are a config error (M5d spec, section 4.3).
pub fn check_summarise(&self, registry: &crate::tools::Registry) -> Result<(), String> {
for name in &self.compact.summarise {
if registry.get(name).is_none() {
return Err(format!("[compact] summarise: unknown tool {name}"));
}
}
Ok(())
}
}
```
### `crates/loopd/src/main.rs`
**M1.** In `run_selftest`, after a successful `Config::load` and before `Client::new`, add:
```rust
if let Err(e) = cfg.check_summarise(&loopd::tools::Registry::m5c()) {
eprintln!("loopd: {e}");
return ExitCode::from(1);
}
```
`Registry` is already imported in `main.rs`. Use `Registry::m5c()`.
**M2.** The same check in `run_serve`, after a successful `load` and before the instance lock.
If `main.rs` already has `use loopd::tools::{Registry, ToolPort};`, write `Registry::m5c()`.
## Steps
- [ ] **1. Copy the given files.**
```sh
cp docs/plans/M5d2/files/crates/loopd/tests/config_heartbeat.rs crates/loopd/tests/
cp docs/plans/M5d2/files/config/heartbeat.md config/heartbeat.md
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test
target `config_heartbeat` does not compile (`E0609` no field `heartbeat`, `E0599` no method
`heartbeat_prompt` / `check_summarise`). Every other target compiles.
- [ ] **3. The edits.** H1 to H4, M1, M2, in order. `cargo check -p loopd` prints no error and
no warning.
- [ ] **4. The tests.** `cargo test -p loopd --test config_heartbeat --test config --test
config_compact`: every test passes, 8 in `config_heartbeat`.
- [ ] **5. Check.** `git diff --stat` lists `crates/loopd/src/config.rs` and
`crates/loopd/src/main.rs`. `git status --short` shows the new test and `config/heartbeat.md`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1525** (1517 before, plus 8).
- [ ] **7. Log and commit.** Task cell `M5d2/03-loopd-heartbeat-config`. Stage with
`git add crates/loopd config/heartbeat.md docs/implementer-log.md Cargo.lock`, then commit
as `AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1525 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/config.rs`, `crates/loopd/src/main.rs`,
`crates/loopd/tests/config_heartbeat.rs`, `config/heartbeat.md` 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 1525; you join `heartbeat.prompt` onto
`system.md`'s directory instead of the config file's parent; you reject an empty `summarise`
list; you check names inside `parse`.
## What the given tests pin
A missing table is disabled and `heartbeat_prompt` is beside the config file; overrides replace
defaults; unknown keys error; default `http_fetch` is known to `m5c`; an unknown name errors
with `summarise` and that name; empty is allowed; `http_fetch` and `clock` together are known.