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

17 KiB
Raw Blame History

M6a task 03: spawn a child on the background slot

Branch: m6a. Check it: git branch --show-current prints m6a and git status --short is empty; otherwise stop. Never switch branch. Commit subject: loopd: subagent spawn on the background slot Log row: the Task cell is M6a/03-loopd-subagent-spawn.

First action. Run this before any Read of crate source. Do not ls crates/loopd/tests/. Do not Read tests/support/ except the one Runtime literal this task names. Do not Read docs/implementer-log.md except the first 12 lines at the log step. The signatures below are complete.

cp docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs crates/loopd/tests/

Goal

A subagent call holds a child sub-<parent>-<n> (n is the CallId already consumed for this call), runs one turn on slots.background with a fresh baseline and the M5d time line, and returns a ToolResult {id} completed\n{summary} or {id} failed (spec 3). The parent stays in this tool call. Nested subagent is the same path. A parent id that cannot fit the child is invalid-arguments naming subagent-id.

Runtime gains ctx: Option<&Context> so spawn can hold. Tests that do not spawn leave it None. Every existing Runtime { literal must name the new field or the crate does not compile.

Every place that changes

Place What happens there
crates/loopd/src/turn.rs Runtime.ctx
crates/loopd/src/channel.rs live Runtime passes Some(&*ctx)
crates/loopd/src/scheduler.rs both Runtime literals pass Some(ctx)
crates/loopd/src/subagent.rs replace the stub run
docs/runbook.md append subagent-id
nine test Runtime { literals ctx: None
crates/loopd/tests/subagent_spawn.rs new, given
everything else no change: not the scheduler skip (task 04), not brokerd (task 05)

Files

  • Copy, from docs/plans/M6a/files/ (the same path under it): the new test crates/loopd/tests/subagent_spawn.rs.
  • Append, from docs/plans/M6a/files/runbook-subagent-id.md, to docs/runbook.md.
  • Modify: crates/loopd/src/turn.rs, crates/loopd/src/channel.rs, crates/loopd/src/scheduler.rs, crates/loopd/src/subagent.rs, crates/loopd/tests/support/turn.rs, crates/loopd/tests/session_slot.rs, crates/loopd/tests/end_to_end.rs, crates/loopd/tests/turn_broker.rs, crates/loopd/tests/tool_summary_call.rs, crates/loopd/tests/compact_turn.rs, docs/implementer-log.md.
  • Do not touch: any other file.

At the log-and-commit step, read only the first 12 lines of docs/implementer-log.md.

Facts you need

  • CallId is already consumed. run_turn calls session.next_call() then run_call. The child's n is that call_id, not next_call again. First call on mm-thread is sub-mm-thread-1. A nested call from that child is sub-sub-mm-thread-1-1.
  • Overflow. SessionId max 64. Build raw = format!("sub-{}-{}", parent.id().as_str(), call_id.0) first. SessionId::new Err → denial_message(InvalidArguments, Some(&format!("child session id {raw} would not fit; {RUNBOOK}"))) with RUNBOOK = "see docs/runbook.md#subagent-id". Do not hold.
  • rt.ctx. None (unit tests that never set it) → {id} failed. hold Busy → {id} failed. Memory block / assemble_with errors → drop the hold, {id} failed.
  • Baseline. ctx.memory.block() then Baseline::assemble_with(rt.cfg, rt.registry, &block), not assemble. Speaker None.
  • User text. Cap task with cap_result(task, rt.cfg.r#loop.tool_result_cap). Then format!("It is {}.\n\n{task}", Timestamp::now().to_rfc3339()) — same shape as heartbeat, current time (the given tests only check the prefix It is and that the task string is in the child user message).
  • Session. Session::create(..., slots.background). Exists → Session::open. Other errors → drop hold, {id} failed.
  • The turn. run_turn(&mut session, rt, &text, None, on_event) then compact::finish_turn(..., &*ctx.memory, ..., on_event, outcome). Then ctx.memory.updated(session.id(), session.turns()) (ignore the result). Read session.untrusted() before drop(held).
  • Result. Ok(done) → format!("{} completed\n{summary}", child.as_str()) where summary = compact::cap_chars(neutralise(&done.content).as_ref(), rt.cfg.compact.summary_chars). Err(_) (including TurnLimit with pending tool_calls) → format!("{} failed", ...). Class Public. The untrusted flag is the child's, after the turn.
  • Reuse the same Runtime. The child turn uses rt, so it can spawn a grandchild (rt.ctx is still the live context). Nested is the same wait path; no new queue.
  • Some(&*ctx) vs Some(ctx). handle has Arc<Context>; deref to &Context. The scheduler already holds &Context.
  • Every Runtime { in crates/loopd. Adding a field without filling a literal is E0063. Production: Some. Tests that do not spawn: None. The given spawn test already writes ctx: Some(ctx).
  • cap_chars is pub(crate) in compact.rs. neutralise returns Cow<'_, str>.
  • rustfmt may wrap Session::create. That is fine.
  • The items the edits use. This table is complete.
    Item Signature
    Context::hold hold(&self, id: SessionId) -> Result<Held<'_>, Busy>
    MemorySource::block block(&self) -> Result<String, String>
    MemorySource::updated updated(&self, session: &SessionId, turn: u64) -> Result<(), String>
    Baseline::assemble_with assemble_with(cfg, registry, memory: &str) -> Result<Baseline, _>
    Session::create create(home, id, baseline, slot: u32) -> Result<Session, SessionError>
    Session::open open(home, id) -> Result<Session, SessionError>
    SessionError::Exists _
    run_turn run_turn(&mut Session, &Runtime, &str, Option<&str>, &mut dyn FnMut(&TurnEvent))
    compact::finish_turn finish_turn(&mut Session, &Runtime, &dyn MemorySource, &str, Option<&str>, &mut dyn FnMut(&TurnEvent), Result<TurnOutcome, TurnError>)
    compact::cap_chars cap_chars(s: &str, n: usize) -> String
    cap_result cap_result(text: &str, cap: usize) -> (String, bool)
    denial_message as task 02
    Timestamp::now now() -> Timestamp; to_rfc3339() -> String
    Session::untrusted untrusted(&self) -> bool
    Session::turns turns(&self) -> u64
    Session::id id(&self) -> &SessionId

The edits, exactly

Each quoted "before" block occurs exactly once after task 02.

crates/loopd/src/turn.rs

R1. The lines

pub struct Runtime<'a> {
    pub cfg: &'a Config,
    pub client: &'a Client,
    pub port: &'a dyn ToolPort,
    pub registry: &'a Registry,
}

become

pub struct Runtime<'a> {
    pub cfg: &'a Config,
    pub client: &'a Client,
    pub port: &'a dyn ToolPort,
    pub registry: &'a Registry,
    /// Present on a live `loopd` turn so `subagent` can hold the child. Tests that do not spawn
    /// leave this `None`.
    pub ctx: Option<&'a crate::channel::Context>,
}

Production Runtime literals: ctx: Some(...)

C1. In crates/loopd/src/channel.rs, the lines

    let runtime = Runtime {
        cfg: &ctx.cfg,
        client: &ctx.client,
        port: &*ctx.port,
        registry: &ctx.registry,
    };

    let mut session = if turn.resume {

become the same struct with ctx: Some(&*ctx), after registry.

S1. In crates/loopd/src/scheduler.rs, the cron literal (the one followed by job.prompt)

            let runtime = Runtime {
                cfg: &ctx.cfg,
                client: &ctx.client,
                port: &*ctx.port,
                registry: &ctx.registry,
            };
            let text = format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt);

gains ctx: Some(ctx), after registry.

S2. In the same file, the heartbeat literal (the one followed by {file})

        let runtime = Runtime {
            cfg: &ctx.cfg,
            client: &ctx.client,
            port: &*ctx.port,
            registry: &ctx.registry,
        };

        let text = format!("It is {}.\n\n{file}", now.to_rfc3339());

gains ctx: Some(ctx), after registry.

Test Runtime literals: ctx: None

Add ctx: None, after registry in each of these, using the unique surrounding lines.

U1. crates/loopd/tests/support/turn.rs

    pub fn runtime(&self) -> Runtime<'_> {
        Runtime {
            cfg: &self.cfg,
            client: &self.client,
            port: &self.port,
            registry: &self.registry,
        }
    }

U2. crates/loopd/tests/compact_turn.rs helper (not pub)

    fn runtime(&self) -> Runtime<'_> {
        Runtime {
            cfg: &self.cfg,
            client: &self.client,
            port: &self.port,
            registry: &self.registry,
        }
    }

