docs: M6a implementation plan

This commit is contained in:
2026-10-03 19:39:35 -07:00
parent a2979bd434
commit 25278b4c03
12 changed files with 2027 additions and 0 deletions
+474
View File
@@ -0,0 +1,474 @@
# 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`.