feat: add v2 agent residency lru (#26632)

## Why

Multi-agent v2 treats agents as durable logical agents, not just live
entries in `ThreadManager`. After the reload-on-delivery change, a v2
agent can be addressed even if its thread is not currently loaded.

This PR adds the next layer: loaded v2 subagents can be paged out of
`ThreadManager` when the session has too many resident agents. That
keeps residency separate from logical identity and prepares the stack
for making v2 concurrency count active execution instead of existing
agents.

## What Changed

- Add an `AgentControl`-scoped LRU for resident v2 subagents.
- Reserve residency before spawning or reloading a v2 subagent.
- If resident capacity is full, unload the least-recently-used idle v2
subagent from `ThreadManager`.
- Keep `ThreadManager` as a primitive loaded-thread store; it does not
own the LRU policy.
- Keep unloaded agents registered and durable so they can be reloaded by
the delivery path.
- Preserve the existing v2 cap semantics by using the derived non-root
v2 cap for residency.

Eviction is intentionally conservative. A thread is unloadable only when
it is a v2 subagent, has completed or errored, has no active turn, and
has no pending mailbox work. Before removal, the rollout is materialized
and flushed.

## Assumptions And Non-Goals

- PR #26623 provides the reload-on-delivery path for unloaded v2 agents.
- `ThreadManager` membership means loaded/resident, not logical agent
existence.
- `AgentRegistry` remains the logical identity/metadata source for v2
agents that may be unloaded.
- `list_agents` remains a recent/resident view for now.
- This does not change active execution concurrency; that is the next
PR.
- This does not change `close_agent` semantics.
- This does not change or remove `resume_agent`.
- This does not add a new residency config knob.

## Stack

1. V2 durable lookup and reload on delivery (#26623) - reload unloaded
v2 agents before delivering follow-up/input.
2. V2 residency LRU (this PR) - unload idle resident v2 agents from
`ThreadManager` when resident capacity is full.
3. V2 active-execution concurrency - count running non-root v2 turns
instead of logical agents.
4. V2 close/interrupt semantics - make v2 close interrupt the current
turn without deleting durable identity.
5. V2 resume cleanup - remove the manual resume surface for v2 while
keeping internal reload support.

## Validation

- Added focused coverage for the residency LRU eviction path.
- Local clippy/check/tests were not run; CI will cover them.
This commit is contained in:
jif authored and GitHub committed 2026-06-08 10:24:48 +02:00
1 parent ed6e5cf919
commit 4e803a017c
5 files changed
+382 -1

No files matched your search

+29 -1
View File
@@ -1,3 +1,4 @@
use super::residency::is_v2_resident_session_source;
use super::*;
const AGENT_NAMES: &str = include_str!("../agent_names.txt");
@@ -116,6 +117,7 @@ impl AgentControl {
) -> CodexResult<()> {
let state = self.upgrade()?;
if 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() {
@@ -143,6 +145,9 @@ impl AgentControl {
if initial_history.get_multi_agent_version() != Some(MultiAgentVersion::V2) {
return Err(CodexErr::ThreadNotFound(thread_id));
}
let residency_slot = self
.reserve_v2_residency_slot(&state, &config, Some(thread_id))
.await?;
let (session_source, _) = initial_history
.get_resumed_session_sources()
@@ -170,11 +175,14 @@ impl AgentControl {
.await
{
Ok(reloaded_thread) => {
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() {
drop(residency_slot);
self.touch_loaded_v2_residency(&state, thread_id).await;
return Ok(());
}
Err(err)
@@ -200,7 +208,24 @@ impl AgentControl {
)
.await;
let agent_max_threads = config.effective_agent_max_threads(multi_agent_version);
let mut reservation = self.state.reserve_spawn_slot(agent_max_threads)?;
let spawn_uses_v2_residency = multi_agent_version == MultiAgentVersion::V2
&& session_source
.as_ref()
.is_some_and(is_v2_resident_session_source);
let residency_slot = if spawn_uses_v2_residency {
Some(
self.reserve_v2_residency_slot(&state, &config, /*protected_thread_id*/ None)
.await?,
)
} else {
None
};
let reservation_max_threads = if spawn_uses_v2_residency {
None
} else {
agent_max_threads
};
let mut reservation = self.state.reserve_spawn_slot(reservation_max_threads)?;
let inheritance = SpawnAgentThreadInheritance {
shell_snapshot: self
.inherited_shell_snapshot_for_source(&state, session_source.as_ref())
@@ -264,6 +289,9 @@ impl AgentControl {
};
agent_metadata.agent_id = Some(new_thread.thread_id);
reservation.commit(agent_metadata.clone());
if let Some(residency_slot) = residency_slot {
residency_slot.commit(new_thread.thread_id);
}
if let Some(SessionSource::SubAgent(
subagent_source @ SubAgentSource::ThreadSpawn {