Add shared service-tier support for host-owned agents (#59770)

## What changed

- Expose an optional `AgentServiceTier` provider so host-owned children, including detached children, inherit the root's committed tier during spawn preparation, startup prewarm, and each inference step.
- Reject per-turn tier overrides for children using the shared provider. Root updates apply to subsequent requests without changing requests already in flight.
- Preserve independent configured tiers and role overrides when no provider is installed, including across eviction and reload. Spawned children inherit the parent's thread preference rather than its per-turn override.
- Publish the latest committed root tier only after successful runtime registration, preventing failed duplicate starts from changing shared settings.

## Testing

Add coverage for detached-child WebSocket prewarm and between-step tier changes, shared-child override rejection, independent-tier inheritance and reload, and settings updates before registration.

GitOrigin-RevId: f0f3676813a1c59bd77baf95121e2d13e0163a8e
This commit is contained in:
rhan-oai
2026-11-06 00:24:27 +00:00
committed by copyberry
parent 74417e9c6b
commit 94ca55a93c
18 changed files with 517 additions and 47 deletions
+89
View File
@@ -33,6 +33,7 @@ use codex_protocol::capabilities::CapabilityRootLocation;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::error::CodexErrKind;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::mcp::ClientMcpExtensions;
use codex_protocol::mcp::MCP_APP_UI_EXTENSION_ID;
use codex_protocol::mcp::OPENAI_FORM_EXTENSION_ID;
@@ -180,6 +181,94 @@ async fn startup_fixture() -> (tempfile::TempDir, Config, ThreadManager) {
(home, config, manager)
}
#[tokio::test]
#[expect(
clippy::await_holding_invalid_type,
reason = "hold startup before registration while the submission loop applies settings"
)]
async fn shared_service_tier_is_published_by_registered_startup() {
let (_home, mut config, manager) = startup_fixture().await;
config.ephemeral = true;
let thread_id = ThreadId::new();
let controller = Arc::new(LocalAgentControl::default().with_session_id(
codex_protocol::SessionId::from(thread_id),
/*max_threads*/ usize::MAX,
));
let manager = manager.with_agent_control_factory({
let controller: Arc<dyn AgentControl> = controller.clone();
move |_| {
let controller = Arc::clone(&controller);
async move { Ok(controller) }
}
});
for (initial_tier, updated_tier, previous_shared_tier) in [
("flex", None, None),
("default", Some("flex".into()), Some("default")),
] {
config.service_tier = Some(initial_tier.into());
let startup = Arc::new(SessionStartup::default());
// An ephemeral startup skips persistence until the submission loop is live.
let persistence = startup.persistence.lock().await;
let mut start = std::pin::pin!(manager.start_thread_inner(
StartThreadOptions {
reserved_thread_id: Some(thread_id),
..StartThreadOptions::new(config.clone())
},
/*forked_from_thread_id*/ None,
Some(Arc::clone(&startup)),
));
let io = std::future::poll_fn(|cx| {
assert!(start.as_mut().poll(cx).is_pending());
startup
.io
.get()
.map_or(std::task::Poll::Pending, std::task::Poll::Ready)
})
.await;
let (reply, applied) = tokio::sync::oneshot::channel();
io.submit(Op::ThreadSettings {
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
service_tier: Some(updated_tier),
..Default::default()
},
reply: Some(reply),
})
.await
.expect("submit settings before registration");
applied
.await
.expect("receive settings reply")
.expect("apply settings");
assert_eq!(
controller.root_service_tier().as_deref(),
previous_shared_tier
);
drop(persistence);
let started = start.await;
if previous_shared_tier.is_some() {
let error = started.err().expect("reject the duplicate runtime");
assert!(
matches!(error.details(), CodexErrorDetails::InvalidRequest(message)
if *message == format!("thread {thread_id} is already running"))
);
startup.cleanup().await;
} else {
started.expect("register the first runtime");
}
assert_eq!(controller.root_service_tier().as_deref(), Some("default"));
}
manager
.get_thread(thread_id)
.await
.expect("find registered thread")
.shutdown_and_wait()
.await
.expect("stop registered thread");
}
fn managed_stack(requirements: Option<RequirementsExecPolicy>) -> ConfigLayerStack {
let config = codex_config::ConfigRequirements {
exec_policy: requirements.map(|policy| {