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

+29 -10
View File
@@ -758,18 +758,36 @@ impl Session {
sub_id: String,
updates: SessionSettingsUpdate,
) -> CodexResult<(Arc<TurnContext>, ThreadSettingsSnapshot)> {
let Some(turn) = self
.new_turn_with_sub_id_if(sub_id, updates, |_, _| true)
.await?
else {
unreachable!("unconditional turn construction must accept valid settings");
};
Ok(turn)
}
/// Commits accepted settings atomically, then constructs the turn without holding
/// the state lock. The caller owns admission policy.
///
/// `should_start` runs under the state lock against the current and validated
/// proposed configurations. It must be fast and side-effect-free, and must not
/// block, acquire other locks, or call back into `Session`.
pub(super) async fn new_turn_with_sub_id_if(
&self,
sub_id: String,
updates: SessionSettingsUpdate,
should_start: impl FnOnce(&SessionConfiguration, &SessionConfiguration) -> bool + Send,
) -> CodexResult<Option<(Arc<TurnContext>, ThreadSettingsSnapshot)>> {
let final_output_json_schema = updates.final_output_json_schema.clone();
let service_tier_for_turn = updates.service_tier_for_turn.clone();
let update_result = self
.update_settings(updates)
.await
.map_err(|err| CodexErr::InvalidRequest(err.to_string()));
let commit = match update_result {
Ok(commit) => commit,
Err(err) => {
let message = err.to_string();
let commit = match self.update_settings_if(updates, should_start).await {
Ok(Some(commit)) => commit,
Ok(None) => return Ok(None),
Err(error) => {
let message = CodexErr::InvalidRequest(error.to_string()).to_string();
self.send_event_raw(Event {
id: sub_id.clone(),
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: message.clone(),
codex_error_info: Some(CodexErrorInfo::BadRequest),
@@ -787,9 +805,10 @@ impl Session {
let turn_context = self
.new_turn_from_configuration(sub_id, configuration, final_output_json_schema)
.await;
Ok((turn_context, commit.snapshot))
Ok(Some((turn_context, commit.snapshot)))
}
/// Builds a turn from the exact committed settings without starting a task.
async fn new_turn_from_configuration(
&self,
sub_id: String,