Files
boxmaker/docs/plans/M5d2/06-loopd-scheduler-heartbeat.md
T
kyleandGrok 4.6 72e87076bc docs: M5d-2 checklist: retain, Tally, mutation counts
Task 08 filters pending after the records walk so a ToolResult
before its ToolSummary is not summarised twice. Task 10 names
Tally to satisfy clippy type_complexity. Task 06 does not
return from tick when every_s is 0. README records 12/12
mutations caught.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 02:42:06 -07:00

225 lines
9.5 KiB
Markdown

# 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(Busy)`;
else insert and return `Held`. `Held` must be `pub` so the given test can keep it. `Drop`
already removes the id. Do not return `Result<_, ()>`: clippy `result_unit_err` is denied.
- **Due.** `every_s == 0` → no heartbeat work (do not `return` from `tick`; task 07 runs jobs after this). 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`. Immediately after that struct (before
`impl Drop for Held`), add:
```rust
/// The session is already in the busy set.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Busy;
```
**C2.** Inside `impl Context`, after `new`, add:
```rust
/// Mark `id` busy. `Err(Busy)` 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<'_>, Busy> {
let mut busy = self.busy.lock().unwrap_or_else(|p| p.into_inner());
if busy.contains(&id) {
return Err(Busy);
}
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 **1551** (1545 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 1551 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 1551; you sleep in a test; you post to
Mattermost; you send `slots.main`; you set a speaker; you `chmod 000`; `Held` stays private;
`hold` returns `Result<_, ()>`.
## 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`.