Files
codex/codex-rs/core/src/session/turn_suspension.rs
T
rhan-oai d893b14475 Add host-owned turn admission to the agent runtime (#58963)
## What changed

Add `AgentRuntimeOwnership::Host` and an `AgentTurnAdmission` provider so hosts can acquire execution capacity before runtime tasks run, including root turns. Require the provider at startup and retain its execution guard for the task lifetime. Controller ownership remains the default; host ownership skips controller rollout-budget accounting, budget reminders, and subagent roster context.

Keep pending admission interruptible without blocking input submission. Report admission failures through the turn lifecycle, record accepted input before exposing the error, and unwind task setup such as review mode. Preserve recoverable input during suspension. On interruption, skip prompts that still require a `UserPromptSubmit` hook rather than recording them without inspection.

## Testing

Add integration tests for missing-provider startup cleanup, denial and immediate retry, steering during admission, cancellation, execution guard lifetime, submit-hook handling, suspension recovery, and review-mode cleanup. Add a context snapshot verifying retained input reaches the next admitted turn.

GitOrigin-RevId: 9ee85fad2ecfbfb38735bac8ef88888fb179a967
2026-11-02 20:10:37 +00:00

122 lines
4.8 KiB
Rust

use super::handlers;
use super::session::Session;
use crate::state::TaskKind;
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::turn_input::SuspendTurnOutcome;
use std::sync::Arc;
use std::time::Duration;
use tracing::warn;
pub(super) async fn suspend_turn_and_shutdown(
session: &Arc<Session>,
submission_id: String,
) -> CodexResult<SuspendTurnOutcome> {
{
let active = session.active_turn.lock().await;
let Some(task) = active.as_ref().and_then(|turn| turn.task.as_ref()) else {
return Ok(SuspendTurnOutcome::NotActive);
};
if task.kind != TaskKind::Regular {
return Ok(SuspendTurnOutcome::UnsupportedTask);
}
}
let live_thread = session
.live_thread_for_persistence("suspend an unfinished root turn")
.map_err(|error| CodexErr::Fatal(error.to_string()))?;
// Flush before canceling execution so a persistence failure leaves the original turn running.
live_thread.flush().await.map_err(|error| {
CodexErr::Fatal(format!("flush before root turn suspension failed: {error}"))
})?;
// The flush can yield while the active turn completes or changes. Recheck its
// kind under the same lock used to remove it.
let mut turn = {
let mut active = session.active_turn.lock().await;
let Some(active_turn) = active.as_ref() else {
return Ok(SuspendTurnOutcome::NotActive);
};
let Some(task) = active_turn.task.as_ref() else {
return Ok(SuspendTurnOutcome::NotActive);
};
if task.kind != TaskKind::Regular {
return Ok(SuspendTurnOutcome::UnsupportedTask);
}
active.take().ok_or_else(|| {
CodexErr::Fatal("accepted root turn suspension had no running turn".to_string())
})?
};
let task = turn.task.take().ok_or_else(|| {
CodexErr::Fatal("accepted root turn suspension had no running task".to_string())
})?;
let turn_id = task.turn_context.sub_id.clone();
// Normal shutdown records a terminal turn event, preventing another worker from
// recovering this turn under its original ID. Cancel the task without that event.
task.cancellation_token.cancel();
task.turn_context
.turn_metadata_state
.cancel_git_enrichment_task();
let mut task_handle = task.handle.detach();
match tokio::time::timeout(
Duration::from_millis(crate::tasks::GRACEFULL_INTERRUPTION_TIMEOUT_MS),
&mut task_handle,
)
.await
{
Ok(Ok(())) => {}
Ok(Err(error)) => {
warn!(thread_id = %session.thread_id, %error, "suspended turn task exited abnormally");
}
Err(_) => {
warn!(
thread_id = %session.thread_id,
"suspended turn task did not stop gracefully; aborting it"
);
task_handle.abort();
let _ = task_handle.await;
}
}
// The task did not run while admission was pending. Preserve its initial input
// before closing the writer so a new worker can recover the same turn.
let recorded = session
.record_unadmitted_input(
&task.turn_context,
&turn.turn_state,
task.pending_admission_input,
/*abort_reason*/ None,
)
.await;
// Other pending input and interactive waiters remain best effort across handoff.
session.input_queue.clear_pending(&turn).await;
// The root task has stopped without a terminal event. Fence new starts and stop every
// other local session, even if the suspension caller disconnects during cleanup.
// The host owns the separate tree-cleanup wait and any persistence-writer fencing.
session.services.local_agent_runtime.request_shutdown();
// Stop all producers before flushing their final history and closing its writer.
// If either persistence step fails, do not report success: the current worker
// retains ownership until worker-failure recovery can take responsibility.
handlers::shutdown_session_runtime(session).await;
live_thread.flush().await.map_err(|error| {
CodexErr::Fatal(format!("flush after root turn suspension failed: {error}"))
})?;
live_thread.shutdown().await.map_err(|error| {
CodexErr::Fatal(format!("close suspended root turn writer failed: {error}"))
})?;
recorded?;
// Announce completion only after extension cleanup and writer closure so a
// replacement worker cannot write the same thread concurrently.
session
.deliver_event_raw(Event {
id: submission_id,
msg: EventMsg::ShutdownComplete,
})
.await;
Ok(SuspendTurnOutcome::Suspended { turn_id })
}