# M6a task 04: heartbeat and cron yield to a held subagent **Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short` is empty; otherwise stop. Never switch branch. **Commit subject:** `loopd: scheduler yields to a held subagent` **Log row:** the Task cell is `M6a/04-loopd-scheduler-skip`. **First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`. Do not Read `tests/support/`, `turn.rs`, `session.rs`, `docs/implementer-log.md` (except the first 12 lines at the log step). The quoted blocks are the only edits. ```sh cp docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs crates/loopd/tests/ ``` ## Goal While any held session id starts with `sub-`, a due heartbeat does not start and a due cron job does not start (spec 3.3). `SlotGate` is FIFO; skipping here is what stops heartbeat and cron from jumping a child that is waiting on the background slot. Drop the hold and the next `tick` runs them. Leftovers still close on the first tick. Compaction already holds its session. Tests call `tick(ctx, now)`; no sleeps (T27). ## Every place that changes | Place | What happens there | |---|---| | `crates/loopd/src/channel.rs` | `holds_prefix` | | `crates/loopd/src/scheduler.rs` | skip heartbeat after due; skip jobs after heartbeat returns | | `crates/loopd/tests/scheduler_subagent.rs` | new, given | | everything else | **no change**: not `subagent.rs`, not `brokerd` | ## Files - Copy, from `docs/plans/M6a/files/` (the same path under it): the new test `crates/loopd/tests/scheduler_subagent.rs`. - Modify: `crates/loopd/src/channel.rs`, `crates/loopd/src/scheduler.rs`, `docs/implementer-log.md`. - Do not touch: any other file. Do not edit `crates/loopd/tests/scheduler_heartbeat.rs` or `scheduler_cron.rs`. ## Facts you need - **`busy` is private.** Tests cannot see the set. `holds_prefix` locks it the same way `hold` does (`unwrap_or_else(|p| p.into_inner())`) and returns whether any id `as_str().starts_with(prefix)`. - **Two skips, both required.** Heartbeat runs *before* the job loop. A skip only after `heartbeat(...)` still lets a due heartbeat start. A skip only inside `heartbeat` still lets a due cron job start. The given tests cover each. - **Where in `heartbeat`.** After the `if !due { return; }` block, before `SessionId::new("heartbeat")`. `every_s == 0` still returns earlier. Busy-skip of the `heartbeat` session itself is unchanged. - **Where in `tick`.** Immediately after `self.heartbeat(...)`, before `jobs_dir`. `return work` so leftover-close on this first tick is already done. - **Prefix `sub-`.** A hold of `heartbeat` or `job-…` does not skip. The given tests hold `sub-mm-thread-1`. - The items the edits use. This table is complete. | Item | Signature | |---|---| | `Context::hold` | already `pub`; the given tests keep the `Held` | | `SessionId::as_str` | `as_str(&self) -> &str` | | `Scheduler::tick` | `tick(&mut self, ctx: &Context, now: Timestamp) -> Vec` | ## The edits, exactly Each quoted "before" block occurs exactly once after task 03. ### `crates/loopd/src/channel.rs` **H1.** After `hold`'s closing `}` and still inside `impl Context`, add: ```rust /// True when any held session id starts with `prefix` (`sub-` for a live child). pub fn holds_prefix(&self, prefix: &str) -> bool { let busy = self.busy.lock().unwrap_or_else(|p| p.into_inner()); busy.iter().any(|id| id.as_str().starts_with(prefix)) } ``` The unique anchor is the end of `hold`: ```rust busy.insert(id.clone()); drop(busy); Ok(Held { ctx: self, id }) } } ``` becomes that same `hold` body, then `holds_prefix`, then the `impl` closing `}`. ### `crates/loopd/src/scheduler.rs` **K1.** In `tick`, the lines ```rust self.heartbeat(ctx, now, &mut work); let jobs_dir = ctx.cfg.paths.home.join("jobs"); ``` become ```rust self.heartbeat(ctx, now, &mut work); if ctx.holds_prefix("sub-") { return work; } let jobs_dir = ctx.cfg.paths.home.join("jobs"); ``` **K2.** In `heartbeat`, the lines ```rust if !due { return; } let id = match SessionId::new("heartbeat") { ``` become ```rust if !due { return; } if ctx.holds_prefix("sub-") { return; } let id = match SessionId::new("heartbeat") { ``` ## Steps - [ ] **1. Copy the given test.** ```sh cp docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs crates/loopd/tests/ ``` - [ ] **2. See it fail.** `cargo test -p loopd --test scheduler_subagent`, timeout 600000 ms. The tests compile (`hold` exists). Both fail: `work` is not empty (a `Heartbeat` or a `Job`) and `requests_to(CHAT)` is not empty while the child is held. - [ ] **3. The edits.** H1, K2, K1. `cargo check -p loopd` prints no error and no warning. `wc -l crates/loopd/src/channel.rs` and `wc -l crates/loopd/src/scheduler.rs` each ≤ 500. - [ ] **4. The tests.** `cargo test -p loopd --test scheduler_subagent --test scheduler_heartbeat --test scheduler_cron --test scheduler_heartbeat_failed`: every test passes, 2 in `scheduler_subagent`, 6 in `scheduler_heartbeat`, 6 in `scheduler_cron`, 1 in `scheduler_heartbeat_failed`. - [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line `gate: ok`. Test count **1587** (1585 before, plus 2). After `gate: ok`, do not add up `test result:` lines. - [ ] **6. Log and commit.** Task cell `M6a/04-loopd-scheduler-skip`. Read only the first 12 lines of `docs/implementer-log.md`; insert the row under the header. 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 1587 tests. - `git diff HEAD~1 --stat` lists only `crates/loopd/src/channel.rs`, `crates/loopd/src/scheduler.rs`, `crates/loopd/tests/scheduler_subagent.rs` and the log. ## Stop and report if - The branch check fails; a "before" block is missing or duplicated; step 2 does not compile, or only one of the two tests fails, or they fail for a different assertion; `cargo check` warns; a heartbeat / cron / failed-heartbeat test fails; you skip only one of K1/K2; a test outside the named files fails in the gate (`git stash -u`, rerun, `git stash pop`, report both). ## What the given tests pin A held `sub-mm-thread-1` makes a due heartbeat tick return empty work and send nothing, then after `drop(held)` the next due tick is `Heartbeat` and one chat request; a held `sub-*` likewise skips a due `0 7 * * *` job at 07:00, then the same timestamp after the drop runs the job.