From 4167628622a0af70374a7a6c44a547a99b5075eb Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 24 Apr 2026 21:16:00 -0700 Subject: [PATCH] Add goal core runtime (4 / 5) (#18076) Adds the core runtime behavior for active goals on top of the model tools from PR 3. ## Why A long-running goal should be a core runtime concern, not something every client has to implement. Core owns the turn lifecycle, tool completion boundaries, interruptions, resume behavior, and token usage, so it is the right place to account progress, enforce budgets, and decide when to continue work. ## What changed - Centralized goal lifecycle side effects behind `Session::goal_runtime_apply(GoalRuntimeEvent::...)`. - Starts goal continuation turns only when the session is idle; pending user input and mailbox work take priority. - Accounts token and wall-clock usage at turn, tool, mutation, interrupt, and resume boundaries; `get_thread_goal` remains read-only. - Preserves sub-second wall-clock remainder across accounting boundaries so long-running goals do not drift downward over time. - Treats token budget exhaustion as a soft stop by marking the goal `budget_limited` and injecting wrap-up steering instead of aborting the active turn. - Suppresses budget steering when `update_goal` marks a goal complete. - Pauses active goals on interrupt and auto-reactivates paused goals when a thread resumes outside plan mode. - Suppresses repeated automatic continuation when a continuation turn makes no tool calls. - Added continuation and budget-limit prompt templates. ## Verification - Added focused core coverage for continuation scheduling, accounting boundaries, budget-limit steering, completion accounting, interrupt pause behavior, resume auto-activation, and wall-clock remainder accounting. --- .../app-server/src/codex_message_processor.rs | 18 + .../thread_goal_handlers.rs | 25 +- .../tests/suite/v2/thread_resume.rs | 11 +- codex-rs/core/src/codex_thread.rs | 48 + codex-rs/core/src/goals.rs | 1436 ++++++++++++++++- codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/session.rs | 3 + codex-rs/core/src/session/tests.rs | 631 +++++++- codex-rs/core/src/state/turn.rs | 6 +- codex-rs/core/src/tasks/mod.rs | 97 +- codex-rs/core/src/thread_manager.rs | 12 +- codex-rs/core/src/thread_manager_tests.rs | 94 ++ codex-rs/core/src/tools/handlers/goal.rs | 12 +- codex-rs/core/src/tools/registry.rs | 12 + codex-rs/core/templates/goals/budget_limit.md | 16 + codex-rs/core/templates/goals/continuation.md | 28 + codex-rs/protocol/src/protocol.rs | 14 + 17 files changed, 2360 insertions(+), 111 deletions(-) create mode 100644 codex-rs/core/templates/goals/budget_limit.md create mode 100644 codex-rs/core/templates/goals/continuation.md 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