mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## Why Startup prewarm waited for tool discovery before opening its WebSocket connection. Slow MCP startup therefore delayed connection setup even though the handshake could proceed independently. ## What changed - Run the WebSocket handshake concurrently with tool capture, then send `generate=false` with the discovered tools and finalized request metadata. - Include model and effective service-tier routing hints in preconnect, including the inherited tier for spawned subagents. Omit routing hints for guardian reviewer requests. - Recover authentication failures during preconnect and switch to HTTP fallback on `426 Upgrade Required`. ## Testing Add and extend regression coverage for handshake overlap with delayed MCP discovery, cancellation before warmup, finalized Responses Lite tool metadata, socket reuse and reconnection, service-tier changes and inheritance, and authentication recovery during preconnect. GitOrigin-RevId: ecb53540765df8227e55e79910a13a05260a4c8d
375 lines
14 KiB
Rust
375 lines
14 KiB
Rust
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
use tokio::task::JoinHandle;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tokio_util::task::AbortOnDropHandle;
|
|
use tracing::Instrument;
|
|
use tracing::info;
|
|
use tracing::instrument;
|
|
use tracing::trace_span;
|
|
use tracing::warn;
|
|
|
|
use crate::client::ModelClientSession;
|
|
use crate::responses_metadata::CodexResponsesRequestKind;
|
|
use crate::session::INITIAL_SUBMIT_ID;
|
|
use crate::session::RequestEffortUsage;
|
|
use crate::session::session::Session;
|
|
use crate::session::turn::build_prompt;
|
|
use codex_features::Feature;
|
|
use codex_otel::STARTUP_PREWARM_AGE_AT_FIRST_TURN_METRIC;
|
|
use codex_otel::STARTUP_PREWARM_DURATION_METRIC;
|
|
use codex_otel::SessionTelemetry;
|
|
use codex_protocol::error::Result as CodexResult;
|
|
use codex_protocol::models::BaseInstructions;
|
|
use codex_protocol::protocol::SessionSource;
|
|
use codex_protocol::protocol::SubAgentSource;
|
|
|
|
pub(crate) struct SessionStartupPrewarmHandle {
|
|
task: AbortOnDropHandle<CodexResult<ModelClientSession>>,
|
|
started_at: Instant,
|
|
timeout: Duration,
|
|
}
|
|
|
|
pub(crate) enum SessionStartupPrewarmResolution {
|
|
Cancelled,
|
|
Ready(Box<ModelClientSession>),
|
|
Unavailable {
|
|
status: &'static str,
|
|
prewarm_duration: Option<Duration>,
|
|
},
|
|
}
|
|
|
|
impl SessionStartupPrewarmHandle {
|
|
pub(crate) fn new(
|
|
task: JoinHandle<CodexResult<ModelClientSession>>,
|
|
started_at: Instant,
|
|
timeout: Duration,
|
|
) -> Self {
|
|
Self {
|
|
task: AbortOnDropHandle::new(task),
|
|
started_at,
|
|
timeout,
|
|
}
|
|
}
|
|
|
|
pub(crate) async fn abort(self) {
|
|
self.task.abort();
|
|
let _ = self.task.await;
|
|
}
|
|
|
|
#[instrument(name = "startup_prewarm.resolve", level = "trace", skip_all)]
|
|
async fn resolve(
|
|
self,
|
|
session_telemetry: &SessionTelemetry,
|
|
cancellation_token: &CancellationToken,
|
|
) -> SessionStartupPrewarmResolution {
|
|
let resolve_started_at = Instant::now();
|
|
let Self {
|
|
mut task,
|
|
started_at,
|
|
timeout,
|
|
} = self;
|
|
let age_at_first_turn = started_at.elapsed();
|
|
let remaining = timeout.saturating_sub(age_at_first_turn);
|
|
|
|
let resolution = if task.is_finished() {
|
|
Self::resolution_from_join_result(task.await, started_at)
|
|
} else {
|
|
match tokio::select! {
|
|
_ = cancellation_token.cancelled() => None,
|
|
result = tokio::time::timeout(remaining, &mut task) => Some(result),
|
|
} {
|
|
Some(Ok(result)) => Self::resolution_from_join_result(result, started_at),
|
|
Some(Err(_elapsed)) => {
|
|
task.abort();
|
|
info!("startup websocket prewarm timed out before the first turn could use it");
|
|
SessionStartupPrewarmResolution::Unavailable {
|
|
status: "timed_out",
|
|
prewarm_duration: Some(started_at.elapsed()),
|
|
}
|
|
}
|
|
None => {
|
|
task.abort();
|
|
session_telemetry.record_startup_phase(
|
|
"startup_prewarm_resolve",
|
|
resolve_started_at.elapsed(),
|
|
Some("cancelled"),
|
|
);
|
|
session_telemetry.record_duration(
|
|
STARTUP_PREWARM_AGE_AT_FIRST_TURN_METRIC,
|
|
age_at_first_turn,
|
|
&[("status", "cancelled")],
|
|
);
|
|
session_telemetry.record_duration(
|
|
STARTUP_PREWARM_DURATION_METRIC,
|
|
started_at.elapsed(),
|
|
&[("status", "cancelled")],
|
|
);
|
|
return SessionStartupPrewarmResolution::Cancelled;
|
|
}
|
|
}
|
|
};
|
|
let status = match &resolution {
|
|
SessionStartupPrewarmResolution::Cancelled => "cancelled",
|
|
SessionStartupPrewarmResolution::Ready(_) => "ready",
|
|
SessionStartupPrewarmResolution::Unavailable { status, .. } => status,
|
|
};
|
|
session_telemetry.record_startup_phase(
|
|
"startup_prewarm_resolve",
|
|
resolve_started_at.elapsed(),
|
|
Some(status),
|
|
);
|
|
|
|
match resolution {
|
|
SessionStartupPrewarmResolution::Cancelled => {
|
|
SessionStartupPrewarmResolution::Cancelled
|
|
}
|
|
SessionStartupPrewarmResolution::Ready(prewarmed_session) => {
|
|
session_telemetry.record_duration(
|
|
STARTUP_PREWARM_AGE_AT_FIRST_TURN_METRIC,
|
|
age_at_first_turn,
|
|
&[("status", "consumed")],
|
|
);
|
|
SessionStartupPrewarmResolution::Ready(prewarmed_session)
|
|
}
|
|
SessionStartupPrewarmResolution::Unavailable {
|
|
status,
|
|
prewarm_duration,
|
|
} => {
|
|
session_telemetry.record_duration(
|
|
STARTUP_PREWARM_AGE_AT_FIRST_TURN_METRIC,
|
|
age_at_first_turn,
|
|
&[("status", status)],
|
|
);
|
|
if let Some(prewarm_duration) = prewarm_duration {
|
|
session_telemetry.record_duration(
|
|
STARTUP_PREWARM_DURATION_METRIC,
|
|
prewarm_duration,
|
|
&[("status", status)],
|
|
);
|
|
}
|
|
SessionStartupPrewarmResolution::Unavailable {
|
|
status,
|
|
prewarm_duration,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn resolution_from_join_result(
|
|
result: std::result::Result<CodexResult<ModelClientSession>, tokio::task::JoinError>,
|
|
started_at: Instant,
|
|
) -> SessionStartupPrewarmResolution {
|
|
match result {
|
|
Ok(Ok(prewarmed_session)) => {
|
|
SessionStartupPrewarmResolution::Ready(Box::new(prewarmed_session))
|
|
}
|
|
Ok(Err(err)) => {
|
|
warn!("startup websocket prewarm setup failed: {err:#}");
|
|
SessionStartupPrewarmResolution::Unavailable {
|
|
status: "failed",
|
|
prewarm_duration: None,
|
|
}
|
|
}
|
|
Err(err) => {
|
|
warn!("startup websocket prewarm setup join failed: {err}");
|
|
SessionStartupPrewarmResolution::Unavailable {
|
|
status: "join_failed",
|
|
prewarm_duration: Some(started_at.elapsed()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Session {
|
|
pub(crate) async fn schedule_startup_prewarm(self: &Arc<Self>, base_instructions: String) {
|
|
if self.features().enabled(Feature::CodeModePrewarm)
|
|
&& self.services.code_mode_service.is_available()
|
|
{
|
|
let session = Arc::clone(self);
|
|
tokio::spawn(async move {
|
|
if session.services.code_mode_service.session().await.is_err() {
|
|
warn!("code-mode host startup prewarm failed");
|
|
}
|
|
});
|
|
}
|
|
|
|
if !self.services.model_client.responses_websocket_enabled() {
|
|
// Without websocket prewarm, resolve auth once so Agent Identity bootstrap can
|
|
// register or engage this session's bearer fallback before the first user request.
|
|
let model_client = self.services.model_client.clone();
|
|
tokio::spawn(async move {
|
|
if let Err(err) = model_client.prewarm_auth().await {
|
|
warn!("startup auth prewarm failed: {err:#}");
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
let session_telemetry = self.services.session_telemetry.clone();
|
|
let websocket_connect_timeout = self.provider().await.websocket_connect_timeout();
|
|
let started_at = Instant::now();
|
|
let startup_prewarm_session = Arc::clone(self);
|
|
let startup_prewarm = tokio::spawn(
|
|
async move {
|
|
let result =
|
|
schedule_startup_prewarm_inner(startup_prewarm_session, base_instructions)
|
|
.await;
|
|
let status = if result.is_ok() { "ready" } else { "failed" };
|
|
session_telemetry.record_startup_phase(
|
|
"startup_prewarm_total",
|
|
started_at.elapsed(),
|
|
Some(status),
|
|
);
|
|
session_telemetry.record_duration(
|
|
STARTUP_PREWARM_DURATION_METRIC,
|
|
started_at.elapsed(),
|
|
&[("status", status)],
|
|
);
|
|
result
|
|
}
|
|
.instrument(trace_span!(
|
|
"startup_prewarm",
|
|
otel.name = "startup_prewarm",
|
|
thread.id = %self.thread_id(),
|
|
)),
|
|
);
|
|
self.set_session_startup_prewarm(SessionStartupPrewarmHandle::new(
|
|
startup_prewarm,
|
|
started_at,
|
|
websocket_connect_timeout,
|
|
))
|
|
.await;
|
|
}
|
|
|
|
pub(crate) async fn consume_startup_prewarm_for_regular_turn(
|
|
&self,
|
|
cancellation_token: &CancellationToken,
|
|
) -> SessionStartupPrewarmResolution {
|
|
let Some(startup_prewarm) = self.take_session_startup_prewarm().await else {
|
|
return SessionStartupPrewarmResolution::Unavailable {
|
|
status: "not_scheduled",
|
|
prewarm_duration: None,
|
|
};
|
|
};
|
|
startup_prewarm
|
|
.resolve(&self.services.session_telemetry, cancellation_token)
|
|
.await
|
|
}
|
|
}
|
|
|
|
async fn schedule_startup_prewarm_inner(
|
|
session: Arc<Session>,
|
|
base_instructions: String,
|
|
) -> CodexResult<ModelClientSession> {
|
|
let prewarm_started_at = Instant::now();
|
|
let startup_turn_context = session
|
|
.new_startup_prewarm_turn_with_sub_id(INITIAL_SUBMIT_ID.to_owned())
|
|
.await;
|
|
startup_turn_context.session_telemetry.record_startup_phase(
|
|
"startup_prewarm_create_turn_context",
|
|
prewarm_started_at.elapsed(),
|
|
/*status*/ None,
|
|
);
|
|
let startup_cancellation_token = CancellationToken::new();
|
|
let preconnect_model_info = Arc::clone(startup_turn_context.model_info());
|
|
// Spawned subagents inherit the root's selection, with the same feature and model filtering
|
|
// that capture applies to the actual request.
|
|
let preconnect_service_tier = if matches!(
|
|
startup_turn_context.session_source,
|
|
SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. })
|
|
) {
|
|
crate::session::get_service_tier(
|
|
session.services.agent_control.service_tier(),
|
|
session.features().enabled(Feature::FastMode),
|
|
&preconnect_model_info,
|
|
)
|
|
} else {
|
|
startup_turn_context.initial_settings.service_tier.clone()
|
|
};
|
|
// The handshake precedes tool capture; attach the finalized step metadata to the warmup.
|
|
let (window_id, window_number, context_window_id) = session.current_window().await;
|
|
let mut handshake_metadata = startup_turn_context
|
|
.turn_metadata_state
|
|
.to_responses_metadata(
|
|
session.installation_id.clone(),
|
|
window_id,
|
|
CodexResponsesRequestKind::Prewarm,
|
|
);
|
|
crate::turn_metadata::ExecutionMetadata::from_settings(&startup_turn_context.initial_settings)
|
|
.apply_to(&mut handshake_metadata);
|
|
let handshake_metadata = session.with_window_and_fork_metadata(
|
|
&startup_turn_context,
|
|
handshake_metadata,
|
|
window_number,
|
|
context_window_id,
|
|
);
|
|
let mut client_session = session.services.model_client.new_session();
|
|
// Start the handshake with the expected route while capturing tools for generate=false.
|
|
let (step_context, ()) = tokio::try_join!(
|
|
async {
|
|
let built_tools_started_at = Instant::now();
|
|
let step_context = session
|
|
.capture_step_context(
|
|
Arc::clone(&startup_turn_context),
|
|
&startup_cancellation_token,
|
|
)
|
|
.await?;
|
|
startup_turn_context.session_telemetry.record_startup_phase(
|
|
"startup_prewarm_build_tools",
|
|
built_tools_started_at.elapsed(),
|
|
/*status*/ None,
|
|
);
|
|
Ok(step_context)
|
|
},
|
|
client_session.preconnect_websocket(
|
|
&preconnect_model_info,
|
|
preconnect_service_tier,
|
|
&startup_turn_context.session_telemetry,
|
|
&handshake_metadata,
|
|
),
|
|
)?;
|
|
let build_prompt_started_at = Instant::now();
|
|
let startup_prompt = build_prompt(
|
|
Vec::new(),
|
|
step_context.as_ref(),
|
|
BaseInstructions {
|
|
text: base_instructions,
|
|
provenance: None,
|
|
},
|
|
);
|
|
startup_turn_context.session_telemetry.record_startup_phase(
|
|
"startup_prewarm_build_prompt",
|
|
build_prompt_started_at.elapsed(),
|
|
/*status*/ None,
|
|
);
|
|
// Tool discovery may have updated Responses Lite metadata since the eager handshake.
|
|
let responses_metadata = session
|
|
.responses_metadata(step_context.as_ref(), CodexResponsesRequestKind::Prewarm)
|
|
.await;
|
|
let websocket_warmup_started_at = Instant::now();
|
|
// Prewarm establishes the request baseline before the first turn can change effort.
|
|
client_session
|
|
.prewarm_websocket(
|
|
&startup_prompt,
|
|
&step_context.settings.model_info,
|
|
&step_context.session_telemetry,
|
|
session
|
|
.reasoning_effort_for_request(&step_context.settings, RequestEffortUsage::Sampling)
|
|
.await,
|
|
step_context.settings.reasoning_summary,
|
|
step_context.settings.service_tier.clone(),
|
|
&responses_metadata,
|
|
)
|
|
.await?;
|
|
startup_turn_context.session_telemetry.record_startup_phase(
|
|
"startup_prewarm_websocket_warmup",
|
|
websocket_warmup_started_at.elapsed(),
|
|
/*status*/ None,
|
|
);
|
|
Ok(client_session)
|
|
}
|