diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a8d1d6d6b0..df619a96e9 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -139,6 +139,7 @@ impl TurnCodexErrorFact { #[serde(rename_all = "snake_case")] pub enum CodexErrKind { TurnAborted, + RolloutBudgetExceeded, Stream, ContextWindowExceeded, ThreadNotFound, @@ -195,6 +196,7 @@ impl From<&CodexErr> for CodexErrKind { fn from(error: &CodexErr) -> Self { match error { CodexErr::TurnAborted => CodexErrKind::TurnAborted, + CodexErr::RolloutBudgetExceeded => CodexErrKind::RolloutBudgetExceeded, CodexErr::Stream(..) => CodexErrKind::Stream, CodexErr::ContextWindowExceeded => CodexErrKind::ContextWindowExceeded, CodexErr::ThreadNotFound(_) => CodexErrKind::ThreadNotFound, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index ea6076fa89..fc22192cc5 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -573,6 +573,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 3cecc0e116..cfefddcee0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7070,6 +7070,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b94593a009..507a534802 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3310,6 +3310,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index fd55d08764..9f54bb0efa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -7,6 +7,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index d87dd74401..c87350ff90 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -30,6 +30,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index e735aaa89b..ccbec39499 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -111,6 +111,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index a428f109db..63972a7c78 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -33,6 +33,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 2731ab409a..38996de6a2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -33,6 +33,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index c591beea3c..b3fe534d2b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -33,6 +33,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 120f972e51..4fcc9d5e88 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -111,6 +111,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 409a1ca5f5..5877ee0a70 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -33,6 +33,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 63efc4e3d6..6f72c42df3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -111,6 +111,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 152d83a150..b7b1941d14 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -33,6 +33,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 291b88a923..c2db095ee7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -33,6 +33,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 566922e80c..9c401d18a1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -30,6 +30,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 627aea2126..1dc27a7c35 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -30,6 +30,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index cacfb01564..980260a49e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -30,6 +30,7 @@ { "enum": [ "contextWindowExceeded", + "rolloutBudgetExceeded", "usageLimitExceeded", "serverOverloaded", "cyberPolicy", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 6e975abf41..0eea30deed 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "rolloutBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs index 94e650d923..00e7c85a05 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -70,6 +70,7 @@ pub enum NonSteerableTurnKind { #[ts(export_to = "v2/")] pub enum CodexErrorInfo { ContextWindowExceeded, + RolloutBudgetExceeded, UsageLimitExceeded, ServerOverloaded, CyberPolicy, @@ -115,6 +116,7 @@ impl From for CodexErrorInfo { fn from(value: CoreCodexErrorInfo) -> Self { match value { CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, + CoreCodexErrorInfo::RolloutBudgetExceeded => CodexErrorInfo::RolloutBudgetExceeded, CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d9357313b2..4317b6a820 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1423,6 +1423,7 @@ There are additional item-specific events: `codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values: - `ContextWindowExceeded` +- `RolloutBudgetExceeded` - `UsageLimitExceeded` - `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx - `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index d049fd49f8..aa871f72ac 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -275,6 +275,12 @@ async fn run_compact_task_inner_impl( Err(err @ (CodexErr::Interrupted | CodexErr::TurnAborted)) => { return Err(err); } + Err(e @ CodexErr::RolloutBudgetExceeded) => { + sess.track_turn_codex_error(turn_context.as_ref(), &e); + let event = EventMsg::Error(e.to_error_event(/*message_prefix*/ None)); + sess.send_event(&turn_context, event).await; + return Err(e); + } Err(e @ CodexErr::ContextWindowExceeded) => { if turn_input_len > 1 { // Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact. diff --git a/codex-rs/core/src/session/rollout_budget.rs b/codex-rs/core/src/session/rollout_budget.rs index fa90cf7b97..1b76047b7d 100644 --- a/codex-rs/core/src/session/rollout_budget.rs +++ b/codex-rs/core/src/session/rollout_budget.rs @@ -30,7 +30,7 @@ impl Session { .rollout_budget() .record_usage(usage) { - return Err(CodexErr::TurnAborted); + return Err(CodexErr::RolloutBudgetExceeded); } Ok(()) } diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 6aa211bcc2..1e7a405f12 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -2,9 +2,9 @@ use anyhow::Result; use codex_core::config::RolloutBudgetConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; -use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; @@ -205,7 +205,7 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> { +async fn exhausted_budget_fails_current_and_later_turns() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -248,18 +248,18 @@ async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> { }) .await?; - let event = wait_for_event(&test.codex, |event| match event { - EventMsg::TurnAborted(_) => true, - EventMsg::TurnComplete(_) => { - panic!("exhausted budget completed the turn instead of aborting") - } - _ => false, + wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::Error(error) + if error.codex_error_info == Some(CodexErrorInfo::RolloutBudgetExceeded) + ) + }) + .await; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) }) .await; - let EventMsg::TurnAborted(abort) = event else { - unreachable!("event filter only accepts TurnAborted") - }; - assert_eq!(abort.reason, TurnAbortReason::Interrupted); } Ok(()) @@ -268,7 +268,7 @@ async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(false ; "local")] #[test_case(true ; "remote_v2")] -async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: bool) -> Result<()> { +async fn compaction_budget_exhaustion_fails_without_retry(remote_v2: bool) -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -311,19 +311,18 @@ async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: b .await?; test.codex.submit(Op::Compact).await?; - let event = wait_for_event(&test.codex, |event| match event { - EventMsg::TurnAborted(_) => true, - EventMsg::Error(error) => panic!("budget exhaustion emitted an error: {}", error.message), - EventMsg::TurnComplete(_) => { - panic!("budget-exhausting compaction completed instead of aborting") - } - _ => false, + wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::Error(error) + if error.codex_error_info == Some(CodexErrorInfo::RolloutBudgetExceeded) + ) + }) + .await; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) }) .await; - let EventMsg::TurnAborted(abort) = event else { - unreachable!("event filter only accepts TurnAborted") - }; - assert_eq!(abort.reason, TurnAbortReason::Interrupted); assert_eq!(responses.requests().len(), 1, "compaction should not retry"); Ok(()) diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index a2ea0eb691..4523c517da 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -69,6 +69,9 @@ pub enum CodexErr { #[error("turn aborted. Something went wrong? Hit `/feedback` to report the issue.")] TurnAborted, + #[error("shared rollout token budget exhausted")] + RolloutBudgetExceeded, + /// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP /// handshake has succeeded but **before** it finished emitting `response.completed`. /// @@ -173,6 +176,7 @@ impl CodexErr { pub fn is_retryable(&self) -> bool { match self { CodexErr::TurnAborted + | CodexErr::RolloutBudgetExceeded | CodexErr::Interrupted | CodexErr::EnvVar(_) | CodexErr::Fatal(_) @@ -220,6 +224,7 @@ impl CodexErr { pub fn to_codex_protocol_error(&self) -> CodexErrorInfo { match self { CodexErr::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, + CodexErr::RolloutBudgetExceeded => CodexErrorInfo::RolloutBudgetExceeded, CodexErr::UsageLimitReached(_) | CodexErr::QuotaExceeded | CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cc0d258075..971c37fcef 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1690,6 +1690,7 @@ pub enum NonSteerableTurnKind { #[ts(rename_all = "snake_case")] pub enum CodexErrorInfo { ContextWindowExceeded, + RolloutBudgetExceeded, UsageLimitExceeded, ServerOverloaded, CyberPolicy, @@ -1727,6 +1728,7 @@ impl CodexErrorInfo { match self { Self::ThreadRollbackFailed | Self::ActiveTurnNotSteerable { .. } => false, Self::ContextWindowExceeded + | Self::RolloutBudgetExceeded | Self::UsageLimitExceeded | Self::ServerOverloaded | Self::CyberPolicy