Add goal core runtime (4 / 5) (#18076)

Adds the core runtime behavior for active goals on top of the model
tools from PR 3.

## Why

A long-running goal should be a core runtime concern, not something
every client has to implement. Core owns the turn lifecycle, tool
completion boundaries, interruptions, resume behavior, and token usage,
so it is the right place to account progress, enforce budgets, and
decide when to continue work.

## What changed

- Centralized goal lifecycle side effects behind
`Session::goal_runtime_apply(GoalRuntimeEvent::...)`.
- Starts goal continuation turns only when the session is idle; pending
user input and mailbox work take priority.
- Accounts token and wall-clock usage at turn, tool, mutation,
interrupt, and resume boundaries; `get_thread_goal` remains read-only.
- Preserves sub-second wall-clock remainder across accounting boundaries
so long-running goals do not drift downward over time.
- Treats token budget exhaustion as a soft stop by marking the goal
`budget_limited` and injecting wrap-up steering instead of aborting the
active turn.
- Suppresses budget steering when `update_goal` marks a goal complete.
- Pauses active goals on interrupt and auto-reactivates paused goals
when a thread resumes outside plan mode.
- Suppresses repeated automatic continuation when a continuation turn
makes no tool calls.
- Added continuation and budget-limit prompt templates.

## Verification

- Added focused core coverage for continuation scheduling, accounting
boundaries, budget-limit steering, completion accounting, interrupt
pause behavior, resume auto-activation, and wall-clock remainder
accounting.
This commit is contained in:
Eric Traut
2026-04-24 21:16:00 -07:00
committed by GitHub
Unverified
parent 32ace07ac5
commit 4167628622
17 changed files with 2360 additions and 111 deletions
@@ -1,4 +1,5 @@
use super::*;
use codex_protocol::protocol::validate_thread_goal_objective;
impl CodexMessageProcessor {
pub(super) async fn thread_goal_set(
@@ -83,12 +84,8 @@ impl CodexMessageProcessor {
let objective = params.objective.as_deref().map(str::trim);
if let Some(objective) = objective {
if objective.is_empty() {
self.send_invalid_request_error(
request_id,
"goal objective must not be empty".to_string(),
)
.await;
if let Err(message) = validate_thread_goal_objective(objective) {
self.send_invalid_request_error(request_id, message).await;
return;
}
if let Err(message) = validate_goal_budget(params.token_budget.flatten()) {
@@ -102,6 +99,10 @@ impl CodexMessageProcessor {
return;
}
if let Some(thread) = running_thread.as_ref() {
thread.prepare_external_goal_mutation().await;
}
let goal = if let Some(objective) = objective {
match state_db.get_thread_goal(thread_id).await {
Ok(goal) => {
@@ -165,6 +166,7 @@ impl CodexMessageProcessor {
return;
}
};
let goal_status = goal.status;
let goal = api_thread_goal_from_state(goal);
self.outgoing
.send_response(
@@ -174,6 +176,9 @@ impl CodexMessageProcessor {
.await;
self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx)
.await;
if let Some(thread) = running_thread.as_ref() {
thread.apply_external_goal_set(goal_status).await;
}
}
pub(super) async fn thread_goal_get(
@@ -287,6 +292,10 @@ impl CodexMessageProcessor {
)
.await;
if let Some(thread) = running_thread.as_ref() {
thread.prepare_external_goal_mutation().await;
}
let listener_command_tx = {
let thread_state = self.thread_state_manager.thread_state(thread_id).await;
let thread_state = thread_state.lock().await;
@@ -301,6 +310,10 @@ impl CodexMessageProcessor {
}
};
if cleared && let Some(thread) = running_thread.as_ref() {
thread.apply_external_goal_clear().await;
}
self.outgoing
.send_response(request_id, ThreadGoalClearResponse { cleared })
.await;