Restore V2 agent identities on root thread resume (#32837)

## Why

After a cold resume, a Multi-Agent V2 root thread must still be able to
address its persisted descendants even though their runtimes are not reopened
eagerly.

## What changed

- Restore metadata for open descendant agents when resuming a V2 root thread.
- Preserve each descendant's path, nickname, and role while leaving its runtime
  unloaded until a message targets it.
- Reuse the spawn metadata preparation path so restored identities retain the
  same reservation and naming checks as newly spawned agents.

## Testing

Added an end-to-end cold-resume test that follows up with a persisted agent by
name and verifies that the original agent thread is loaded on demand.

GitOrigin-RevId: a1663c1ae60eefadb948cd39f933cd3bbb49a492
This commit is contained in:
jif authored and copyberry committed 2026-07-13 17:24:10 +00:00
1 parent 19a000259d
commit 088239294a
5 files changed
+363 -19

No files matched your search

+36 -19
View File
@@ -540,6 +540,32 @@ impl AgentControl {
});
}
fn prepare_agent_metadata(
&self,
reservation: &mut crate::agent::registry::SpawnReservation,
config: &Config,
agent_path: Option<AgentPath>,
agent_role: Option<String>,
preferred_agent_nickname: Option<String>,
) -> CodexResult<AgentMetadata> {
if let Some(agent_path) = agent_path.as_ref() {
reservation.reserve_agent_path(agent_path)?;
}
let candidate_names = spawn::agent_nickname_candidates(config, agent_role.as_deref());
let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect();
let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference(
&candidate_name_refs,
preferred_agent_nickname.as_deref(),
)?);
Ok(AgentMetadata {
agent_id: None,
agent_path,
agent_nickname,
agent_role,
last_task_message: None,
})
}
#[allow(clippy::too_many_arguments)]
fn prepare_thread_spawn(
&self,
@@ -554,29 +580,20 @@ impl AgentControl {
if depth == 1 {
self.state.register_root_thread(parent_thread_id);
}
if let Some(agent_path) = agent_path.as_ref() {
reservation.reserve_agent_path(agent_path)?;
}
let candidate_names = spawn::agent_nickname_candidates(config, agent_role.as_deref());
let candidate_name_refs: Vec<&str> = candidate_names.iter().map(String::as_str).collect();
let agent_nickname = Some(reservation.reserve_agent_nickname_with_preference(
&candidate_name_refs,
preferred_agent_nickname.as_deref(),
)?);
let agent_metadata = self.prepare_agent_metadata(
reservation,
config,
agent_path,
agent_role,
preferred_agent_nickname,
)?;
let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_path: agent_path.clone(),
agent_nickname: agent_nickname.clone(),
agent_role: agent_role.clone(),
agent_path: agent_metadata.agent_path.clone(),
agent_nickname: agent_metadata.agent_nickname.clone(),
agent_role: agent_metadata.agent_role.clone(),
});
let agent_metadata = AgentMetadata {
agent_id: None,
agent_path,
agent_nickname,
agent_role,
last_task_message: None,
};
Ok((session_source, agent_metadata))
}
+71
View File
@@ -95,6 +95,77 @@ fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &
}
impl AgentControl {
/// Restore persisted V2 agent identities without reopening their runtimes.
pub(crate) async fn restore_v2_agent_metadata(
&self,
config: &Config,
root_thread_id: ThreadId,
) {
self.state.register_root_thread(root_thread_id);
let Ok(state) = self.upgrade() else {
return;
};
let Some(agent_graph_store) = state.agent_graph_store() else {
return;
};
let descendant_ids = match agent_graph_store
.list_thread_spawn_descendants(
root_thread_id,
Some(codex_agent_graph_store::ThreadSpawnEdgeStatus::Open),
)
.await
{
Ok(descendant_ids) => descendant_ids,
Err(err) => {
warn!("failed to restore persisted V2 agent metadata for {root_thread_id}: {err}");
return;
}
};
for thread_id in descendant_ids {
if self.state.agent_metadata_for_thread(thread_id).is_some() {
continue;
}
let restore_result = async {
let stored_thread = state
.read_stored_thread(ReadThreadParams {
thread_id,
include_archived: true,
include_history: false,
})
.await?;
let stored_agent_path = stored_thread
.agent_path
.as_deref()
.map(AgentPath::try_from)
.transpose()
.map_err(|err| {
CodexErr::InvalidRequest(format!("invalid stored agent path: {err}"))
})?;
let mut reservation = self.state.reserve_spawn_slot(/*max_threads*/ None)?;
let mut metadata = self.prepare_agent_metadata(
&mut reservation,
config,
stored_agent_path.or_else(|| stored_thread.source.get_agent_path()),
stored_thread
.agent_role
.or_else(|| stored_thread.source.get_agent_role()),
stored_thread
.agent_nickname
.or_else(|| stored_thread.source.get_nickname()),
)?;
metadata.agent_id = Some(thread_id);
reservation.commit(metadata);
Ok::<(), CodexErr>(())
}
.await;
if let Err(err) = restore_result {
warn!("failed to restore V2 agent metadata for {thread_id}: {err}");
}
}
}
/// Spawn a new agent thread and submit the initial prompt.
#[cfg(test)]
pub(crate) async fn spawn_agent(
+8
View File
@@ -793,6 +793,14 @@ impl ThreadManager {
let (session_source, thread_source) = initial_history
.get_resumed_session_sources()
.unwrap_or_else(|| (self.state.session_source.clone(), None));
if let InitialHistory::Resumed(resumed) = &initial_history
&& initial_history.get_multi_agent_version() == Some(MultiAgentVersion::V2)
&& !session_source.is_non_root_agent()
{
agent_control
.restore_v2_agent_metadata(&config, resumed.conversation_id)
.await;
}
Box::pin(self.state.spawn_thread_with_source(
config,
initial_history,