feat: handle goal usage limits in goal extension (#24628)

## Why

The extracted goal runtime needs a host-callable path for turns that
stop because the workspace usage limit is reached. In that case, any
in-turn goal progress should be accounted before the goal becomes
terminal, and active goal accounting must be cleared so later
tool-finish or turn-stop handling does not keep charging usage to a
stopped goal.

## What changed

- Adds `GoalRuntimeHandle::usage_limit_active_goal_for_turn`, which
accounts current active-goal progress, marks the active or
budget-limited thread goal as `UsageLimited`, records terminal metrics
when the status changes, clears active goal accounting, and emits the
updated goal event.
- Covers both active and budget-limited goals in
`ext/goal/tests/goal_extension_backend.rs`, including the invariant that
later token/tool events do not add usage after the goal has been
usage-limited.

## Testing

- Added
`usage_limit_active_goal_accounts_progress_and_clears_accounting`.
- Added `usage_limit_budget_limited_goal_accounts_remaining_progress`.
This commit is contained in:
jif-oai authored and GitHub committed 2026-05-27 13:00:06 +02:00
1 parent ca2e343f65
commit bc005029bd
3 files changed
+303 -1

No files matched your search

+48
View File
@@ -192,6 +192,54 @@ impl GoalRuntimeHandle {
Ok(())
}
pub async fn usage_limit_active_goal_for_turn(&self, turn_id: &str) -> Result<(), String> {
if !self.is_enabled() {
return Ok(());
}
if !self
.inner
.accounting_state
.turn_is_current_active_goal(turn_id)
{
return Ok(());
}
let progress_event_id = format!("{turn_id}:usage-limit-progress");
self.account_active_goal_progress(
turn_id,
progress_event_id.as_str(),
codex_state::GoalAccountingMode::ActiveOnly,
BudgetLimitedGoalDisposition::ClearActive,
)
.await?;
let previous_status = self
.current_goal_status_for_metrics(/*expected_goal_id*/ None)
.await?;
let Some(goal) = self
.inner
.state_dbs
.thread_goals()
.usage_limit_active_thread_goal(self.thread_id())
.await
.map_err(|err| err.to_string())?
else {
return Ok(());
};
self.inner
.metrics
.record_terminal_if_status_changed(previous_status, &goal);
self.inner.accounting_state.clear_active_goal();
let goal = protocol_goal_from_state(goal);
self.inner.event_emitter.thread_goal_updated(
format!("{turn_id}:usage-limit"),
Some(turn_id.to_string()),
goal,
);
Ok(())
}
pub async fn restore_after_resume(&self) -> Result<(), String> {
if !self.is_enabled() {
return Ok(());