# 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--t` 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 **1556** (1550 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 1556 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 1556; 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.