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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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, ®istry).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, ®istry),
|
||||
"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, ®istry).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, ®istry),
|
||||
"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, ®istry).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, ®istry),
|
||||
"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, ®istry).unwrap(),
|
||||
cfg.slots.main,
|
||||
)
|
||||
.unwrap();
|
||||
let mut beat = Session::create(
|
||||
&home.dir,
|
||||
SessionId::new("heartbeat").unwrap(),
|
||||
Baseline::assemble(&cfg, ®istry).unwrap(),
|
||||
cfg.slots.background,
|
||||
)
|
||||
.unwrap();
|
||||
let rt = runtime(&cfg, &client, &port, ®istry);
|
||||
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, ®istry).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, ®istry).unwrap(),
|
||||
cfg.slots.main,
|
||||
)
|
||||
.unwrap();
|
||||
let rt = Runtime {
|
||||
cfg: &cfg,
|
||||
client: &client,
|
||||
port: &port,
|
||||
registry: ®istry,
|
||||
};
|
||||
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).
|
||||
Reference in New Issue
Block a user