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>
217 lines
9.1 KiB
Markdown
217 lines
9.1 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(())`;
|
|
else insert and return `Held`. `Held` must be `pub` so the given test can keep it. `Drop`
|
|
already removes the id.
|
|
- **Due.** `every_s == 0` → no heartbeat work. 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`.
|
|
|
|
**C2.** Inside `impl Context`, after `new`, add:
|
|
|
|
```rust
|
|
/// 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.
|
|
|
|
```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.
|
|
|
|
## 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`.
|