Files
boxmaker/docs/plans/M5d2/07-loopd-scheduler-cron.md
T
kyleandGrok 4.6 df987c3058 docs: M5d-2 checklist: given-test lifetimes and cron AND
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>
2026-10-03 01:31:07 -07:00

108 lines
5.1 KiB
Markdown

# M5d-2 task 07: cron 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: cron tick, new session per run`
**Log row:** the Task cell is `M5d2/07-loopd-scheduler-cron`.
## Goal
After the heartbeat work, `tick` loads `{home}/jobs/*.toml` and runs each job whose schedule
matches `now` (spec 4.2). A new session `job-<name>-<yyyymmdd>t<hhmm>` on the background slot.
Skip the tick when a previous run of that name is still live. Leftover `started` from a dead
process becomes `failed` on the first tick (already started in task 06: only when that session
is not busy). Speaker `None`. Same time-line prefix as heartbeat.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/scheduler.rs` | after heartbeat, iterate jobs (edit T1) |
| `crates/loopd/tests/scheduler_cron.rs` | new, given |
| everything else | **no change**: not `TickWork`, not `hold`, not heartbeat |
## Files
- Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test
`crates/loopd/tests/scheduler_cron.rs`.
- Modify: `crates/loopd/src/scheduler.rs`, `docs/implementer-log.md`.
- Do not touch: any other file.
## Facts you need
- **Load.** `jobs::load(&ctx.cfg.paths.home.join("jobs"))`. On `Err`, log `loopd: {e}` and
skip cron this tick (the process stays up: README known limits). On `Ok`, for each `Job` in
order:
1. `cron::due(&job.schedule, now)`. `Err` → log and skip that job. `Ok(false)` → skip.
2. Ledger path `{home}/jobs/runs.jsonl`. If `live_session` is `Some(_)`, push
`SkippedJob { name }` and continue. (Task 06 has already closed leftovers that were not
busy.)
3. `session_id(&job.name, now)`. `Err` → log and skip.
4. `ctx.hold(session.clone())`. `Err` → `SkippedJob`. `Ok(held)` → create the session
(`Session::create` with `assemble` and `slots.background`; if `Exists`, `open` instead),
`jobs::append` `Started`, run `run_turn` then `finish_turn` with user text
`format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt)`, speaker `None`, then
`append` `Finished` or `Failed` (turn `Err` → `Failed`). `drop(held)`. Push
`TickWork::Job { name, session }`.
- **Two due minutes.** Two ticks at 06:59 and 07:00 with `* * * * *` make two session ids.
- **Not due.** `0 7 * * *` at 06:59 pushes nothing.
- **`scheduler.rs` stays ≤ 500 lines.** After this task it is about 200.
- The items the edits use. This table is complete.
| Item | What it does |
|---|---|
| `jobs::load` / `session_id` / `append` / `live_session` | task 05 |
| `cron::due` | task 04 |
| `Context::hold` | task 06 |
| `run_turn` / `finish_turn` | same as heartbeat |
## The edits, exactly
There is no quoted "before" for a unique heartbeat-only block: you add a loop after the
heartbeat section of `tick`, before `work` is returned. If `tick` currently ends with
`work` after heartbeat, insert the job loop there. If you cannot find a single place to
insert without touching heartbeat, stop.
Do not change heartbeat behaviour. Do not add a sleep.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M5d2/files/crates/loopd/tests/scheduler_cron.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo test -p loopd --test scheduler_cron`, timeout 600000 ms: the
tests compile (`TickWork::Job` already exists) and `a_due_job_starts_a_new_session_on_the_background_slot`
fails: `work` is empty, not a `Job`. `a_job_not_due_does_not_run` may pass.
- [ ] **3. The edit.** Add the job loop. `cargo check -p loopd` prints no error and no
warning. `wc -l crates/loopd/src/scheduler.rs` prints a number ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test scheduler_cron --test scheduler_heartbeat
--test jobs --test cron`: every test passes, 6 in `scheduler_cron`.
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/loopd/src/scheduler.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line
`gate: ok`. Test count **1557** (1551 before, plus 6).
- [ ] **7. Log and commit.** Task cell `M5d2/07-loopd-scheduler-cron`. Stage with
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1557 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/scheduler.rs`,
`crates/loopd/tests/scheduler_cron.rs` and the log.
## Stop and report if
- The branch check fails; step 2 differs (tests do not compile, or a different assertion
fails); `cargo check` warns; a test fails; the gate count is not 1557; you reuse a session
across runs; you send `slots.main`; you set a speaker; you skip leftovers that are not busy;
a heartbeat test fails.
## What the given tests pin
A due job creates `job-mail-…` on slot 1 with the time line and the prompt; a job not due
does nothing; a live previous run (Started + `hold`) is `SkippedJob` with no request; leftover
`started` is `failed` on the first tick (the due job may then run); speaker is `None`; two due
minutes make two sessions.