Make automatic turn admission atomic with settings updates (#40648)

## Why

Turn settings can change after an input request previews its overrides. Automatic idle work could therefore make a Plan-mode admission decision from stale settings or apply a sparse override built from an outdated collaboration mode.

## What changed

- Evaluate automatic turn admission against both the current and proposed configuration under the settings publication lock.
- Reject automatic work that would enter or leave Plan mode without committing settings or triggering runtime configuration effects.
- Keep model and reasoning-effort overrides sparse until they are merged with the configuration being committed.
- Continue allowing explicit user and recovery turns to make valid mode transitions.

## Testing

Add coverage for concurrent settings changes, sparse model and effort updates, commit-time constraint validation, atomic Plan-mode rejection, and subsequent explicit user submission.

GitOrigin-RevId: 234f06de116bdce7c3a3fb5cac3d15583304a253
This commit is contained in:
joeflorencio-openai authored and copyberry committed 2026-08-25 17:30:45 +00:00
1 parent 1d64085e67
commit ba6cf9c692
9 files changed
+756 -137

No files matched your search

+107 -57
View File
@@ -10,6 +10,7 @@
use super::TurnInput;
use super::session::Session;
use super::session::SessionConfiguration;
use super::session::SessionSettingsUpdate;
use super::thread_settings;
use super::turn_context::TurnContext;
@@ -44,6 +45,33 @@ use uuid::Uuid;
#[path = "turn_input_tests.rs"]
mod tests;
/// Why input is starting a turn; shared by admission and input delivery.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TurnStartKind {
User,
Automatic,
Recovery,
}
impl TurnStartKind {
fn permits_mode(self, mode: ModeKind) -> bool {
match self {
Self::User | Self::Recovery => true,
Self::Automatic => mode != ModeKind::Plan,
}
}
/// Automatic work may neither leave an existing Plan mode nor enter it.
fn permits_settings(
self,
current: &SessionConfiguration,
proposed: &SessionConfiguration,
) -> bool {
self.permits_mode(current.step_settings.collaboration_mode.mode)
&& self.permits_mode(proposed.step_settings.collaboration_mode.mode)
}
}
/// Thread settings and start-only options prepared before Core knows whether
/// turn input starts or steers.
///
@@ -65,7 +93,7 @@ impl PreparedTurnInputSettings {
let thread_settings_update = if thread_settings == ThreadSettingsOverrides::default() {
None
} else {
let updates = thread_settings::prepare_update(session, thread_settings).await;
let updates = thread_settings::prepare_update(thread_settings);
session
.preview_settings(&updates)
.await
@@ -82,20 +110,15 @@ impl PreparedTurnInputSettings {
self.start_options.final_output_json_schema.as_ref()
}
fn would_enter_plan_mode(&self) -> bool {
self.thread_settings_update
.as_ref()
.and_then(|updates| updates.step_settings.collaboration_mode.as_ref())
.is_some_and(|collaboration_mode| collaboration_mode.mode == ModeKind::Plan)
}
/// Applies persistent settings and start-only options before creating a
/// new turn context.
/// new turn context. Returns `None` if admission rejects the candidate,
/// without committing its settings.
async fn apply_started(
self,
session: &Arc<Session>,
submission_id: String,
) -> CodexResult<Arc<TurnContext>> {
kind: TurnStartKind,
) -> CodexResult<Option<Arc<TurnContext>>> {
let TurnStartOptions {
final_output_json_schema,
service_tier,
@@ -107,10 +130,23 @@ impl PreparedTurnInputSettings {
updates.final_output_json_schema = Some(final_output_json_schema);
updates.service_tier_for_turn = service_tier;
// new_turn_with_sub_id already emits an error event when settings are invalid.
let (turn_context, settings_snapshot) = session
.new_turn_with_sub_id(submission_id.clone(), updates)
.await?;
let turn = match kind {
TurnStartKind::User | TurnStartKind::Recovery => Some(
session
.new_turn_with_sub_id(submission_id.clone(), updates)
.await?,
),
TurnStartKind::Automatic => {
session
.new_turn_with_sub_id_if(submission_id.clone(), updates, |current, proposed| {
kind.permits_settings(current, proposed)
})
.await?
}
};
let Some((turn_context, settings_snapshot)) = turn else {
return Ok(None);
};
if emit_thread_settings_applied {
thread_settings::emit_applied(session, submission_id, settings_snapshot).await;
}
@@ -124,7 +160,7 @@ impl PreparedTurnInputSettings {
.turn_metadata_state
.set_root_turn_id(root_turn_id);
}
Ok(turn_context)
Ok(Some(turn_context))
}
/// Applies only persistent settings after steering succeeds. The active
@@ -148,7 +184,15 @@ pub(super) async fn handle(
match mode {
TurnInputMode::StartOrSteer => start_or_steer(session, request, submission_id).await,
TurnInputMode::StartIfIdle => {
start_if_idle(session, request, submission_id, /*is_recovery*/ false).await
let kind = match &request.input {
SubmittedTurnInput::UserInput { content, .. } if !content.is_empty() => {
TurnStartKind::User
}
SubmittedTurnInput::UserInput { .. }
| SubmittedTurnInput::ResponseItem(_)
| SubmittedTurnInput::InterAgentCommunication(_) => TurnStartKind::Automatic,
};
start_if_idle(session, request, submission_id, kind).await
}
TurnInputMode::Steer { expected_turn_id } => {
steer(session, request, expected_turn_id, submission_id).await
@@ -162,7 +206,7 @@ pub(super) async fn handle_recovery(
submission_id: String,
) -> CodexResult<TurnInputSubmission> {
let request = TurnInputRequest::user_input(Vec::new()).with_thread_settings(thread_settings);
start_if_idle(session, request, submission_id, /*is_recovery*/ true).await
start_if_idle(session, request, submission_id, TurnStartKind::Recovery).await
}
async fn start_or_steer(
@@ -210,9 +254,12 @@ async fn start_or_steer(
Ok(TurnInputSubmission::Steered { turn_id })
}
Err(NotSubmittedReason::NoActiveTurn) => {
let turn_context = settings
.apply_started(session, submission_id.clone())
.await?;
let Some(turn_context) = settings
.apply_started(session, submission_id.clone(), TurnStartKind::User)
.await?
else {
unreachable!("explicit user input can enter Plan mode");
};
if can_start_root_turn
&& !items.is_empty()
&& turn_context
@@ -254,7 +301,7 @@ async fn start_if_idle(
session: &Arc<Session>,
request: TurnInputRequest,
submission_id: String,
is_recovery: bool,
kind: TurnStartKind,
) -> CodexResult<TurnInputSubmission> {
let TurnInputRequest {
input,
@@ -264,17 +311,17 @@ async fn start_if_idle(
responsesapi_client_metadata,
..
} = request;
let has_user_input = has_nonempty_user_input(&input);
let is_automatic_idle_work = !has_user_input && !is_recovery;
let can_start_root_turn = start.parent_turn_id.is_none() && start.root_turn_id.is_none();
if session.input_queue.has_trigger_turn_mailbox_items().await {
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PendingTriggerTurn,
});
}
// Empty non-recovery starts are automatic wakeups, not explicit user requests.
// Do not let them start a Plan turn.
if is_automatic_idle_work && session.collaboration_mode().await.mode == ModeKind::Plan {
// Preserve current-Plan rejection before reservation and settings errors.
// The commit-time decision also checks the proposed mode.
if kind == TurnStartKind::Automatic
&& !kind.permits_mode(session.collaboration_mode().await.mode)
{
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PlanMode,
});
@@ -306,17 +353,17 @@ async fn start_if_idle(
return Err(error);
}
};
// Automatic work must not use persistent settings to start a turn
// whose effective collaboration mode is Plan.
if is_automatic_idle_work && settings.would_enter_plan_mode() {
session.clear_reserved_idle_turn(&turn_state).await;
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PlanMode,
});
}
let turn_context = match settings.apply_started(session, submission_id.clone()).await {
Ok(turn_context) => turn_context,
let turn_context = match settings
.apply_started(session, submission_id.clone(), kind)
.await
{
Ok(Some(turn_context)) => turn_context,
Ok(None) => {
session.clear_reserved_idle_turn(&turn_state).await;
return Ok(TurnInputSubmission::NotSubmitted {
reason: NotSubmittedReason::PlanMode,
});
}
Err(error) => {
session.clear_reserved_idle_turn(&turn_state).await;
return Err(error);
@@ -327,7 +374,7 @@ async fn start_if_idle(
.turn_metadata_state
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
if has_user_input
if kind == TurnStartKind::User
&& can_start_root_turn
&& turn_context
.turn_metadata_state
@@ -342,22 +389,29 @@ async fn start_if_idle(
.await;
let mut task_input = merge_additional_context_input(session, additional_context).await;
if has_user_input {
session.clear_connector_selection().await;
if let SubmittedTurnInput::UserInput { content, .. } = &input {
turn_context.session_telemetry.user_prompt(content);
match kind {
TurnStartKind::User => {
session.clear_connector_selection().await;
if let SubmittedTurnInput::UserInput { content, .. } = &input {
turn_context.session_telemetry.user_prompt(content);
}
task_input.push(pending_turn_input(input));
}
TurnStartKind::Automatic => {
// Empty automatic user input resumes sampling without a new message.
if !matches!(&input, SubmittedTurnInput::UserInput { .. }) {
session
.input_queue
.extend_pending_input_for_turn_state(
turn_state.as_ref(),
vec![pending_turn_input(input)],
)
.await;
}
}
TurnStartKind::Recovery => {
// Recovery resumes an existing turn without a new empty user message.
}
task_input.push(pending_turn_input(input));
} else if is_automatic_idle_work && !matches!(&input, SubmittedTurnInput::UserInput { .. }) {
// Automatic response-item work still needs to be queued, but an empty
// user-input request should start sampling without adding a message.
session
.input_queue
.extend_pending_input_for_turn_state(
turn_state.as_ref(),
vec![pending_turn_input(input)],
)
.await;
}
session
.start_task(
@@ -566,10 +620,6 @@ impl Session {
}
}
fn has_nonempty_user_input(input: &SubmittedTurnInput) -> bool {
matches!(input, SubmittedTurnInput::UserInput { content, .. } if !content.is_empty())
}
async fn merge_additional_context_input(
session: &Session,
additional_context: BTreeMap<String, AdditionalContextEntry>,