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>
This commit is contained in:
2026-10-02 21:54:37 -07:00
co-authored by Grok 4.6
parent 069cd1686a
commit 3726ae585f
48 changed files with 7319 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
# M5d-2 task 01: `run_turn` sends `session.slot()`
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: live turns use the session's slot`
**Log row:** the Task cell is `M5d2/01-loopd-session-slot`.
## Goal
Heartbeat and cron run on the background slot (spec 4). Live Mattermost and `bxctl chat`
sessions keep `cfg.slots.main`. Today `run_turn` always sends `slots.main`. This task makes it
send `session.slot()`: the slot stored on that session's `SessionStart` (M5d-1 task 02).
Nothing else changes. Compaction's summary call already uses `slots.background`. The scheduler
does not exist yet.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/turn.rs` | `ChatRequest.slot` is `session.slot()` (edit T1) |
| `crates/loopd/tests/session_slot.rs` | new, given |
| everything else | **no change**: not `session.rs`, not `compact.rs`, not channel |
## Files
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test
`crates/loopd/tests/session_slot.rs`.
- Modify: `crates/loopd/src/turn.rs`, `docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **`Session::slot()`** returns the `u32` stored on create / on the current epoch's
`SessionStart`. It is already `pub` (M5d-1 task 02).
- **`messages_with_carry`** stays as it is. Only the `slot:` line changes.
- **Existing turn tests** create sessions with `cfg.slots.main` (0). They still send 0.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `Session::slot` | the live slot for this session |
| `ChatRequest.slot` | becomes `id_slot` on the wire |
## The edits, exactly
Each quoted "before" block occurs exactly once after M5d-1 task 08.
### `crates/loopd/src/turn.rs`
**T1.** The lines
```rust
let request = ChatRequest {
slot: rt.cfg.slots.main,
messages: messages_with_carry(baseline, session.records(), session.carry()),
tools: baseline.tools.clone(),
thinking: true,
};
```
become
```rust
let request = ChatRequest {
slot: session.slot(),
messages: messages_with_carry(baseline, session.records(), session.carry()),
tools: baseline.tools.clone(),
thinking: true,
};
```
## Steps
- [ ] **1. Copy the given test.** From the repository root, one command:
```sh
cp docs/plans/M5d2/files/crates/loopd/tests/session_slot.rs crates/loopd/tests/
```
Then `git status --short` lists one new file, `crates/loopd/tests/session_slot.rs`.
- [ ] **2. See it fail.** `cargo test -p loopd --test session_slot`, with a shell timeout of
600000 ms: the four tests compile (they call `session.slot()`, which exists) and
`a_session_created_on_the_background_slot_sends_that_slot` fails: `id_slot` is `0`, not `1`.
`a_main_slot_session_still_sends_main` may pass. `the_request_slot_follows_session_start_not_the_config_main`
fails: `id_slot` is `0`, not `7`.
- [ ] **3. The edit.** Make edit T1. `cargo check -p loopd` prints no error and no warning.
- [ ] **4. The tests.** `cargo test -p loopd --test session_slot --test turn --test channel
--test compact_turn`: every test passes, 4 in `session_slot` and the others as before.
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/loopd/src/turn.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of 600000 ms.
Expected last line: `gate: ok`. The test count is **1511** after this task, 1507 before it,
and the 4 new ones are the tests of `session_slot.rs`. To count, add up the `N passed` of
every `test result:` line the gate's `cargo test --workspace` printed, and add 1. Never run
the gate again only to count.
- [ ] **7. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
`M5d2/01-loopd-session-slot`. Stage with
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
`Cargo.lock` does not change in this task; the `git add` is harmless.
## Done when
- `make gate` prints `gate: ok` with 1511 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/turn.rs`,
`crates/loopd/tests/session_slot.rs` and `docs/implementer-log.md`.
## Stop and report if
- The branch check at the top fails.
- A "before" block is not in its file exactly as shown, or is there more than once.
- Step 2 shows a different result: the tests do not compile, or a different assertion fails.
- `cargo check` in step 3 prints an error or a warning. Quote it, and stop.
- A test fails in step 4. Do not change the test: stop, and give the failing tests' names and
their messages.
- The fix seems to need a change anywhere else. It does not; stop and say why you think so.
- A test outside `session_slot.rs` fails in the gate: check once whether it fails without your
change (`git stash -u`, run it, `git stash pop`), then stop and report both results.
- The gate passes with a count other than 1511.
## What the given tests pin
A session created with `cfg.slots.background` sends that slot; one created with `cfg.slots.main`
still sends main; a session created with slot `7` sends `7` even when `cfg.slots.main` is `0`; a
heartbeat turn between two main turns does not change the main session's message prefix.
+165
View File
@@ -0,0 +1,165 @@
# M5d-2 task 02: `LogRecord::ToolSummary`
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `bxcore: ToolSummary log record; messages ignore it`
**Log row:** the Task cell is `M5d2/02-bxcore-tool-summary`.
## Goal
A tool that opts in gets a second record, for the index only (spec 4.3):
```text
LogRecord::ToolSummary { time, call, tool_call_id, content, class, untrusted }
```
JSON type `tool_summary`. `messages()` / `messages_with_carry` ignore it, so the epoch's prefix
does not change. `support/turn.rs` `types` names it (T19: this task ships its own copy).
Nothing summarises yet. That is task 08.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/bxcore/src/log.rs` | the variant, after `ToolResult` (edit L1) |
| `crates/loopd/src/baseline.rs` | ignore arm (edit B1) |
| `crates/loopd/tests/support/turn.rs` | replaced, given |
| `crates/bxcore/tests/tool_summary.rs` | new, given |
| `crates/loopd/tests/tool_summary_messages.rs` | new, given |
| everything else | **no change**: not `index.rs` (task 09), not `turn.rs` |
`memoryd/src/index.rs` `index_record` already has `_ => {}`. It compiles. Summaries are skipped
until task 09.
## Files
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new tests
`crates/bxcore/tests/tool_summary.rs` and
`crates/loopd/tests/tool_summary_messages.rs`, and the replacement
`crates/loopd/tests/support/turn.rs`.
- Modify: `crates/bxcore/src/log.rs`, `crates/loopd/src/baseline.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **Field order is the wire format.** `time`, `call`, `tool_call_id`, `content`, `class`,
`untrusted`. No `truncated`: a summary is already capped.
- **Unknown fields are errors**, as on every `LogRecord` variant (`deny_unknown_fields` is on
the enum).
- **`types` is exhaustive.** Adding the variant without the replacement `support/turn.rs` fails
to compile `turn` / `limits` tests. Copy the given file over the existing one. The only
intended change is the `ToolSummary => "tool_summary"` arm. `Setup::turn` still returns
`(Result<TurnOutcome, TurnError>, Vec<TurnEvent>)`.
- **`messages` names every variant.** The new arm is empty, like `Usage` / `CacheLoss` /
`EpochEnd`.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `CallId` | already imported in `log.rs` |
| `DataClass` | already imported in `log.rs` |
## The edits, exactly
Each quoted "before" block occurs exactly once after task 01.
### `crates/bxcore/src/log.rs`
**L1.** After the `ToolResult` variant (the lines)
```rust
truncated: bool,
},
Usage {
```
become
```rust
truncated: bool,
},
ToolSummary {
time: Timestamp,
call: CallId,
tool_call_id: String,
content: String,
class: DataClass,
untrusted: bool,
},
Usage {
```
Keep the fields in that order.
### `crates/loopd/src/baseline.rs`
**B1.** After the `ToolResult` arm (the lines)
```rust
} => out.push(ChatMessage::Tool {
tool_call_id: tool_call_id.clone(),
content: content.clone(),
}),
LogRecord::Usage { .. } => {}
```
become
```rust
} => out.push(ChatMessage::Tool {
tool_call_id: tool_call_id.clone(),
content: content.clone(),
}),
LogRecord::ToolSummary { .. } => {}
LogRecord::Usage { .. } => {}
```
## Steps
- [ ] **1. Copy the given files.** From the repository root:
```sh
cp docs/plans/M5d2/files/crates/bxcore/tests/tool_summary.rs crates/bxcore/tests/
cp docs/plans/M5d2/files/crates/loopd/tests/tool_summary_messages.rs crates/loopd/tests/
cp docs/plans/M5d2/files/crates/loopd/tests/support/turn.rs crates/loopd/tests/support/turn.rs
```
- [ ] **2. See them fail.** `cargo build --tests --keep-going -p bxcore -p loopd`, timeout
600000 ms: `tool_summary` and `tool_summary_messages` do not compile (`E0599` no variant
`ToolSummary`). `support/turn.rs` does not compile for the same reason until L1. After L1
and before B1, `baseline.rs` fails (`non-exhaustive patterns`). Do L1 then B1 in step 3;
this step is the first `cargo build` against the given tests only, which is E0599.
- [ ] **3. The edits.** Make L1 then B1. `cargo check -p bxcore -p loopd` prints no error and
no warning.
- [ ] **4. The tests.** `cargo test -p bxcore --test tool_summary --test records` and
`cargo test -p loopd --test tool_summary_messages --test turn --test baseline --test
session_slot`: every test passes, 3 in `tool_summary`, 3 in `tool_summary_messages`.
- [ ] **5. Check.** `git diff --stat` lists `crates/bxcore/src/log.rs`,
`crates/loopd/src/baseline.rs`, `crates/loopd/tests/support/turn.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1517** (1511 before, plus 6).
- [ ] **7. Log and commit.** Task cell `M5d2/02-bxcore-tool-summary`. Stage with
`git add crates/bxcore crates/loopd docs/implementer-log.md Cargo.lock`, then commit as
`AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1517 tests.
- `git diff HEAD~1 --stat` lists only `crates/bxcore/src/log.rs`,
`crates/bxcore/tests/tool_summary.rs`, `crates/loopd/src/baseline.rs`,
`crates/loopd/tests/tool_summary_messages.rs`, `crates/loopd/tests/support/turn.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 1517; you replay a summary as a `User` or
`Tool` message; you add `truncated`; you change `Setup::turn`'s return type.
## What the given tests pin
JSON type is `tool_summary`; unknown fields are refused; field order is the struct order;
`messages()` with and without a summary are equal; a summary is not a tool message; inserting a
summary between a result and a later user does not break prefix extension.
@@ -0,0 +1,218 @@
# 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.
+192
View File
@@ -0,0 +1,192 @@
# M5d-2 task 04: five-field UTC cron matcher
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: five-field UTC cron matcher`
**Log row:** the Task cell is `M5d2/04-loopd-cron`.
## Goal
`loopd::cron::due(schedule, Timestamp) -> Result<bool, CronError>` (spec 4.2). Five fields:
minute hour day-of-month month day-of-week. Each field is `*`, a single number, `n-m`, or
`*/n`. Day-of-week `0` is Sunday. No cron crate. DOM and DOW are AND (README known limits).
Nothing loads job files yet.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/cron.rs` | new: `due`, `CronError` |
| `crates/loopd/src/lib.rs` | `pub mod cron;` |
| `crates/loopd/tests/cron.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/cron.rs`.
- Create: `crates/loopd/src/cron.rs` (skeleton below).
- Modify: `crates/loopd/src/lib.rs`, `docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **Civil fields from RFC 3339.** `Timestamp::to_rfc3339` is `YYYY-MM-DDTHH:MM:SS.mmmZ`.
Minute = `rfc[14..16]`, hour = `rfc[11..13]`, day = `rfc[8..10]`, month = `rfc[5..7]`,
parsed as `u32`. If a slice is missing, `CronError::Fields` naming the schedule.
- **Weekday.** `0` is Sunday. `(unix_millis() / 1000 / 86400 + 4) % 7` as `u32` (`1970-01-01`
was Thursday, so `+ 4`). `unix_millis` is `pub` on `Timestamp`.
- **Ranges.** Inclusive. `*/n` matches when `value % n == 0` (multiples from 0). `n == 0` in
a step is `Fields`.
- **Bounds.** Minute 0–59, hour 0–23, DOM 1–31, month 1–12, DOW 0–6. A number outside its
field's range is `Fields`. Four or six fields, `@daily`, and any other token are `Fields`.
- **`Display`.** Names the schedule, e.g. contains `x x x x x` when that was the input. Do not
add a runbook pointer; this error is used by job loading in task 05.
- **Split.** `split_whitespace`. Five pieces or `Fields`.
- rustfmt decides `mod` order in `lib.rs`. After `pub mod compact;` alphabetically `cron`
comes after `config` and before `http`. Insert where rustfmt will put it; run `cargo fmt`
before the gate.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `Timestamp::to_rfc3339` | civil fields |
| `Timestamp::unix_millis` | weekday |
| `str::parse::<u32>` | numbers |
## Skeleton
`crates/loopd/src/cron.rs`:
```rust
//! Five-field UTC crontab matcher (M5d spec, section 4.2). No cron crate.
use bxcore::Timestamp;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CronError {
Fields(String),
}
impl std::fmt::Display for CronError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CronError::Fields(schedule) => {
write!(f, "cron schedule {schedule:?} is not five UTC fields")
}
}
}
}
impl std::error::Error for CronError {}
/// Whether `schedule` matches `now` in UTC.
pub fn due(schedule: &str, now: Timestamp) -> Result<bool, CronError> {
let fields: Vec<&str> = schedule.split_whitespace().collect();
if fields.len() != 5 {
return Err(CronError::Fields(schedule.to_string()));
}
let rfc = now.to_rfc3339();
let minute = number(&rfc, 14, 16, schedule)?;
let hour = number(&rfc, 11, 13, schedule)?;
let day = number(&rfc, 8, 10, schedule)?;
let month = number(&rfc, 5, 7, schedule)?;
let dow = {
let days = now.unix_millis() / 1000 / 86_400;
((days + 4) % 7) as u32
};
Ok(match_field(fields[0], minute, 0, 59, schedule)?
&& match_field(fields[1], hour, 0, 23, schedule)?
&& match_field(fields[2], day, 1, 31, schedule)?
&& match_field(fields[3], month, 1, 12, schedule)?
&& match_field(fields[4], dow, 0, 6, schedule)?)
}
fn number(rfc: &str, start: usize, end: usize, schedule: &str) -> Result<u32, CronError> {
let piece = rfc
.get(start..end)
.ok_or_else(|| CronError::Fields(schedule.to_string()))?;
piece
.parse()
.map_err(|_| CronError::Fields(schedule.to_string()))
}
fn match_field(
field: &str,
value: u32,
min: u32,
max: u32,
schedule: &str,
) -> Result<bool, CronError> {
let err = || CronError::Fields(schedule.to_string());
if field == "*" {
return Ok(true);
}
if let Some(step) = field.strip_prefix("*/") {
let n: u32 = step.parse().map_err(|_| err())?;
if n == 0 {
return Err(err());
}
return Ok(value.is_multiple_of(n));
}
if let Some((a, b)) = field.split_once('-') {
let lo: u32 = a.parse().map_err(|_| err())?;
let hi: u32 = b.parse().map_err(|_| err())?;
if lo < min || hi > max || lo > hi {
return Err(err());
}
return Ok(value >= lo && value <= hi);
}
let n: u32 = field.parse().map_err(|_| err())?;
if n < min || n > max {
return Err(err());
}
Ok(n == value)
}
```
`u32::is_multiple_of` is stable in 1.98. Do not use `%` with a comment that `n` was checked;
the skeleton already returns `Fields` for `n == 0`.
If `is_multiple_of` is not in scope on `u32` in this toolchain, use `value % n == 0` after the
`n == 0` check, and stop only if both fail to compile.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M5d2/files/crates/loopd/tests/cron.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test
target `cron` does not compile (`E0432` unresolved import `loopd::cron`). Every other
target compiles.
- [ ] **3. The module.** Write `cron.rs` from the skeleton. Add `pub mod cron;` to `lib.rs`
where rustfmt will put it. `cargo check -p loopd` prints no error and no warning.
- [ ] **4. The tests.** `cargo test -p loopd --test cron`: every test passes, 10 tests.
- [ ] **5. Check.** `git diff --stat` lists `crates/loopd/src/cron.rs` and
`crates/loopd/src/lib.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1535** (1525 before, plus 10).
- [ ] **7. Log and commit.** Task cell `M5d2/04-loopd-cron`. 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 1535 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/cron.rs`, `crates/loopd/src/lib.rs`,
`crates/loopd/tests/cron.rs` and the log.
## Stop and report if
- The branch check fails; step 2 differs; `cargo check` warns; a test fails; the gate count is
not 1535; you add a dependency; you treat DOM and DOW as OR; you accept `@daily` or a sixth
field; `cron.rs` exceeds 500 lines.
## What the given tests pin
`0 7 * * *` matches that minute only; `* * * * *` matches; `*/5` is multiples from 0; `0-10`
is inclusive; Sunday is 0 on 2026-10-04; the 1st of the month; four or six fields error;
minute 60, hour 24, DOW 7 error; `@daily` errors; `Display` names the schedule.
+219
View File
@@ -0,0 +1,219 @@
# M5d-2 task 05: job files and the run ledger
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: job files, run ledger, jobs-invalid`
**Log row:** the Task cell is `M5d2/05-loopd-jobs`.
## Goal
Owner-written `{home}/jobs/<name>.toml` and a run ledger `{home}/jobs/runs.jsonl` (spec 4.2).
`loopd` reads job files and cannot write them. Unknown keys, a name that is not `[a-z0-9-]+`,
or a name so long that `job-<name>-<yyyymmdd>t<hhmm>` is not a `SessionId`, are
`jobs-invalid`. A missing jobs directory is an empty list.
Nothing ticks yet. `loopd serve` refuses to start when `load` errors, so a misspelt job cannot
sit silently.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/jobs.rs` | new: `Job`, `Run`, `RunStatus`, `load`, `session_id`, `append`, `close_leftovers`, `live_session` |
| `crates/loopd/src/lib.rs` | `pub mod jobs;` |
| `crates/loopd/src/main.rs` | `run_serve` calls `jobs::load` after `check_summarise` |
| `docs/runbook.md` | append the given `jobs-invalid` entry |
| `crates/loopd/tests/jobs.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/jobs.rs`.
- Append, from `docs/plans/M5d2/files/runbook-jobs-invalid.md`, to `docs/runbook.md`.
- Create: `crates/loopd/src/jobs.rs` (skeleton below).
- Modify: `crates/loopd/src/lib.rs`, `crates/loopd/src/main.rs`, `docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **`name` from the file stem.** `mail.toml` → `mail`. The stem must match `^[a-z0-9-]+$`
(bytes, nonempty) and `session_id(name, now)` must succeed. `Mail.toml` is `JobsError::Name`.
- **`session_id`.** `format!("job-{name}-{}t{}", ymd, hm)` with `ymd` = `rfc[0..4]+rfc[5..7]+rfc[8..10]`
and `hm` = `rfc[11..13]+rfc[14..16]`, all from `now.to_rfc3339()`, then
`SessionId::new`. `SessionId` is `[a-z0-9-]`, max 64. `"n".repeat(50)` is too long (`job-` +
50 + `-` + 8 + `t` + 4 = 68).
- **`load`.** `read_dir`. `ErrorKind::NotFound` → `Ok(vec![])`. Any other IO error is
`JobsError::Invalid` naming the directory. Skip names that do not end in `.toml`. Sort by
`name` so the order is stable. Parse with `#[serde(deny_unknown_fields)]` on a private
`File { schedule, prompt }`.
- **`RunStatus`.** `started` / `finished` / `failed` (`rename_all = "snake_case"`).
- **`append`.** Create the parent directory (0700 is fine; do not weaken a tighter mode). One
JSON object, then a newline. Open append, write, `sync_all`.
- **`live_session`.** Walk every line of the ledger (missing file → `Ok(None)`). The last
`Run` whose `name` matches: `Started` → `Some(session)`, anything else → `None`.
- **`close_leftovers`.** Names whose last run is `Started` get one more `Run` with
`status: Failed` and the same `session`, `time: now`. Return how many names were closed.
A missing file is `Ok(0)`.
- **`Display` for `Invalid`.** `"{path}: {detail}; see docs/runbook.md#jobs-invalid"`. The
given unknown-keys test checks `ends_with("see docs/runbook.md#jobs-invalid")` and that
the path's file name is in the string.
- **`run_serve`.** After `check_summarise`, `jobs::load(&cfg.paths.home.join("jobs"))`. On
`Err`, print `loopd: {e}` and return exit 1. Do not create the directory here.
- A file that exists but cannot be read is an error. Only a missing directory is empty.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `toml::from_str` | already a `loopd` dependency |
| `SessionId::new` | the 64-byte alphabet check |
| `serde` `deny_unknown_fields` | unknown keys |
## Skeleton
`crates/loopd/src/jobs.rs` — fill every function; no `todo!()`. Keep the types in this order.
```rust
//! Owner-written job files and the run ledger (M5d spec, section 4.2).
use std::path::{Path, PathBuf};
use bxcore::{SessionId, Timestamp};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Job {
pub name: String,
pub schedule: String,
pub prompt: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Started,
Finished,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Run {
pub time: Timestamp,
pub name: String,
pub session: SessionId,
pub status: RunStatus,
}
#[derive(Debug)]
pub enum JobsError {
Name(String),
Invalid { path: PathBuf, detail: String },
}
impl std::fmt::Display for JobsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JobsError::Name(name) => write!(
f,
"job name {name:?} is not a valid job; see docs/runbook.md#jobs-invalid"
),
JobsError::Invalid { path, detail } => write!(
f,
"{}: {detail}; see docs/runbook.md#jobs-invalid",
path.display()
),
}
}
}
impl std::error::Error for JobsError {}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct File {
schedule: String,
prompt: String,
}
pub fn session_id(name: &str, now: Timestamp) -> Result<SessionId, JobsError> {
// Build job-<name>-<yyyymmdd>t<hhmm> from now.to_rfc3339() as in Facts. SessionId::new
// maps a failure to JobsError::Name(name.to_string()).
let _ = (name, now);
Err(JobsError::Name(name.to_string()))
}
pub fn load(dir: &Path) -> Result<Vec<Job>, JobsError> {
let _ = dir;
Ok(Vec::new())
}
pub fn append(path: &Path, run: &Run) -> Result<(), JobsError> {
let _ = (path, run);
Ok(())
}
pub fn live_session(path: &Path, name: &str) -> Result<Option<SessionId>, JobsError> {
let _ = (path, name);
Ok(None)
}
pub fn close_leftovers(path: &Path, now: Timestamp) -> Result<usize, JobsError> {
let _ = (path, now);
Ok(0)
}
```
Replace the four stub bodies in the same task. Do not leave a stub that returns `Ok(None)` /
`Ok(0)` / `Ok(vec![])` once you have written the real body. `session_id` must not stay on the
`Err` path.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M5d2/files/crates/loopd/tests/jobs.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test
target `jobs` does not compile (`E0432` unresolved `loopd::jobs`). Every other target
compiles.
- [ ] **3. The module.** Write `jobs.rs` from the skeleton, then fill the four bodies one at a
time (`session_id`, `load`, `append`, `live_session` / `close_leftovers`). Add `pub mod jobs;`
to `lib.rs`. Wire `run_serve` as in Facts. `cargo check -p loopd` prints no error and no
warning. `wc -l crates/loopd/src/jobs.rs` prints a number ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test jobs`: every test passes, 8 tests.
- [ ] **5. The runbook.** From the repository root:
```sh
{ echo; cat docs/plans/M5d2/files/runbook-jobs-invalid.md; } >> docs/runbook.md
```
Then `grep -c '^## jobs-invalid$' docs/runbook.md` prints `1`.
- [ ] **6. Check.** `git diff --stat` lists `crates/loopd/src/jobs.rs`,
`crates/loopd/src/lib.rs`, `crates/loopd/src/main.rs`, `docs/runbook.md`.
- [ ] **7. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1543** (1535 before, plus 8).
- [ ] **8. Log and commit.** Task cell `M5d2/05-loopd-jobs`. Stage with
`git add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock`, then commit as
`AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1543 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/jobs.rs`, `crates/loopd/src/lib.rs`,
`crates/loopd/src/main.rs`, `crates/loopd/tests/jobs.rs`, `docs/runbook.md` and the log.
## Stop and report if
- The branch check fails; step 2 differs; `cargo check` warns; a test fails; the gate count is
not 1543; you add a cron crate; a missing jobs directory is an error; `Display` for `Invalid`
does not end with the runbook pointer; `jobs.rs` exceeds 500 lines; you write a job file.
## What the given tests pin
A valid `mail.toml` loads; unknown keys name the file and the runbook; `Mail.toml` is `Name`;
`session_id` is `job-mail-20261003t0700`; a 50-character name is `Name`; a missing directory is
empty; leftover `started` becomes `failed` and is no longer live; a `started` without a close
is live until `finished`.
@@ -0,0 +1,216 @@
# M5d-2 task 06: heartbeat tick
**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 tick on the background slot`
**Log row:** the Task cell is `M5d2/06-loopd-scheduler-heartbeat`.
## Goal
A `Scheduler` whose `tick(&Context, now)` runs the standing `heartbeat` session when
`every_s` has elapsed since the last heartbeat turn, or since `Scheduler::new(started)` if
none (spec 4.1). The user text is `It is <RFC 3339 UTC>.\n\n` plus the prompt file. Slot is
background. Speaker is `None`. Skip if the session is busy. Skip and log once if the prompt
cannot be read (`heartbeat-prompt-unreadable`).
Tests call `tick(ctx, now)` with injected time. No sleeps (T27).
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/channel.rs` | `Held` is `pub`; `Context::hold` |
| `crates/loopd/src/scheduler.rs` | new: `Scheduler`, `TickWork`, `tick` |
| `crates/loopd/src/lib.rs` | `pub mod scheduler;` |
| `crates/loopd/src/main.rs` | a thread that `tick`s once a second, before `channel::serve` |
| `docs/runbook.md` | append the given `heartbeat-prompt-unreadable` entry |
| `crates/loopd/tests/scheduler_heartbeat.rs` | new, given |
| everything else | **no change**: not cron job runs (task 07) |
## Files
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test
`crates/loopd/tests/scheduler_heartbeat.rs`.
- Append, from `docs/plans/M5d2/files/runbook-heartbeat-prompt-unreadable.md`, to
`docs/runbook.md`.
- Create: `crates/loopd/src/scheduler.rs` (skeleton below).
- Modify: `crates/loopd/src/channel.rs`, `crates/loopd/src/lib.rs`,
`crates/loopd/src/main.rs`, `docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **`Context::hold`.** Same insert as `handle`: if the id is already in `busy`, `Err(())`;
else insert and return `Held`. `Held` must be `pub` so the given test can keep it. `Drop`
already removes the id.
- **Due.** `every_s == 0` → no heartbeat work. Else `now.unix_millis().saturating_sub(last.unix_millis()) >= every_s.saturating_mul(1000)`
where `last` is `last_heartbeat.unwrap_or(started)`. `07:00` to `07:01` with `every_s = 60`
is due. `07:01` then `07:01:30` is not.
- **Prompt first, then hold.** If `read_to_string(cfg.heartbeat_prompt())` fails, do not hold,
push `HeartbeatUnreadable`, log
`loopd: {path}: {e}; see docs/runbook.md#heartbeat-prompt-unreadable` only while
`heartbeat_unreadable_logged` is false, then set that flag. Do not update `last_heartbeat`.
A directory at the prompt path is the given-test stand-in for an unreadable file (T27: no
`chmod 000`).
- **Session id `heartbeat`.** `Session::open`; `NotFound` → `Session::create` with
`Baseline::assemble(&ctx.cfg, &ctx.registry)` and `ctx.cfg.slots.background`. Other open
errors: log and skip (do not panic).
- **The turn.** User text `format!("It is {}.\n\n{file}", now.to_rfc3339())` where `file` is
the prompt contents as read (no extra trim beyond what the file has). `run_turn` then
`compact::finish_turn` with `&*ctx.memory`, speaker `None`, `on_event` a no-op. Then
`last_heartbeat = Some(now)`, push `Heartbeat`.
- **`TickWork`.** Include `Job` / `SkippedJob` now so task 07 does not change the type.
Task 06 never pushes them.
- **`close_leftovers`.** On the first `tick` only, call
`jobs::close_leftovers(&ctx.cfg.paths.home.join("jobs").join("runs.jsonl"), now)` and ignore
a missing-file `Ok(0)`. A leftover that is currently `hold`ing its session is **not** closed:
skip names whose `live_session` session is in `busy` (or: close only when `hold` of that
session would succeed, then immediately `drop` that hold). Task 07's live-skip test depends
on this. If `close_leftovers` has no busy-set filter, add a loop here: for each live name,
if `hold(session)` is `Ok`, drop it and treat as leftover (append Failed yourself or call a
helper). Simplest: **do not call `close_leftovers` blindly**. Walk: if `live_session` is
`Some(id)` and `hold(id)` is `Ok`, drop the hold and append a `Failed` for that name (or
call `close_leftovers` only when no job session is held). The given leftover test does not
hold; the live-skip test does. Closing only leftovers that are not busy is required.
- **Main thread.** After `Context::new` and before `channel::serve`:
```rust
let ctx_sched = Arc::clone(&ctx);
std::thread::spawn(move || {
let mut sched = loopd::scheduler::Scheduler::new(bxcore::Timestamp::now());
loop {
sched.tick(&ctx_sched, bxcore::Timestamp::now());
std::thread::sleep(std::time::Duration::from_secs(1));
}
});
```
That sleep is production only. Tests never sleep.
- **`channel.rs` stays ≤ 500 lines.** `hold` is about 15 lines.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `Session::slot` / `create` / `open` | standing session |
| `run_turn` / `finish_turn` | the pulse |
| `Baseline::assemble` | new session |
| `jobs::close_leftovers` / `live_session` / `append` | leftovers |
| `Timestamp::unix_millis` / `to_rfc3339` | due and the time line |
## The edits, exactly
### `crates/loopd/src/channel.rs`
**C1.** `struct Held` becomes `pub struct Held`.
**C2.** Inside `impl Context`, after `new`, add:
```rust
/// Mark `id` busy. `Err(())` if it already is. Tests and the scheduler use this so a skip
/// is not a sleep (T27).
pub fn hold(&self, id: SessionId) -> Result<Held<'_>, ()> {
let mut busy = self.busy.lock().unwrap_or_else(|p| p.into_inner());
if busy.contains(&id) {
return Err(());
}
busy.insert(id.clone());
drop(busy);
Ok(Held { ctx: self, id })
}
```
### `crates/loopd/src/scheduler.rs`
Write this module. Fill `tick`. Cron jobs stay a comment until task 07: after heartbeat, do
not iterate job files yet.
```rust
//! Isolated heartbeat (and, from task 07, cron) turns (M5d spec, section 4).
use crate::channel::Context;
use bxcore::{SessionId, Timestamp};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TickWork {
Heartbeat,
SkippedBusy { session: SessionId },
HeartbeatUnreadable,
Job { name: String, session: SessionId },
SkippedJob { name: String },
}
pub struct Scheduler {
started: Timestamp,
last_heartbeat: Option<Timestamp>,
leftovers_closed: bool,
heartbeat_unreadable_logged: bool,
}
impl Scheduler {
pub fn new(started: Timestamp) -> Scheduler {
Scheduler {
started,
last_heartbeat: None,
leftovers_closed: false,
heartbeat_unreadable_logged: false,
}
}
pub fn tick(&mut self, ctx: &Context, now: Timestamp) -> Vec<TickWork> {
let _ = (ctx, now);
Vec::new()
}
}
```
Replace the `tick` body in this task. `leftovers_closed` is set true after the first leftover
pass so it runs once.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M5d2/files/crates/loopd/tests/scheduler_heartbeat.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test
target `scheduler_heartbeat` does not compile (`E0432` unresolved `loopd::scheduler`,
`E0599` no method `hold`). Every other target compiles.
- [ ] **3. The edits.** C1, C2, `scheduler.rs`, `pub mod scheduler;`, main thread. Fill
`tick`. `cargo check -p loopd` prints no error and no warning.
`wc -l crates/loopd/src/scheduler.rs` and `wc -l crates/loopd/src/channel.rs` each ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test scheduler_heartbeat --test channel --test
session_slot`: every test passes, 6 in `scheduler_heartbeat`.
- [ ] **5. The runbook.**
```sh
{ echo; cat docs/plans/M5d2/files/runbook-heartbeat-prompt-unreadable.md; } >> docs/runbook.md
```
`grep -c '^## heartbeat-prompt-unreadable$' docs/runbook.md` prints `1`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1549** (1543 before, plus 6).
- [ ] **7. Log and commit.** Task cell `M5d2/06-loopd-scheduler-heartbeat`. Stage with
`git add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock`, then commit as
`AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1549 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/channel.rs`,
`crates/loopd/src/scheduler.rs`, `crates/loopd/src/lib.rs`, `crates/loopd/src/main.rs`,
`crates/loopd/tests/scheduler_heartbeat.rs`, `docs/runbook.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 1549; you sleep in a test; you post to
Mattermost; you send `slots.main`; you set a speaker; you `chmod 000`; `Held` stays private.
## What the given tests pin
`every_s` 0 does nothing; a due heartbeat sends `id_slot` 1 with a time line and the file
text, session `heartbeat` has `slot() == 1`; a second tick before `every_s` does not run; a
held session is `SkippedBusy` with no request; a directory at the prompt path is
`HeartbeatUnreadable` with no request; the user record has `speaker: None`.
+107
View File
@@ -0,0 +1,107 @@
# M5d-2 task 07: cron tick
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: cron tick, new session per run`
**Log row:** the Task cell is `M5d2/07-loopd-scheduler-cron`.
## Goal
After the heartbeat work, `tick` loads `{home}/jobs/*.toml` and runs each job whose schedule
matches `now` (spec 4.2). A new session `job-<name>-<yyyymmdd>t<hhmm>` on the background slot.
Skip the tick when a previous run of that name is still live. Leftover `started` from a dead
process becomes `failed` on the first tick (already started in task 06: only when that session
is not busy). Speaker `None`. Same time-line prefix as heartbeat.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/scheduler.rs` | after heartbeat, iterate jobs (edit T1) |
| `crates/loopd/tests/scheduler_cron.rs` | new, given |
| everything else | **no change**: not `TickWork`, not `hold`, not heartbeat |
## Files
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test
`crates/loopd/tests/scheduler_cron.rs`.
- Modify: `crates/loopd/src/scheduler.rs`, `docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **Load.** `jobs::load(&ctx.cfg.paths.home.join("jobs"))`. On `Err`, log `loopd: {e}` and
skip cron this tick (the process stays up: README known limits). On `Ok`, for each `Job` in
order:
1. `cron::due(&job.schedule, now)`. `Err` → log and skip that job. `Ok(false)` → skip.
2. Ledger path `{home}/jobs/runs.jsonl`. If `live_session` is `Some(_)`, push
`SkippedJob { name }` and continue. (Task 06 has already closed leftovers that were not
busy.)
3. `session_id(&job.name, now)`. `Err` → log and skip.
4. `ctx.hold(session.clone())`. `Err` → `SkippedJob`. `Ok(held)` → create the session
(`Session::create` with `assemble` and `slots.background`; if `Exists`, `open` instead),
`jobs::append` `Started`, run `run_turn` then `finish_turn` with user text
`format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt)`, speaker `None`, then
`append` `Finished` or `Failed` (turn `Err` → `Failed`). `drop(held)`. Push
`TickWork::Job { name, session }`.
- **Two due minutes.** Two ticks at 06:59 and 07:00 with `* * * * *` make two session ids.
- **Not due.** `0 7 * * *` at 06:59 pushes nothing.
- **`scheduler.rs` stays ≤ 500 lines.** After this task it is about 200.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `jobs::load` / `session_id` / `append` / `live_session` | task 05 |
| `cron::due` | task 04 |
| `Context::hold` | task 06 |
| `run_turn` / `finish_turn` | same as heartbeat |
## The edits, exactly
There is no quoted "before" for a unique heartbeat-only block: you add a loop after the
heartbeat section of `tick`, before `work` is returned. If `tick` currently ends with
`work` after heartbeat, insert the job loop there. If you cannot find a single place to
insert without touching heartbeat, stop.
Do not change heartbeat behaviour. Do not add a sleep.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M5d2/files/crates/loopd/tests/scheduler_cron.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo test -p loopd --test scheduler_cron`, timeout 600000 ms: the
tests compile (`TickWork::Job` already exists) and `a_due_job_starts_a_new_session_on_the_background_slot`
fails: `work` is empty, not a `Job`. `a_job_not_due_does_not_run` may pass.
- [ ] **3. The edit.** Add the job loop. `cargo check -p loopd` prints no error and no
warning. `wc -l crates/loopd/src/scheduler.rs` prints a number ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test scheduler_cron --test scheduler_heartbeat
--test jobs --test cron`: every test passes, 6 in `scheduler_cron`.
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/loopd/src/scheduler.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1555** (1549 before, plus 6).
- [ ] **7. Log and commit.** Task cell `M5d2/07-loopd-scheduler-cron`. 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 1555 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/scheduler.rs`,
`crates/loopd/tests/scheduler_cron.rs` and the log.
## Stop and report if
- The branch check fails; step 2 differs (tests do not compile, or a different assertion
fails); `cargo check` warns; a test fails; the gate count is not 1555; you reuse a session
across runs; you send `slots.main`; you set a speaker; you skip leftovers that are not busy;
a heartbeat test fails.
## What the given tests pin
A due job creates `job-mail-…` on slot 1 with the time line and the prompt; a job not due
does nothing; a live previous run (Started + `hold`) is `SkippedJob` with no request; leftover
`started` is `failed` on the first tick (the due job may then run); speaker is `None`; two due
minutes make two sessions.
+257
View File
@@ -0,0 +1,257 @@
# M5d-2 task 08: opted-in tool-output summaries
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: background ToolSummary for opted-in results`
**Log row:** the Task cell is `M5d2/08-loopd-tool-summary`.
## Goal
After a turn that logged an opted-in `ToolResult` with no matching `ToolSummary`, run a
no-tools completion on the background slot and append `ToolSummary` with the result's labels
(spec 4.3). `messages()` already ignores it (task 02). `http_fetch` opts in via
`[compact] summarise`.
`summarise::missing` is the function. `finish_turn` calls it on `Ok` and on `SessionFull`,
**before** compaction, so the current epoch still holds the `ToolResult` (README known
limits: v1 runs this inside the turn's hold).
The starter `config/summarise.md` is copied like `config/heartbeat.md`.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/summarise.rs` | new: `missing` |
| `crates/loopd/src/lib.rs` | `pub mod summarise;` |
| `crates/loopd/src/compact.rs` | `finish_turn` calls `missing` before compact (edit F1) |
| `config/summarise.md` | new, given starter |
| `crates/loopd/tests/tool_summary_call.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/tool_summary_call.rs` and the starter `config/summarise.md`.
- Create: `crates/loopd/src/summarise.rs` (function below).
- Modify: `crates/loopd/src/lib.rs`, `crates/loopd/src/compact.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **Which results.** Walk `session.records()` once. From each `Assistant`, remember
`tool_calls` by `id → name`. From each `ToolSummary`, remember `call: CallId`. Then for
each `ToolResult` whose `tool_call_id` maps to a name in `cfg.compact.summarise` and whose
`call` is not already summarised, queue it. Do not append while iterating `records()`.
- **The call.** `messages_with_carry(session.baseline(), session.records(), session.carry())`,
then one `ChatMessage::User` whose content is
`format!("{}\n\n{}", prompt.trim_end(), crate::compact::cap_chars(result_content, cfg.r#loop.tool_result_cap))`
where `prompt` is `read_to_string` of
`cfg.baseline.system.with_file_name("summarise.md")`. Missing file: return `Err` naming
the path (do not guess). `ChatRequest { slot: cfg.slots.background, tools: Vec::new(),
thinking: true, messages }`. `client.chat_with_retry(&request, &mut |_| {})`.
- **The record.** Completion `content.unwrap_or_default()`, `neutralise`, `cap_chars` to
`cfg.compact.summary_chars`. Append `LogRecord::ToolSummary { time: Timestamp::now(),
call, tool_call_id, content, class, untrusted }` copying `call`, `tool_call_id`, `class`,
`untrusted` from that `ToolResult`.
- **Context-full / other infer errors.** Skip that result (log `loopd: tool summary: {e}`),
continue the queue. Do not fail the turn.
- **`finish_turn`.** At the start of the `Ok(done)` arm and the `Err(SessionFull)` arm, before
`compact_session`:
```rust
if let Err(e) = crate::summarise::missing(rt.cfg, rt.client, session) {
eprintln!("loopd: tool summary: {e}");
}
```
A compact-due turn summarises first, then compacts. The last given test goes through
`finish_turn` (not `missing` alone) and expects a `ToolSummary`.
- **`cap_chars` is `pub(crate)`** in `compact.rs` (M5d-1 task 05).
- **`tool_call.http` calls `read_file`.** The last given test sets
`summarise = ["read_file"]`. Default `["http_fetch"]` would leave it alone.
- rustfmt `mod` order: `summarise` after `sse` / `session` as rustfmt decides.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `messages_with_carry` | prefix plus carry |
| `ChatRequest.tools: Vec::new()` | omits `tools` on the wire |
| `neutralise` | `bxcore::sanitize` |
| `cap_chars` | character cap |
| `Session::append` | the new record |
## The function
`crates/loopd/src/summarise.rs`:
```rust
//! Opted-in tool-output summaries (M5d spec, section 4.3).
use crate::baseline::messages_with_carry;
use crate::compact::cap_chars;
use crate::config::Config;
use crate::llama::{ChatMessage, ChatRequest, Client};
use crate::session::Session;
use bxcore::sanitize::neutralise;
use bxcore::{CallId, DataClass, LogRecord, Timestamp, ToolCall};
use std::collections::{HashMap, HashSet};
/// Append a `ToolSummary` for each opted-in `ToolResult` in this epoch that does not yet have
/// one. Inference uses the background slot and no tools.
pub fn missing(
cfg: &Config,
client: &Client,
session: &mut Session,
) -> Result<(), String> {
let path = cfg.baseline.system.with_file_name("summarise.md");
let prompt = std::fs::read_to_string(&path)
.map_err(|e| format!("{}: {e}", path.display()))?;
let mut names: HashMap<String, String> = HashMap::new();
let mut done: HashSet<u64> = HashSet::new();
let mut pending: Vec<(CallId, String, String, DataClass, bool)> = Vec::new();
for record in session.records() {
match record {
LogRecord::Assistant { tool_calls, .. } => {
for ToolCall { id, name, .. } in tool_calls {
names.insert(id.clone(), name.clone());
}
}
LogRecord::ToolSummary { call, .. } => {
done.insert(call.0);
}
LogRecord::ToolResult {
call,
tool_call_id,
content,
class,
untrusted,
..
} => {
if done.contains(&call.0) {
continue;
}
let Some(name) = names.get(tool_call_id) else {
continue;
};
if !cfg.compact.summarise.iter().any(|n| n == name) {
continue;
}
pending.push((
*call,
tool_call_id.clone(),
content.clone(),
*class,
*untrusted,
));
}
_ => {}
}
}
for (call, tool_call_id, content, class, untrusted) in pending {
let mut messages = messages_with_carry(session.baseline(), session.records(), session.carry());
messages.push(ChatMessage::User {
content: format!(
"{}\n\n{}",
prompt.trim_end(),
cap_chars(&content, cfg.r#loop.tool_result_cap)
),
});
let request = ChatRequest {
slot: cfg.slots.background,
messages,
tools: Vec::new(),
thinking: true,
};
let text = match client.chat_with_retry(&request, &mut |_| {}) {
Ok(completion) => completion.content.unwrap_or_default(),
Err(e) => {
eprintln!("loopd: tool summary: {e}");
continue;
}
};
let text = cap_chars(neutralise(&text).as_ref(), cfg.compact.summary_chars);
session
.append(LogRecord::ToolSummary {
time: Timestamp::now(),
call,
tool_call_id,
content: text,
class,
untrusted,
})
.map_err(|e| e.to_string())?;
}
Ok(())
}
```
`CallId` is a tuple struct `CallId(pub u64)` (already used as `CallId(1)` in tests). If
`call.0` does not compile, stop and report.
### `crates/loopd/src/compact.rs`
**F1.** The `Ok(done)` arm. The lines
```rust
Ok(done) => {
if let Some(usage) = session.last_usage()
&& due(&usage, rt.cfg.compact.threshold, rt.cfg.expect.n_ctx)
&& let Err(e) = compact_session(session, rt, memory)
{
log_compact(session, &e);
}
Ok(done)
}
```
become the same, with the `missing` call inserted as the first statement of the arm (see
Facts). The `Err(TurnError::SessionFull)` arm gets the same `missing` call before
`compact_session`.
## Steps
- [ ] **1. Copy the given files.**
```sh
cp docs/plans/M5d2/files/crates/loopd/tests/tool_summary_call.rs crates/loopd/tests/
cp docs/plans/M5d2/files/config/summarise.md config/summarise.md
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test
target `tool_summary_call` does not compile (`E0432` unresolved `loopd::summarise`). Every
other target compiles.
- [ ] **3. The edits.** `summarise.rs`, `pub mod summarise;`, F1. `cargo check -p loopd`
prints no error and no warning. `wc -l crates/loopd/src/summarise.rs` and
`wc -l crates/loopd/src/compact.rs` each ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test tool_summary_call --test
tool_summary_messages --test compact_turn --test turn`: every test passes, 6 in
`tool_summary_call`.
- [ ] **5. Check.** `git diff --stat` lists `crates/loopd/src/summarise.rs`,
`crates/loopd/src/lib.rs`, `crates/loopd/src/compact.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1561** (1555 before, plus 6).
- [ ] **7. Log and commit.** Task cell `M5d2/08-loopd-tool-summary`. Stage with
`git add crates/loopd config/summarise.md docs/implementer-log.md Cargo.lock`, then commit
as `AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1561 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/summarise.rs`,
`crates/loopd/src/lib.rs`, `crates/loopd/src/compact.rs`,
`crates/loopd/tests/tool_summary_call.rs`, `config/summarise.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 1561; you send tools on the summary
request; you send `slots.main`; you replay the summary in `messages()`; you summarise a
name not in the list; you summarise twice; you compact before summarising.
## What the given tests pin
An opted-in result is summarised on slot 1 with no `tools` key; labels are copied; a name not
in the list is left alone; a second `missing` does not call again; `messages()` is unchanged
after a summary; `finish_turn` after a `read_file` turn appends a `ToolSummary` on slot 1.
+141
View File
@@ -0,0 +1,141 @@
# M5d-2 task 09: index `ToolSummary` as kind `summary`
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `memoryd: index ToolSummary as kind summary`
**Log row:** the Task cell is `M5d2/09-memoryd-summary-index`.
## Goal
`memoryd` indexes `LogRecord::ToolSummary` content as kind `summary`, with that record's
`class` and `untrusted` (spec 4.3, P26). `recall` finds it and tags `memory` or
`memory-tainted` as for any other session hit. Raw `ToolResult` bodies stay unindexed.
`index_record` today has `_ => {}`. It compiles. This task adds the arm.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/memoryd/src/index.rs` | `ToolSummary` arm (edit I1); module doc |
| `crates/memoryd/tests/tool_summary_index.rs` | new, given |
| everything else | **no change**: not `recall.rs` headers (role `"summary"` uses the existing "the assistant said" branch) |
## Files
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test
`crates/memoryd/tests/tool_summary_index.rs`.
- Modify: `crates/memoryd/src/index.rs`, `docs/implementer-log.md`.
- Do not touch: any other file. Do not edit `tests/support/logs.rs`.
## Facts you need
- **Kind `summary`.** Set `kind: "summary".to_string()` on the `Source`, not the log's
`"session"`. Role `"summary"`. Time from the record. `class` / `untrusted` from the
`ToolSummary`, not from `base` (the rolling session label). Skip when
`summary.class > self.max_class`, even if `base.class` is still `Private`. Skip when
`content.trim()` is empty.
- **The existing `if base.class > self.max_class` stays.** A secret `ToolResult` raises the
rolling label before the next record, so a following secret summary is also skipped that
way. Still check the summary's own class.
- **Do not add `ToolSummary` to the rolling-label update** in `index_log` (that loop still
looks at `ToolResult` only). The result already carried the same labels.
- **Recall headers.** `kind` `"summary"` is not `moved` / `dropped` / `noted`, so the tag is
`memory` or `memory-tainted`. Role `"summary"` is not `owner` or `call`, so the line says
`the assistant said`. The given tests look for `memory · session mm-a` and
`memory-tainted`.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `self.add` | FTS row plus `sources` |
| `Source` | kind / role / class / untrusted |
## The edits, exactly
Each quoted "before" block occurs exactly once.
### `crates/memoryd/src/index.rs`
**I1.** After the `Assistant` arm, before `_ => {}`. The lines
```rust
}
_ => {}
}
Ok(())
}
```
become
```rust
}
LogRecord::ToolSummary {
time,
content,
class,
untrusted,
..
} if !content.trim().is_empty() && *class <= self.max_class => {
let src = Source {
kind: "summary".to_string(),
time: time.to_rfc3339(),
role: "summary".to_string(),
class: *class,
untrusted: *untrusted,
..base.clone()
};
self.add(content, &src)?;
}
_ => {}
}
Ok(())
}
```
`DataClass` already implements `Ord` (`Public < Private < Secret`). If `<=` does not
compile, stop.
Optionally update the module doc "never a tool's result" to say summaries of opted-in
results are indexed. A comment that does not fit its code: stop.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M5d2/files/crates/memoryd/tests/tool_summary_index.rs crates/memoryd/tests/
```
- [ ] **2. See it fail.** `cargo test -p memoryd --test tool_summary_index`, timeout 600000
ms: the tests compile (`ToolSummary` exists) and `a_tool_summary_is_found_as_kind_summary`
fails: `(no matches for "tea")` or the unique phrase is missing. `the_raw_tool_result_is_still_not_indexed`
may pass (results were already unindexed).
- [ ] **3. The edit.** I1. `cargo check -p memoryd` prints no error and no warning.
- [ ] **4. The tests.** `cargo test -p memoryd --test tool_summary_index --test recall`: every
test passes, 5 in `tool_summary_index`.
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/memoryd/src/index.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1566** (1561 before, plus 5).
- [ ] **7. Log and commit.** Task cell `M5d2/09-memoryd-summary-index`. Stage with
`git add crates/memoryd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
says.
## Done when
- `make gate` prints `gate: ok` with 1566 tests.
- `git diff HEAD~1 --stat` lists only `crates/memoryd/src/index.rs`,
`crates/memoryd/tests/tool_summary_index.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 1566; you index `ToolResult` content; you
leave `kind` as `"session"`; a secret summary is searchable on a private index.
## What the given tests pin
A summary's content is found and tagged `memory · session …`; the raw tool body is not; an
untrusted summary taints the recall (`memory-tainted`); a trusted summary does not taint; a
secret summary is skipped when `max_class` is `Private`.
+227
View File
@@ -0,0 +1,227 @@
# M5d-2 task 10: `MemoryCommand::Tiers` and `bxctl memory tiers`
**Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `memoryd: Tiers listing; bxctl memory tiers`
**Log row:** the Task cell is `M5d2/10-memoryd-tiers`.
## Goal
`MemoryCommand::Tiers` lists each identity entry and each note with how many times `recalls/`
named it and when it was last found (spec 4.4). Untrusted notes are included and marked.
Identity entries list with 0 hits (README known limits). `bxctl memory tiers` prints that
listing. The starter `config/jobs/tier.toml.example` documents a weekly job; nothing in
`loopd` special-cases it.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/bxcore/src/memory.rs` | `MemoryCommand::Tiers` after `Sync` |
| `crates/memoryd/src/tiers.rs` | new: `list` |
| `crates/memoryd/src/lib.rs` | `pub mod tiers;` |
| `crates/memoryd/src/serve/admin.rs` | `Tiers` arm |
| `crates/bxctl/src/memory.rs` | `["tiers"]` |
| `crates/bxctl/src/cli.rs` | `USAGE` names `tiers` |
| `config/jobs/tier.toml.example` | new, given starter |
| `crates/memoryd/tests/tiers.rs` | new, given |
| `crates/bxctl/tests/cli_memory_tiers.rs` | new, given |
| everything else | **no change**: not `cli_memory.rs` (T19: new file) |
## Files
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new tests
`crates/memoryd/tests/tiers.rs` and `crates/bxctl/tests/cli_memory_tiers.rs`, and the
starter `config/jobs/tier.toml.example`.
- Create: `crates/memoryd/src/tiers.rs` (function below).
- Modify: `crates/bxcore/src/memory.rs`, `crates/memoryd/src/lib.rs`,
`crates/memoryd/src/serve/admin.rs`, `crates/bxctl/src/memory.rs`,
`crates/bxctl/src/cli.rs`, `docs/implementer-log.md`.
- Do not touch: any other file. Do not edit `crates/bxctl/tests/cli_memory.rs`.
## Facts you need
- **`MemoryCommand` is `rename_all = "snake_case"`.** JSON is `"tiers"`. Add the variant after
`Sync`, with a doc comment. `admin.rs` match is exhaustive: add the arm in this task or it
does not compile.
- **Listing format, exact.** One block per identity entry, then one per note, in file order:
```text
identity memory.md hits 0 last -
I like tea.
identity user.md hits 0 last -
The owner is Kyle.
note session mm-a hits 2 last 2026-10-03T07:30:00.000Z private trusted
launch window moved.
note session mm-c hits 0 last - private untrusted
example.org says the launch moved.
```
Two spaces before the text line. `hits` and `last` for identity are always `0` and `-`.
For a note, `hits` is how many `recalls/*.jsonl` lines contain a hit with
`kind == "noted"`, `session == note.session`, `record ==` that note's index in
`notes::parse` (from 0). `last` is the maximum `time` string among those lines, or `-`.
Class is `public` / `private` / `secret` (`notes::class_name`). Trust is `untrusted` when
`Kind::Noted { untrusted: true }`, else `trusted`. Moved/dropped notes: still list them as
`note` using the same line, class `private`, `trusted` (they have no untrusted flag).
- **`recalls/`.** `store.path("recalls")`. Missing directory: all counts 0. A file that
exists but cannot be read is an error. Ignore a line that does not parse. Deserialize:
```rust
#[derive(Deserialize)]
struct Logged {
time: String,
hits: Vec<Hit>,
}
#[derive(Deserialize)]
struct Hit {
kind: String,
session: String,
record: i64,
}
```
(extra fields in the file are ignored: this is our format but old lines may grow; the given
tests use exactly those fields plus `session` / `query` / `epoch` on the line. Use
`deny_unknown_fields` **off** so `epoch` and `query` do not fail.)
- **Identity.** `store.entries(MemoryFile::Memory)` then `User`. Empty file: no identity
lines for that file.
- **`list` returns `Result<String, String>`.** Trailing newline on the last block is fine;
`bxctl` adds another when printing `MemoryAdminReply.text` (`writeln!`). The given bxctl
test includes a trailing newline in the reply text and expects it plus `writeln!`'s
newline.
- **`admin` arm.** `MemoryCommand::Tiers => tiers::list(&self.store, now)` — `now` is unused
by `list` today; keep the signature `list(store, now)` so the given tests compile.
- **`bxctl`.** `["tiers"] => Ask::Admin(MemoryCommand::Tiers, String::new())`. `["tiers", _]`
is usage. USAGE: add `| tiers` on the `bxctl memory` line.
- rustfmt `mod` order in `memoryd/src/lib.rs`.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `Store::entries` | identity |
| `notes::parse` / `class_name` | notes |
| `Store::path("recalls")` | the jsonl directory |
| `MemoryFile::name` | `memory.md` / `user.md` |
## The function
`crates/memoryd/src/tiers.rs` — fill `list`. No `todo!()`.
```rust
//! The tiering listing: identity entries and notes with recall-hit counts (M5d spec, 4.4).
use bxcore::{MemoryFile, Timestamp};
use serde::Deserialize;
use crate::files::Store;
use crate::notes::{self, Kind};
#[derive(Deserialize)]
struct Logged {
time: String,
hits: Vec<Hit>,
}
#[derive(Deserialize)]
struct Hit {
kind: String,
session: String,
record: i64,
}
/// One listing for `bxctl memory tiers` and for the tiering job's user text.
pub fn list(store: &Store, _now: Timestamp) -> Result<String, String> {
let _ = store;
Ok(String::new())
}
```
Replace the stub body in this task.
### `crates/bxcore/src/memory.rs`
**M1.** After `Sync,` in `MemoryCommand`:
```rust
/// Identity entries and notes with recall-hit counts (M5d spec, section 4.4).
Tiers,
```
### `crates/memoryd/src/serve/admin.rs`
**A1.** After the `Sync` arm, before the closing of the `match`:
```rust
MemoryCommand::Tiers => crate::tiers::list(&self.store, now),
```
### `crates/bxctl/src/memory.rs`
**B1.** After `["sync"] => ...`, before `_ => return Err(UsageError)`:
```rust
["tiers"] => Ask::Admin(MemoryCommand::Tiers, String::new()),
```
### `crates/bxctl/src/cli.rs`
**U1.** The `bxctl memory` USAGE line. The line
```text
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> | sync [--memory-socket PATH]
```
becomes
```text
bxctl memory log [N] | undo <id> | pending | accept <id> | reject <id> | sync | tiers [--memory-socket PATH]
```
## Steps
- [ ] **1. Copy the given files.**
```sh
cp docs/plans/M5d2/files/crates/memoryd/tests/tiers.rs crates/memoryd/tests/
cp docs/plans/M5d2/files/crates/bxctl/tests/cli_memory_tiers.rs crates/bxctl/tests/
mkdir -p config/jobs
cp docs/plans/M5d2/files/config/jobs/tier.toml.example config/jobs/tier.toml.example
```
- [ ] **2. See them fail.** `cargo build --tests --keep-going -p bxcore -p memoryd -p bxctl`,
timeout 600000 ms: `tiers` does not compile (`E0432` unresolved `memoryd::tiers`);
`cli_memory_tiers` does not compile (`E0599` no variant `Tiers`). After M1 alone,
`admin.rs` fails (`non-exhaustive patterns`). Do M1, A1, `tiers.rs`, B1, U1 together in
step 3. This step is the first build against the given tests.
- [ ] **3. The edits.** M1, `tiers.rs` with a real `list`, `pub mod tiers;`, A1, B1, U1.
`cargo check -p bxcore -p memoryd -p bxctl` prints no error and no warning.
`wc -l crates/memoryd/src/tiers.rs` prints a number ≤ 500.
- [ ] **4. The tests.** `cargo test -p memoryd --test tiers` (4) and
`cargo test -p bxctl --test cli_memory_tiers --test cli_memory --test cli_memory_sync`
(2 new): every test passes.
- [ ] **5. Check.** `git diff --stat` lists the files in "Every place that changes" except
the new tests and starter (those are untracked until `git add`).
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1572** (1566 before, plus 6).
- [ ] **7. Log and commit.** Task cell `M5d2/10-memoryd-tiers`. Stage with
`git add crates/bxcore crates/memoryd crates/bxctl config/jobs docs/implementer-log.md
Cargo.lock`, then commit as `AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1572 tests.
- `git diff HEAD~1 --stat` lists only the files this task names plus 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 1572; you edit `cli_memory.rs`; identity
entries show a non-zero hit count; untrusted notes are omitted; you add a cron crate; you
special-case the name `tier` in `loopd`.
## What the given tests pin
Identity lines are `hits 0 last -` with the entry text; notes join `recalls/` on
`(noted, session, record)` and take the latest `time`; untrusted notes are listed and marked;
a hit on note 1 does not count for note 0; `bxctl memory tiers` parses and prints the reply.
+132
View File
@@ -0,0 +1,132 @@
# M5d-2 implementation plan: the scheduler
> For the implementer: each task file is one closed task for one fresh session. Read `AGENTS.md`,
> then the task file, and do exactly that task. The driver is `tools/run-plan.sh`.
**Goal:** heartbeat and cron run as isolated sessions on the background slot; live turns use the
session's slot; opted-in tool results get a `ToolSummary` that `messages()` ignores and `recall`
indexes; the tiering listing is an ordinary admin command. Answers stay in the session log.
Nothing is posted to Mattermost.
**Architecture:** `run_turn` reads `session.slot()`. `loopd` gains `[heartbeat]`, a cron matcher,
job-file loading, a scheduler thread that shares `Context` and the busy set, and a no-tools
summary call for `http_fetch`. `bxcore` gains `LogRecord::ToolSummary` and `MemoryCommand::Tiers`.
`memoryd` indexes summaries and answers `Tiers` from `recalls/`. `bxctl memory tiers` prints that
listing. No new crate.
**Tech stack:** Rust 1.98.1, edition 2024; no cron crate; no new dependency.
**Spec:** `docs/specs/2026-10-03-m5d-scheduler.md`, sections 4, 5, 6, 7 (the M5d-2 tests), and 8
(`jobs-invalid`, `heartbeat-prompt-unreadable`). M5d-1 (epochs and compaction) is already on the
branch this plan starts from.
## The owner's decisions (2026-10-03)
1. Heartbeat and cron answers stay in the session log. They are not posted to Mattermost.
2. Five-field UTC crontab, `{home}/jobs/<name>.toml`, a new session each run, a tick skipped
when that job is still live.
3. Tool summaries on the background slot; a `ToolSummary` log record with the result's labels,
FTS-indexed; `http_fetch` opts in via `[compact] summarise`.
## What the code already has, and what it lacks
- M5d-1 writes epochs, compact after a turn, Honcho over every epoch file. Live turns still send
`slots.main`. `[compact] summarise` is stored and unused. Unknown names are not a config error.
- `LogRecord` has no `ToolSummary`. `messages()` names every variant. `support/turn.rs` `types`
is exhaustive.
- `loopd serve` binds the channel and blocks in `channel::serve`. There is no scheduler thread.
- `MemoryCommand` has no `Tiers`. `bxctl memory` has no `tiers`.
- `SessionId` is `[a-z0-9-]`, max 64. Job ids `job-<name>-<yyyymmdd>t<hhmm>` must fit.
## Global constraints
- Roles depend on `bxcore` only. No `unsafe`, no async runtime, no new crate.
- No file the implementer reads holds a chat-template tag whole (T33).
- Every path a given test creates is removed by a guard (T44); no test depends on timing (T27):
the scheduler is `tick(now)`, never a sleep.
- Fail-closed states `jobs-invalid` and `heartbeat-prompt-unreadable` have runbook entries,
added by the task that adds their pointer (T49).
- Each task gives its baseline and its gate count (T50, T53); the branch line is a check, never
`git switch` (T51).
- `Cargo.lock` is in the `git add` line (T24). Gate timeout 600000 ms (T40).
- A later task that changes a shared given file ships its own copy (T19): task 02 replaces
`crates/loopd/tests/support/turn.rs` so `types` names `ToolSummary`.
## Tasks
Every task below has been written to be replayed from its text. The gate count is the passed
total of `cargo test --workspace` plus one, for the ignored `end_to_end` test the gate runs on
its own. Before task 01 it is **1507** (talos, rustc 1.98.1; M5d-1 task 08).
| # | Delivers | Crates | Gate after | Tests added |
|---|---|---|---|---|
| 01 | `run_turn` sends `session.slot()`; a session created on the background slot has `id_slot` of that slot; `mm-*` stays main | loopd | 1511 | 4 |
| 02 | `LogRecord::ToolSummary`; `messages()` ignores it; `types` names it; JSON round-trip | bxcore, loopd | 1517 | 6 |
| 03 | `[heartbeat]` (`every_s` 0, `prompt` `heartbeat.md`); unknown `[compact] summarise` names are a config error; starter `heartbeat.md` | loopd | 1525 | 8 |
| 04 | Five-field UTC cron matcher: `*`, `n`, `n-m`, `*/n`; day-of-week 0 is Sunday | loopd | 1535 | 10 |
| 05 | `{home}/jobs/<name>.toml`, `runs.jsonl`, `jobs-invalid` pointer and runbook | loopd | 1543 | 8 |
| 06 | Heartbeat tick: time line, standing session `heartbeat`, skip if busy, unreadable prompt logged once | loopd | 1549 | 6 |
| 07 | Cron tick: new session per run, skip if previous live, leftover `started` becomes `failed` | loopd | 1555 | 6 |
| 08 | After a turn, opted-in `ToolResult` without a `ToolSummary` gets a background summary appended with the result's labels | loopd | 1561 | 6 |
| 09 | `memoryd` indexes `ToolSummary` as kind `summary`; `recall` finds it and taints when untrusted | memoryd | 1566 | 5 |
| 10 | `MemoryCommand::Tiers`, `bxctl memory tiers`, starter `config/jobs/tier.toml.example` | bxcore, memoryd, bxctl | 1572 | 6 |
After task 10, the design model runs the M5d-2 device check (spec 7) on straylight: a heartbeat
turn on the background slot leaves a following main-slot turn's `cache_n` near its previous
total; a `jobs/tier.toml` for the check promotes only a trusted note.
## Review Focus
These are implied by the spec and pinned in the named task. A reviewer who only reads the
happy-path tests will miss them.
- A live `mm-*` turn still sends `slots.main` after heartbeat used the background slot (task 01).
- Inserting a `ToolSummary` between a `ToolResult` and a later `User` does not break prefix
extension (task 02).
- An unknown `[compact] summarise` name is a config error; an empty list is not (task 03).
- DOM and DOW are AND; `@daily` and a sixth field are errors (task 04).
- A leftover `started` whose session is currently held is not closed as `failed` (tasks 06, 07).
- `finish_turn` summarises before it compacts, so the `ToolResult` is still in the current epoch
(task 08).
- A secret `ToolSummary` is skipped when the index is private (task 09).
- Untrusted notes appear in `Tiers` and are marked; they cannot be omitted (task 10).
## Known limits
These are known, accepted for M5d-2, and not bugs to fix in a task:
- **No Mattermost delivery.** Heartbeat and cron answers are in `sessions/<id>/` only. The owner
reads them with `bxctl chat --session` or by opening the log.
- **DOM and DOW are AND.** Both restricted (`0 7 1 * 0`) matches only a Sunday that is also the
1st. Standard Vixie cron would OR them. The spec does not ask for OR.
- **Tool-summary inference runs inside `finish_turn`, before compaction, while the session is
still held.** That is how compaction waits for the summary, and how `missing` still sees the
current epoch's `ToolResult`. A second live turn on that session waits. The summary request
itself uses the background slot and omits `tools`. A later split that infers without the hold
is out of scope.
- **Job files are checked at start.** A file that becomes invalid after `loopd` is running is
skipped that tick and logged; it does not stop the process.
- **Identity entries in `Tiers` list with 0 hits** unless a `recalls/` line names that exact
index row. Notes carry counts from `recalls/`. That is enough for the tiering prompt.
## Before hand-over
Work through `docs/plans/CHECKLIST.md` with a reference in a scratch worktree with its own build
directory, replayed from its task text; mutations; `/tmp` compared around a gate run; the
reference moved to straylight as a bundle before the run (T18). Task files name their log row
(T47).
## Running it
The reference (`m5d2-ref`, tasks 01 to 10) is moved to straylight as a bundle and deleted here
before the run (T18). Then, from the repository root, with the OpenCode TUI closed, after M5d-1
has landed on master:
```sh
git switch -c m5d2 master
OPENCODE_MODEL=straylight/ornith-1.5-35b-a3b RESUME=1 \
SLOTS_URL='http://straylight:11434/slots?model=ornith-1.5-35b-a3b' \
tools/run-plan.sh docs/plans/M5d2 01
```
After task 10, the gate count is 1572. Then the device check above.
@@ -0,0 +1,4 @@
It is time for a heartbeat.
Look at recent notes and the current identity. Write a note only if something needs the owner, or
if a standing fact should move. Do not use tools that fetch. Do not address the owner.
@@ -0,0 +1,6 @@
# Copy to {BOXMAKER_HOME}/jobs/tier.toml and edit the schedule.
# Five-field UTC crontab. This example is Sunday 07:00.
schedule = "0 7 * * 0"
prompt = """Read the listing in this message. Promote only trusted notes into identity
(memory.md / user.md) with add or consolidate. Do not promote an untrusted note.
Do not fetch. Write a note of what you changed, or that nothing needed changing."""
@@ -0,0 +1,2 @@
Summarise this tool result for later recall. Keep facts, names, numbers and URLs. Drop boilerplate
and navigation. Do not use tools. Do not address the owner. Do not invent facts.
@@ -0,0 +1,52 @@
//! Tests for `LogRecord::ToolSummary`. Do not edit.
use bxcore::{CallId, DataClass, LogRecord, Timestamp};
fn ts() -> Timestamp {
Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
}
#[test]
fn tool_summary_round_trips() {
let rec = LogRecord::ToolSummary {
time: ts(),
call: CallId(3),
tool_call_id: "call_x".to_string(),
content: "the page is about tea".to_string(),
class: DataClass::Public,
untrusted: true,
};
let line = serde_json::to_string(&rec).unwrap();
assert!(line.contains("\"type\":\"tool_summary\""), "{line}");
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back, rec);
}
#[test]
fn unknown_fields_are_refused() {
let line = r#"{"type":"tool_summary","time":"2026-09-18T08:00:00.000Z","call":3,"tool_call_id":"c","content":"x","class":"public","untrusted":false,"extra":1}"#;
assert!(serde_json::from_str::<LogRecord>(line).is_err());
}
#[test]
fn field_order_is_the_struct_order() {
let rec = LogRecord::ToolSummary {
time: ts(),
call: CallId(1),
tool_call_id: "c".to_string(),
content: "x".to_string(),
class: DataClass::Private,
untrusted: false,
};
let line = serde_json::to_string(&rec).unwrap();
let time = line.find("\"time\"").unwrap();
let call = line.find("\"call\"").unwrap();
let id = line.find("\"tool_call_id\"").unwrap();
let content = line.find("\"content\"").unwrap();
let class = line.find("\"class\"").unwrap();
let untrusted = line.find("\"untrusted\"").unwrap();
assert!(
time < call && call < id && id < content && content < class && class < untrusted,
"{line}"
);
}
@@ -0,0 +1,86 @@
//! Tests for `bxctl memory tiers`. Do not edit.
#[path = "support/temp.rs"]
mod temp;
use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use bxcore::{Envelope, MemoryAdminReply, MemoryCommand, Message, read_frame, write_frame};
use bxctl::cli::{Command, UsageError, parse};
use bxctl::memory::{Ask, run};
fn args(line: &str) -> Vec<String> {
line.split_whitespace().map(str::to_string).collect()
}
fn home() -> PathBuf {
PathBuf::from("/srv/bx")
}
fn fake(socket: &Path, answer: Message) -> mpsc::Receiver<Message> {
let listener = UnixListener::bind(socket).unwrap();
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let env = read_frame(&mut stream).unwrap();
tx.send(env.msg).unwrap();
let reply = Envelope {
v: 1,
id: env.id,
r#final: true,
msg: answer,
};
write_frame(&mut stream, &reply).unwrap();
});
rx
}
#[test]
fn memory_tiers_parses() {
let h = home();
assert_eq!(
parse(&args("memory tiers"), &h),
Ok(Command::Memory {
socket: PathBuf::from("/srv/bx/run/owner-memory/admin.sock"),
ask: Ask::Admin(MemoryCommand::Tiers, String::new())
})
);
assert_eq!(
parse(&args("memory --memory-socket /r/m.sock tiers"), &h),
Ok(Command::Memory {
socket: PathBuf::from("/r/m.sock"),
ask: Ask::Admin(MemoryCommand::Tiers, String::new())
})
);
assert_eq!(parse(&args("memory tiers extra"), &h), Err(UsageError));
}
#[test]
fn memory_tiers_prints_the_reply() {
let d = temp::TempDir::new("bx-tiers");
let socket = d.join("admin.sock");
let listing = "identity memory.md hits 0 last -\n I like tea.\n";
let seen = fake(
&socket,
Message::MemoryAdminReply(MemoryAdminReply {
text: listing.to_string(),
}),
);
let mut out = Vec::new();
run(
&socket,
&Ask::Admin(MemoryCommand::Tiers, String::new()),
Duration::from_secs(5),
&mut out,
)
.unwrap();
assert_eq!(String::from_utf8(out).unwrap(), format!("{listing}\n"));
match seen.recv().unwrap() {
Message::MemoryAdmin(m) => {
assert_eq!((m.command, m.arg.as_str()), (MemoryCommand::Tiers, ""))
}
other => panic!("{other:?}"),
}
}
@@ -0,0 +1,110 @@
//! Tests for `[heartbeat]` and unknown `[compact] summarise` names. Do not edit.
use loopd::config::Config;
use loopd::tools::Registry;
use std::path::{Path, PathBuf};
fn fixture(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/config")
.join(name)
}
fn minimal() -> String {
std::fs::read_to_string(fixture("minimal.toml")).unwrap()
}
struct Scratch {
dir: PathBuf,
}
impl Scratch {
fn new() -> Scratch {
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("loopd-hb-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Scratch { dir }
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
fn missing_heartbeat_table_is_disabled() {
let c = Config::load(&fixture("minimal.toml")).unwrap();
assert_eq!(c.heartbeat.every_s, 0);
assert_eq!(
c.heartbeat_prompt(),
fixture("minimal.toml")
.parent()
.unwrap()
.join("heartbeat.md")
);
}
#[test]
fn the_table_overrides_every_default() {
let text = format!(
"{}\n[heartbeat]\nevery_s = 60\nprompt = \"pulse.md\"\n",
minimal()
);
let c = Config::parse(&text).unwrap();
assert_eq!(c.heartbeat.every_s, 60);
assert_eq!(c.heartbeat.prompt, PathBuf::from("pulse.md"));
}
#[test]
fn unknown_keys_are_errors() {
let bad = format!("{}\n[heartbeat]\nzz_unknown = 1\n", minimal());
assert!(Config::parse(&bad).is_err());
}
#[test]
fn heartbeat_prompt_is_beside_the_config_file() {
let home = Scratch::new();
let cfg_path = home.dir.join("loopd.toml");
std::fs::write(&cfg_path, minimal()).unwrap();
std::fs::write(home.dir.join("system.md"), "sys\n").unwrap();
let c = Config::load(&cfg_path).unwrap();
assert_eq!(c.heartbeat_prompt(), home.dir.join("heartbeat.md"));
}
#[test]
fn default_summarise_http_fetch_is_known_to_m5c() {
let c = Config::parse(&minimal()).unwrap();
c.check_summarise(&Registry::m5c()).unwrap();
}
#[test]
fn an_unknown_summarise_name_is_an_error() {
let text = format!("{}\n[compact]\nsummarise = [\"no_such_tool\"]\n", minimal());
let c = Config::parse(&text).unwrap();
let err = c.check_summarise(&Registry::m5c()).unwrap_err();
assert!(err.contains("no_such_tool"), "{err}");
assert!(err.contains("summarise"), "{err}");
}
#[test]
fn empty_summarise_is_allowed() {
let text = format!("{}\n[compact]\nsummarise = []\n", minimal());
let c = Config::parse(&text).unwrap();
c.check_summarise(&Registry::m5c()).unwrap();
}
#[test]
fn http_fetch_is_known_and_clock_is_known() {
let text = format!(
"{}\n[compact]\nsummarise = [\"http_fetch\", \"clock\"]\n",
minimal()
);
let c = Config::parse(&text).unwrap();
c.check_summarise(&Registry::m5c()).unwrap();
}
@@ -0,0 +1,85 @@
//! Tests for the five-field UTC cron matcher. Do not edit.
use bxcore::Timestamp;
use loopd::cron::{self, CronError};
fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
fn due(schedule: &str, at: &str) -> bool {
cron::due(schedule, ts(at)).unwrap()
}
#[test]
fn a_daily_hour_matches_that_minute_only() {
assert!(due("0 7 * * *", "2026-10-03T07:00:00.000Z"));
assert!(!due("0 7 * * *", "2026-10-03T07:01:00.000Z"));
assert!(!due("0 7 * * *", "2026-10-03T06:00:00.000Z"));
}
#[test]
fn every_minute_matches() {
assert!(due("* * * * *", "2026-10-03T00:00:00.000Z"));
assert!(due("* * * * *", "2026-10-03T23:59:00.000Z"));
}
#[test]
fn a_step_matches_multiples_from_zero() {
assert!(due("*/5 * * * *", "2026-10-03T00:00:00.000Z"));
assert!(due("*/5 * * * *", "2026-10-03T00:05:00.000Z"));
assert!(!due("*/5 * * * *", "2026-10-03T00:06:00.000Z"));
}
#[test]
fn a_range_is_inclusive() {
assert!(due("0-10 * * * *", "2026-10-03T00:00:00.000Z"));
assert!(due("0-10 * * * *", "2026-10-03T00:10:00.000Z"));
assert!(!due("0-10 * * * *", "2026-10-03T00:11:00.000Z"));
}
#[test]
fn sunday_is_zero() {
// 2026-10-04 is a Sunday.
assert!(due("0 7 * * 0", "2026-10-04T07:00:00.000Z"));
// 2026-10-03 is a Saturday.
assert!(!due("0 7 * * 0", "2026-10-03T07:00:00.000Z"));
}
#[test]
fn the_first_of_the_month() {
assert!(due("0 7 1 * *", "2026-10-01T07:00:00.000Z"));
assert!(!due("0 7 1 * *", "2026-10-02T07:00:00.000Z"));
}
#[test]
fn four_or_six_fields_are_errors() {
assert!(matches!(
cron::due("* * * *", ts("2026-10-03T00:00:00.000Z")),
Err(CronError::Fields(_))
));
assert!(matches!(
cron::due("* * * * * *", ts("2026-10-03T00:00:00.000Z")),
Err(CronError::Fields(_))
));
}
#[test]
fn an_out_of_range_number_is_an_error() {
assert!(cron::due("60 * * * *", ts("2026-10-03T00:00:00.000Z")).is_err());
assert!(cron::due("* 24 * * *", ts("2026-10-03T00:00:00.000Z")).is_err());
assert!(cron::due("* * * * 7", ts("2026-10-03T00:00:00.000Z")).is_err());
}
#[test]
fn nicknames_are_errors() {
let err = cron::due("@daily", ts("2026-10-03T00:00:00.000Z")).unwrap_err();
assert!(matches!(err, CronError::Fields(_)), "{err:?}");
}
#[test]
fn display_names_the_schedule() {
let err = cron::due("x x x x x", ts("2026-10-03T00:00:00.000Z")).unwrap_err();
let s = err.to_string();
assert!(s.contains("x x x x x"), "{s}");
}
@@ -0,0 +1,151 @@
//! Tests for job files and the run ledger. Do not edit.
use bxcore::{SessionId, Timestamp};
use loopd::jobs::{self, JobsError, RunStatus};
fn ts() -> Timestamp {
Timestamp::parse("2026-10-03T07:00:00.000Z").unwrap()
}
struct Scratch {
dir: std::path::PathBuf,
}
impl Scratch {
fn new() -> Scratch {
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("loopd-jobs-{}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
Scratch { dir }
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[test]
fn a_valid_job_file_loads() {
let home = Scratch::new();
let jobs = home.dir.join("jobs");
std::fs::create_dir(&jobs).unwrap();
std::fs::write(
jobs.join("mail.toml"),
"schedule = \"0 7 * * *\"\nprompt = \"Check mail.\"\n",
)
.unwrap();
let loaded = jobs::load(&jobs).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "mail");
assert_eq!(loaded[0].schedule, "0 7 * * *");
assert_eq!(loaded[0].prompt, "Check mail.");
}
#[test]
fn unknown_keys_are_errors_with_the_runbook() {
let home = Scratch::new();
let jobs = home.dir.join("jobs");
std::fs::create_dir(&jobs).unwrap();
std::fs::write(
jobs.join("mail.toml"),
"schedule = \"* * * * *\"\nprompt = \"x\"\nwhen = \"now\"\n",
)
.unwrap();
let err = jobs::load(&jobs).unwrap_err();
let s = err.to_string();
assert!(s.contains("mail.toml"), "{s}");
assert!(s.ends_with("see docs/runbook.md#jobs-invalid"), "{s}");
}
#[test]
fn a_bad_name_is_an_error() {
let home = Scratch::new();
let jobs = home.dir.join("jobs");
std::fs::create_dir(&jobs).unwrap();
std::fs::write(
jobs.join("Mail.toml"),
"schedule = \"* * * * *\"\nprompt = \"x\"\n",
)
.unwrap();
let err = jobs::load(&jobs).unwrap_err();
assert!(matches!(err, JobsError::Name(_)), "{err:?}");
}
#[test]
fn session_id_fits_and_is_lowercase() {
let id = jobs::session_id("mail", ts()).unwrap();
assert_eq!(id.as_str(), "job-mail-20261003t0700");
SessionId::new(id.as_str()).unwrap();
}
#[test]
fn a_name_too_long_for_a_session_id_is_an_error() {
let long = "n".repeat(50);
let err = jobs::session_id(&long, ts()).unwrap_err();
assert!(matches!(err, JobsError::Name(_)), "{err:?}");
}
#[test]
fn a_missing_jobs_directory_is_empty() {
let home = Scratch::new();
let loaded = jobs::load(&home.dir.join("jobs")).unwrap();
assert!(loaded.is_empty());
}
#[test]
fn leftover_started_is_closed_as_failed() {
let home = Scratch::new();
let path = home.dir.join("runs.jsonl");
let session = jobs::session_id("mail", ts()).unwrap();
jobs::append(
&path,
&jobs::Run {
time: ts(),
name: "mail".to_string(),
session: session.clone(),
status: RunStatus::Started,
},
)
.unwrap();
let n = jobs::close_leftovers(&path, ts()).unwrap();
assert_eq!(n, 1);
assert!(jobs::live_session(&path, "mail").unwrap().is_none());
}
#[test]
fn a_started_run_without_a_close_is_live() {
let home = Scratch::new();
let path = home.dir.join("runs.jsonl");
let session = jobs::session_id("mail", ts()).unwrap();
jobs::append(
&path,
&jobs::Run {
time: ts(),
name: "mail".to_string(),
session: session.clone(),
status: RunStatus::Started,
},
)
.unwrap();
assert_eq!(
jobs::live_session(&path, "mail").unwrap().as_ref(),
Some(&session)
);
jobs::append(
&path,
&jobs::Run {
time: ts(),
name: "mail".to_string(),
session,
status: RunStatus::Finished,
},
)
.unwrap();
assert!(jobs::live_session(&path, "mail").unwrap().is_none());
}
@@ -0,0 +1,186 @@
//! Tests for the cron tick. Do not edit.
mod support;
use bxcore::{SessionId, Timestamp};
use loopd::channel::Context;
use loopd::jobs::{self, RunStatus};
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::scheduler::{Scheduler, TickWork};
use loopd::tools::Registry;
use std::sync::Arc;
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
fn sse() -> Reply {
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"done"}}],"id":"j","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"j","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
fn world(schedule: &str) -> (Home, FakeServer, Arc<Context>) {
let home = Home::new();
home.write("compact.md", "Summarise.\n");
home.write(
"jobs/mail.toml",
&format!("schedule = \"{schedule}\"\nprompt = \"Check mail.\"\n"),
);
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse(), sse()]);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
(home, server, ctx)
}
#[test]
fn a_due_job_starts_a_new_session_on_the_background_slot() {
let (home, server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let want = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
assert_eq!(
work,
vec![TickWork::Job {
name: "mail".to_string(),
session: want.clone()
}]
);
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], 1);
let last = sent[0].json()["messages"]
.as_array()
.unwrap()
.last()
.unwrap();
let content = last["content"].as_str().unwrap();
assert!(
content.starts_with("It is 2026-10-03T07:00:00.000Z."),
"{content}"
);
assert!(content.contains("Check mail."), "{content}");
let s = loopd::session::Session::open(&home.dir, want).unwrap();
assert_eq!(s.slot(), 1);
}
#[test]
fn a_job_not_due_does_not_run() {
let (_home, server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T06:59:00.000Z"));
assert!(work.is_empty());
assert!(server.requests_to(CHAT).is_empty());
}
#[test]
fn a_live_previous_run_skips_the_tick() {
let (home, server, ctx) = world("* * * * *");
let ledger = home.dir.join("jobs/runs.jsonl");
let previous = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
jobs::append(
&ledger,
&jobs::Run {
time: ts("2026-10-03T06:59:00.000Z"),
name: "mail".to_string(),
session: previous.clone(),
status: RunStatus::Started,
},
)
.unwrap();
let held = ctx.hold(previous).expect("hold");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
assert_eq!(
work,
vec![TickWork::SkippedJob {
name: "mail".to_string()
}]
);
assert!(server.requests_to(CHAT).is_empty());
drop(held);
}
#[test]
fn leftover_started_is_failed_on_the_first_tick() {
let (home, _server, ctx) = world("* * * * *");
let ledger = home.dir.join("jobs/runs.jsonl");
let previous = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
jobs::append(
&ledger,
&jobs::Run {
time: ts("2026-10-03T06:59:00.000Z"),
name: "mail".to_string(),
session: previous.clone(),
status: RunStatus::Started,
},
)
.unwrap();
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let text = home.read("jobs/runs.jsonl");
assert!(
text.contains("failed"),
"leftover started was closed: {text}"
);
assert!(text.contains(previous.as_str()), "{text}");
}
#[test]
fn a_cron_turn_has_no_speaker() {
let (home, _server, ctx) = world("0 7 * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
let id = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
let recs = {
let text = home.read(&format!("sessions/{}/0.jsonl", id.as_str()));
text.lines()
.map(|l| serde_json::from_str::<bxcore::LogRecord>(l).unwrap())
.collect::<Vec<_>>()
};
let user = recs
.iter()
.find_map(|r| match r {
bxcore::LogRecord::User { speaker, .. } => Some(speaker),
_ => None,
})
.unwrap();
assert_eq!(user, &None);
}
#[test]
fn two_due_minutes_make_two_sessions() {
let (home, server, ctx) = world("* * * * *");
let mut sched = Scheduler::new(ts("2026-10-03T06:58:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T06:59:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
assert_eq!(server.requests_to(CHAT).len(), 2);
let a = jobs::session_id("mail", ts("2026-10-03T06:59:00.000Z")).unwrap();
let b = jobs::session_id("mail", ts("2026-10-03T07:00:00.000Z")).unwrap();
assert_ne!(a, b);
assert!(
home.dir
.join(format!("sessions/{}/0.jsonl", a.as_str()))
.is_file()
);
assert!(
home.dir
.join(format!("sessions/{}/0.jsonl", b.as_str()))
.is_file()
);
}
@@ -0,0 +1,148 @@
//! Tests for the heartbeat tick. Do not edit.
mod support;
use bxcore::{SessionId, Timestamp};
use loopd::channel::Context;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::scheduler::{self, Scheduler, TickWork};
use loopd::tools::Registry;
use std::sync::Arc;
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
const PROMPT: &str = "Look around.\n";
fn sse() -> Reply {
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"noted"}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
fn world(every_s: u64) -> (Home, FakeServer, Arc<Context>) {
let home = Home::new();
home.write("heartbeat.md", PROMPT);
home.write("compact.md", "Summarise.\n");
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.heartbeat.every_s = every_s;
cfg.heartbeat.prompt = home.dir.join("heartbeat.md");
server.route(CHAT, vec![sse()]);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
(home, server, ctx)
}
#[test]
fn every_s_zero_does_nothing() {
let (_home, server, ctx) = world(0);
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T08:00:00.000Z"));
assert!(work.is_empty());
assert!(server.requests_to(CHAT).is_empty());
}
#[test]
fn a_due_heartbeat_runs_on_the_background_slot_with_a_time_line() {
let (home, server, ctx) = world(60);
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
assert_eq!(work, vec![TickWork::Heartbeat]);
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], 1);
let last = sent[0].json()["messages"]
.as_array()
.unwrap()
.last()
.unwrap();
assert_eq!(last["role"], "user");
let content = last["content"].as_str().unwrap();
assert!(
content.starts_with("It is 2026-10-03T07:01:00.000Z."),
"{content}"
);
assert!(content.contains("Look around."), "{content}");
let s = loopd::session::Session::open(&home.dir, SessionId::new("heartbeat").unwrap()).unwrap();
assert_eq!(s.slot(), 1);
}
#[test]
fn a_second_tick_before_every_s_does_not_run() {
let (_home, server, ctx) = world(60);
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:30.000Z"));
assert!(work.is_empty());
assert_eq!(server.requests_to(CHAT).len(), 1);
}
#[test]
fn a_busy_heartbeat_session_is_skipped() {
let (_home, server, ctx) = world(60);
let held = ctx
.hold(SessionId::new("heartbeat").unwrap())
.expect("hold");
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
assert_eq!(
work,
vec![TickWork::SkippedBusy {
session: SessionId::new("heartbeat").unwrap()
}]
);
assert!(server.requests_to(CHAT).is_empty());
drop(held);
}
#[test]
fn an_unreadable_prompt_is_skipped_and_names_the_runbook() {
let home = Home::new();
let path = home.dir.join("heartbeat.md");
std::fs::create_dir(&path).unwrap();
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.heartbeat.every_s = 60;
cfg.heartbeat.prompt = path;
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
assert_eq!(work, vec![TickWork::HeartbeatUnreadable]);
assert!(server.requests_to(CHAT).is_empty());
}
#[test]
fn heartbeat_has_no_speaker() {
let (home, _server, ctx) = world(60);
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
let recs = home.records("heartbeat");
let user = recs
.iter()
.find_map(|r| match r {
bxcore::LogRecord::User { speaker, .. } => Some(speaker),
_ => None,
})
.unwrap();
assert_eq!(user, &None);
}
@@ -0,0 +1,170 @@
//! Tests that a live turn uses the session's slot. Do not edit.
mod support;
use bxcore::SessionId;
use loopd::baseline::Baseline;
use loopd::llama::Client;
use loopd::session::Session;
use loopd::tools::Registry;
use loopd::turn::{Runtime, run_turn};
use support::{FakeServer, Home, Reply};
const CHAT: &str = "/v1/chat/completions";
fn sse() -> Reply {
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"ok"}}],"id":"t","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"t","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn runtime<'a>(
cfg: &'a loopd::config::Config,
client: &'a Client,
port: &'a support::ScriptedPort,
registry: &'a Registry,
) -> Runtime<'a> {
Runtime {
cfg,
client,
port,
registry,
}
}
#[test]
fn a_session_created_on_the_background_slot_sends_that_slot() {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse()]);
let client = Client::new(cfg.clone());
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let baseline = Baseline::assemble(&cfg, &registry).unwrap();
let mut s = Session::create(
&home.dir,
SessionId::new("heartbeat").unwrap(),
baseline,
cfg.slots.background,
)
.unwrap();
let mut events = Vec::new();
run_turn(
&mut s,
&runtime(&cfg, &client, &port, &registry),
"pulse",
None,
&mut |e| events.push(e.clone()),
)
.unwrap();
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], cfg.slots.background);
}
#[test]
fn a_main_slot_session_still_sends_main() {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse()]);
let client = Client::new(cfg.clone());
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let baseline = Baseline::assemble(&cfg, &registry).unwrap();
let mut s = Session::create(
&home.dir,
SessionId::new("mm-thread").unwrap(),
baseline,
cfg.slots.main,
)
.unwrap();
let mut events = Vec::new();
run_turn(
&mut s,
&runtime(&cfg, &client, &port, &registry),
"hi",
None,
&mut |e| events.push(e.clone()),
)
.unwrap();
assert_eq!(
server.requests_to(CHAT)[0].json()["id_slot"],
cfg.slots.main
);
}
#[test]
fn the_request_slot_follows_session_start_not_the_config_main() {
let home = Home::new();
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.slots.main = 0;
cfg.slots.background = 1;
server.route(CHAT, vec![sse()]);
let client = Client::new(cfg.clone());
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let baseline = Baseline::assemble(&cfg, &registry).unwrap();
let mut s = Session::create(&home.dir, SessionId::new("a").unwrap(), baseline, 7).unwrap();
let mut events = Vec::new();
run_turn(
&mut s,
&runtime(&cfg, &client, &port, &registry),
"x",
None,
&mut |e| events.push(e.clone()),
)
.unwrap();
assert_eq!(server.requests_to(CHAT)[0].json()["id_slot"], 7);
}
#[test]
fn a_heartbeat_turn_between_two_main_turns_does_not_change_the_main_prefix() {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse(), sse(), sse()]);
let client = Client::new(cfg.clone());
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let mut main = Session::create(
&home.dir,
SessionId::new("mm-a").unwrap(),
Baseline::assemble(&cfg, &registry).unwrap(),
cfg.slots.main,
)
.unwrap();
let mut beat = Session::create(
&home.dir,
SessionId::new("heartbeat").unwrap(),
Baseline::assemble(&cfg, &registry).unwrap(),
cfg.slots.background,
)
.unwrap();
let rt = runtime(&cfg, &client, &port, &registry);
let mut events = Vec::new();
run_turn(&mut main, &rt, "one", None, &mut |e| events.push(e.clone())).unwrap();
run_turn(&mut beat, &rt, "pulse", None, &mut |e| {
events.push(e.clone())
})
.unwrap();
run_turn(&mut main, &rt, "two", None, &mut |e| events.push(e.clone())).unwrap();
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 3);
assert_eq!(sent[0].json()["id_slot"], 0);
assert_eq!(sent[1].json()["id_slot"], 1);
assert_eq!(sent[2].json()["id_slot"], 0);
let first = sent[0].json()["messages"].as_array().unwrap().clone();
let third = sent[2].json()["messages"].as_array().unwrap();
assert!(
third.len() > first.len(),
"the main session's second turn is a longer prefix"
);
assert_eq!(&third[..first.len()], &first[..]);
}
@@ -0,0 +1,98 @@
//! A turn-loop setup shared by the `turn` and `limits` tests: a home, a fake server, a scripted
//! port and a registry. Included with `#[path]` because it needs `loopd::turn`, which the earlier
//! tasks' tests must not depend on. Do not edit.
#![allow(dead_code)]
use crate::support::{FakeServer, Home, ScriptedPort};
use bxcore::{LogRecord, SessionId, ToolResponse, TurnEvent};
use loopd::baseline::Baseline;
use loopd::llama::Client;
use loopd::session::Session;
use loopd::tools::Registry;
use loopd::turn::{Runtime, TurnError, run_turn};
pub struct Setup {
pub home: Home,
pub server: FakeServer,
pub cfg: loopd::config::Config,
pub client: Client,
pub port: ScriptedPort,
pub registry: Registry,
}
pub fn setup(replies: Vec<ToolResponse>) -> Setup {
let home = Home::new();
let server = FakeServer::start();
let cfg = home.config(&server.socket);
let client = Client::new(cfg.clone());
Setup {
home,
server,
cfg,
client,
port: ScriptedPort::new(replies),
registry: Registry::m2b(),
}
}
impl Setup {
pub fn session(&self, id: &str) -> Session {
let baseline = Baseline::assemble(&self.cfg, &self.registry).unwrap();
Session::create(
&self.home.dir,
SessionId::new(id).unwrap(),
baseline,
self.cfg.slots.main,
)
.unwrap()
}
pub fn runtime(&self) -> Runtime<'_> {
Runtime {
cfg: &self.cfg,
client: &self.client,
port: &self.port,
registry: &self.registry,
}
}
pub fn turn(
&self,
session: &mut Session,
text: &str,
) -> (Result<loopd::turn::TurnOutcome, TurnError>, Vec<TurnEvent>) {
let mut events = Vec::new();
let result = run_turn(session, &self.runtime(), text, None, &mut |e| {
events.push(e.clone())
});
(result, events)
}
}
/// The recordings were made in separate conversations, so their timings do not line up and the
/// loop rightly reports cache losses between them. Most tests are not about that, so `types`
/// and `records` leave `CacheLoss` out; one test checks it on purpose.
pub fn without_cache_loss(records: &[LogRecord]) -> Vec<LogRecord> {
records
.iter()
.filter(|r| !matches!(r, LogRecord::CacheLoss { .. }))
.cloned()
.collect()
}
pub fn types(records: &[LogRecord]) -> Vec<&'static str> {
without_cache_loss(records)
.iter()
.map(|r| match r {
LogRecord::SessionStart { .. } => "start",
LogRecord::User { .. } => "user",
LogRecord::Assistant { .. } => "assistant",
LogRecord::Usage { .. } => "usage",
LogRecord::ToolResult { .. } => "tool_result",
LogRecord::ToolSummary { .. } => "tool_summary",
LogRecord::CacheLoss { .. } => "cache_loss",
LogRecord::EpochEnd { .. } => "epoch_end",
})
.collect()
}
@@ -0,0 +1,241 @@
//! Tests for the opted-in tool-output summary. Do not edit.
mod support;
use bxcore::{CallId, DataClass, LogRecord, SessionId, ToolCall};
use loopd::baseline::Baseline;
use loopd::compact;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::session::Session;
use loopd::summarise;
use loopd::tools::Registry;
use loopd::turn::{Runtime, run_turn};
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
const SUMMARISE: &str = "Summarise this tool result.\n";
fn sse(content: &str) -> Reply {
let content_json = serde_json::to_string(content).unwrap();
let data1 = format!(
r#"data: {{"choices":[{{"finish_reason":null,"index":0,"delta":{{"content":{content_json}}}}}],"id":"s","timings":{{"cache_n":0,"prompt_n":10,"predicted_n":1}}}}"#
);
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"s","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn ts() -> bxcore::Timestamp {
bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
}
fn assistant(name: &str) -> LogRecord {
LogRecord::Assistant {
time: ts(),
content: None,
reasoning_content: None,
tool_calls: vec![ToolCall {
id: "call_f".to_string(),
name: name.to_string(),
arguments: "{}".to_string(),
}],
}
}
fn result(untrusted: bool) -> LogRecord {
LogRecord::ToolResult {
time: ts(),
call: CallId(1),
tool_call_id: "call_f".to_string(),
content: "the fetched page is about tea".to_string(),
class: DataClass::Public,
untrusted,
truncated: false,
}
}
fn session(home: &Home, cfg: &loopd::config::Config) -> Session {
let registry = Registry::m2b();
Session::create(
&home.dir,
SessionId::new("a").unwrap(),
Baseline::assemble(cfg, &registry).unwrap(),
cfg.slots.main,
)
.unwrap()
}
#[test]
fn an_opted_in_result_gets_a_summary_on_the_background_slot() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("tea page")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], 1);
assert!(sent[0].json().get("tools").is_none());
assert!(
s.records().iter().any(|r| matches!(r, LogRecord::ToolSummary { content, call, untrusted, .. } if content == "tea page" && *call == CallId(1) && !*untrusted)),
"{:?}",
s.records()
);
}
#[test]
fn the_summary_copies_the_result_labels() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("x")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(true)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
match s.records().last() {
Some(LogRecord::ToolSummary {
class,
untrusted,
tool_call_id,
..
}) => {
assert_eq!(*class, DataClass::Public);
assert!(*untrusted);
assert_eq!(tool_call_id, "call_f");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_tool_not_in_summarise_is_left_alone() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("clock")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
assert!(server.requests_to(CHAT).is_empty());
assert!(
!s.records()
.iter()
.any(|r| matches!(r, LogRecord::ToolSummary { .. }))
);
}
#[test]
fn a_result_that_already_has_a_summary_is_not_summarised_again() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("once")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
assert_eq!(server.requests_to(CHAT).len(), 1);
assert_eq!(
s.records()
.iter()
.filter(|r| matches!(r, LogRecord::ToolSummary { .. }))
.count(),
1
);
}
#[test]
fn messages_stay_the_same_after_a_summary() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("tea page")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
let before = loopd::baseline::messages(s.baseline(), s.records());
summarise::missing(&cfg, &client, &mut s).unwrap();
let after = loopd::baseline::messages(s.baseline(), s.records());
assert_eq!(before, after);
}
#[test]
fn after_a_turn_an_opted_in_call_is_summarised() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
home.write("compact.md", "Summarise.\n");
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["read_file".to_string()];
server.route(
CHAT,
vec![
Reply::fixture("tool_call"),
Reply::fixture("plain"),
sse("hosts file"),
],
);
let client = Client::new(cfg.clone());
let port = ScriptedPort::new(vec![support::ok_result("127.0.0.1 localhost\n")]);
let registry = Registry::m2b();
let mut s = Session::create(
&home.dir,
SessionId::new("a").unwrap(),
Baseline::assemble(&cfg, &registry).unwrap(),
cfg.slots.main,
)
.unwrap();
let rt = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(&mut s, &rt, "hosts?", None, &mut |e| events.push(e.clone()));
compact::finish_turn(
&mut s,
&rt,
&NoMemory,
"hosts?",
None,
&mut |e| events.push(e.clone()),
outcome,
)
.unwrap();
assert!(
s.records()
.iter()
.any(|r| matches!(r, LogRecord::ToolSummary { .. })),
"{:?}",
s.records()
);
assert_eq!(
server.requests_to(CHAT).last().unwrap().json()["id_slot"],
1
);
}
@@ -0,0 +1,88 @@
//! Tests that `messages()` ignores `ToolSummary`. Do not edit.
use bxcore::{CallId, DataClass, Epoch, Hash32, LogRecord, SessionId, Timestamp};
use loopd::baseline::{Baseline, messages};
use loopd::llama::ChatMessage;
use loopd::llama::ToolSchema;
fn ts() -> Timestamp {
Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
}
fn baseline() -> Baseline {
Baseline {
system: "sys".to_string(),
tools: vec![ToolSchema {
name: "clock".to_string(),
description: "t".to_string(),
parameters: serde_json::json!({"type": "object", "properties": {}}),
}],
}
}
fn user(content: &str) -> LogRecord {
LogRecord::User {
time: ts(),
content: content.to_string(),
speaker: None,
}
}
fn result() -> LogRecord {
LogRecord::ToolResult {
time: ts(),
call: CallId(1),
tool_call_id: "c".to_string(),
content: "raw body".to_string(),
class: DataClass::Public,
untrusted: false,
truncated: false,
}
}
fn summary() -> LogRecord {
LogRecord::ToolSummary {
time: ts(),
call: CallId(1),
tool_call_id: "c".to_string(),
content: "a short account".to_string(),
class: DataClass::Public,
untrusted: false,
}
}
#[test]
fn messages_ignore_tool_summary() {
let start = LogRecord::SessionStart {
time: ts(),
session: SessionId::new("a").unwrap(),
epoch: Epoch(0),
slot: 0,
baseline: Hash32::ZERO,
};
let without = messages(&baseline(), &[start.clone(), user("hi"), result()]);
let with = messages(&baseline(), &[start, user("hi"), result(), summary()]);
assert_eq!(without, with);
assert!(
!with
.iter()
.any(|m| matches!(m, ChatMessage::User { content } if content == "a short account")),
"{with:?}"
);
}
#[test]
fn a_summary_is_not_replayed_as_a_tool_message() {
let msgs = messages(&baseline(), &[summary()]);
assert_eq!(msgs.len(), 1, "only the system message");
assert!(matches!(msgs[0], ChatMessage::System { .. }));
}
#[test]
fn inserting_a_summary_does_not_break_prefix_extension() {
let a = vec![user("hi"), result()];
let b = vec![user("hi"), result(), summary(), user("next")];
let ma = messages(&baseline(), &a);
let mb = messages(&baseline(), &b);
assert_eq!(&mb[..ma.len()], &ma[..]);
}
@@ -0,0 +1,122 @@
//! Tests for `MemoryCommand::Tiers`: identity entries and notes with recall-hit counts. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use bxcore::{DataClass, MemoryFile, Timestamp};
use memoryd::files::Store;
use memoryd::tiers;
use tmp::TempDir;
fn now() -> Timestamp {
Timestamp::parse("2026-10-03T08:00:00.000Z").unwrap()
}
struct Setup {
dir: TempDir,
store: Store,
}
fn setup(tag: &str) -> Setup {
let dir = TempDir::new(tag);
let starter = dir.write("etc/soul.md", "Soul.\n");
let store = Store::new(&dir.path().join("memory"));
store.init(&starter).unwrap();
Setup { dir, store }
}
fn write_recall(store: &Store, line: &str) {
std::fs::create_dir_all(store.path("recalls")).unwrap();
store
.append("recalls/2026-10.jsonl", &format!("{line}\n"))
.unwrap();
}
#[test]
fn identity_entries_list_with_zero_hits() {
let s = setup("tiers-id");
s.store
.write_entries(MemoryFile::Memory, &["I like tea.".to_string()])
.unwrap();
s.store
.write_entries(MemoryFile::User, &["The owner is Kyle.".to_string()])
.unwrap();
let text = tiers::list(&s.store, now()).unwrap();
assert!(
text.contains("identity memory.md hits 0 last -"),
"{text}"
);
assert!(text.contains("I like tea."), "{text}");
assert!(text.contains("identity user.md hits 0 last -"), "{text}");
assert!(text.contains("The owner is Kyle."), "{text}");
}
#[test]
fn notes_carry_counts_from_recalls() {
let s = setup("tiers-notes");
std::fs::write(
s.store.path("notes.md"),
"§ noted 2026-09-22T18:20Z in session mm-a · private\nlaunch window moved.\n",
)
.unwrap();
write_recall(
&s.store,
r#"{"time":"2026-10-03T07:00:00.000Z","session":"mm-now","query":"launch","hits":[{"kind":"noted","session":"mm-a","epoch":0,"record":0}]}"#,
);
write_recall(
&s.store,
r#"{"time":"2026-10-03T07:30:00.000Z","session":"mm-now","query":"window","hits":[{"kind":"noted","session":"mm-a","epoch":0,"record":0}]}"#,
);
let text = tiers::list(&s.store, now()).unwrap();
assert!(
text.contains(
"note session mm-a hits 2 last 2026-10-03T07:30:00.000Z private trusted"
),
"{text}"
);
assert!(text.contains("launch window moved."), "{text}");
}
#[test]
fn untrusted_notes_are_listed_and_marked() {
let s = setup("tiers-untrusted");
std::fs::write(
s.store.path("notes.md"),
"§ noted 2026-09-22T18:20Z in session mm-c · private · untrusted\nexample.org says the launch moved.\n",
)
.unwrap();
let text = tiers::list(&s.store, now()).unwrap();
assert!(
text.contains("note session mm-c hits 0 last - private untrusted"),
"{text}"
);
assert!(
text.contains("example.org says the launch moved."),
"{text}"
);
}
#[test]
fn a_recall_of_a_different_note_does_not_count() {
let s = setup("tiers-other");
std::fs::write(
s.store.path("notes.md"),
"§ noted 2026-09-22T18:20Z in session mm-a · private\nfirst note.\n§ noted 2026-09-22T18:21Z in session mm-b · private\nsecond note.\n",
)
.unwrap();
write_recall(
&s.store,
r#"{"time":"2026-10-03T07:00:00.000Z","session":"mm-now","query":"second","hits":[{"kind":"noted","session":"mm-b","epoch":0,"record":1}]}"#,
);
let text = tiers::list(&s.store, now()).unwrap();
assert!(
text.contains("note session mm-a hits 0 last - private trusted"),
"{text}"
);
assert!(
text.contains(
"note session mm-b hits 1 last 2026-10-03T07:00:00.000Z private trusted"
),
"{text}"
);
}
@@ -0,0 +1,191 @@
//! Tests that `ToolSummary` is indexed as kind `summary` and found by `recall`. Do not edit.
#[path = "support/logs.rs"]
mod logs;
#[path = "support/tmp.rs"]
mod tmp;
use bxcore::{CallId, DataClass, LogRecord, RecallArgs, Timestamp};
use logs::{assistant, result, user, write_log};
use memoryd::files::Store;
use memoryd::index::Index;
use memoryd::recall::recall;
use tmp::TempDir;
fn now() -> Timestamp {
Timestamp::parse("2026-09-24T21:00:00.000Z").unwrap()
}
fn summary(minute: u32, content: &str, untrusted: bool) -> LogRecord {
LogRecord::ToolSummary {
time: logs::at(minute),
call: CallId(1),
tool_call_id: "c0".to_string(),
content: content.to_string(),
class: DataClass::Public,
untrusted,
}
}
struct Setup {
dir: TempDir,
store: Store,
index: Index,
}
fn setup(tag: &str) -> Setup {
let dir = TempDir::new(tag);
let starter = dir.write("etc/soul.md", "Soul.\n");
let store = Store::new(&dir.path().join("memory"));
store.init(&starter).unwrap();
let index = Index::open(&dir.path().join("index/memory.sqlite3"), DataClass::Private).unwrap();
Setup { dir, store, index }
}
fn ask(s: &Setup, query: &str) -> memoryd::recall::Recalled {
let args = RecallArgs {
query: query.to_string(),
k: 10,
};
recall(&s.index, &s.store, &args, "mm-now", now()).unwrap()
}
#[test]
fn a_tool_summary_is_found_as_kind_summary() {
let s = setup("summary-found");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch the launch page"),
assistant(
3,
None,
&[("http_fetch", r#"{"url":"https://example.org"}"#)],
),
result(4, "raw html body about tea", DataClass::Public, false),
summary(5, "the page is about tea and the launch window", false),
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "tea");
assert!(
got.text
.contains("the page is about tea and the launch window"),
"{got:?}"
);
assert!(
got.text.contains("memory · session mm-a"),
"tagged as a session hit: {}",
got.text
);
assert!(!got.untrusted);
}
#[test]
fn the_raw_tool_result_is_still_not_indexed() {
let s = setup("summary-no-raw");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch"),
assistant(3, None, &[("http_fetch", "{}")]),
result(4, "UNIQUE_RAW_BODY_XYZ", DataClass::Public, false),
summary(5, "a short account of the page", false),
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "UNIQUE_RAW_BODY_XYZ");
assert!(
got.text.contains("(no matches"),
"raw tool result stays unindexed: {}",
got.text
);
}
#[test]
fn an_untrusted_summary_taints_the_recall() {
let s = setup("summary-taint");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch"),
assistant(3, None, &[("http_fetch", "{}")]),
result(4, "page", DataClass::Public, true),
summary(5, "the fetched page mentions the launch window", true),
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "launch");
assert!(got.untrusted, "{got:?}");
assert!(got.text.contains("memory-tainted"), "{}", got.text);
}
#[test]
fn a_trusted_summary_does_not_taint() {
let s = setup("summary-trusted");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch"),
assistant(3, None, &[("http_fetch", "{}")]),
result(4, "page", DataClass::Public, false),
summary(5, "the fetched page mentions the launch window", false),
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "launch");
assert!(!got.untrusted, "{got:?}");
assert!(got.text.contains("memory · session mm-a"), "{}", got.text);
assert!(!got.text.contains("memory-tainted"), "{}", got.text);
}
#[test]
fn a_secret_summary_is_skipped_when_the_index_is_private() {
let s = setup("summary-secret");
write_log(
s.dir.path(),
"mm-a",
&[
user(2, "fetch"),
assistant(3, None, &[("http_fetch", "{}")]),
LogRecord::ToolResult {
time: logs::at(4),
call: CallId(1),
tool_call_id: "c0".to_string(),
content: "secret page",
class: DataClass::Secret,
untrusted: false,
truncated: false,
},
LogRecord::ToolSummary {
time: logs::at(5),
call: CallId(1),
tool_call_id: "c0".to_string(),
content: "SECRET_SUMMARY_PHRASE",
class: DataClass::Secret,
untrusted: false,
},
],
);
s.index
.catch_up(&s.dir.path().join("sessions"), &s.store.path("notes.md"))
.unwrap();
let got = ask(&s, "SECRET_SUMMARY_PHRASE");
assert!(
got.text.contains("(no matches"),
"secret summary above max_class: {}",
got.text
);
}
@@ -0,0 +1,25 @@
## heartbeat-prompt-unreadable
**What you see.** `loopd` is serving, but heartbeat turns do not run. It logs this line once,
until the file reads:
```text
loopd: heartbeat.md: Permission denied (os error 13); see docs/runbook.md#heartbeat-prompt-unreadable
```
**Why.** `[heartbeat] prompt` is next to the config file (default `heartbeat.md`). The scheduler
skips the tick rather than send an empty or guessed user message. A missing file is the same:
restore the starter from `config/heartbeat.md` in the repository.
**Confirm.**
```sh
ls -l "$(dirname "$LOOPD_CONFIG")/heartbeat.md"
```
**Fix.** Restore mode `0644` (or whatever the owner uses for the other prompts) and content.
The next tick after a successful read logs nothing extra and runs the standing `heartbeat`
session.
**Check.** `sessions/heartbeat/0.jsonl` gains a new `User` whose text starts with `It is ` and an
RFC 3339 timestamp.
@@ -0,0 +1,24 @@
## jobs-invalid
**What you see.** `loopd` does not start. It prints a line naming `{home}/jobs/<name>.toml` and
this entry:
```text
loopd: jobs/mail.toml: unknown field `when`, expected `schedule` or `prompt`; see docs/runbook.md#jobs-invalid
```
**Why.** Job files are owner-written. `loopd` reads them and cannot write them. Unknown keys, a
name that is not `[a-z0-9-]+`, a name so long that `job-<name>-<yyyymmdd>t<hhmm>` is not a valid
session id (64 bytes, `[a-z0-9-]`), a schedule that is not five UTC fields, or a missing
`prompt`, all refuse start so a misspelt job cannot sit silently.
**Confirm.**
```sh
ls -l "$BOXMAKER_HOME/jobs/"
```
**Fix.** Rename or edit the named file so it matches the example in `config/jobs/tier.toml.example`.
Then start `loopd` again.
**Check.** `loopd serve` prints `serving on` and `{home}/jobs/runs.jsonl` exists (empty is fine).