feat: reload v2 agents on delivery (#26623)

## Summary

This is the first small step toward making multi-agent v2 agents durable
logical agents whose `ThreadManager` residency is only an implementation
detail.

This PR adds a narrow v2 reload-on-delivery hook:

- If a known v2 agent target is already loaded, delivery is unchanged.
- If the target is still registered but missing from `ThreadManager`,
delivery reloads that exact v2 thread from durable rollout history
before submitting the message.
- If the target is unknown, closed, missing from storage, or not a v2
thread, delivery still fails as not found.

The reload is wired only into existing-agent delivery paths: v2
`send_message` / `followup_task`, and legacy `send_input` when its
target is a known v2 agent.

## Stack

1. **Reload on delivery**: load known unloaded v2 agents before
`followup_task`, `send_message`, or `send_input` delivery. This PR.
2. **Residency LRU**: unload idle resident v2 agents from
`ThreadManager` without making them closed or unreachable.
3. **Execution concurrency**: count active non-root turns, not logical
agents or resident idle threads.
4. **Close semantics**: make v2 close interrupt-only and leave durable
agent identity intact.
5. **Resume cleanup**: remove user-facing v2 resume semantics;
addressing an unloaded durable agent reloads it implicitly.

## Validation

- Ran `just fmt`.
- Left broader tests and clippy to CI.
This commit is contained in:
jif authored and GitHub committed 2026-06-05 18:18:29 +02:00
1 parent e5af672d73
commit d5e4f01af4
4 files changed
+206 -6

No files matched your search

+73
View File
@@ -109,6 +109,79 @@ impl AgentControl {
.await
}
pub(crate) async fn ensure_v2_agent_loaded(
&self,
config: Config,
thread_id: ThreadId,
) -> CodexResult<()> {
let state = self.upgrade()?;
if state.get_thread(thread_id).await.is_ok() {
return Ok(());
}
if self.state.agent_metadata_for_thread(thread_id).is_none() {
return Err(CodexErr::ThreadNotFound(thread_id));
}
let stored_thread = state
.read_stored_thread(ReadThreadParams {
thread_id,
include_archived: true,
include_history: true,
})
.await?;
let stored_source = stored_thread.source.clone();
let stored_parent_thread_id = stored_thread.parent_thread_id;
let history = stored_thread
.history
.ok_or(CodexErr::ThreadNotFound(thread_id))?
.items;
let initial_history = InitialHistory::Resumed(ResumedHistory {
conversation_id: thread_id,
history,
rollout_path: stored_thread.rollout_path,
});
if initial_history.get_multi_agent_version() != Some(MultiAgentVersion::V2) {
return Err(CodexErr::ThreadNotFound(thread_id));
}
let (session_source, _) = initial_history
.get_resumed_session_sources()
.unwrap_or((stored_source, None));
let parent_thread_id = initial_history
.get_resumed_parent_thread_id()
.or(stored_parent_thread_id);
let inherited_shell_snapshot = self
.inherited_shell_snapshot_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,
initial_history,
agent_control: self.clone(),
session_source,
parent_thread_id,
inherited_shell_snapshot,
inherited_exec_policy,
})
.await
{
Ok(reloaded_thread) => {
state.notify_thread_created(reloaded_thread.thread_id);
Ok(())
}
Err(err) => {
if state.get_thread(thread_id).await.is_ok() {
return Ok(());
}
Err(err)
}
}
}
async fn spawn_agent_internal(
&self,
config: Config,