sync_parses requires the M5c memory USAGE line. U1 inserts "| tiers", so the task now edits that substring. 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.