Reload Multi-Agent V2 children through their parent (#40477)

## Why

Multi-Agent V2 children are owned by their parent at runtime. Resuming an
unloaded child directly could rebuild it from caller-provided settings instead
of the parent's current authority.

## What changed

- Route child reloads through their loaded immediate parent and reject direct
  cold resume when that parent is unavailable.
- Preserve the child's recorded model, provider, reasoning effort, and role
  while inheriting the parent's execution policy and MCP extensions.
- Validate cached environments against the parent, intersect local permission
  profiles, and reject stale remote or workspace-root configurations.
- Keep paginated cold-resume token usage attribution when attaching to the
  reloaded child.

## Testing

Add coverage for owner validation, nested-parent reloads, permission and
environment changes, role instruction restoration, paginated usage replay,
and app-server child resume behavior.

GitOrigin-RevId: 84014762bcdb57f94fc23974c9c1af55dc1babf1
This commit is contained in:
rhan-oai
2026-08-24 20:47:10 +00:00
committed by copyberry
parent 3a211471d2
commit d21794d6ba
15 changed files with 1074 additions and 143 deletions
@@ -52,7 +52,7 @@ impl Handler {
session
.services
.agent_control
.ensure_v2_agent_loaded(resume_config, receiver_thread_id)
.ensure_v2_agent_loaded(resume_config, receiver_thread_id, /*parent*/ None)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
@@ -86,7 +86,7 @@ pub(crate) async fn handle_message_string_tool(
session
.services
.agent_control
.ensure_v2_agent_loaded(resume_config, receiver_thread_id)
.ensure_v2_agent_loaded(resume_config, receiver_thread_id, /*parent*/ None)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
let author = turn
@@ -150,11 +150,20 @@ async fn handle_spawn_agent(
.await?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
// Remember an applied configured default so cold reload reapplies its restrictions.
let persisted_role_name = role_name.or_else(|| {
(!is_full_history_fork
&& config
.agent_roles
.get(DEFAULT_ROLE_NAME)
.is_some_and(|role| role.config_file.is_some()))
.then_some(DEFAULT_ROLE_NAME)
});
let spawn_source = thread_spawn_source(
session.thread_id,
&turn.session_source,
child_depth,
role_name,
persisted_role_name,
Some(args.task_name.clone()),
)?;
let new_agent_path = spawn_source.get_agent_path().ok_or_else(|| {