The second 06 session listed tests/, read eight loopd modules and support/mod.rs, grepped bxcore, then hit Context size exceeded with no commit. The cp is now the first action; tick is glue over two todo!() bodies; run-plan tells the session to run step 1 before reading crate source. Co-Authored-By: Grok 4.6 <noreply@x.ai>
262 lines
12 KiB
Markdown
262 lines
12 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`.
|
|
|
|
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
|
|
Do not Read `tests/support/`, `session.rs`, `turn.rs`, `compact.rs`, `config.rs`, `jobs.rs`,
|
|
`baseline.rs`, `bxcore`, or `docs/implementer-log.md`. The signatures below are complete.
|
|
|
|
```sh
|
|
cp docs/plans/M5d2/files/crates/loopd/tests/scheduler_heartbeat.rs crates/loopd/tests/
|
|
```
|
|
|
|
## 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.
|
|
|
|
At step 7, read only the first 12 lines of `docs/implementer-log.md`. The rest of that file
|
|
fills the session (~360 kB).
|
|
|
|
## 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.
|
|
- **Uses.** The skeleton compiles with only `Context` and `Timestamp`. When filling `heartbeat`,
|
|
add `use std::fs`, `Baseline`, `compact`, `Session`/`SessionError`, `Runtime`/`run_turn`,
|
|
`TurnEvent`. When filling leftovers, add `jobs::{self, Run, RunStatus}`.
|
|
- The items the edits use. This table is complete.
|
|
|
|
| Item | Signature |
|
|
|---|---|
|
|
| `Session::open` | `open(home: &Path, id: SessionId) -> Result<Session, SessionError>` |
|
|
| `Session::create` | `create(home, id, baseline, slot: u32) -> Result<Session, SessionError>` |
|
|
| `SessionError::NotFound` | `_` |
|
|
| `Baseline::assemble` | `assemble(&ctx.cfg, &ctx.registry) -> Result<Baseline, _>` |
|
|
| `Runtime` | `{ cfg: &ctx.cfg, client: &ctx.client, port: &*ctx.port, registry: &ctx.registry }` |
|
|
| `run_turn` | `run_turn(&mut session, &runtime, &text, None, &mut emit)` |
|
|
| `compact::finish_turn` | `finish_turn(&mut session, &runtime, &*ctx.memory, &text, None, &mut emit, outcome)` |
|
|
| `jobs::load` | `load(dir: &Path) -> Result<Vec<Job>, JobsError>` |
|
|
| `jobs::live_session` | `live_session(ledger, name) -> Result<Option<SessionId>, _>` |
|
|
| `jobs::append` | `append(ledger, &Run { time, name, session, status })` |
|
|
| `RunStatus::Failed` | leftover close |
|
|
| `Timestamp` | `unix_millis() -> u64`, `to_rfc3339() -> String`, `now()` |
|
|
|
|
## 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. `tick` is already glue. Fill `heartbeat` then `close_idle_leftovers`,
|
|
`cargo check -p loopd` after each (T25). Do not iterate job files (task 07). Do not add
|
|
`run_jobs`.
|
|
|
|
```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 mut work = Vec::new();
|
|
if !self.leftovers_closed {
|
|
close_idle_leftovers(ctx, now);
|
|
self.leftovers_closed = true;
|
|
}
|
|
self.heartbeat(ctx, now, &mut work);
|
|
work
|
|
}
|
|
|
|
fn heartbeat(&mut self, ctx: &Context, now: Timestamp, work: &mut Vec<TickWork>) {
|
|
let _ = (ctx, now, work);
|
|
todo!()
|
|
}
|
|
}
|
|
|
|
fn close_idle_leftovers(ctx: &Context, now: Timestamp) {
|
|
let _ = (ctx, now);
|
|
todo!()
|
|
}
|
|
```
|
|
|
|
## 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` (fill `heartbeat` then `close_idle_leftovers`,
|
|
`cargo check -p loopd` after each), `pub mod scheduler;`, main thread. `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). After `gate: ok`, do not add up
|
|
`test result:` lines.
|
|
- [ ] **7. Log and commit.** Task cell `M5d2/06-loopd-scheduler-heartbeat`. Read only the first
|
|
12 lines of `docs/implementer-log.md`; insert the row under the header. 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's last line is not `gate: ok`; 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`.
|