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>
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 1551 (1545 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 1551 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 1551; 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.