Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5dd9458
Keep goals active on model capacity errors
etraut-openai Jul 5, 2026
752aad3
Delay goal retries after model capacity errors
etraut-openai Jul 5, 2026
0129ff5
codex: address PR review feedback (#31176)
etraut-openai Jul 5, 2026
e80644f
codex: preserve goal capacity backoff across resumes (#31176)
etraut-openai Jul 5, 2026
bc1cc8d
codex: scope goal capacity backoff to live runtime (#31176)
etraut-openai Jul 5, 2026
f7f7c06
codex: remove resume-specific goal retry coverage (#31176)
etraut-openai Jul 5, 2026
f44e577
codex: simplify goal capacity retry coverage (#31176)
etraut-openai Jul 5, 2026
b3b25f0
codex: pause goal accounting during capacity backoff (#31176)
etraut-openai Jul 5, 2026
347ba2f
codex: keep capacity backoff semantics minimal (#31176)
etraut-openai Jul 5, 2026
918e8d0
codex: generalize deferred goal retry names (#31176)
etraut-openai Jul 5, 2026
00649bb
codex: document deferred goal retries (#31176)
etraut-openai Jul 5, 2026
dd90396
codex: address PR review feedback (#31176)
etraut-openai Jul 8, 2026
52929f1
Merge remote-tracking branch 'origin/main' into etraut/goal-capacity-…
etraut-openai Jul 8, 2026
c20cab2
Simplify deferred goal retries
etraut-openai Jul 8, 2026
b515673
Add jitter to deferred goal retries
etraut-openai Jul 8, 2026
61be8ad
codex: address retry review feedback (#31176)
etraut-openai Jul 8, 2026
135c262
codex: refine deferred retry handling (#31176)
etraut-openai Jul 8, 2026
ab636f4
codex: simplify capacity retry handling (#31176)
etraut-openai Jul 9, 2026
340d922
codex: address PR review feedback (#31176)
etraut-openai Jul 9, 2026
cfb8ddb
codex: address PR review feedback (#31176)
etraut-openai Jul 9, 2026
6a66264
codex: address PR review feedback (#31176)
etraut-openai Jul 9, 2026
fb40d56
codex: address PR review feedback (#31176)
etraut-openai Jul 9, 2026
7a0b03d
Merge remote-tracking branch 'origin/main' into etraut/goal-capacity-…
etraut-openai Jul 9, 2026
cf5851b
codex: simplify deferred goal retry handling (#31176)
etraut-openai Jul 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 0 additions & 3 deletions codex-rs/core/src/session/inject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,6 @@ impl Session {
self: &Arc<Self>,
input: Vec<ResponseItem>,
) -> Result<(), TryStartTurnIfIdleError> {
if input.is_empty() {
return Ok(());
}
if self.input_queue.has_trigger_turn_mailbox_items().await {
return Err(TryStartTurnIfIdleError::new(
TryStartTurnIfIdleRejectionReason::PendingTriggerTurn,
Expand Down
9 changes: 6 additions & 3 deletions codex-rs/core/src/session/turn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ pub(crate) async fn run_turn(
return Err(err);
}
let error = err.to_codex_protocol_error();
sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone())
sess.emit_compaction_turn_error_lifecycle(turn_context.as_ref(), error.clone())
.await;
error!("Failed to run pre-sampling compact");
return Ok(None);
Expand Down Expand Up @@ -361,8 +361,11 @@ pub(crate) async fn run_turn(
return Err(err);
}
let error = err.to_codex_protocol_error();
sess.emit_turn_error_lifecycle(turn_context.as_ref(), error.clone())
.await;
sess.emit_compaction_turn_error_lifecycle(
turn_context.as_ref(),
error.clone(),
)
.await;
return Ok(None);
}
can_drain_pending_input = !model_needs_follow_up;
Expand Down
20 changes: 20 additions & 0 deletions codex-rs/core/src/tasks/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,32 @@ impl Session {
&self,
turn_context: &TurnContext,
error: CodexErrorInfo,
) {
self.emit_turn_error_lifecycle_for(turn_context, error, /*is_compaction*/ false)
.await;
}

pub(crate) async fn emit_compaction_turn_error_lifecycle(
&self,
turn_context: &TurnContext,
error: CodexErrorInfo,
) {
self.emit_turn_error_lifecycle_for(turn_context, error, /*is_compaction*/ true)
.await;
}

async fn emit_turn_error_lifecycle_for(
&self,
turn_context: &TurnContext,
error: CodexErrorInfo,
is_compaction: bool,
) {
for contributor in self.services.extensions.turn_lifecycle_contributors() {
contributor
.on_turn_error(codex_extension_api::TurnErrorInput {
turn_id: turn_context.sub_id.as_str(),
error: error.clone(),
is_compaction,
session_store: &self.services.session_extension_data,
thread_store: &self.services.thread_extension_data,
turn_store: turn_context.extension_data.as_ref(),
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/ext/extension-api/src/contributors/turn_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ pub struct TurnErrorInput<'a> {
pub turn_id: &'a str,
/// Error surfaced by the host for this turn.
pub error: CodexErrorInfo,
/// Whether the error came from compaction rather than model sampling.
pub is_compaction: bool,
/// Store scoped to the host session runtime.
pub session_store: &'a ExtensionData,
/// Store scoped to this thread runtime.
Expand Down
1 change: 1 addition & 0 deletions codex-rs/ext/goal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ codex-protocol = { workspace = true }
codex-state = { workspace = true }
codex-tools = { workspace = true }
codex-utils-template = { workspace = true }
rand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] }
Expand Down
13 changes: 13 additions & 0 deletions codex-rs/ext/goal/src/extension.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::sync::Arc;
use std::sync::Weak;
use std::time::Duration;

use codex_analytics::AnalyticsEventsClient;
use codex_core::ThreadManager;
Expand Down Expand Up @@ -303,6 +304,18 @@ where
};

let reason = match input.error {
// Capacity failures do not consume user tokens. Jittering the
// delay prevents clients that fail together from retrying in lockstep.
CodexErrorInfo::ServerOverloaded
if !input.is_compaction
&& runtime.tools_visible()
&& runtime
.accounting_state()
.turn_is_current_active_goal(input.turn_id) =>
{
runtime.defer_retry(Duration::from_secs(rand::random_range(4 * 60..=6 * 60)));
return;
}
CodexErrorInfo::UsageLimitExceeded => ActiveGoalStopReason::UsageLimit,
// The turn has ended because the error was non-retryable or its
// retries were exhausted. Block the goal to prevent automatic
Expand Down
47 changes: 45 additions & 2 deletions codex-rs/ext/goal/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::sync::Arc;
use std::sync::Weak;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;

use codex_core::ThreadManager;
use codex_protocol::ThreadId;
Expand Down Expand Up @@ -45,6 +46,7 @@ struct GoalRuntimeInner {
thread_manager: Weak<ThreadManager>,
accounting_state: Arc<GoalAccountingState>,
enabled: AtomicBool,
retry_pending: AtomicBool,
tools_available_for_thread: bool,
goal_state_lock: Semaphore,
}
Expand Down Expand Up @@ -97,6 +99,7 @@ impl GoalRuntimeHandle {
thread_manager,
accounting_state,
enabled: AtomicBool::new(config.enabled),
retry_pending: AtomicBool::new(false),
tools_available_for_thread: config.tools_available_for_thread,
goal_state_lock: Semaphore::new(/*permits*/ 1),
}),
Expand All @@ -115,6 +118,32 @@ impl GoalRuntimeHandle {
self.is_enabled() && self.inner.tools_available_for_thread
}

/// Suppresses ordinary idle continuation while a retry timer is pending,
/// then starts a new turn from the existing conversation history. The weak
/// reference ensures the timer neither keeps an unloaded runtime alive nor
/// survives a thread lifecycle.
pub(crate) fn defer_retry(&self, delay: Duration) {
if self.inner.retry_pending.swap(true, Ordering::Relaxed) {
return;
}

let runtime = Arc::downgrade(&self.inner);
drop(tokio::spawn(async move {
tokio::time::sleep(delay).await;
let Some(inner) = runtime.upgrade() else {
return;
};
inner.retry_pending.store(false, Ordering::Relaxed);
let runtime = GoalRuntimeHandle { inner };
if let Err(err) = runtime.retry_if_idle().await {
tracing::warn!(
"failed to retry active goal for idle thread {}: {err}",
runtime.thread_id()
);
}
}));
}

pub(crate) fn thread_id(&self) -> ThreadId {
self.inner.thread_id
}
Expand Down Expand Up @@ -357,6 +386,21 @@ impl GoalRuntimeHandle {
}

pub(crate) async fn continue_if_idle(&self) -> Result<(), String> {
if self.inner.retry_pending.load(Ordering::Relaxed) {
return Ok(());
}
self.start_if_idle(|goal| vec![continuation_steering_item(&protocol_goal_from_state(goal))])
.await
}

async fn retry_if_idle(&self) -> Result<(), String> {
self.start_if_idle(|_| Vec::new()).await
}

async fn start_if_idle(
&self,
input_for_goal: impl FnOnce(codex_state::ThreadGoal) -> Vec<ResponseItem>,
) -> Result<(), String> {
if !self.tools_visible() {
self.inner.accounting_state.clear_active_goal();
return Ok(());
Expand Down Expand Up @@ -389,9 +433,8 @@ impl GoalRuntimeHandle {
self.inner.accounting_state.clear_active_goal();
return Ok(());
}
let item = continuation_steering_item(&protocol_goal_from_state(goal));

if let Err(err) = thread.try_start_turn_if_idle(vec![item]).await {
if let Err(err) = thread.try_start_turn_if_idle(input_for_goal(goal)).await {
let reason = err.reason();
tracing::debug!(
?reason,
Expand Down
32 changes: 28 additions & 4 deletions codex-rs/ext/goal/tests/goal_extension_backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,11 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any
)
.await;
harness
.notify_turn_error("turn-1", CodexErrorInfo::UsageLimitExceeded)
.notify_turn_error(
"turn-1",
CodexErrorInfo::UsageLimitExceeded,
/*is_compaction*/ false,
)
.await;

let goal = runtime
Expand Down Expand Up @@ -574,7 +578,7 @@ async fn turn_error_usage_limit_accounts_progress_and_clears_accounting() -> any
}

#[tokio::test]
async fn turn_error_blocks_goal() -> anyhow::Result<()> {
async fn capacity_error_retries_sampling_but_not_compaction() -> anyhow::Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cover goal capacity retries end to end

This test drives GoalExtensionHarness directly and only asserts the persisted goal status after a synthetic error; it never runs a real test_codex session through ServerOverloaded, the delayed idle restart, and the follow-up model request. Because this change alters agent/session retry behavior, regressions in lifecycle events, empty-input retry turns, or request contents would pass this crate-level test; add a core/suite integration test for the new user-facing retry flow.

AGENTS.md reference: AGENTS.md:L114-L118

Useful? React with 👍 / 👎.

let runtime = test_runtime().await?;
let thread_id = test_thread_id()?;
seed_thread_metadata(runtime.as_ref(), thread_id).await?;
Expand All @@ -591,7 +595,26 @@ async fn turn_error_blocks_goal() -> anyhow::Result<()> {
.await?;

harness
.notify_turn_error("turn-1", CodexErrorInfo::Other)
.notify_turn_error(
"turn-1",
CodexErrorInfo::ServerOverloaded,
/*is_compaction*/ false,
)
.await;

let goal = runtime
.thread_goals()
.get_thread_goal(thread_id)
.await?
.ok_or_else(|| anyhow::anyhow!("goal should exist"))?;
assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status);

harness
.notify_turn_error(
"turn-1",
CodexErrorInfo::ServerOverloaded,
/*is_compaction*/ true,
)
.await;

let goal = runtime
Expand Down Expand Up @@ -1305,13 +1328,14 @@ impl GoalExtensionHarness {
}
}

async fn notify_turn_error(&self, turn_id: &str, error: CodexErrorInfo) {
async fn notify_turn_error(&self, turn_id: &str, error: CodexErrorInfo, is_compaction: bool) {
let turn_store = ExtensionData::new(turn_id);
for contributor in self.registry.turn_lifecycle_contributors() {
contributor
.on_turn_error(TurnErrorInput {
turn_id,
error: error.clone(),
is_compaction,
session_store: &self.session_store,
thread_store: &self.thread_store,
turn_store: &turn_store,
Expand Down
Loading