mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
## Why The goal extension can create and surface goals, but the live turn-accounting path still stopped short of persisting active-goal progress. That leaves token and wall-clock usage, plus `ThreadGoalUpdated` events, out of sync with the extension boundary once work actually advances or a goal transitions out of active state. ## What changed - Teach `GoalAccountingState` to track the current turn, active goal, token deltas, and wall-clock progress snapshots against the persisted goal id. - Flush active-goal accounting from tool-finish, turn-stop, and turn-abort lifecycle hooks, and emit `ThreadGoalUpdated` events when persisted progress changes. - Route `create_goal` and `update_goal` through the same accounting state so new goals start from the right baseline, final progress is flushed before status changes, and `update_goal` can mark a goal `blocked` as well as `complete`. - Keep budget-limited goals accruing through the end of the turn while clearing local active-goal state once a turn or explicit update is finished. - Expand backend and lifecycle coverage around store ids, baseline reset, tool-finish accounting, budget-limited carry-through, and blocked-goal updates. ## Testing - Added focused backend coverage in `codex-rs/ext/goal/tests/goal_extension_backend.rs` for baseline reset, tool-finish accounting, budget-limited turns, and blocked-goal updates. - Extended `codex-rs/core/src/session/tests.rs` to assert that lifecycle inputs expose the expected session, thread, and turn store ids.
322 lines
9.7 KiB
Rust
322 lines
9.7 KiB
Rust
use codex_protocol::config_types::ModeKind;
|
|
use codex_protocol::protocol::TokenUsage;
|
|
use codex_state::ThreadGoalStatus;
|
|
use std::collections::HashMap;
|
|
use std::sync::Mutex;
|
|
use std::sync::PoisonError;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
|
|
#[derive(Debug, Default)]
|
|
pub(crate) struct GoalAccountingState {
|
|
inner: Mutex<GoalAccountingInner>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct GoalAccountingInner {
|
|
current_turn_id: Option<String>,
|
|
turns: HashMap<String, GoalTurnAccounting>,
|
|
wall_clock: GoalWallClockAccounting,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct GoalTurnAccounting {
|
|
current_token_usage: TokenUsage,
|
|
last_accounted_token_usage: TokenUsage,
|
|
active_goal_id: Option<String>,
|
|
account_tokens: bool,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct GoalWallClockAccounting {
|
|
last_accounted_at: Instant,
|
|
active_goal_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct GoalProgressSnapshot {
|
|
pub(crate) current_token_usage: TokenUsage,
|
|
pub(crate) expected_goal_id: String,
|
|
pub(crate) time_delta_seconds: i64,
|
|
pub(crate) token_delta: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum BudgetLimitedGoalDisposition {
|
|
KeepActive,
|
|
ClearActive,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) struct RecordedTokenDelta {
|
|
pub(crate) turn_delta: i64,
|
|
pub(crate) thread_unflushed_delta: i64,
|
|
}
|
|
|
|
impl GoalAccountingState {
|
|
pub(crate) fn start_turn(
|
|
&self,
|
|
turn_id: impl Into<String>,
|
|
collaboration_mode: ModeKind,
|
|
token_usage_at_turn_start: &TokenUsage,
|
|
) {
|
|
let turn_id = turn_id.into();
|
|
let mut inner = self.inner();
|
|
inner.current_turn_id = Some(turn_id.clone());
|
|
inner.turns.insert(
|
|
turn_id,
|
|
GoalTurnAccounting::new(
|
|
token_usage_at_turn_start.clone(),
|
|
!matches!(collaboration_mode, ModeKind::Plan),
|
|
),
|
|
);
|
|
}
|
|
|
|
pub(crate) fn current_turn_id(&self) -> Option<String> {
|
|
self.inner().current_turn_id.clone()
|
|
}
|
|
|
|
pub(crate) fn record_token_usage(
|
|
&self,
|
|
turn_id: impl Into<String>,
|
|
total_usage: &TokenUsage,
|
|
) -> Option<RecordedTokenDelta> {
|
|
let turn_id = turn_id.into();
|
|
let mut inner = self.inner();
|
|
let turn = inner.turns.get_mut(&turn_id)?;
|
|
turn.current_token_usage = total_usage.clone();
|
|
if !turn.account_tokens {
|
|
return None;
|
|
}
|
|
|
|
let delta = turn.token_delta_since_last_accounting();
|
|
if delta <= 0 {
|
|
return None;
|
|
}
|
|
Some(RecordedTokenDelta {
|
|
turn_delta: delta,
|
|
thread_unflushed_delta: inner.thread_unflushed_token_delta(),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn mark_turn_goal_active(&self, turn_id: &str, goal_id: impl Into<String>) {
|
|
let mut inner = self.inner();
|
|
let goal_id = goal_id.into();
|
|
if let Some(turn) = inner.turns.get_mut(turn_id) {
|
|
turn.active_goal_id = Some(goal_id.clone());
|
|
if inner.current_turn_id.as_deref() == Some(turn_id) {
|
|
inner.wall_clock.mark_active_goal(goal_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn mark_current_turn_goal_active(
|
|
&self,
|
|
goal_id: impl Into<String>,
|
|
) -> Option<String> {
|
|
let mut inner = self.inner();
|
|
let turn_id = inner.current_turn_id.clone()?;
|
|
let goal_id = goal_id.into();
|
|
let turn = inner.turns.get_mut(turn_id.as_str())?;
|
|
turn.active_goal_id = Some(goal_id.clone());
|
|
turn.reset_baseline_to_current();
|
|
inner.wall_clock.mark_active_goal(goal_id);
|
|
Some(turn_id)
|
|
}
|
|
|
|
pub(crate) fn clear_current_turn_goal(&self) -> Option<String> {
|
|
let mut inner = self.inner();
|
|
let turn_id = inner.current_turn_id.clone()?;
|
|
if let Some(turn) = inner.turns.get_mut(turn_id.as_str()) {
|
|
turn.active_goal_id = None;
|
|
}
|
|
inner.wall_clock.clear_active_goal();
|
|
Some(turn_id)
|
|
}
|
|
|
|
pub(crate) fn progress_snapshot(&self, turn_id: &str) -> Option<GoalProgressSnapshot> {
|
|
let inner = self.inner();
|
|
let turn = inner.turns.get(turn_id)?;
|
|
if !turn.account_tokens {
|
|
return None;
|
|
}
|
|
let expected_goal_id = turn.active_goal_id()?;
|
|
let token_delta = turn.token_delta_since_last_accounting();
|
|
let time_delta_seconds =
|
|
if inner.wall_clock.active_goal_id.as_deref() == Some(expected_goal_id.as_str()) {
|
|
inner.wall_clock.time_delta_since_last_accounting()
|
|
} else {
|
|
0
|
|
};
|
|
if time_delta_seconds == 0 && token_delta <= 0 {
|
|
return None;
|
|
}
|
|
Some(GoalProgressSnapshot {
|
|
current_token_usage: turn.current_token_usage.clone(),
|
|
expected_goal_id,
|
|
time_delta_seconds,
|
|
token_delta,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn mark_progress_accounted_for_status(
|
|
&self,
|
|
turn_id: &str,
|
|
snapshot: &GoalProgressSnapshot,
|
|
status: ThreadGoalStatus,
|
|
budget_limited_goal_disposition: BudgetLimitedGoalDisposition,
|
|
) {
|
|
let clear_active_goal = should_clear_active_goal(status, budget_limited_goal_disposition);
|
|
let mut inner = self.inner();
|
|
if let Some(turn) = inner.turns.get_mut(turn_id) {
|
|
turn.last_accounted_token_usage = snapshot.current_token_usage.clone();
|
|
if clear_active_goal {
|
|
turn.active_goal_id = None;
|
|
}
|
|
}
|
|
inner.wall_clock.mark_accounted(snapshot.time_delta_seconds);
|
|
if clear_active_goal {
|
|
inner.wall_clock.clear_active_goal();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn finish_turn(&self, turn_id: &str) {
|
|
let mut inner = self.inner();
|
|
inner.turns.remove(turn_id);
|
|
if inner.current_turn_id.as_deref() == Some(turn_id) {
|
|
inner.current_turn_id = None;
|
|
}
|
|
}
|
|
|
|
fn inner(&self) -> std::sync::MutexGuard<'_, GoalAccountingInner> {
|
|
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
|
|
}
|
|
}
|
|
|
|
fn token_delta_since_last_accounting(last: &TokenUsage, current: &TokenUsage) -> i64 {
|
|
let delta = TokenUsage {
|
|
input_tokens: current.input_tokens.saturating_sub(last.input_tokens),
|
|
cached_input_tokens: current
|
|
.cached_input_tokens
|
|
.saturating_sub(last.cached_input_tokens),
|
|
output_tokens: current.output_tokens.saturating_sub(last.output_tokens),
|
|
reasoning_output_tokens: current
|
|
.reasoning_output_tokens
|
|
.saturating_sub(last.reasoning_output_tokens),
|
|
total_tokens: current.total_tokens.saturating_sub(last.total_tokens),
|
|
};
|
|
goal_token_delta_for_usage(&delta)
|
|
}
|
|
|
|
pub(crate) fn goal_token_delta_for_usage(usage: &TokenUsage) -> i64 {
|
|
usage
|
|
.input_tokens
|
|
.saturating_sub(usage.cached_input_tokens)
|
|
.saturating_add(usage.output_tokens.max(0))
|
|
}
|
|
|
|
impl Default for GoalAccountingInner {
|
|
fn default() -> Self {
|
|
Self {
|
|
current_turn_id: None,
|
|
turns: HashMap::new(),
|
|
wall_clock: GoalWallClockAccounting::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl GoalAccountingInner {
|
|
fn thread_unflushed_token_delta(&self) -> i64 {
|
|
self.turns
|
|
.values()
|
|
.filter(|turn| turn.account_tokens)
|
|
.fold(0_i64, |total, turn| {
|
|
total.saturating_add(turn.token_delta_since_last_accounting().max(0))
|
|
})
|
|
}
|
|
}
|
|
|
|
impl GoalTurnAccounting {
|
|
fn new(current_token_usage: TokenUsage, account_tokens: bool) -> Self {
|
|
Self {
|
|
last_accounted_token_usage: current_token_usage.clone(),
|
|
current_token_usage,
|
|
active_goal_id: None,
|
|
account_tokens,
|
|
}
|
|
}
|
|
|
|
fn active_goal_id(&self) -> Option<String> {
|
|
self.active_goal_id.clone()
|
|
}
|
|
|
|
fn reset_baseline_to_current(&mut self) {
|
|
self.last_accounted_token_usage = self.current_token_usage.clone();
|
|
}
|
|
|
|
fn token_delta_since_last_accounting(&self) -> i64 {
|
|
token_delta_since_last_accounting(
|
|
&self.last_accounted_token_usage,
|
|
&self.current_token_usage,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl GoalWallClockAccounting {
|
|
fn new() -> Self {
|
|
Self {
|
|
last_accounted_at: Instant::now(),
|
|
active_goal_id: None,
|
|
}
|
|
}
|
|
|
|
fn time_delta_since_last_accounting(&self) -> i64 {
|
|
i64::try_from(self.last_accounted_at.elapsed().as_secs()).unwrap_or(i64::MAX)
|
|
}
|
|
|
|
fn mark_accounted(&mut self, accounted_seconds: i64) {
|
|
if accounted_seconds <= 0 {
|
|
return;
|
|
}
|
|
let advance = Duration::from_secs(u64::try_from(accounted_seconds).unwrap_or(u64::MAX));
|
|
self.last_accounted_at = self
|
|
.last_accounted_at
|
|
.checked_add(advance)
|
|
.unwrap_or_else(Instant::now);
|
|
}
|
|
|
|
fn reset_baseline(&mut self) {
|
|
self.last_accounted_at = Instant::now();
|
|
}
|
|
|
|
fn mark_active_goal(&mut self, goal_id: impl Into<String>) {
|
|
let goal_id = goal_id.into();
|
|
if self.active_goal_id.as_deref() != Some(goal_id.as_str()) {
|
|
self.reset_baseline();
|
|
self.active_goal_id = Some(goal_id);
|
|
}
|
|
}
|
|
|
|
fn clear_active_goal(&mut self) {
|
|
self.active_goal_id = None;
|
|
self.reset_baseline();
|
|
}
|
|
}
|
|
|
|
fn should_clear_active_goal(
|
|
status: ThreadGoalStatus,
|
|
budget_limited_goal_disposition: BudgetLimitedGoalDisposition,
|
|
) -> bool {
|
|
match status {
|
|
ThreadGoalStatus::Active => false,
|
|
ThreadGoalStatus::BudgetLimited => matches!(
|
|
budget_limited_goal_disposition,
|
|
BudgetLimitedGoalDisposition::ClearActive
|
|
),
|
|
ThreadGoalStatus::Paused
|
|
| ThreadGoalStatus::Blocked
|
|
| ThreadGoalStatus::UsageLimited
|
|
| ThreadGoalStatus::Complete => true,
|
|
}
|
|
}
|