Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
203 changes: 195 additions & 8 deletions codex-rs/core/src/usage_profile_health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,32 @@ pub fn exhausted_auto_switch_window_for_snapshot(
.find_map(exhausted_auto_switch_window_for_limit)
}

/// Earliest future reset timestamp (unix seconds) among exhausted (`used_percent >= 100`)
/// codex windows in `snapshot`.
///
/// Returns `None` when the limit is not codex, nothing is exhausted, or every exhausted
/// window's reset is missing or already in the past. Callers use this to suppress usage
/// heartbeats for a profile that is already capped until it resets, instead of re-polling
/// the usage endpoint every heartbeat interval (which just hammers a limit we already know
/// about). When the reset is unknown, `None` lets the caller fall back to the normal
/// interval/backoff so heartbeats are never permanently blocked.
pub fn earliest_exhausted_reset_at(
snapshot: &UsageProfileRateLimitSnapshot<'_>,
now_unix_secs: i64,
) -> Option<i64> {
if !is_codex_limit(snapshot) {
return None;
}

[snapshot.secondary, snapshot.primary]
.into_iter()
.flatten()
.filter(|window| window.used_percent >= 100.0)
.filter_map(|window| window.resets_at)
.filter(|reset_at| *reset_at > now_unix_secs)
.min()
}

