diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index a8d1d6d6b..df619a96e 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 ea6076fa8..fc22192cc 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 3cecc0e11..cfefddcee 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 b94593a00..507a53480 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 fd55d0876..9f54bb0ef 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 d87dd7440..c87350ff9 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 e735aaa89..ccbec3949 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 a428f109d..63972a7c7 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 2731ab409..38996de6a 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 c591beea3..b3fe534d2 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 120f972e5..4fcc9d5e8 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 409a1ca5f..5877ee0a7 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 63efc4e3d..6f72c42df 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 152d83a15..b7b1941d1 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 291b88a92..c2db095ee 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 566922e80..9c401d18a 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 627aea212..1dc27a7c3 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 cacfb0156..980260a49 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 6e975abf4..0eea30dee 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 94e650d92..00e7c85a0 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 d9357313b..4317b6a82 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 d049fd49f..aa871f72a 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 fa90cf7b9..1b76047b7 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 6aa211bcc..1e7a405f1 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 a2ea0eb69..4523c517d 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 cc0d25807..971c37fce 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