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

6.6 KiB

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.

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:

    /// 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:

        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

        self.heartbeat(ctx, now, &mut work);

        let jobs_dir = ctx.cfg.paths.home.join("jobs");

become

        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

        if !due {
            return;
        }

        let id = match SessionId::new("heartbeat") {

become

        if !due {
            return;
        }
        if ctx.holds_prefix("sub-") {
            return;
        }

        let id = match SessionId::new("heartbeat") {

Steps

  • 1. Copy the given test.

    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.