mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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:
1 parent
1d64085e67
commit
ba6cf9c692
9 files changed
+756
-137
No files matched your search
@@ -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>,
|
||||
|
||||
Reference in new issue
Block a user