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
@@ -195,7 +195,7 @@ UPDATE thread_goals
|
||||
SET
|
||||
objective = COALESCE(?, objective),
|
||||
status = CASE
|
||||
WHEN status = ? AND ? = ? THEN status
|
||||
WHEN status = ? AND ? IN (?, ?) THEN status
|
||||
WHEN ? = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ?
|
||||
ELSE ?
|
||||
END,
|
||||
@@ -209,6 +209,7 @@ WHERE thread_id = ?
|
||||
.bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
|
||||
.bind(status.as_str())
|
||||
.bind(crate::ThreadGoalStatus::Paused.as_str())
|
||||
.bind(crate::ThreadGoalStatus::Blocked.as_str())
|
||||
.bind(status.as_str())
|
||||
.bind(token_budget)
|
||||
.bind(token_budget)
|
||||
@@ -229,7 +230,7 @@ UPDATE thread_goals
|
||||
SET
|
||||
objective = COALESCE(?, objective),
|
||||
status = CASE
|
||||
WHEN status = ? AND ? = ? THEN status
|
||||
WHEN status = ? AND ? IN (?, ?) THEN status
|
||||
WHEN ? = 'active' AND token_budget IS NOT NULL AND tokens_used >= token_budget THEN ?
|
||||
ELSE ?
|
||||
END,
|
||||
@@ -242,6 +243,7 @@ WHERE thread_id = ?
|
||||
.bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
|
||||
.bind(status.as_str())
|
||||
.bind(crate::ThreadGoalStatus::Paused.as_str())
|
||||
.bind(crate::ThreadGoalStatus::Blocked.as_str())
|
||||
.bind(status.as_str())
|
||||
.bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
|
||||
.bind(status.as_str())
|
||||
@@ -323,6 +325,23 @@ WHERE thread_id = ?
|
||||
pub async fn pause_active_thread_goal(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
) -> anyhow::Result<Option<crate::ThreadGoal>> {
|
||||
self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::Paused)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn usage_limit_active_thread_goal(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
) -> anyhow::Result<Option<crate::ThreadGoal>> {
|
||||
self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::UsageLimited)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_active_thread_goal_status(
|
||||
&self,
|
||||
thread_id: ThreadId,
|
||||
status: crate::ThreadGoalStatus,
|
||||
) -> anyhow::Result<Option<crate::ThreadGoal>> {
|
||||
let now_ms = datetime_to_epoch_millis(Utc::now());
|
||||
let result = sqlx::query(
|
||||
@@ -332,12 +351,19 @@ SET
|
||||
status = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE thread_id = ?
|
||||
AND status = 'active'
|
||||
AND (
|
||||
status = 'active'
|
||||
OR (
|
||||
? = 'usage_limited'
|
||||
AND status = 'budget_limited'
|
||||
)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(crate::ThreadGoalStatus::Paused.as_str())
|
||||
.bind(status.as_str())
|
||||
.bind(now_ms)
|
||||
.bind(thread_id.to_string())
|
||||
.bind(status.as_str())
|
||||
.execute(self.pool.as_ref())
|
||||
.await?;
|
||||
|
||||
@@ -386,7 +412,7 @@ WHERE thread_id = ?
|
||||
"status IN ('active', 'budget_limited', 'complete')"
|
||||
}
|
||||
ThreadGoalAccountingMode::ActiveOrStopped => {
|
||||
"status IN ('active', 'paused', 'budget_limited')"
|
||||
"status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')"
|
||||
}
|
||||
};
|
||||
let budget_limit_status_filter = match mode {
|
||||
@@ -394,7 +420,7 @@ WHERE thread_id = ?
|
||||
| ThreadGoalAccountingMode::ActiveOnly
|
||||
| ThreadGoalAccountingMode::ActiveOrComplete => "status = 'active'",
|
||||
ThreadGoalAccountingMode::ActiveOrStopped => {
|
||||
"status IN ('active', 'paused', 'budget_limited')"
|
||||
"status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')"
|
||||
}
|
||||
};
|
||||
let goal_id_filter = if expected_goal_id.is_some() {
|
||||
@@ -1019,6 +1045,66 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_active_thread_goal_updates_active_or_budget_limited_goals() {
|
||||
let runtime = test_runtime().await;
|
||||
let thread_id = test_thread_id();
|
||||
upsert_test_thread(&runtime, thread_id).await;
|
||||
let goal = runtime
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
thread_id,
|
||||
"optimize the benchmark",
|
||||
crate::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("goal replacement should succeed");
|
||||
|
||||
let usage_limited = runtime
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(thread_id)
|
||||
.await
|
||||
.expect("usage limiting should succeed")
|
||||
.expect("active goal should become usage limited");
|
||||
let expected = crate::ThreadGoal {
|
||||
status: crate::ThreadGoalStatus::UsageLimited,
|
||||
updated_at: usage_limited.updated_at,
|
||||
..goal
|
||||
};
|
||||
assert_eq!(expected, usage_limited);
|
||||
|
||||
let second_update = runtime
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(thread_id)
|
||||
.await
|
||||
.expect("repeated usage limiting should succeed");
|
||||
assert_eq!(None, second_update);
|
||||
|
||||
let budget_limited = runtime
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
thread_id,
|
||||
"keep the usage failure visible",
|
||||
crate::ThreadGoalStatus::BudgetLimited,
|
||||
/*token_budget*/ Some(1),
|
||||
)
|
||||
.await
|
||||
.expect("goal replacement should succeed");
|
||||
let usage_limited = runtime
|
||||
.thread_goals()
|
||||
.usage_limit_active_thread_goal(thread_id)
|
||||
.await
|
||||
.expect("usage limiting should succeed")
|
||||
.expect("budget-limited goal should become usage limited");
|
||||
let expected = crate::ThreadGoal {
|
||||
status: crate::ThreadGoalStatus::UsageLimited,
|
||||
updated_at: usage_limited.updated_at,
|
||||
..budget_limited
|
||||
};
|
||||
assert_eq!(expected, usage_limited);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_accounting_updates_active_goals_and_accounts_budget_limited_in_flight_usage() {
|
||||
let runtime = test_runtime().await;
|
||||
@@ -1318,6 +1404,58 @@ mod tests {
|
||||
assert_eq!(50, paused.tokens_used);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blocking_budget_limited_goal_preserves_terminal_status() {
|
||||
let runtime = test_runtime().await;
|
||||
let thread_id = test_thread_id();
|
||||
upsert_test_thread(&runtime, thread_id).await;
|
||||
runtime
|
||||
.thread_goals()
|
||||
.replace_thread_goal(
|
||||
thread_id,
|
||||
"stay within budget",
|
||||
crate::ThreadGoalStatus::Active,
|
||||
/*token_budget*/ Some(40),
|
||||
)
|
||||
.await
|
||||
.expect("goal replacement should succeed");
|
||||
let outcome = runtime
|
||||
.thread_goals()
|
||||
.account_thread_goal_usage(
|
||||
thread_id,
|
||||
/*time_delta_seconds*/ 1,
|
||||
/*token_delta*/ 50,
|
||||
ThreadGoalAccountingMode::ActiveOnly,
|
||||
/*expected_goal_id*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("usage accounting should succeed");
|
||||
let ThreadGoalAccountingOutcome::Updated(budget_limited) = outcome else {
|
||||
panic!("budget crossing should update the goal");
|
||||
};
|
||||
|
||||
let blocked = runtime
|
||||
.thread_goals()
|
||||
.update_thread_goal(
|
||||
thread_id,
|
||||
ThreadGoalUpdate {
|
||||
objective: None,
|
||||
status: Some(crate::ThreadGoalStatus::Blocked),
|
||||
token_budget: None,
|
||||
expected_goal_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("goal update should succeed")
|
||||
.expect("goal should exist");
|
||||
|
||||
let expected = crate::ThreadGoal {
|
||||
updated_at: blocked.updated_at,
|
||||
..budget_limited
|
||||
};
|
||||
assert_eq!(expected, blocked);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() {
|
||||
let runtime = test_runtime().await;
|
||||
|
||||
Reference in New Issue
Block a user