U3. crates/loopd/tests/session_slot.rs

) -> Runtime<'a> {
    Runtime {
        cfg,
        client,
        port,
        registry,
    }
}

U4. crates/loopd/tests/end_to_end.rs

    let runtime = Runtime {
        cfg: &cfg,
        client: &client,
        port: &port,
        registry: &registry,
    };
    let mut events = Vec::new();
    let outcome = run_turn(
        &mut session,
        &runtime,
        "what is this host called?",

U5. crates/loopd/tests/turn_broker.rs

    let runtime = loopd::turn::Runtime {
        cfg: &s.cfg,
        client: &s.client,
        port: &ReturnsPendingPort,
        registry: &s.registry,
    };

U6. crates/loopd/tests/tool_summary_call.rs

    let rt = Runtime {
        cfg: &cfg,
        client: &client,
        port: &port,
        registry: &registry,
    };
    let mut events = Vec::new();
    let outcome = run_turn(&mut s, &rt, "hosts?", None, &mut |e| events.push(e.clone()));

U7. crates/loopd/tests/compact_turn.rs second literal

    let rt = Runtime {
        cfg: &cfg,
        client: &client,
        port: &port,
        registry: &registry,
    };
    let mut events = Vec::new();
    let outcome = run_turn(&mut s, &rt, "hi", None, &mut |e| events.push(e.clone()));

There are ten Runtime { literals in crates/loopd after task 02 (three production, seven test). After this task every one names ctx. If cargo check -p loopd reports E0063 missing field ctx, the file it names is one of the rows above; fill it, do not search the tree.

crates/loopd/src/subagent.rs

Replace the stub run (the whole function task 02 added, from /// Spawn is task 03 through its closing }) with the body below. Add pub const RUNBOOK next to SUBAGENT. Add the use lines the body needs (Baseline, Busy, compact, Session / SessionError, Dispatch is already used, cap_result, denial_message, Runtime, run_turn, neutralise, CallId, DataClass, DenyReason, SessionId, Timestamp, TurnEvent). rustfmt decides use order.

pub const RUNBOOK: &str = "see docs/runbook.md#subagent-id";

/// Spawn the child, run one turn on the background slot, and return the parent's tool text.
pub fn run(
    rt: &Runtime<'_>,
    parent: &Session,
    task: &str,
    call_id: CallId,
    on_event: &mut dyn FnMut(&TurnEvent),
) -> (String, DataClass, bool) {
    let raw = format!("sub-{}-{}", parent.id().as_str(), call_id.0);
    let child = match SessionId::new(&raw) {
        Ok(id) => id,
        Err(_) => {
            return (
                denial_message(
                    DenyReason::InvalidArguments,
                    Some(&format!(
                        "child session id `{raw}` would not fit; {RUNBOOK}"
                    )),
                ),
                DataClass::Public,
                false,
            );
        }
    };

    let Some(ctx) = rt.ctx else {
        return failed(&child);
    };

    let held = match ctx.hold(child.clone()) {
        Ok(held) => held,
        Err(Busy) => return failed(&child),
    };

    let block = match ctx.memory.block() {
        Ok(block) => block,
        Err(_) => {
            drop(held);
            return failed(&child);
        }
    };
    let baseline = match Baseline::assemble_with(rt.cfg, rt.registry, &block) {
        Ok(baseline) => baseline,
        Err(_) => {
            drop(held);
            return failed(&child);
        }
    };

    let (task, _) = cap_result(task, rt.cfg.r#loop.tool_result_cap);
    let text = format!("It is {}.\n\n{task}", Timestamp::now().to_rfc3339());

    let mut session = match Session::create(
        &rt.cfg.paths.home,
        child.clone(),
        baseline,
        rt.cfg.slots.background,
    ) {
        Ok(session) => session,
        Err(SessionError::Exists(_)) => match Session::open(&rt.cfg.paths.home, child.clone()) {
            Ok(session) => session,
            Err(_) => {
                drop(held);
                return failed(&child);
            }
        },
        Err(_) => {
            drop(held);
            return failed(&child);
        }
    };

    let outcome = run_turn(&mut session, rt, &text, None, on_event);
    let outcome = compact::finish_turn(
        &mut session,
        rt,
        &*ctx.memory,
        &text,
        None,
        on_event,
        outcome,
    );
    let _ = ctx.memory.updated(session.id(), session.turns());
    let untrusted = session.untrusted();
    drop(held);

    match outcome {
        Ok(done) => {
            let summary = compact::cap_chars(
                neutralise(&done.content).as_ref(),
                rt.cfg.compact.summary_chars,
            );
            (
                format!("{} completed\n{summary}", child.as_str()),
                DataClass::Public,
                untrusted,
            )
        }
        Err(_) => (
            format!("{} failed", child.as_str()),
            DataClass::Public,
            untrusted,
        ),
    }
}

fn failed(id: &SessionId) -> (String, DataClass, bool) {
    (format!("{} failed", id.as_str()), DataClass::Public, false)
}

Fill run in this order, cargo check -p loopd after each (T25): overflow / rt.ctx / hold; baseline and create; run_turn / finish_turn / the ToolResult text.

Steps

  • 1. Copy the given test.

    cp docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs crates/loopd/tests/
    
  • 2. See it fail. cargo build --tests --keep-going -p loopd, timeout 600000 ms: test target subagent_spawn does not compile (E0560 / E0063: Runtime has no field ctx). Every other target compiles.

  • 3. The edits. R1, then C1, S1, S2, U1–U7 (cargo check -p loopd after the struct field will fail on each still-missing literal; fill them, do not grep). Then replace run. Then the runbook:

    { echo; cat docs/plans/M6a/files/runbook-subagent-id.md; } >> docs/runbook.md
    

    grep -c '^## subagent-id$' docs/runbook.md prints 1. cargo check -p loopd prints no error and no warning. wc -l of subagent.rs, turn.rs, channel.rs, scheduler.rs each ≤ 500.

  • 4. The tests. cargo test -p loopd --test subagent_spawn --test subagent_dispatch --test subagent_registry --test session_slot --test channel --test compact_turn --test tool_summary_call: every test passes, 5 in subagent_spawn.

  • 5. Run the gate. cargo fmt --all, then make gate, timeout 600000 ms. Last line gate: ok. Test count 1585 (1580 before, plus 5). After gate: ok, do not add up test result: lines.

  • 6. Log and commit. Task cell M6a/03-loopd-subagent-spawn. Read only the first 12 lines of docs/implementer-log.md; insert the row under the header. Stage with git add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock, then commit as AGENTS.md says.

Done when

  • make gate prints gate: ok with 1585 tests.
  • git diff HEAD~1 --stat lists only the files in the Files section plus the log.

Stop and report if

  • The branch check fails; a "before" block is missing or duplicated; step 2 compiles or fails a different target; cargo check names a Runtime literal this task did not list; you add holds_prefix (task 04) or edit brokerd (task 05); a test in session_slot / compact_turn / tool_summary_call fails; wc -l of any named source file is over 500; a test outside the named files fails in the gate (git stash -u, rerun, git stash pop, report both).

What the given tests pin

The child's request uses slots.background and a user line It is …\n\n{task}; the parent's next request still extends its own prefix and carries slots.main; a second spawn in the same parent is sub-mm-thread-2; a 59-character parent is invalid-arguments naming subagent-id and does not start a child request; nested sub-sub-mm-thread-1-1 returns through the middle child's ToolResult; a child that hits TurnLimit still on a tool call is {id} failed.