# 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/.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--t` 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/.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 | 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//` 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 10, the gate count is 1574. Then the device check above.