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
@@ -114,7 +114,7 @@ async fn interrupted_v2_agent_is_lost_after_residency_eviction() {
mark_thread_completed(second.thread.as_ref()).await;
let err = control
.ensure_v2_agent_loaded(config, first.thread_id)
.ensure_v2_agent_loaded(config, first.thread_id, /*parent*/ None)
.await
.expect_err("evicted interrupted agent should stay lost");
match err.details() {
+189 -13
View File
@@ -1,6 +1,7 @@
use super::residency::is_v2_resident_session_source;
use super::*;
use crate::agent::role::apply_role_to_config;
use crate::codex_thread::CodexThread;
use crate::config::PermissionProfileSnapshot;
use crate::context::ContextualUserFragment;
use crate::context::CurrentTimeReminder;
@@ -9,9 +10,13 @@ use crate::context::ManagedDeveloperInstructions;
use crate::context::MultiAgentModeInstructions;
use crate::context::MultiAgentRoleInstructions;
use crate::session::multi_agents::resolve_usage_hints;
use crate::tools::handlers::multi_agents_common::build_agent_resume_config;
use codex_context_fragments::set_annotated_content;
use codex_context_fragments::to_annotated_content;
use codex_extension_api::ExtensionDataInit;
use codex_protocol::intersect_effective_permission_profiles;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_utils_path_uri::PathUri;
const AGENT_NAMES: &str = include_str!("../agent_names.txt");
@@ -267,20 +272,65 @@ impl AgentControl {
.await
}
fn validate_loaded_v2_child(
&self,
thread: &CodexThread,
parent_thread_id: ThreadId,
) -> CodexResult<()> {
if thread.is_running()
&& thread.multi_agent_version() == Some(MultiAgentVersion::V2)
&& thread.session_source.parent_thread_id() == Some(parent_thread_id)
&& Arc::ptr_eq(&self.state, &thread.session.services.agent_control.state)
{
return Ok(());
}
Err(CodexErr::InvalidRequest(format!(
"multi-agent v2 child {} is not owned by its loaded parent",
thread.session.thread_id
)))
}
/// A provided parent enables owner-validated reloads; `None` preserves sender-driven reloads.
pub(crate) async fn ensure_v2_agent_loaded(
&self,
mut config: Config,
thread_id: ThreadId,
parent: Option<Arc<CodexThread>>,
) -> CodexResult<()> {
let state = self.upgrade()?;
if state.get_thread(thread_id).await.is_ok() {
let parent = if let Some(parent) = parent {
let parent_thread_id = parent.session.thread_id;
let turn = parent.session.new_default_turn().await;
config = build_agent_resume_config(&turn).map_err(|_| {
CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id} with the current parent settings"
))
})?;
let registered_parent = state.get_thread(parent_thread_id).await.ok();
if !registered_parent
.as_ref()
.is_some_and(|registered| Arc::ptr_eq(registered, &parent))
|| !parent.is_running()
|| parent.multi_agent_version() != Some(MultiAgentVersion::V2)
|| !Arc::ptr_eq(&self.state, &parent.session.services.agent_control.state)
{
return Err(CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id}: parent ownership is unavailable; resume the parent first"
)));
}
Some((parent, turn.environments.clone()))
} else {
None
};
let owner_thread_id = parent.as_ref().map(|(parent, _)| parent.session.thread_id);
if owner_thread_id.is_none() && state.get_thread(thread_id).await.is_ok() {
self.touch_loaded_v2_residency(&state, thread_id).await;
return Ok(());
}
if self.state.agent_metadata_for_thread(thread_id).is_none() {
return Err(CodexErr::ThreadNotFound(thread_id));
}
let environment_selections = self.state.evicted_environments(thread_id);
let mut environment_selections = self.state.evicted_environments(thread_id);
let stored_thread = state
.read_stored_thread(ReadThreadParams {
@@ -291,6 +341,7 @@ impl AgentControl {
.await?;
let stored_model = stored_thread.model.clone();
let stored_model_provider = stored_thread.model_provider.clone();
let stored_reasoning_effort = stored_thread.reasoning_effort.clone();
let stored_source = stored_thread.source.clone();
let stored_parent_thread_id = stored_thread.parent_thread_id;
let history = load_agent_model_context(&state, thread_id, stored_thread.history_mode)
@@ -307,6 +358,25 @@ impl AgentControl {
let (session_source, _) = initial_history
.get_resumed_session_sources()
.unwrap_or((stored_source, None));
if let Some(parent_thread_id) = owner_thread_id {
if session_source.parent_thread_id() != Some(parent_thread_id)
|| initial_history
.get_resumed_parent_thread_id()
.is_some_and(|recorded_parent| recorded_parent != parent_thread_id)
|| stored_parent_thread_id
.is_some_and(|recorded_parent| recorded_parent != parent_thread_id)
{
return Err(CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id}: recorded parent ownership is inconsistent"
)));
}
if let Ok(thread) = state.get_thread(thread_id).await {
self.validate_loaded_v2_child(&thread, parent_thread_id)?;
self.touch_loaded_v2_residency(&state, thread_id).await;
return Ok(());
}
}
config.model_reasoning_effort = stored_reasoning_effort;
if let Some(role_name) = session_source.get_agent_role() {
let runtime_approval_policy = config.permissions.approval_policy.value();
let runtime_approvals_reviewer = config.approvals_reviewer;
@@ -358,20 +428,118 @@ impl AgentControl {
})?;
config.model_provider_id = stored_model_provider;
}
let parent_thread_id = owner_thread_id
.or_else(|| initial_history.get_resumed_parent_thread_id())
.or(stored_parent_thread_id);
let (inherited_environments, inherited_exec_policy, client_mcp_extensions) = if let Some(
(parent, parent_environments),
) =
parent.as_ref()
{
let parent_config = parent.session.get_config().await;
if !crate::exec_policy::child_uses_parent_exec_policy(&parent_config, &config) {
return Err(CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id}: parent execution policy has changed; retry through the parent"
)));
}
if let Some(selections) = environment_selections.as_mut() {
for selection in selections {
let environment_id = &selection.environment_id;
let invalid_environment = |reason: &str| {
CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {thread_id}: cached environment {environment_id} {reason}"
))
};
// Matching the attachment also keeps startup on the captured owner executor.
let owner_environment = parent_environments
.turn_environments()
.find(|environment| {
environment.selection.environment_id == *environment_id
&& environment.cwd() == &selection.cwd
&& environment.workspace_roots()
== selection.workspace_roots.as_slice()
})
.ok_or_else(|| {
invalid_environment("no longer matches a ready parent environment")
})?;
let owner_config = owner_environment.config();
let child_config = match &selection.config {
EnvironmentConfigState::FromThread => {
// Pin current owner authority instead of re-inferring child settings.
selection.config = EnvironmentConfigState::Ready(owner_config.clone());
continue;
}
EnvironmentConfigState::Ready(config) => config,
EnvironmentConfigState::Pending | EnvironmentConfigState::Failed(_) => {
return Err(invalid_environment("configuration is not ready"));
}
};
let mut bounded_config = child_config.clone();
bounded_config.permission_profile = owner_config.permission_profile.clone();
if bounded_config != *owner_config {
return Err(invalid_environment(
"configuration differs from the current parent",
));
}
if child_config.permission_profile == owner_config.permission_profile {
continue;
}
if owner_environment.environment.is_remote() {
return Err(invalid_environment(
"permissions changed on a remote executor",
));
}
let cwd = selection.cwd.to_abs_path().map_err(|_| {
invalid_environment("working directory is not a local absolute path")
})?;
let roots = selection
.workspace_roots
.iter()
.map(PathUri::to_abs_path)
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
invalid_environment("workspace roots are not local absolute paths")
})?;
let authority = owner_environment
.permission_profile()
.clone()
.materialize_project_roots_with_workspace_roots(&roots);
let requested = child_config
.permission_profile
.permission_profile()
.clone()
.materialize_project_roots_with_workspace_roots(&roots);
let permissions =
intersect_effective_permission_profiles(&authority, &requested, &cwd)
.map_err(|err| {
invalid_environment(&format!(
"permissions cannot be intersected safely: {err}"
))
})?;
bounded_config.permission_profile =
PermissionProfileSnapshot::legacy(permissions);
selection.config = EnvironmentConfigState::Ready(bounded_config);
}
}
(
Some(parent_environments.clone()),
Some(Arc::clone(&parent.session.services.exec_policy)),
Some(parent.client_mcp_extensions()),
)
} else {
(
self.inherited_environments_for_source(&state, Some(&session_source))
.await,
self.inherited_exec_policy_for_source(&state, Some(&session_source), &config)
.await,
None,
)
};
// Reserving a slot can evict an idle nested parent. Keep its authority captured above.
let residency_slot = self
.reserve_v2_residency_slot(&state, &config, Some(thread_id))
.await?;
let parent_thread_id = initial_history
.get_resumed_parent_thread_id()
.or(stored_parent_thread_id);
let inherited_environments = self
.inherited_environments_for_source(&state, Some(&session_source))
.await;
let inherited_exec_policy = self
.inherited_exec_policy_for_source(&state, Some(&session_source), &config)
.await;
match state
.resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions {
config,
@@ -382,17 +550,24 @@ impl AgentControl {
environment_selections,
inherited_environments,
inherited_exec_policy,
client_mcp_extensions,
})
.await
{
Ok(reloaded_thread) => {
if let Some(parent_thread_id) = owner_thread_id {
self.validate_loaded_v2_child(&reloaded_thread.thread, parent_thread_id)?;
}
self.state.clear_evicted_environments(thread_id);
residency_slot.commit(reloaded_thread.thread_id);
state.notify_thread_created(reloaded_thread.thread_id);
Ok(())
}
Err(err) => {
if state.get_thread(thread_id).await.is_ok() {
if let Ok(thread) = state.get_thread(thread_id).await {
if let Some(parent_thread_id) = owner_thread_id {
self.validate_loaded_v2_child(&thread, parent_thread_id)?;
}
self.state.clear_evicted_environments(thread_id);
drop(residency_slot);
self.touch_loaded_v2_residency(&state, thread_id).await;
@@ -1042,6 +1217,7 @@ impl AgentControl {
environment_selections: None,
inherited_environments,
inherited_exec_policy,
client_mcp_extensions: None,
})
.await?;
let mut agent_metadata = agent_metadata;
+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(),
+39 -1
View File
@@ -328,6 +328,7 @@ pub(crate) struct ResumeThreadWithHistoryOptions {
pub(crate) environment_selections: Option<Vec<TurnEnvironmentSelection>>,
pub(crate) inherited_environments: Option<TurnEnvironmentSnapshot>,
pub(crate) inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
pub(crate) client_mcp_extensions: Option<ClientMcpExtensions>,
}
/// Shared, `Arc`-owned state for [`ThreadManager`]. This `Arc` is required to have a single
@@ -1012,6 +1013,39 @@ impl ThreadManager {
.await
}
/// Reloads a recorded Multi-Agent V2 child through its currently loaded immediate parent.
///
/// The child keeps the existing parent-controlled reload semantics. Callers cannot supply
/// configuration overrides, and an unavailable or unrecognized owner is an error.
pub async fn ensure_multi_agent_v2_child_loaded(
&self,
child_thread_id: ThreadId,
) -> CodexResult<()> {
let stored_thread = self
.state
.read_stored_thread(ReadThreadParams {
thread_id: child_thread_id,
include_archived: true,
include_history: false,
})
.await?;
let Some(parent_thread_id) = stored_thread.parent_thread_id else {
return Err(CodexErr::InvalidRequest(format!(
"thread {child_thread_id} is not a recorded multi-agent v2 child"
)));
};
let parent = self.get_thread(parent_thread_id).await.map_err(|_| {
CodexErr::InvalidRequest(format!(
"cannot resume multi-agent v2 child {child_thread_id}: parent {parent_thread_id} is not loaded; resume the parent first"
))
})?;
let config = parent.session.get_config().await.as_ref().clone();
let agent_control = parent.session.services.agent_control.clone();
agent_control
.ensure_v2_agent_loaded(config, child_thread_id, Some(parent))
.await
}
#[instrument(level = "trace", skip_all)]
pub async fn resume_thread_with_history(
&self,
@@ -1708,8 +1742,12 @@ impl ThreadManagerState {
environment_selections,
inherited_environments,
inherited_exec_policy,
client_mcp_extensions,
} = options;
let client_mcp_extensions = self.client_mcp_extensions_for_child(parent_thread_id).await;
let client_mcp_extensions = match client_mcp_extensions {
Some(client_mcp_extensions) => client_mcp_extensions,
None => self.client_mcp_extensions_for_child(parent_thread_id).await,
};
let thread_source = initial_history.get_resumed_thread_source();
let environments = environment_selections.or_else(|| {
inherited_environments
@@ -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(|| {