Gate automatic idle turns in Plan mode (#26147)

## Why

Goal idle continuation is extension-triggered model-visible work, so it
should follow one core-owned rule for when automatic work may start. In
particular, it should not jump ahead of queued user/client work, start
while another task is active, or inject a continuation turn while the
thread is in Plan mode.

Keeping this policy in `try_start_turn_if_idle` avoids passing
`collaboration_mode` or review-specific state through
`ThreadLifecycleContributor::on_thread_idle`. Active `/review` is
covered by the same active-task gate because Review turns are not
steerable.

## What Changed

- Teach `Session::try_start_turn_if_idle` to reject automatic idle turns
in Plan mode, both before reserving an idle turn and after building the
turn context.
- Document `CodexThread::try_start_turn_if_idle` as the extension-facing
gate for automatic idle work, including Plan-mode and active Review-task
behavior.
- Add focused coverage for Plan-mode rejection and active Review-task
rejection without queuing synthetic input.

## Testing

- `just test -p codex-core try_start_turn_if_idle`
This commit is contained in:
jif
2026-06-04 14:44:45 +02:00
committed by GitHub
Unverified
parent 16d02ec77c
commit d297616d3e
5 changed files with 192 additions and 12 deletions
+45 -7
View File
@@ -1,9 +1,12 @@
use super::input_queue::TurnInput;
use super::session::Session;
use super::turn_context::TurnContext;
use crate::codex_thread::TryStartTurnIfIdleError;
use crate::codex_thread::TryStartTurnIfIdleRejectionReason;
use crate::state::ActiveTurn;
use crate::state::TurnState;
use crate::tasks::RegularTask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ResponseItem;
use std::sync::Arc;
@@ -32,22 +35,40 @@ impl Session {
}
}
/// Starts a regular turn with the provided items only if the session is idle.
/// Starts a regular turn with the provided items only if automatic idle work
/// is allowed for the current session state.
///
/// This is the shared gate for extension-initiated idle work. It refuses to
/// start a turn when user/client-triggered work is queued, any task is still
/// active, or the session is currently in Plan mode. Active Review tasks are
/// covered by the active-task check because Review turns are not steerable.
pub(crate) async fn try_start_turn_if_idle(
self: &Arc<Self>,
input: Vec<ResponseItem>,
) -> Result<(), Vec<ResponseItem>> {
) -> Result<(), TryStartTurnIfIdleError> {
if input.is_empty() {
return Ok(());
}
if self.input_queue.has_trigger_turn_mailbox_items().await {
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
if self.collaboration_mode().await.mode == ModeKind::Plan {
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PlanMode,
input,
));
}
let turn_state = {
let mut active_turn = self.active_turn.lock().await;
if active_turn.is_some() {
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::Busy,
input,
));
}
let active_turn = active_turn.get_or_insert_with(ActiveTurn::default);
Arc::clone(&active_turn.turn_state)
@@ -56,18 +77,32 @@ impl Session {
if self.input_queue.has_trigger_turn_mailbox_items().await {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
let turn_context = self
.new_default_turn_with_sub_id(uuid::Uuid::new_v4().to_string())
.await;
if turn_context.collaboration_mode.mode == ModeKind::Plan {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PlanMode,
input,
));
}
self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref())
.await;
if self.input_queue.has_trigger_turn_mailbox_items().await {
self.clear_reserved_idle_turn(&turn_state).await;
self.maybe_start_turn_for_pending_work().await;
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
input,
));
}
let still_reserved = {
let active_turn = self.active_turn.lock().await;
@@ -77,7 +112,10 @@ impl Session {
};
if !still_reserved {
self.clear_reserved_idle_turn(&turn_state).await;
return Err(input);
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::Busy,
input,
));
}
self.input_queue
+85 -1
View File
@@ -1,5 +1,6 @@
use super::turn_context::TurnEnvironment;
use super::*;
use crate::codex_thread::TryStartTurnIfIdleRejectionReason;
use crate::config::ConfigBuilder;
use crate::config::ConfigOverrides;
use crate::config::test_config;
@@ -8770,7 +8771,90 @@ async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() {
.await
.expect_err("active turn should reject idle-only input");
assert_eq!(vec![item], err);
assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason());
assert_eq!(vec![item], err.into_input());
assert_eq!(
Vec::<TurnInput>::new(),
sess.input_queue.get_pending_input(&sess.active_turn).await
);
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let mut collaboration_mode = sess.collaboration_mode().await;
collaboration_mode.mode = ModeKind::Plan;
{
let mut state = sess.state.lock().await;
state.session_configuration.collaboration_mode = collaboration_mode;
}
let item = user_message("synthetic idle input");
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("plan mode should reject automatic idle input");
assert_eq!(TryStartTurnIfIdleRejectionReason::PlanMode, err.reason());
assert_eq!(vec![item], err.into_input());
assert!(sess.active_turn.lock().await.is_none());
assert_eq!(
Vec::<TurnInput>::new(),
sess.input_queue.get_pending_input(&sess.active_turn).await
);
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
sess.input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new(
AgentPath::root(),
AgentPath::root(),
Vec::new(),
"pending trigger".to_string(),
/*trigger_turn*/ true,
))
.await;
let item = user_message("synthetic idle input");
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("pending trigger-turn mail should reject automatic idle input");
assert_eq!(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
err.reason()
);
assert_eq!(vec![item], err.into_input());
assert!(sess.active_turn.lock().await.is_none());
assert!(sess.input_queue.has_trigger_turn_mailbox_items().await);
}
#[tokio::test]
async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
sess.spawn_task(
Arc::clone(&tc),
Vec::new(),
NeverEndingTask {
kind: TaskKind::Review,
listen_to_cancellation_token: true,
},
)
.await;
let item = user_message("synthetic idle input");
let err = sess
.try_start_turn_if_idle(vec![item.clone()])
.await
.expect_err("active review turn should reject automatic idle input");
assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason());
assert_eq!(vec![item], err.into_input());
assert_eq!(
Vec::<TurnInput>::new(),
sess.input_queue.get_pending_input(&sess.active_turn).await