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

475 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-<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
```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: &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`
```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: &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
```rust
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.
```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`.