pub fn usage_health_for_snapshots(
snapshots: &[UsageProfileRateLimitSnapshot<'_>],
config: &AuthProfileAutoSwitchConfig,
Expand Down Expand Up @@ -224,29 +250,45 @@ pub fn choose_profile_for_auto_switch(
let mut retry_at = None;
match config.strategy {
AuthProfileAutoSwitchStrategy::Ordered => {
// Respect the configured order, but never optimistically switch to an
// Unknown profile when a later candidate is known to be healthy: a
// known-healthy profile is always a safer switch target than one whose
// usage we have not confirmed.
let mut first_healthy = None;
let mut first_unknown = None;
for candidate in candidates {
match health_by_profile
.get(candidate)
.copied()
.unwrap_or(UsageProfileHealth::Unknown)
{
UsageProfileHealth::Healthy(_) => {
return UsageProfileSelection::selected(
candidate.clone(),
UsageProfileSelectionReason::SelectedHealthyProfile,
);
if first_healthy.is_none() {
first_healthy = Some(candidate.as_str());
}
}
UsageProfileHealth::Unknown => {
return UsageProfileSelection::selected(
candidate.clone(),
UsageProfileSelectionReason::SelectedUnknownProfile,
);
if first_unknown.is_none() {
first_unknown = Some(candidate.as_str());
}
}
UsageProfileHealth::Exhausted {
retry_at: profile_retry_at,
} => merge_retry_at(&mut retry_at, profile_retry_at),
}
}
if let Some(profile) = first_healthy {
return UsageProfileSelection::selected(
profile.to_string(),
UsageProfileSelectionReason::SelectedHealthyProfile,
);
}
if let Some(profile) = first_unknown {
return UsageProfileSelection::selected(
profile.to_string(),
UsageProfileSelectionReason::SelectedUnknownProfile,
);
}
}
AuthProfileAutoSwitchStrategy::HighestAvailable => {
let mut best: Option<(&str, UsageProfileScore)> = None;
Expand Down Expand Up @@ -644,6 +686,151 @@ mod tests {
);
}

fn ordered_config() -> AuthProfileAutoSwitchConfig {
AuthProfileAutoSwitchConfig {
enabled: true,
strategy: AuthProfileAutoSwitchStrategy::Ordered,
..Default::default()
}
}

#[test]
fn ordered_prefers_healthy_over_earlier_unknown() {
let health_by_profile = BTreeMap::from([
("first".to_string(), UsageProfileHealth::Unknown),
(
"second".to_string(),
UsageProfileHealth::Healthy(UsageProfileScore {
trigger_remaining_percent: 40.0,
limiting_remaining_percent: 40.0,
}),
),
]);

assert_eq!(
UsageProfileSelection {
selected_profile: Some("second".to_string()),
retry_at: None,
reason: UsageProfileSelectionReason::SelectedHealthyProfile,
},
choose_profile_for_auto_switch(
&ordered_config(),
&["first".to_string(), "second".to_string()],
&health_by_profile,
)
);
}

#[test]
fn ordered_keeps_first_healthy_in_configured_order() {
let health_by_profile = BTreeMap::from([
(
"first".to_string(),
UsageProfileHealth::Healthy(UsageProfileScore {
trigger_remaining_percent: 20.0,
limiting_remaining_percent: 20.0,
}),
),
(
"second".to_string(),
UsageProfileHealth::Healthy(UsageProfileScore {
trigger_remaining_percent: 90.0,
limiting_remaining_percent: 90.0,
}),
),
]);

assert_eq!(
UsageProfileSelection {
selected_profile: Some("first".to_string()),
retry_at: None,
reason: UsageProfileSelectionReason::SelectedHealthyProfile,
},
choose_profile_for_auto_switch(
&ordered_config(),
&["first".to_string(), "second".to_string()],
&health_by_profile,
)
);
}

#[test]
fn ordered_falls_back_to_first_unknown_when_no_healthy() {
let health_by_profile = BTreeMap::from([(
"first".to_string(),
UsageProfileHealth::Exhausted {
retry_at: Some(500),
},
)]);

assert_eq!(
UsageProfileSelection {
selected_profile: Some("second".to_string()),
retry_at: None,
reason: UsageProfileSelectionReason::SelectedUnknownProfile,
},
choose_profile_for_auto_switch(
&ordered_config(),
&["first".to_string(), "second".to_string()],
&health_by_profile,
)
);
}

#[test]
fn earliest_exhausted_reset_at_returns_future_reset() {
assert_eq!(
Some(1_000),
earliest_exhausted_reset_at(
&snapshot(
Some(window(100.0, MINUTES_PER_5_HOURS, Some(1_000))),
Some(window(100.0, MINUTES_PER_WEEK, Some(2_000))),
),
500,
)
);
}

#[test]
fn earliest_exhausted_reset_at_ignores_unexhausted_and_past_resets() {
// Not exhausted -> None.
assert_eq!(
None,
earliest_exhausted_reset_at(
&snapshot(Some(window(80.0, MINUTES_PER_5_HOURS, Some(1_000))), None),
500,
)
);
// Exhausted but reset already elapsed -> None (fall back to normal interval).
assert_eq!(
None,
earliest_exhausted_reset_at(
&snapshot(Some(window(100.0, MINUTES_PER_5_HOURS, Some(400))), None),
500,
)
);
// Exhausted but reset unknown -> None.
assert_eq!(
None,
earliest_exhausted_reset_at(
&snapshot(Some(window(100.0, MINUTES_PER_5_HOURS, None)), None),
500,
)
);
}

#[test]
fn earliest_exhausted_reset_at_ignores_non_codex_limits() {
let snapshot = UsageProfileRateLimitSnapshot {
limit_id: Some("not-codex"),
limit_name: None,
primary: Some(window(100.0, MINUTES_PER_5_HOURS, Some(1_000))),
secondary: None,
};

assert_eq!(None, earliest_exhausted_reset_at(&snapshot, 500));
}

#[test]
fn exhausted_candidates_merge_earliest_retry_timestamp() {
let health_by_profile = BTreeMap::from([
Expand Down
13 changes: 13 additions & 0 deletions codex-rs/tui/src/app/app_server_event_targets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ pub(super) enum ServerNotificationThreadTarget {
Global,
}

impl ServerNotificationThreadTarget {
/// The thread this notification targets, if it is addressed to a specific
/// thread. Non-thread targets (app-scoped, global, invalid) return `None`.
pub(super) fn thread_id(&self) -> Option<ThreadId> {
match self {
ServerNotificationThreadTarget::Thread(thread_id) => Some(*thread_id),
ServerNotificationThreadTarget::InvalidThreadId(_)
| ServerNotificationThreadTarget::AppScoped
| ServerNotificationThreadTarget::Global => None,
}
}
}

pub(super) fn server_notification_thread_target(
notification: &ServerNotification,
) -> ServerNotificationThreadTarget {
Expand Down
38 changes: 28 additions & 10 deletions codex-rs/tui/src/app/app_server_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,21 @@ impl App {
self.note_session_recap_turn_completed(thread_id, &notification.turn);
}

let result = if self.primary_thread_id == Some(thread_id)
|| self.primary_thread_id.is_none()
{
self.enqueue_primary_thread_notification(notification).await
let result = if self.primary_thread_id == Some(thread_id) {
self.enqueue_thread_notification(thread_id, notification)
.await
} else if self.primary_thread_id.is_none() {
// Startup pre-primary window: the primary thread has not
// finished starting yet. Only buffer as a pending primary
// event when the target thread is not already tracked; a
// known loaded/scheduled thread routes to its own channel
// so its events never leak into the primary buffer.
if self.is_tracked_thread(thread_id) {
self.enqueue_thread_notification(thread_id, notification)
.await
} else {
self.enqueue_primary_thread_notification(notification).await
}
} else {
self.enqueue_thread_notification(thread_id, notification)
.await
Expand Down Expand Up @@ -202,12 +213,19 @@ impl App {
return;
};

let result =
if self.primary_thread_id == Some(thread_id) || self.primary_thread_id.is_none() {
self.enqueue_primary_thread_request(request).await
} else {
self.enqueue_thread_request(thread_id, request).await
};
let result = if self.primary_thread_id == Some(thread_id) {
self.enqueue_thread_request(thread_id, request).await
} else if self.primary_thread_id.is_none() {
// Startup pre-primary window: route requests to their addressed
// thread channel immediately. If this later becomes the primary
// thread, startup attaches to the existing channel and preserves
// the buffered request. If it is some other top-level thread, the
// request must not be parked in the pending-primary queue and then
// dropped after already being registered for resolution.
self.enqueue_thread_request(thread_id, request).await
} else {
self.enqueue_thread_request(thread_id, request).await
};
if let Err(err) = result {
tracing::warn!("failed to enqueue app-server request: {err}");
}
Expand Down
24 changes: 24 additions & 0 deletions codex-rs/tui/src/app/event_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,12 @@ impl App {
AppEvent::ReloadMcpServers => {
self.reload_mcp_servers(app_server);
}
AppEvent::OpenChangelogBrowser => {
self.chat_widget.open_changelog_browser();
}
AppEvent::OpenChangelogRelease { version } => {
self.chat_widget.open_changelog_release(version);
}
AppEvent::OpenBackgroundTerminalManager => {
self.chat_widget.open_background_terminal_manager();
}
Expand Down Expand Up @@ -958,6 +964,24 @@ impl App {
self.manage_thread_workflow(app_server, thread_id, action)
.await;
}
AppEvent::OpenThreadWorkflowManager { thread_id } => {
self.open_thread_workflow_manager(app_server, thread_id)
.await;
}
AppEvent::OpenThreadWorkflowDraftPrompt => {
self.chat_widget.show_thread_workflow_draft_prompt();
}
AppEvent::OpenThreadWorkflowActions {
thread_id,
workflow,
} => {
self.chat_widget
.show_thread_workflow_actions(thread_id, workflow);
}
AppEvent::OpenThreadWorkflowRunActions { thread_id, run } => {
self.chat_widget
.show_thread_workflow_run_actions(thread_id, run);
}
AppEvent::OpenThreadGoalPlanDetail { thread_id, plan } => {
self.chat_widget.show_goal_plan_detail(thread_id, plan);
}
Expand Down
18 changes: 10 additions & 8 deletions codex-rs/tui/src/app/loaded_threads.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

use codex_app_server_protocol::Thread;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SubAgentSource;
use std::collections::HashMap;
use std::collections::HashSet;

Expand Down Expand Up @@ -93,16 +94,17 @@ pub(crate) fn find_loaded_subagent_threads_for_primary(
loaded_threads
}

fn thread_spawn_parent_thread_id(
pub(crate) fn thread_spawn_parent_thread_id(
source: &codex_app_server_protocol::SessionSource,
) -> Option<ThreadId> {
let value = serde_json::to_value(source).ok()?;
let parent_thread_id = value
.get("subAgent")?
.get("thread_spawn")?
.get("parent_thread_id")?
.as_str()?;
ThreadId::from_string(parent_thread_id).ok()
let codex_app_server_protocol::SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
..
}) = source
else {
return None;
};
Some(*parent_thread_id)
}

#[cfg(test)]
Expand Down
Loading
Loading