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 authored and copyberry committed 2026-08-24 20:47:10 +00:00
1 parent 3a211471d2
commit d21794d6ba
15 files changed
+1074 -143

No files matched your search

+149 -28
View File
@@ -3,6 +3,7 @@ use crate::CodexThread;
use crate::StateDbHandle;
use crate::ThreadManager;
use crate::agent::agent_status_from_event;
use crate::agent::next_thread_spawn_depth;
use crate::agent_communication::AgentCommunicationContext;
use crate::agent_communication::AgentCommunicationKind;
use crate::config::AgentRoleConfig;
@@ -14,6 +15,7 @@ use crate::context::MultiAgentRoleInstructions;
use crate::context::SubagentNotification;
use crate::init_state_db;
use crate::thread_manager::StartThreadOptions;
use crate::tools::handlers::multi_agents_common::thread_spawn_source;
use assert_matches::assert_matches;
use codex_extension_api::ExtensionDataInit;
use codex_extension_api::empty_extension_registry;
@@ -34,6 +36,8 @@ use codex_protocol::config_types::Settings;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ContentItemKind;
use codex_protocol::models::FunctionCallOutputPayload;
@@ -42,6 +46,7 @@ use codex_protocol::models::MessagePhase;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InterAgentCommunication;
@@ -706,34 +711,99 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
#[tokio::test]
async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
let (home, mut config) = test_config().await;
let _ = config.features.enable(Feature::MultiAgentV2);
let _ = config.features.enable(Feature::Sqlite);
config.model = Some("gpt-5.6-sol".to_string());
let harness = AgentControlHarness::new_with_config(home, config).await;
let (parent_thread_id, _parent_thread) = harness.start_paginated_thread().await;
let agent_path = AgentPath::try_from("/root/worker").expect("agent path");
let mut child_config = harness.config.clone();
child_config.model = Some("gpt-5.6-luna".to_string());
let spawned_agent = harness
.control
check_v2_agent_reload(V2ReloadRoute::Sender).await;
}
#[tokio::test]
async fn ensure_v2_child_loaded_preserves_evicted_parent_authority() {
check_v2_agent_reload(V2ReloadRoute::NestedParent).await;
}
#[derive(Clone, Copy)]
enum V2ReloadRoute {
Sender,
NestedParent,
}
async fn spawn_v2_reload_test_child(
control: &AgentControl,
config: Config,
parent: &CodexThread,
task_name: &str,
) -> LiveAgent {
let source = thread_spawn_source(
parent.session.thread_id,
&parent.session_source,
next_thread_spawn_depth(&parent.session_source),
/*agent_role*/ None,
Some(task_name.to_string()),
)
.expect("child source");
control
.spawn_agent_with_metadata(
child_config,
config,
text_input("hello child"),
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: Some(agent_path.clone()),
agent_nickname: None,
agent_role: None,
})),
Some(source),
SpawnAgentOptions {
parent_thread_id: Some(parent_thread_id),
parent_thread_id: Some(parent.session.thread_id),
..Default::default()
},
)
.await
.expect("spawn_agent should succeed");
.expect("spawn_agent should succeed")
}
async fn check_v2_agent_reload(route: V2ReloadRoute) {
let (home, mut config) = test_config().await;
let _ = config.features.enable(Feature::MultiAgentV2);
let _ = config.features.enable(Feature::Sqlite);
config.model = Some("gpt-5.6-sol".to_string());
config.multi_agent_v2.max_concurrent_threads_per_session = 3;
config.permissions.allow_login_shell = true;
config
.permissions
.set_permission_profile(PermissionProfile::read_only())
.expect("read-only parent profile");
let harness = AgentControlHarness::new_with_config(home, config).await;
let client_mcp_extensions =
ClientMcpExtensions::new([(OPENAI_FORM_EXTENSION_ID.to_string(), serde_json::json!({}))]);
let root = harness
.manager
.start_thread(StartThreadOptions {
history_mode: Some(ThreadHistoryMode::Paginated),
client_mcp_extensions: client_mcp_extensions.clone(),
..StartThreadOptions::new(harness.config.clone())
})
.await
.expect("start root thread");
let control = root.thread.session.services.agent_control.clone();
let parent_thread = match route {
V2ReloadRoute::Sender => root.thread,
V2ReloadRoute::NestedParent => {
let parent = spawn_v2_reload_test_child(
&control,
harness.config.clone(),
&root.thread,
"parent",
)
.await;
harness
.manager
.get_thread(parent.thread_id)
.await
.expect("nested parent should exist")
}
};
let parent_thread_id = parent_thread.session.thread_id;
let mut child_config = harness.config.clone();
child_config.model = Some("gpt-5.6-luna".to_string());
let spawned_agent =
spawn_v2_reload_test_child(&control, child_config, &parent_thread, "worker").await;
let agent_path = spawned_agent
.metadata
.agent_path
.clone()
.expect("agent path");
let child_thread = harness
.manager
.get_thread(spawned_agent.thread_id)
@@ -781,16 +851,68 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
.cloned()
.expect("ollama provider should be configured");
harness
.control
.ensure_v2_agent_loaded(sender_config, spawned_agent.thread_id)
.await
.expect("known v2 agent should reload");
let mut parent_turn = parent_thread.session.new_default_turn().await;
match route {
V2ReloadRoute::Sender => control
.ensure_v2_agent_loaded(sender_config, spawned_agent.thread_id, /*parent*/ None)
.await
.expect("known v2 agent should reload"),
V2ReloadRoute::NestedParent => {
let environment = parent_turn
.environments
.primary()
.expect("parent environment");
let thread_config = environment.config().clone();
let mut owner_config = thread_config.clone();
owner_config.allow_login_shell = false;
let mut selection = environment.selection();
selection.config = EnvironmentConfigState::Ready(owner_config);
parent_thread
.session
.services
.turn_environments
.update_selections(std::slice::from_ref(&selection), &thread_config);
parent_turn = parent_thread.session.new_default_turn().await;
parent_thread.session.mark_interrupted();
// The fixture has no task runner to finish the turn or consume child results.
*parent_thread.session.active_turn.lock().await = None;
let _ = parent_thread
.session
.input_queue
.drain_mailbox_input_items()
.await;
harness
.manager
.ensure_multi_agent_v2_child_loaded(spawned_agent.thread_id)
.await
.expect("known child should reload through its parent");
assert!(harness.manager.get_thread(parent_thread_id).await.is_err());
}
}
let reloaded_child = harness
.manager
.get_thread(spawned_agent.thread_id)
.await
.expect("reloaded child thread should exist");
if matches!(route, V2ReloadRoute::NestedParent) {
let reloaded_turn = reloaded_child.session.new_default_turn().await;
assert_eq!(
(
reloaded_turn.environments.to_selections(),
reloaded_turn.permission_profile(),
reloaded_child.client_mcp_extensions(),
),
(
parent_turn.environments.to_selections(),
parent_turn.permission_profile(),
client_mcp_extensions,
),
);
assert!(Arc::ptr_eq(
&reloaded_child.session.services.exec_policy,
&parent_thread.session.services.exec_policy,
));
}
assert_eq!(
reloaded_child.config_snapshot().await.model,
"gpt-5.6-luna",
@@ -821,8 +943,7 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
"hello after reload".to_string(),
/*trigger_turn*/ false,
);
harness
.control
control
.send_inter_agent_communication(
spawned_agent.thread_id,
communication.clone(),