Eight closed tasks for epochs and compaction, then ten for the scheduler, ToolSummary, and tiers. Given tests first. Gate 1456 → 1507 → 1572. Co-Authored-By: Grok 4.6 <noreply@x.ai>
9.1 KiB
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 ticks 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 testcrates/loopd/tests/scheduler_heartbeat.rs. - Append, from
docs/plans/M5d2/files/runbook-heartbeat-prompt-unreadable.md, todocs/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 ashandle: if the id is already inbusy,Err(()); else insert and returnHeld.Heldmust bepubso the given test can keep it.Dropalready removes the id. -
Due.
every_s == 0→ no heartbeat work. Elsenow.unix_millis().saturating_sub(last.unix_millis()) >= every_s.saturating_mul(1000)wherelastislast_heartbeat.unwrap_or(started).07:00to07:01withevery_s = 60is due.07:01then07:01:30is not. -
Prompt first, then hold. If
read_to_string(cfg.heartbeat_prompt())fails, do not hold, pushHeartbeatUnreadable, logloopd: {path}: {e}; see docs/runbook.md#heartbeat-prompt-unreadableonly whileheartbeat_unreadable_loggedis false, then set that flag. Do not updatelast_heartbeat. A directory at the prompt path is the given-test stand-in for an unreadable file (T27: nochmod 000). -
Session id
heartbeat.Session::open;NotFound→Session::createwithBaseline::assemble(&ctx.cfg, &ctx.registry)andctx.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())wherefileis the prompt contents as read (no extra trim beyond what the file has).run_turnthencompact::finish_turnwith&*ctx.memory, speakerNone,on_eventa no-op. Thenlast_heartbeat = Some(now), pushHeartbeat. -
TickWork. IncludeJob/SkippedJobnow so task 07 does not change the type. Task 06 never pushes them. -
close_leftovers. On the firsttickonly, calljobs::close_leftovers(&ctx.cfg.paths.home.join("jobs").join("runs.jsonl"), now)and ignore a missing-fileOk(0). A leftover that is currentlyholding its session is not closed: skip names whoselive_sessionsession is inbusy(or: close only whenholdof that session would succeed, then immediatelydropthat hold). Task 07's live-skip test depends on this. Ifclose_leftovershas no busy-set filter, add a loop here: for each live name, ifhold(session)isOk, drop it and treat as leftover (append Failed yourself or call a helper). Simplest: do not callclose_leftoversblindly. Walk: iflive_sessionisSome(id)andhold(id)isOk, drop the hold and append aFailedfor that name (or callclose_leftoversonly 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::newand beforechannel::serve: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.rsstays ≤ 500 lines.holdis about 15 lines. -
The items the edits use. This table is complete.
Item What it does Session::slot/create/openstanding session run_turn/finish_turnthe pulse Baseline::assemblenew session jobs::close_leftovers/live_session/appendleftovers Timestamp::unix_millis/to_rfc3339due and the time line
The edits, exactly
crates/loopd/src/channel.rs
C1. struct Held becomes pub struct Held.
C2. Inside impl Context, after new, add:
/// Mark `id` busy. `Err(())` 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<'_>, ()> {
let mut busy = self.busy.lock().unwrap_or_else(|p| p.into_inner());
if busy.contains(&id) {
return Err(());
}
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.
//! 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.
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 targetscheduler_heartbeatdoes not compile (E0432unresolvedloopd::scheduler,E0599no methodhold). Every other target compiles. -
3. The edits. C1, C2,
scheduler.rs,pub mod scheduler;, main thread. Filltick.cargo check -p loopdprints no error and no warning.wc -l crates/loopd/src/scheduler.rsandwc -l crates/loopd/src/channel.rseach ≤ 500. -
4. The tests.
cargo test -p loopd --test scheduler_heartbeat --test channel --test session_slot: every test passes, 6 inscheduler_heartbeat. -
5. The runbook.
{ echo; cat docs/plans/M5d2/files/runbook-heartbeat-prompt-unreadable.md; } >> docs/runbook.mdgrep -c '^## heartbeat-prompt-unreadable$' docs/runbook.mdprints1. -
6. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1549 (1543 before, plus 6). -
7. Log and commit. Task cell
M5d2/06-loopd-scheduler-heartbeat. Stage withgit add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1549 tests.git diff HEAD~1 --statlists onlycrates/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.mdand the log.
Stop and report if
- The branch check fails; a "before" block is not found exactly once; step 2 differs;
cargo checkwarns; a test fails; the gate count is not 1549; you sleep in a test; you post to Mattermost; you sendslots.main; you set a speaker; youchmod 000;Heldstays private.
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.