# 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. ```sh cp docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs crates/loopd/tests/ ``` ## Goal A `subagent` call holds a child `sub--` (`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`; 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, Busy>` | | `MemorySource::block` | `block(&self) -> Result` | | `MemorySource::updated` | `updated(&self, session: &SessionId, turn: u64) -> Result<(), String>` | | `Baseline::assemble_with` | `assemble_with(cfg, registry, memory: &str) -> Result` | | `Session::create` | `create(home, id, baseline, slot: u32) -> Result` | | `Session::open` | `open(home, id) -> Result` | | `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)` | | `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 ```rust pub struct Runtime<'a> { pub cfg: &'a Config, pub client: &'a Client, pub port: &'a dyn ToolPort, pub registry: &'a Registry, } ``` become ```rust 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 ```rust 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`) ```rust 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}`) ```rust 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` ```rust 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`) ```rust fn runtime(&self) -> Runtime<'_> { Runtime { cfg: &self.cfg, client: &self.client, port: &self.port, registry: &self.registry, } } ``` **U3.** `crates/loopd/tests/session_slot.rs` ```rust ) -> Runtime<'a> { Runtime { cfg, client, port, registry, } } ``` **U4.** `crates/loopd/tests/end_to_end.rs` ```rust let runtime = Runtime { cfg: &cfg, client: &client, port: &port, registry: ®istry, }; let mut events = Vec::new(); let outcome = run_turn( &mut session, &runtime, "what is this host called?", ``` **U5.** `crates/loopd/tests/turn_broker.rs` ```rust let runtime = loopd::turn::Runtime { cfg: &s.cfg, client: &s.client, port: &ReturnsPendingPort, registry: &s.registry, }; ``` **U6.** `crates/loopd/tests/tool_summary_call.rs` ```rust let rt = Runtime { cfg: &cfg, client: &client, port: &port, registry: ®istry, }; 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 ```rust let rt = Runtime { cfg: &cfg, client: &client, port: &port, registry: ®istry, }; 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. ```rust 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.** ```sh 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: ```sh { 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`.