goal: pause continuation loops on usage limits and blockers (#23094)

Addresses #22833, #22245, #23067

## Why
`/goal` can keep synthesizing turns even when the next turn cannot make
meaningful progress. Hard usage exhaustion can replay failing turns, and
repeated permission or external-resource blockers can keep burning
tokens while waiting for user or system intervention.

## What changed
- Add resumable `blocked` and `usageLimited` goal states. As with
`paused`, goal continuation stops with these states.
- Move to `usageLimited` after usage-limit failures.
- Allow the built-in `update_goal` tool to set `blocked` only under
explicit repeated-impasse guidance. Updated goal continuation prompt to
specify that agent should use `blocked` only when it has made at least
three attempts to get past an impasse.

Most of the files touched by this PR are because of the small app server
protocol update.

## Validation

I manually reproduced a number of situations where an agent can run into
a true impasse and verified that it properly enters `blocked` state. I
then resumed and verified that it once again entered `blocked` state
several turns later if the impasse still exists.

I also manually reproduced the usage-limit condition by creating a
simulated responses API endpoint that returns 429 errors with the
appropriate error message. Verified that the goal runtime properly moves
the goal into `usageLimited` state and TUI UI updates appropriately.
Verified that `/goal resume` resumes (and immediately goes back into
`ussageLImited` state if appropriate).


## Follow-up PRs

Small changes will be needed to the GUI clients to properly handle the
two new states.
This commit is contained in:
Eric Traut
2026-05-18 11:28:53 -07:00
committed by GitHub
Unverified
parent d32cb2c6ac
commit 0d344aca9b
32 changed files with 837 additions and 35 deletions
+4 -1
View File
@@ -63,7 +63,10 @@ impl App {
let Some(goal) = response.goal else {
return;
};
if goal.status == ThreadGoalStatus::Paused {
if matches!(
goal.status,
ThreadGoalStatus::Paused | ThreadGoalStatus::Blocked | ThreadGoalStatus::UsageLimited
) {
self.chat_widget
.show_resume_paused_goal_prompt(thread_id, goal.objective);
}
+4
View File
@@ -98,6 +98,8 @@ pub(crate) enum CollaborationModeIndicator {
pub(crate) enum GoalStatusIndicator {
Active { usage: Option<String> },
Paused,
Blocked,
UsageLimited,
BudgetLimited { usage: Option<String> },
Complete { usage: Option<String> },
}
@@ -547,6 +549,8 @@ pub(crate) fn goal_status_indicator_line(
}
}
GoalStatusIndicator::Paused => "Goal paused (/goal resume)".to_string(),
GoalStatusIndicator::Blocked => "Goal blocked (/goal resume)".to_string(),
GoalStatusIndicator::UsageLimited => "Goal hit usage limits (/goal resume)".to_string(),
GoalStatusIndicator::BudgetLimited { usage } => {
if let Some(usage) = usage {
format!("Goal unmet ({usage})")
+8 -2
View File
@@ -103,7 +103,9 @@ fn goal_summary_lines(goal: &AppThreadGoal) -> Vec<Line<'static>> {
}
let command_hint = match goal.status {
AppThreadGoalStatus::Active => "Commands: /goal edit, /goal pause, /goal clear",
AppThreadGoalStatus::Paused => "Commands: /goal edit, /goal resume, /goal clear",
AppThreadGoalStatus::Paused
| AppThreadGoalStatus::Blocked
| AppThreadGoalStatus::UsageLimited => "Commands: /goal edit, /goal resume, /goal clear",
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
"Commands: /goal edit, /goal clear"
}
@@ -117,6 +119,8 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str {
match status {
AppThreadGoalStatus::Active => "active",
AppThreadGoalStatus::Paused => "paused",
AppThreadGoalStatus::Blocked => "blocked",
AppThreadGoalStatus::UsageLimited => "usage limited",
AppThreadGoalStatus::BudgetLimited => "limited by budget",
AppThreadGoalStatus::Complete => "complete",
}
@@ -125,7 +129,9 @@ fn goal_status_label(status: AppThreadGoalStatus) -> &'static str {
fn edited_goal_status(status: AppThreadGoalStatus) -> AppThreadGoalStatus {
match status {
AppThreadGoalStatus::Active => AppThreadGoalStatus::Active,
AppThreadGoalStatus::Paused => AppThreadGoalStatus::Paused,
AppThreadGoalStatus::Paused
| AppThreadGoalStatus::Blocked
| AppThreadGoalStatus::UsageLimited => status,
AppThreadGoalStatus::BudgetLimited | AppThreadGoalStatus::Complete => {
AppThreadGoalStatus::Active
}
@@ -50,6 +50,8 @@ pub(super) fn goal_status_indicator_from_app_goal(
usage: active_goal_usage(goal.token_budget, goal.tokens_used, goal.time_used_seconds),
}),
AppThreadGoalStatus::Paused => Some(GoalStatusIndicator::Paused),
AppThreadGoalStatus::Blocked => Some(GoalStatusIndicator::Blocked),
AppThreadGoalStatus::UsageLimited => Some(GoalStatusIndicator::UsageLimited),
AppThreadGoalStatus::BudgetLimited => Some(GoalStatusIndicator::BudgetLimited {
usage: stopped_goal_budget_usage(goal.token_budget, goal.tokens_used),
}),
@@ -375,6 +375,7 @@ impl ChatWidget {
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.bottom_pane.clear_quit_shortcut_hint();
self.pause_active_goal_for_interrupt();
self.submit_op(AppCommand::interrupt());
} else {
self.request_quit_without_confirmation();
@@ -392,6 +393,7 @@ impl ChatWidget {
self.arm_quit_shortcut(key);
if self.is_cancellable_work_active() {
self.pause_active_goal_for_interrupt();
self.submit_op(AppCommand::interrupt());
}
}
@@ -452,4 +454,24 @@ impl ChatWidget {
fn is_cancellable_work_active(&self) -> bool {
self.bottom_pane.is_task_running() || self.review.is_review_mode
}
fn pause_active_goal_for_interrupt(&self) {
if !self.turn_lifecycle.agent_turn_running {
return;
}
if !self
.current_goal_status
.as_ref()
.is_some_and(GoalStatusState::is_active)
{
return;
}
let Some(thread_id) = self.thread_id else {
return;
};
self.app_event_tx.send(AppEvent::SetThreadGoalStatus {
thread_id,
status: AppThreadGoalStatus::Paused,
});
}
}
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/goal_menu.rs
expression: rendered_goal_summary(&mut rx)
---
Goal
Status: blocked
Objective: Keep improving the bare goal command until it feels calm and useful.
Time used: 1m
Tokens used: 12.5K
Commands: /goal edit, /goal resume, /goal clear
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests/goal_menu.rs
expression: rendered_goal_summary(&mut rx)
---
Goal
Status: usage limited
Objective: Keep improving the bare goal command until it feels calm and useful.
Time used: 1m
Tokens used: 12.5K
Commands: /goal edit, /goal resume, /goal clear
@@ -28,6 +28,34 @@ async fn goal_menu_paused_snapshot() {
assert_chatwidget_snapshot!("goal_menu_paused", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn goal_menu_blocked_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_summary(test_goal(
thread_id,
AppThreadGoalStatus::Blocked,
/*token_budget*/ None,
));
assert_chatwidget_snapshot!("goal_menu_blocked", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn goal_menu_usage_limited_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_summary(test_goal(
thread_id,
AppThreadGoalStatus::UsageLimited,
/*token_budget*/ None,
));
assert_chatwidget_snapshot!("goal_menu_usage_limited", rendered_goal_summary(&mut rx));
}
#[tokio::test]
async fn goal_menu_budget_limited_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -117,6 +145,42 @@ async fn goal_edit_prompt_submits_preserved_status_and_budget() {
assert!(chat.no_modal_or_popup_active());
}
#[tokio::test]
async fn goal_edit_prompt_preserves_resumable_stopped_statuses() {
for stopped_status in [
AppThreadGoalStatus::Blocked,
AppThreadGoalStatus::UsageLimited,
] {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.show_goal_edit_prompt(
thread_id,
test_goal(
thread_id,
stopped_status,
/*token_budget*/ Some(80_000),
),
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match rx.try_recv() {
Ok(AppEvent::SetThreadGoalObjective {
mode:
crate::app_event::ThreadGoalSetMode::UpdateExisting {
status,
token_budget,
},
..
}) => {
assert_eq!(status, stopped_status);
assert_eq!(token_budget, Some(80_000));
}
other => panic!("expected SetThreadGoalObjective event, got {other:?}"),
}
}
}
#[tokio::test]
async fn goal_edit_prompt_resets_terminal_status_to_active() {
let cases = [
@@ -1136,6 +1136,45 @@ async fn streaming_final_answer_keeps_task_running_state() {
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn ctrl_c_interrupt_pauses_active_goal_turn() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let thread_id = ThreadId::new();
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.thread_id = Some(thread_id);
let mut goal = test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
);
goal.thread_id = thread_id.to_string();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: None,
goal,
},
),
/*replay_kind*/ None,
);
chat.on_task_started();
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert_matches!(
rx.try_recv(),
Ok(AppEvent::SetThreadGoalStatus {
thread_id: event_thread_id,
status: AppThreadGoalStatus::Paused,
}) if event_thread_id == thread_id
);
}
#[tokio::test]
async fn idle_commit_ticks_do_not_restore_status_without_commentary_completion() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -2293,6 +2332,22 @@ fn goal_status_indicator_formats_statuses_and_budgets() {
usage: Some("30m".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Blocked,
/*token_budget*/ None,
/*tokens_used*/ 0,
)),
Some(GoalStatusIndicator::Blocked)
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::UsageLimited,
/*token_budget*/ None,
/*tokens_used*/ 0,
)),
Some(GoalStatusIndicator::UsageLimited)
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
@@ -2339,6 +2394,11 @@ fn goal_status_indicator_line_formats_goal_text() {
"Goal unmet (4K / 5K tokens)",
),
(GoalStatusIndicator::Paused, "Goal paused (/goal resume)"),
(GoalStatusIndicator::Blocked, "Goal blocked (/goal resume)"),
(
GoalStatusIndicator::UsageLimited,
"Goal hit usage limits (/goal resume)",
),
(
GoalStatusIndicator::BudgetLimited { usage: None },
"Goal abandoned",
+2
View File
@@ -32,6 +32,8 @@ pub(crate) fn goal_status_label(status: ThreadGoalStatus) -> &'static str {
match status {
ThreadGoalStatus::Active => "active",
ThreadGoalStatus::Paused => "paused",
ThreadGoalStatus::Blocked => "blocked",
ThreadGoalStatus::UsageLimited => "usage limited",
ThreadGoalStatus::BudgetLimited => "limited by budget",
ThreadGoalStatus::Complete => "complete",
}