session_slot and the scheduler tests held a temporary JSON Value across statements (E0716). Cron now pins DOM and DOW as AND, which the given tests had not. Gate counts from task 04 on move up by one. Co-Authored-By: Grok 4.6 <noreply@x.ai>
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 istools/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)
- Heartbeat and cron answers stay in the session log. They are not posted to Mattermost.
- Five-field UTC crontab,
{home}/jobs/<name>.toml, a new session each run, a tick skipped when that job is still live. - Tool summaries on the background slot; a
ToolSummarylog record with the result's labels, FTS-indexed;http_fetchopts 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] summariseis stored and unused. Unknown names are not a config error. LogRecordhas noToolSummary.messages()names every variant.support/turn.rstypesis exhaustive.loopd servebinds the channel and blocks inchannel::serve. There is no scheduler thread.MemoryCommandhas noTiers.bxctl memoryhas notiers.SessionIdis[a-z0-9-], max 64. Job idsjob-<name>-<yyyymmdd>t<hhmm>must fit.
Global constraints
- Roles depend on
bxcoreonly. Nounsafe, 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-invalidandheartbeat-prompt-unreadablehave 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.lockis in thegit addline (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.rssotypesnamesToolSummary.
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 |
|---|---|---|---|---|
| 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 |
| 02 | LogRecord::ToolSummary; messages() ignores it; types names it; JSON round-trip |
bxcore, loopd | 1518 | 6 |
| 03 | [heartbeat] (every_s 0, prompt heartbeat.md); unknown [compact] summarise names are a config error; starter heartbeat.md |
loopd | 1526 | 8 |
| 04 | Five-field UTC cron matcher: *, n, n-m, */n; day-of-week 0 is Sunday |
loopd | 1537 | 11 |
| 05 | {home}/jobs/<name>.toml, runs.jsonl, jobs-invalid pointer and runbook |
loopd | 1545 | 8 |
| 06 | Heartbeat tick: time line, standing session heartbeat, skip if busy, unreadable prompt logged once |
loopd | 1551 | 6 |
| 07 | Cron tick: new session per run, skip if previous live, leftover started becomes failed |
loopd | 1557 | 6 |
| 08 | After a turn, opted-in ToolResult without a ToolSummary gets a background summary appended with the result's labels |
loopd | 1563 | 6 |
| 09 | memoryd indexes ToolSummary as kind summary; recall finds it and taints when untrusted |
memoryd | 1568 | 5 |
| 10 | MemoryCommand::Tiers, bxctl memory tiers, starter config/jobs/tier.toml.example |
bxcore, memoryd, bxctl | 1574 | 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 sendsslots.mainafter heartbeat used the background slot (task 01). - Inserting a
ToolSummarybetween aToolResultand a laterUserdoes not break prefix extension (task 02). - An unknown
[compact] summarisename is a config error; an empty list is not (task 03). - DOM and DOW are AND;
@dailyand a sixth field are errors (task 04). - A leftover
startedwhose session is currently held is not closed asfailed(tasks 06, 07). finish_turnsummarises before it compacts, so theToolResultis still in the current epoch (task 08).- A secret
ToolSummaryis skipped when the index is private (task 09). - Untrusted notes appear in
Tiersand 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 withbxctl chat --sessionor 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 howmissingstill sees the current epoch'sToolResult. A second live turn on that session waits. The summary request itself uses the background slot and omitstools. A later split that infers without the hold is out of scope. - Job files are checked at start. A file that becomes invalid after
loopdis running is skipped that tick and logged; it does not stop the process. - Identity entries in
Tierslist with 0 hits unless arecalls/line names that exact index row. Notes carry counts fromrecalls/. 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:
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.