Files
boxmaker/docs/plans/M5d2/README.md
T
kyleandGrok 4.6 3940588ce7 docs: M5d-2 task 11, failed heartbeat still advances last_heartbeat
Review finding 1: last_heartbeat and TickWork::Heartbeat were gated on
outcome.is_ok(). The given test fails today with left: [] / right: [Heartbeat].

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 18:20:47 -07:00

145 lines
8.3 KiB
Markdown

# 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 **1508** (straylight, rustc 1.98.1 / clippy 0.1.98; M5d-1 task 08).
| # | Delivers | Crates | Gate after | Tests added | Mutations tried, caught |
|---|---|---|---|---|---|
| 01 | `run_turn` sends `session.slot()`; a session created on the background slot has `id_slot` of that slot; `mm-*` stays main | loopd | 1512 | 4 | 1, 1 |
| 02 | `LogRecord::ToolSummary`; `messages()` ignores it; `types` names it; JSON round-trip | bxcore, loopd | 1518 | 6 | 1, 1 |
| 03 | `[heartbeat]` (`every_s` 0, `prompt` `heartbeat.md`); unknown `[compact] summarise` names are a config error; starter `heartbeat.md` | loopd | 1526 | 8 | 1, 1 |
| 04 | Five-field UTC cron matcher: `*`, `n`, `n-m`, `*/n`; day-of-week 0 is Sunday | loopd | 1537 | 11 | 1, 1 |
| 05 | `{home}/jobs/<name>.toml`, `runs.jsonl`, `jobs-invalid` pointer and runbook | loopd | 1545 | 8 | 1, 1 |
| 06 | Heartbeat tick: time line, standing session `heartbeat`, skip if busy, unreadable prompt logged once | loopd | 1551 | 6 | 1, 1 |
| 07 | Cron tick: new session per run, skip if previous live, leftover `started` becomes `failed` | loopd | 1557 | 6 | 1, 1 |
| 08 | After a turn, opted-in `ToolResult` without a `ToolSummary` gets a background summary appended with the result's labels | loopd | 1563 | 6 | 2, 2 |
| 09 | `memoryd` indexes `ToolSummary` as kind `summary`; `recall` finds it and taints when untrusted | memoryd | 1568 | 5 | 1, 1 |
| 10 | `MemoryCommand::Tiers`, `bxctl memory tiers`, starter `config/jobs/tier.toml.example` | bxcore, memoryd, bxctl | 1574 | 6 | 1, 1 |
| 11 | A failed heartbeat turn still sets `last_heartbeat` and pushes `Heartbeat` | loopd | 1575 | 1 | 1, 1 |
After task 11, 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).
- A failed heartbeat turn still advances `last_heartbeat`; the next tick before `every_s` does
not retry (task 11).
## 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=llama.cpp/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 11, the gate count is 1575. Then the device check above.
Task 11 is the M5d-2 review follow-up (finding 1). From the repository root, with the OpenCode
TUI closed:
```sh
OPENCODE_MODEL=llama.cpp/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 11
```