17 KiB
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 testcrates/loopd/tests/subagent_spawn.rs. - Append, from
docs/plans/M6a/files/runbook-subagent-id.md, todocs/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
CallIdis already consumed.run_turncallssession.next_call()thenrun_call. The child'snis thatcall_id, notnext_callagain. First call onmm-threadissub-mm-thread-1. A nested call from that child issub-sub-mm-thread-1-1.- Overflow.
SessionIdmax 64. Buildraw = format!("sub-{}-{}", parent.id().as_str(), call_id.0)first.SessionId::newErr→denial_message(InvalidArguments, Some(&format!("child session id{raw}would not fit; {RUNBOOK}")))withRUNBOOK = "see docs/runbook.md#subagent-id". Do nothold. rt.ctx.None(unit tests that never set it) →{id} failed.holdBusy→{id} failed. Memoryblock/assemble_witherrors → drop the hold,{id} failed.- Baseline.
ctx.memory.block()thenBaseline::assemble_with(rt.cfg, rt.registry, &block), notassemble. SpeakerNone. - User text. Cap
taskwithcap_result(task, rt.cfg.r#loop.tool_result_cap). Thenformat!("It is {}.\n\n{task}", Timestamp::now().to_rfc3339())— same shape as heartbeat, current time (the given tests only check the prefixIt isand 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)thencompact::finish_turn(..., &*ctx.memory, ..., on_event, outcome). Thenctx.memory.updated(session.id(), session.turns())(ignore the result). Readsession.untrusted()beforedrop(held). - Result.
Ok(done)→format!("{} completed\n{summary}", child.as_str())wheresummary = compact::cap_chars(neutralise(&done.content).as_ref(), rt.cfg.compact.summary_chars).Err(_)(includingTurnLimitwith pending tool_calls) →format!("{} failed", ...). ClassPublic. The untrusted flag is the child's, after the turn. - Reuse the same
Runtime. The child turn usesrt, so it can spawn a grandchild (rt.ctxis still the live context). Nested is the same wait path; no new queue. Some(&*ctx)vsSome(ctx).handlehasArc<Context>; deref to&Context. The scheduler already holds&Context.- Every
Runtime {incrates/loopd. Adding a field without filling a literal isE0063. Production:Some. Tests that do not spawn:None. The given spawn test already writesctx: Some(ctx). cap_charsispub(crate)incompact.rs.neutralisereturnsCow<'_, str>.- rustfmt may wrap
Session::create. That is fine. - The items the edits use. This table is complete.
Item Signature Context::holdhold(&self, id: SessionId) -> Result<Held<'_>, Busy>MemorySource::blockblock(&self) -> Result<String, String>MemorySource::updatedupdated(&self, session: &SessionId, turn: u64) -> Result<(), String>Baseline::assemble_withassemble_with(cfg, registry, memory: &str) -> Result<Baseline, _>Session::createcreate(home, id, baseline, slot: u32) -> Result<Session, SessionError>Session::openopen(home, id) -> Result<Session, SessionError>SessionError::Exists_run_turnrun_turn(&mut Session, &Runtime, &str, Option<&str>, &mut dyn FnMut(&TurnEvent))compact::finish_turnfinish_turn(&mut Session, &Runtime, &dyn MemorySource, &str, Option<&str>, &mut dyn FnMut(&TurnEvent), Result<TurnOutcome, TurnError>)compact::cap_charscap_chars(s: &str, n: usize) -> Stringcap_resultcap_result(text: &str, cap: usize) -> (String, bool)denial_messageas task 02 Timestamp::nownow() -> Timestamp;to_rfc3339() -> StringSession::untrusteduntrusted(&self) -> boolSession::turnsturns(&self) -> u64Session::idid(&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: ®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
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: ®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
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.
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 targetsubagent_spawndoes not compile (E0560/E0063:Runtimehas no fieldctx). Every other target compiles. -
3. The edits. R1, then C1, S1, S2, U1–U7 (
cargo check -p loopdafter the struct field will fail on each still-missing literal; fill them, do not grep). Then replacerun. Then the runbook:{ echo; cat docs/plans/M6a/files/runbook-subagent-id.md; } >> docs/runbook.mdgrep -c '^## subagent-id$' docs/runbook.mdprints1.cargo check -p loopdprints no error and no warning.wc -lofsubagent.rs,turn.rs,channel.rs,scheduler.rseach ≤ 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 insubagent_spawn. -
5. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1585 (1580 before, plus 5). Aftergate: ok, do not add uptest result:lines. -
6. Log and commit. Task cell
M6a/03-loopd-subagent-spawn. Read only the first 12 lines ofdocs/implementer-log.md; insert the row under the header. Stage withgit add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1585 tests.git diff HEAD~1 --statlists 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 checknames aRuntimeliteral this task did not list; you addholds_prefix(task 04) or editbrokerd(task 05); a test insession_slot/compact_turn/tool_summary_callfails;wc -lof 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.