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
+144 -6
View File
@@ -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;