diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index dfec182fd..4d31fd1ea 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4722,6 +4722,11 @@ impl CodexMessageProcessor { } if self.config.features.enabled(Feature::Goals) { self.emit_thread_goal_snapshot(thread_id).await; + // App-server owns resume response and snapshot ordering, so wait + // until those are sent before letting core start goal continuation. + if let Err(err) = codex_thread.continue_active_goal_if_idle().await { + tracing::warn!("failed to continue active goal after resume: {err}"); + } } } Err(err) => { @@ -8980,6 +8985,12 @@ async fn handle_pending_thread_resume_request( } } + if pending.emit_thread_goal_update + && let Err(err) = conversation.apply_goal_resume_runtime_effects().await + { + tracing::warn!("failed to apply goal resume runtime effects: {err}"); + } + let ThreadConfigSnapshot { model, model_provider_id, @@ -9042,6 +9053,13 @@ async fn handle_pending_thread_resume_request( outgoing .replay_requests_to_connection_for_thread(connection_id, conversation_id) .await; + // App-server owns resume response and snapshot ordering, so wait until + // replay completes before letting core start goal continuation. + if pending.emit_thread_goal_update + && let Err(err) = conversation.continue_active_goal_if_idle().await + { + tracing::warn!("failed to continue active goal after running-thread resume: {err}"); + } } async fn send_thread_goal_snapshot_notification( diff --git a/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs b/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs index f837ef9dc..049e0af21 100644 --- a/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs +++ b/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::protocol::validate_thread_goal_objective; impl CodexMessageProcessor { pub(super) async fn thread_goal_set( @@ -83,12 +84,8 @@ impl CodexMessageProcessor { let objective = params.objective.as_deref().map(str::trim); if let Some(objective) = objective { - if objective.is_empty() { - self.send_invalid_request_error( - request_id, - "goal objective must not be empty".to_string(), - ) - .await; + if let Err(message) = validate_thread_goal_objective(objective) { + self.send_invalid_request_error(request_id, message).await; return; } if let Err(message) = validate_goal_budget(params.token_budget.flatten()) { @@ -102,6 +99,10 @@ impl CodexMessageProcessor { return; } + if let Some(thread) = running_thread.as_ref() { + thread.prepare_external_goal_mutation().await; + } + let goal = if let Some(objective) = objective { match state_db.get_thread_goal(thread_id).await { Ok(goal) => { @@ -165,6 +166,7 @@ impl CodexMessageProcessor { return; } }; + let goal_status = goal.status; let goal = api_thread_goal_from_state(goal); self.outgoing .send_response( @@ -174,6 +176,9 @@ impl CodexMessageProcessor { .await; self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) .await; + if let Some(thread) = running_thread.as_ref() { + thread.apply_external_goal_set(goal_status).await; + } } pub(super) async fn thread_goal_get( @@ -287,6 +292,10 @@ impl CodexMessageProcessor { ) .await; + if let Some(thread) = running_thread.as_ref() { + thread.prepare_external_goal_mutation().await; + } + let listener_command_tx = { let thread_state = self.thread_state_manager.thread_state(thread_id).await; let thread_state = thread_state.lock().await; @@ -301,6 +310,10 @@ impl CodexMessageProcessor { } }; + if cleared && let Some(thread) = running_thread.as_ref() { + thread.apply_external_goal_clear().await; + } + self.outgoing .send_response(request_id, ThreadGoalClearResponse { cleared }) .await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index f3d392375..6e85c4ee4 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -387,7 +387,7 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { } #[tokio::test] -async fn thread_resume_emits_paused_goal_update() -> Result<()> { +async fn thread_resume_emits_active_goal_update_before_continuation() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -459,6 +459,7 @@ async fn thread_resume_emits_paused_goal_update() -> Result<()> { mcp.read_stream_until_notification_message("thread/goal/updated"), ) .await??; + mcp.clear_message_buffer(); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { @@ -481,7 +482,13 @@ async fn thread_resume_emits_paused_goal_update() -> Result<()> { let ServerNotification::ThreadGoalUpdated(notification) = notification else { anyhow::bail!("expected thread goal update notification"); }; - assert_eq!(notification.goal.status, ThreadGoalStatus::Paused); + assert_eq!(notification.goal.status, ThreadGoalStatus::Active); + assert!( + !mcp.pending_notification_methods() + .iter() + .any(|method| method == "turn/started"), + "goal continuation should start only after the resume goal snapshot" + ); Ok(()) } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index cda2d22fb..a32cda4a1 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,6 +1,7 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use crate::goals::GoalRuntimeEvent; use crate::session::Codex; use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; @@ -103,6 +104,53 @@ impl CodexThread { self.codex.shutdown_and_wait().await } + pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { + self.codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ThreadResumed) + .await + } + + pub async fn continue_active_goal_if_idle(&self) -> anyhow::Result<()> { + self.codex + .session + .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) + .await + } + + pub async fn prepare_external_goal_mutation(&self) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) + .await + { + tracing::warn!("failed to prepare external goal mutation: {err}"); + } + } + + pub async fn apply_external_goal_set(&self, status: codex_state::ThreadGoalStatus) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { status }) + .await + { + tracing::warn!("failed to apply external goal status runtime effects: {err}"); + } + } + + pub async fn apply_external_goal_clear(&self) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalClear) + .await + { + tracing::warn!("failed to apply external goal clear runtime effects: {err}"); + } + } + #[doc(hidden)] pub async fn ensure_rollout_materialized(&self) { self.codex.session.ensure_rollout_materialized().await; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 18fa8da0a..f3c64f1b3 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -7,14 +7,35 @@ use crate::StateDbHandle; use crate::session::session::Session; use crate::session::turn_context::TurnContext; +use crate::state::ActiveTurn; +use crate::state::TurnState; +use crate::tasks::RegularTask; use anyhow::Context; use codex_features::Feature; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::validate_thread_goal_objective; use codex_rollout::state_db::reconcile_rollout; use codex_thread_store::LocalThreadStore; +use codex_utils_template::Template; +use futures::future::BoxFuture; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; pub(crate) struct SetGoalRequest { pub(crate) objective: Option, @@ -27,13 +48,318 @@ pub(crate) struct CreateGoalRequest { pub(crate) token_budget: Option, } +static CONTINUATION_PROMPT_TEMPLATE: LazyLock