mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
d32cb2c6ac
commit
0d344aca9b
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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
|
||||
+11
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user