Files
boxmaker/docs/plans/M5d2/03-loopd-heartbeat-config.md
T
kyleandGrok 4.6 fb2e269a85 docs: M5d-2 gate count 1508 and OpenCode model id
The M5d-1 gate on straylight is 1508, so every M5d-2 expected count moves up by one.
OpenCode 1.15.10 serves Ornith as llama.cpp/ornith-1.5-35b-a3b.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 01:24:25 -07:00

7.9 KiB

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

    #[serde(default)]
    pub compact: Compact,
}

become

    #[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:

/// `[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

        if let Some(parent) = path.parent() {
            config.baseline.system = parent.join(&config.baseline.system);
        }
        Ok(config)

become

        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)

    pub fn compact_prompt(&self) -> std::path::PathBuf {
        self.baseline.system.with_file_name("compact.md")
    }
}

become

    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:

    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.

    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 1526 (1518 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 1526 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 1526; 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.