Files
codex/codex-rs/core/src/session/thread_settings.rs
T
rhan-oai 30b42cb802 Keep interrupts responsive during host service-tier updates (#60644)
## Why

Waiting for host acceptance of a service-tier update in the submission loop can block interrupts while the host receipt is pending.

## What changed

Move the host acceptance wait out of the submission loop while keeping explicit thread settings replies pending until acceptance completes. Report handoff failures to the caller while preserving the locally committed settings, and allow a subsequent explicit request for the same tier to retry a failed handoff.

## Testing

Add a WebSocket regression test covering interrupts with pending host receipts, failure reporting, same-tier retries, and continued thread operation after startup caller cancellation.

GitOrigin-RevId: 1b27d156eba3ebb47e717a987f41f4f595635be8
2026-11-10 19:13:55 +00:00

195 lines
7.1 KiB
Rust

//! Handles persistent thread-settings updates and serializes their persistence
//! with checkpoints written directly to storage.
use super::session::Session;
use super::session::SessionSettingsCommit;
use super::session::SessionSettingsUpdate;
use super::step_settings::StepSettingsUpdate;
use crate::WithTurnExtensionData;
use crate::config::ConstraintResult;
use codex_history::RolloutItem;
use codex_protocol::capabilities::SelectedCapabilityRoot;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadSettingsAppliedEvent;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::ThreadSettingsSnapshot;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::ThreadStoreResult;
use std::sync::Arc;
use tokio::sync::SemaphorePermit;
impl Session {
/// Persist before publishing so a failed write cannot change the running catalog.
pub(crate) async fn replace_dynamic_tools(
&self,
tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
) -> CodexResult<()> {
let _settings_guard = acquire_persistence_lock(self).await;
if let Some(live_thread) = self.live_thread() {
let config = self.get_config().await;
if !config.allow_persistent_dynamic_tool_updates {
return Err(CodexErr::UnsupportedOperation(
"The host has disabled dynamic tool updates for persistent threads.".into(),
));
}
let persistence_error = |error: ThreadStoreError| match error {
ThreadStoreError::Unsupported { .. } => {
CodexErr::UnsupportedOperation(error.to_string())
}
_ => CodexErr::Fatal(format!("failed to replace dynamic tools: {error}")),
};
live_thread
.append_items(&[RolloutItem::DynamicToolsUpdated {
thread_id: self.thread_id(),
tools: tools.clone(),
}])
.await
.map_err(persistence_error)?;
live_thread.flush().await.map_err(persistence_error)?;
}
let tools = Arc::from(tools);
let mut state = self.state.lock().await;
state.persisted_dynamic_tools = Arc::clone(&tools);
state.session_configuration.dynamic_tools = tools;
Ok(())
}
/// Captures and flushes current settings under the shared persistence permit.
pub(crate) async fn checkpoint_thread_settings(&self) -> ThreadStoreResult<()> {
let _settings_guard = acquire_persistence_lock(self).await;
if let Some(live_thread) = self.live_thread() {
live_thread
.append_items(&[RolloutItem::EventMsg(applied_event(self).await)])
.await?;
live_thread.flush().await?;
}
Ok(())
}
}
/// Applies standalone thread settings. The caller holds the persistence permit through notification.
#[expect(
clippy::await_holding_invalid_type,
reason = "the active-turn guard makes continuation invalidation atomic with turn admission"
)]
pub(super) async fn update(
session: &Session,
overrides: impl Into<WithTurnExtensionData<ThreadSettingsOverrides>>,
reply_requested: bool,
) -> ConstraintResult<SessionSettingsCommit> {
let mut updates = prepare_update(overrides, &session.services.selected_capability_roots);
updates.host_acceptance_in_reply = reply_requested;
let commit = session.update_settings(updates).await?;
let active_turn = session.active_turn.lock().await;
// A registered running task retains its lineage so it can be recovered after a daemon
// restart. Reservations and finishing turns have no interrupted work to continue.
if active_turn
.as_ref()
.is_none_or(|active_turn| active_turn.task.is_none())
{
session.state.lock().await.last_started_turn_id = None;
}
Ok(commit)
}
/// Converts protocol overrides into the internal settings update shape.
pub(super) fn prepare_update(
overrides: impl Into<WithTurnExtensionData<ThreadSettingsOverrides>>,
roots: &[SelectedCapabilityRoot],
) -> SessionSettingsUpdate {
let WithTurnExtensionData {
request: overrides,
turn_extension_init,
} = overrides.into();
let ThreadSettingsOverrides {
environments: environment_requests,
runtime_workspace_roots,
profile_workspace_roots,
approval_policy,
approvals_reviewer,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
disabled_plugin_ids,
} = overrides;
SessionSettingsUpdate {
turn_extension_init,
step_settings: StepSettingsUpdate {
model,
effort,
collaboration_mode,
reasoning_summary: summary,
service_tier,
personality,
approval_policy,
approvals_reviewer,
},
environments: environment_requests.map(|requests| requests.select(roots)),
runtime_workspace_roots,
profile_workspace_roots,
sandbox_policy,
permission_profile,
active_permission_profile,
windows_sandbox_level,
disabled_plugin_ids,
..Default::default()
}
}
/// Acquires the shared permit before capturing or changing persistent settings.
pub(super) async fn acquire_persistence_lock(session: &Session) -> SemaphorePermit<'_> {
session
.thread_settings_persistence
.acquire()
.await
.unwrap_or_else(|_| unreachable!("thread settings persistence semaphore is never closed"))
}
/// Applies persistent settings and emits the resulting thread-owned snapshot.
pub(super) async fn apply_update(
session: &Session,
submission_id: String,
updates: SessionSettingsUpdate,
) -> ConstraintResult<()> {
let _settings_guard = acquire_persistence_lock(session).await;
let commit = session.update_settings(updates).await?;
emit_applied(session, submission_id, commit.snapshot).await;
Ok(())
}
/// Emits the snapshot published by one successful settings update.
pub(super) async fn emit_applied(
session: &Session,
submission_id: String,
snapshot: ThreadSettingsSnapshot,
) {
let msg = EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
thread_id: Some(session.thread_id()),
thread_settings: snapshot,
});
session
.send_event_raw_without_materializing_rollout(Event {
id: submission_id,
msg,
})
.await;
}
/// Builds a current thread-owned snapshot for storage checkpoints.
pub(super) async fn applied_event(session: &Session) -> EventMsg {
EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent {
thread_id: Some(session.thread_id()),
thread_settings: session.thread_settings_snapshot().await,
})
}