mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
Reference in New Issue
Block a user