Files
boxmaker/docs/plans/M6a/04-loopd-scheduler-skip.md
T
2026-10-03 19:39:35 -07:00

185 lines
6.6 KiB
Markdown

# 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<TickWork>` |
## 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.