185 lines
6.6 KiB
Markdown
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.
|