mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
## Why `start_or_steer_turn` rejected agent messages, preventing callers from submitting follow-up work through the turn input API. ## What changed Route `TurnInput::InterAgentCommunication` through native agent message delivery and return `TurnInputSubmission::Accepted`, without implying an immediate turn ID. These submissions request work regardless of `trigger_turn`, retain the recipient's settings, and forward turn-start options. Wrap plaintext messages with agent context while preserving encrypted content. Reject submissions to idle recipients when the host is draining. ## Testing Add scenario tests and snapshots covering queued-message ordering, encrypted follow-ups, preemption at reasoning boundaries, and follow-ups arriving before or after a final answer. Extend drain-gate coverage to agent submissions with parent-turn lineage. GitOrigin-RevId: 92d49465f97bb366babbf1b1bc18a1e8a6c6ed1c
142 lines
6.2 KiB
Rust
142 lines
6.2 KiB
Rust
//! Attempts one new continuation turn immediately after daemon thread restoration.
|
|
//! Completed or superseded work is discarded; recovery never waits for a later idle event.
|
|
//! Permissions and the saved local executor selection must match the resumed runtime.
|
|
|
|
use super::ThreadRequestProcessor;
|
|
use codex_app_server_protocol::ThreadEnvironment;
|
|
use codex_app_server_transport::daemon_recovery::InterruptedTurn;
|
|
use codex_core::TurnInput;
|
|
use codex_core::TurnInputRequest;
|
|
use codex_core::TurnInputSubmission;
|
|
use codex_core::TurnStartOptions;
|
|
use codex_core::context::ContextualUserFragment;
|
|
use codex_core::context::InternalContextSource;
|
|
use codex_core::context::InternalModelContextFragment;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::protocol::EnvironmentConfigState;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::TurnAbortReason;
|
|
use codex_protocol::protocol::TurnAbortedEvent;
|
|
use codex_rollout::RolloutItem;
|
|
|
|
impl ThreadRequestProcessor {
|
|
pub(crate) async fn continue_daemon_turn(&self, thread_id: &str, saved: InterruptedTurn) {
|
|
let Ok(thread_id) = ThreadId::from_string(thread_id) else {
|
|
return;
|
|
};
|
|
let Ok(thread) = self.thread_manager.get_thread(thread_id).await else {
|
|
return;
|
|
};
|
|
let Some(path) = thread.rollout_path() else {
|
|
return;
|
|
};
|
|
let history = match codex_rollout::RolloutRecorder::load_rollout_items(&path).await {
|
|
Ok((history, _, _)) => history,
|
|
Err(err) => {
|
|
tracing::warn!(%thread_id, %err, "failed to read interrupted turn history");
|
|
return;
|
|
}
|
|
};
|
|
// The snapshot has complete recorded input, but the old process may have
|
|
// completed or replaced its turn during the shutdown grace period.
|
|
let Some(start) = history
|
|
.iter()
|
|
.rposition(|item| matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_))))
|
|
else {
|
|
return;
|
|
};
|
|
if !matches!(&history[start], RolloutItem::EventMsg(EventMsg::TurnStarted(event))
|
|
if event.turn_id == saved.turn_id)
|
|
|| history[start..].iter().any(|item| match item {
|
|
RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => {
|
|
event.turn_id == saved.turn_id
|
|
}
|
|
RolloutItem::EventMsg(EventMsg::TurnAborted(event)) => event
|
|
.turn_id
|
|
.as_ref()
|
|
.is_none_or(|turn_id| turn_id == &saved.turn_id),
|
|
_ => false,
|
|
})
|
|
{
|
|
return;
|
|
}
|
|
let previous_context = history.iter().rev().find_map(|item| match item {
|
|
RolloutItem::TurnContext(context)
|
|
if context.turn_id.as_ref() == Some(&saved.turn_id) =>
|
|
{
|
|
Some(context)
|
|
}
|
|
_ => None,
|
|
});
|
|
let Some(previous_context) = previous_context else {
|
|
return;
|
|
};
|
|
let config = thread.config_snapshot().await;
|
|
let [environment] = config.environment_selections() else {
|
|
return;
|
|
};
|
|
if environment.environment_id != codex_exec_server::LOCAL_ENVIRONMENT_ID
|
|
|| environment.config != EnvironmentConfigState::FromThread
|
|
|| saved.local_environment.as_ref() != Some(&ThreadEnvironment::from(environment))
|
|
{
|
|
return;
|
|
}
|
|
// Recovery must not override a stricter saved or newly configured policy.
|
|
if previous_context.permission_profile() != config.permission_profile {
|
|
return;
|
|
}
|
|
let continuation = ContextualUserFragment::into(InternalModelContextFragment::new(
|
|
InternalContextSource::from_static("daemon_recovery"),
|
|
"The server restarted and interrupted the previous turn. Continue the unfinished work from the saved conversation. Check the current state before repeating actions that may already have completed.",
|
|
));
|
|
let request = TurnInputRequest::new(TurnInput::ResponseItem(continuation)).on_start(
|
|
TurnStartOptions {
|
|
turn_trigger: Some("daemon_recovery".to_string()),
|
|
final_output_json_schema: saved.output_schema,
|
|
service_tier: saved.service_tier,
|
|
cyber_access_program: saved.cyber_access_program,
|
|
root_turn_id: previous_context.root_turn_id.clone(),
|
|
..Default::default()
|
|
},
|
|
);
|
|
// Close the old turn in persisted history before exposing a new running turn.
|
|
if let Err(err) = thread
|
|
.append_rollout_items(&[RolloutItem::EventMsg(EventMsg::TurnAborted(
|
|
TurnAbortedEvent {
|
|
root_turn_id: None,
|
|
turn_id: Some(saved.turn_id.clone()),
|
|
reason: TurnAbortReason::Interrupted,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
))])
|
|
.await
|
|
{
|
|
tracing::warn!(%thread_id, %err, "failed to close interrupted turn history");
|
|
return;
|
|
}
|
|
match thread.continue_turn_if_idle(request, saved.turn_id).await {
|
|
Ok(TurnInputSubmission::Started { .. }) => {
|
|
crate::extensions::send_thread_warning(
|
|
&self.outgoing,
|
|
&self.thread_state_manager,
|
|
thread_id,
|
|
"Resuming interrupted work".to_string(),
|
|
)
|
|
.await;
|
|
}
|
|
Ok(TurnInputSubmission::NotSubmitted { reason }) => {
|
|
tracing::debug!(%thread_id, ?reason, "recovery continuation was not started");
|
|
}
|
|
Ok(TurnInputSubmission::Steered { .. } | TurnInputSubmission::Accepted) => {
|
|
// continue_turn_if_idle uses start_if_idle, which cannot steer or
|
|
// enter the agent-message path that returns Accepted.
|
|
unreachable!("continuation must start or decline")
|
|
}
|
|
Err(err) => tracing::warn!(%thread_id, %err, "recovery continuation failed"),
|
|
}
|
|
}
|
|
}
|