Add goal extension idle continuation (#25060)

## Why

The goal extension needs a way to resume an active goal after the thread
becomes idle, but the old core goal runtime should not be refactored as
part of this step. The missing piece is a small core-owned turn-start
primitive: let an extension ask for a normal model turn only when the
thread is idle, and otherwise fail without injecting into whatever is
currently active.

## What Changed

- Adds `CodexThread::try_start_turn_if_idle(...)` as the narrow
extension-facing primitive for synthetic idle work.
- Implements the session side so it refuses to start when:
  - the provided input is empty,
  - the session is in plan mode,
  - a turn is already active, or
  - trigger-turn mailbox work is pending.
- Gives trigger-turn mailbox work priority if it appears while the idle
turn is being prepared.
- Wires `GoalExtension::on_thread_idle` to read the active persisted
goal and submit the continuation prompt through this idle-only
primitive.
- Keeps the legacy core goal continuation implementation in place
instead of folding it into this PR.

## Behavior

This is intentionally best-effort. If `try_start_turn_if_idle` observes
that the thread is not idle, or that higher-priority mailbox work should
run first, it returns the input to the caller. The goal extension drops
that continuation prompt and waits for a future idle opportunity instead
of injecting stale synthetic goal text into an active turn.

## Validation

- `just test -p codex-core
try_start_turn_if_idle_rejects_active_turn_without_injecting`
- `just test -p codex-goal-extension`
This commit is contained in:
jif-oai
2026-06-01 10:42:01 +02:00
committed by GitHub
parent 8d49394feb
commit f1b1b64005
6 changed files with 239 additions and 0 deletions
+52
View File
@@ -12,6 +12,7 @@ use crate::accounting::BudgetLimitedGoalDisposition;
use crate::accounting::GoalAccountingState;
use crate::events::GoalEventEmitter;
use crate::metrics::GoalMetrics;
use crate::steering::continuation_steering_item;
use crate::steering::objective_updated_steering_item;
use crate::tool::protocol_goal_from_state;
@@ -275,6 +276,57 @@ impl GoalRuntimeHandle {
Ok(())
}
pub(crate) async fn continue_if_idle(&self) -> Result<(), String> {
if !self.tools_visible() {
self.inner.accounting_state.clear_active_goal();
return Ok(());
}
let Some(goal) = self
.inner
.state_dbs
.thread_goals()
.get_thread_goal(self.thread_id())
.await
.map_err(|err| err.to_string())?
else {
self.inner.accounting_state.clear_active_goal();
return Ok(());
};
if goal.status != codex_state::ThreadGoalStatus::Active {
self.inner.accounting_state.clear_active_goal();
return Ok(());
}
let item = continuation_steering_item(&protocol_goal_from_state(goal));
let Some(thread_manager) = self.inner.thread_manager.upgrade() else {
tracing::debug!("skipping goal continuation because thread manager is unavailable");
return Ok(());
};
let Ok(thread) = thread_manager.get_thread(self.inner.thread_id).await else {
tracing::debug!("skipping goal continuation because live thread is unavailable");
return Ok(());
};
if thread.try_start_turn_if_idle(vec![item]).await.is_err() {
tracing::debug!("skipping goal continuation because the thread is no longer idle");
}
let current_turn_is_goal_active = self
.inner
.accounting_state
.current_turn_id()
.is_some_and(|turn_id| {
self.inner
.accounting_state
.turn_is_current_active_goal(turn_id.as_str())
});
if !current_turn_is_goal_active {
self.inner.accounting_state.clear_active_goal();
}
Ok(())
}
pub(crate) async fn inject_active_turn_steering(&self, item: ResponseItem) {
let Some(thread_manager) = self.inner.thread_manager.upgrade() else {
tracing::debug!("skipping goal steering because thread manager is unavailable");