475 lines
17 KiB
Markdown
475 lines
17 KiB
Markdown
# 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: ®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`.
|