From 6ae8cf4c4c9efb9bec0ab24df9510315016d1598 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Wed, 15 Apr 2026 21:22:38 -0700
Subject: [PATCH 01/23] Add goal mode state foundation

---
 codex-rs/core/config.schema.json              |   6 +
 codex-rs/features/src/lib.rs                  |   8 +
 codex-rs/features/src/tests.rs                |   7 +
 .../state/migrations/0026_thread_goals.sql    |   9 +
 codex-rs/state/src/lib.rs                     |   5 +
 codex-rs/state/src/model/mod.rs               |   4 +
 codex-rs/state/src/model/thread_goal.rs       | 101 +++++
 codex-rs/state/src/runtime.rs                 |   5 +
 codex-rs/state/src/runtime/goals.rs           | 355 ++++++++++++++++++
 9 files changed, 500 insertions(+)
 create mode 100644 codex-rs/state/migrations/0026_thread_goals.sql
 create mode 100644 codex-rs/state/src/model/thread_goal.rs
 create mode 100644 codex-rs/state/src/runtime/goals.rs

diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json
index c61daa8b5650..703e92d9ab8b 100644
--- a/codex-rs/core/config.schema.json
+++ b/codex-rs/core/config.schema.json
@@ -395,6 +395,9 @@
             "general_analytics": {
               "type": "boolean"
             },
+            "goal_mode": {
+              "type": "boolean"
+            },
             "guardian_approval": {
               "type": "boolean"
             },
@@ -2258,6 +2261,9 @@
         "general_analytics": {
           "type": "boolean"
         },
+        "goal_mode": {
+          "type": "boolean"
+        },
         "guardian_approval": {
           "type": "boolean"
         },
diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs
index ce7a196362b1..1e34d4b23aac 100644
--- a/codex-rs/features/src/lib.rs
+++ b/codex-rs/features/src/lib.rs
@@ -165,6 +165,8 @@ pub enum Feature {
     DefaultModeRequestUserInput,
     /// Enable automatic review for approval prompts.
     GuardianApproval,
+    /// Enable persisted thread goals and automatic goal continuation.
+    GoalMode,
     /// Enable collaboration modes (Plan, Default).
     /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled.
     CollaborationModes,
@@ -849,6 +851,12 @@ pub const FEATURES: &[FeatureSpec] = &[
         },
         default_enabled: false,
     },
+    FeatureSpec {
+        id: Feature::GoalMode,
+        key: "goal_mode",
+        stage: Stage::UnderDevelopment,
+        default_enabled: false,
+    },
     FeatureSpec {
         id: Feature::CollaborationModes,
         key: "collaboration_modes",
diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs
index 85cb01be2372..9f1da92a5562 100644
--- a/codex-rs/features/src/tests.rs
+++ b/codex-rs/features/src/tests.rs
@@ -121,6 +121,13 @@ fn request_permissions_tool_is_under_development() {
     assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
 }
 
+#[test]
+fn goal_mode_is_under_development() {
+    assert_eq!(Feature::GoalMode.stage(), Stage::UnderDevelopment);
+    assert_eq!(Feature::GoalMode.default_enabled(), false);
+    assert_eq!(feature_for_key("goal_mode"), Some(Feature::GoalMode));
+}
+
 #[test]
 fn tool_suggest_is_stable_and_enabled_by_default() {
     assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable);
diff --git a/codex-rs/state/migrations/0026_thread_goals.sql b/codex-rs/state/migrations/0026_thread_goals.sql
new file mode 100644
index 000000000000..e40d9dcadc06
--- /dev/null
+++ b/codex-rs/state/migrations/0026_thread_goals.sql
@@ -0,0 +1,9 @@
+CREATE TABLE thread_goals (
+    thread_id TEXT PRIMARY KEY NOT NULL,
+    objective TEXT NOT NULL,
+    status TEXT NOT NULL CHECK(status IN ('active', 'paused', 'budget_stopped', 'complete')),
+    token_budget INTEGER,
+    tokens_used INTEGER NOT NULL DEFAULT 0,
+    created_at_ms INTEGER NOT NULL,
+    updated_at_ms INTEGER NOT NULL
+);
diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs
index efad7651f5ef..d55a01e80a21 100644
--- a/codex-rs/state/src/lib.rs
+++ b/codex-rs/state/src/lib.rs
@@ -43,10 +43,15 @@ pub use model::Stage1JobClaimOutcome;
 pub use model::Stage1Output;
 pub use model::Stage1OutputRef;
 pub use model::Stage1StartupClaimParams;
+pub use model::ThreadGoal;
+pub use model::ThreadGoalStatus;
 pub use model::ThreadMetadata;
 pub use model::ThreadMetadataBuilder;
 pub use model::ThreadsPage;
 pub use runtime::RemoteControlEnrollmentRecord;
+pub use runtime::ThreadGoalAccountingMode;
+pub use runtime::ThreadGoalAccountingOutcome;
+pub use runtime::ThreadGoalUpdate;
 pub use runtime::logs_db_filename;
 pub use runtime::logs_db_path;
 pub use runtime::state_db_filename;
diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs
index af4b30a8fe86..4c03a20632c2 100644
--- a/codex-rs/state/src/model/mod.rs
+++ b/codex-rs/state/src/model/mod.rs
@@ -3,6 +3,7 @@ mod backfill_state;
 mod graph;
 mod log;
 mod memories;
+mod thread_goal;
 mod thread_metadata;
 
 pub use agent_job::AgentJob;
@@ -25,6 +26,8 @@ pub use memories::Stage1JobClaimOutcome;
 pub use memories::Stage1Output;
 pub use memories::Stage1OutputRef;
 pub use memories::Stage1StartupClaimParams;
+pub use thread_goal::ThreadGoal;
+pub use thread_goal::ThreadGoalStatus;
 pub use thread_metadata::Anchor;
 pub use thread_metadata::BackfillStats;
 pub use thread_metadata::ExtractionOutcome;
@@ -37,6 +40,7 @@ pub(crate) use agent_job::AgentJobItemRow;
 pub(crate) use agent_job::AgentJobRow;
 pub(crate) use memories::Stage1OutputRow;
 pub(crate) use memories::stage1_output_ref_from_parts;
+pub(crate) use thread_goal::ThreadGoalRow;
 pub(crate) use thread_metadata::ThreadRow;
 pub(crate) use thread_metadata::anchor_from_item;
 pub(crate) use thread_metadata::datetime_to_epoch_millis;
diff --git a/codex-rs/state/src/model/thread_goal.rs b/codex-rs/state/src/model/thread_goal.rs
new file mode 100644
index 000000000000..e93d6c5b4f09
--- /dev/null
+++ b/codex-rs/state/src/model/thread_goal.rs
@@ -0,0 +1,101 @@
+use anyhow::Result;
+use anyhow::anyhow;
+use chrono::DateTime;
+use chrono::Utc;
+use codex_protocol::ThreadId;
+use sqlx::Row;
+use sqlx::sqlite::SqliteRow;
+
+use super::epoch_millis_to_datetime;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ThreadGoalStatus {
+    Active,
+    Paused,
+    BudgetStopped,
+    Complete,
+}
+
+impl ThreadGoalStatus {
+    pub fn as_str(self) -> &'static str {
+        match self {
+            Self::Active => "active",
+            Self::Paused => "paused",
+            Self::BudgetStopped => "budget_stopped",
+            Self::Complete => "complete",
+        }
+    }
+
+    pub fn is_active(self) -> bool {
+        self == Self::Active
+    }
+
+    pub fn is_terminal(self) -> bool {
+        matches!(self, Self::BudgetStopped | Self::Complete)
+    }
+}
+
+impl TryFrom<&str> for ThreadGoalStatus {
+    type Error = anyhow::Error;
+
+    fn try_from(value: &str) -> Result<Self> {
+        match value {
+            "active" => Ok(Self::Active),
+            "paused" => Ok(Self::Paused),
+            "budget_stopped" | "budget_exceeded" => Ok(Self::BudgetStopped),
+            "complete" => Ok(Self::Complete),
+            other => Err(anyhow!("unknown thread goal status `{other}`")),
+        }
+    }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ThreadGoal {
+    pub thread_id: ThreadId,
+    pub objective: String,
+    pub status: ThreadGoalStatus,
+    pub token_budget: Option<i64>,
+    pub tokens_used: i64,
+    pub created_at: DateTime<Utc>,
+    pub updated_at: DateTime<Utc>,
+}
+
+pub(crate) struct ThreadGoalRow {
+    pub thread_id: String,
+    pub objective: String,
+    pub status: String,
+    pub token_budget: Option<i64>,
+    pub tokens_used: i64,
+    pub created_at_ms: i64,
+    pub updated_at_ms: i64,
+}
+
+impl ThreadGoalRow {
+    pub(crate) fn try_from_row(row: &SqliteRow) -> Result<Self> {
+        Ok(Self {
+            thread_id: row.try_get("thread_id")?,
+            objective: row.try_get("objective")?,
+            status: row.try_get("status")?,
+            token_budget: row.try_get("token_budget")?,
+            tokens_used: row.try_get("tokens_used")?,
+            created_at_ms: row.try_get("created_at_ms")?,
+            updated_at_ms: row.try_get("updated_at_ms")?,
+        })
+    }
+}
+
+impl TryFrom<ThreadGoalRow> for ThreadGoal {
+    type Error = anyhow::Error;
+
+    fn try_from(row: ThreadGoalRow) -> Result<Self> {
+        Ok(Self {
+            thread_id: ThreadId::try_from(row.thread_id)?,
+            objective: row.objective,
+            status: ThreadGoalStatus::try_from(row.status.as_str())?,
+            token_budget: row.token_budget,
+            tokens_used: row.tokens_used,
+            created_at: epoch_millis_to_datetime(row.created_at_ms)?,
+            updated_at: epoch_millis_to_datetime(row.updated_at_ms)?,
+        })
+    }
+}
diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs
index 610e45e68a6f..5aadf0279829 100644
--- a/codex-rs/state/src/runtime.rs
+++ b/codex-rs/state/src/runtime.rs
@@ -20,6 +20,7 @@ use crate::apply_rollout_item;
 use crate::migrations::runtime_logs_migrator;
 use crate::migrations::runtime_state_migrator;
 use crate::model::AgentJobRow;
+use crate::model::ThreadGoalRow;
 use crate::model::ThreadRow;
 use crate::model::anchor_from_item;
 use crate::model::datetime_to_epoch_millis;
@@ -55,6 +56,7 @@ use tracing::warn;
 
 mod agent_jobs;
 mod backfill;
+mod goals;
 mod logs;
 mod memories;
 mod remote_control;
@@ -62,6 +64,9 @@ mod remote_control;
 mod test_support;
 mod threads;
 
+pub use goals::ThreadGoalAccountingMode;
+pub use goals::ThreadGoalAccountingOutcome;
+pub use goals::ThreadGoalUpdate;
 pub use remote_control::RemoteControlEnrollmentRecord;
 
 // "Partition" is the retained-log-content bucket we cap at 10 MiB:
diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
new file mode 100644
index 000000000000..936fb3b305b7
--- /dev/null
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -0,0 +1,355 @@
+use super::*;
+
+pub struct ThreadGoalUpdate {
+    pub status: Option<crate::ThreadGoalStatus>,
+    pub token_budget: Option<Option<i64>>,
+}
+
+pub enum ThreadGoalAccountingOutcome {
+    Unchanged(Option<crate::ThreadGoal>),
+    Updated(crate::ThreadGoal),
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ThreadGoalAccountingMode {
+    ActiveOnly,
+    ActiveOrComplete,
+}
+
+impl StateRuntime {
+    pub async fn get_thread_goal(
+        &self,
+        thread_id: ThreadId,
+    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
+        let row = sqlx::query(
+            r#"
+SELECT
+    thread_id,
+    objective,
+    status,
+    token_budget,
+    tokens_used,
+    created_at_ms,
+    updated_at_ms
+FROM thread_goals
+WHERE thread_id = ?
+            "#,
+        )
+        .bind(thread_id.to_string())
+        .fetch_optional(self.pool.as_ref())
+        .await?;
+
+        row.map(|row| ThreadGoalRow::try_from_row(&row).and_then(crate::ThreadGoal::try_from))
+            .transpose()
+    }
+
+    pub async fn replace_thread_goal(
+        &self,
+        thread_id: ThreadId,
+        objective: &str,
+        status: crate::ThreadGoalStatus,
+        token_budget: Option<i64>,
+    ) -> anyhow::Result<crate::ThreadGoal> {
+        let now_ms = datetime_to_epoch_millis(Utc::now());
+        sqlx::query(
+            r#"
+INSERT INTO thread_goals (
+    thread_id,
+    objective,
+    status,
+    token_budget,
+    tokens_used,
+    created_at_ms,
+    updated_at_ms
+) VALUES (?, ?, ?, ?, 0, ?, ?)
+ON CONFLICT(thread_id) DO UPDATE SET
+    objective = excluded.objective,
+    status = excluded.status,
+    token_budget = excluded.token_budget,
+    tokens_used = 0,
+    created_at_ms = excluded.created_at_ms,
+    updated_at_ms = excluded.updated_at_ms
+            "#,
+        )
+        .bind(thread_id.to_string())
+        .bind(objective)
+        .bind(status.as_str())
+        .bind(token_budget)
+        .bind(now_ms)
+        .bind(now_ms)
+        .execute(self.pool.as_ref())
+        .await?;
+
+        self.get_thread_goal(thread_id)
+            .await?
+            .ok_or_else(|| anyhow::anyhow!("thread goal disappeared after replacement"))
+    }
+
+    pub async fn update_thread_goal(
+        &self,
+        thread_id: ThreadId,
+        update: ThreadGoalUpdate,
+    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
+        let Some(existing) = self.get_thread_goal(thread_id).await? else {
+            return Ok(None);
+        };
+        let status = update.status.unwrap_or(existing.status);
+        let token_budget = update.token_budget.unwrap_or(existing.token_budget);
+        let now_ms = datetime_to_epoch_millis(Utc::now());
+
+        sqlx::query(
+            r#"
+UPDATE thread_goals
+SET
+    status = ?,
+    token_budget = ?,
+    updated_at_ms = ?
+WHERE thread_id = ?
+            "#,
+        )
+        .bind(status.as_str())
+        .bind(token_budget)
+        .bind(now_ms)
+        .bind(thread_id.to_string())
+        .execute(self.pool.as_ref())
+        .await?;
+
+        self.get_thread_goal(thread_id).await
+    }
+
+    pub async fn account_thread_goal_usage(
+        &self,
+        thread_id: ThreadId,
+        token_delta: i64,
+        mode: ThreadGoalAccountingMode,
+    ) -> anyhow::Result<ThreadGoalAccountingOutcome> {
+        if token_delta <= 0 {
+            return Ok(ThreadGoalAccountingOutcome::Unchanged(
+                self.get_thread_goal(thread_id).await?,
+            ));
+        }
+
+        let Some(existing) = self.get_thread_goal(thread_id).await? else {
+            return Ok(ThreadGoalAccountingOutcome::Unchanged(None));
+        };
+        let should_account = match mode {
+            ThreadGoalAccountingMode::ActiveOnly => existing.status.is_active(),
+            ThreadGoalAccountingMode::ActiveOrComplete => {
+                existing.status.is_active() || existing.status == crate::ThreadGoalStatus::Complete
+            }
+        };
+        if !should_account {
+            return Ok(ThreadGoalAccountingOutcome::Unchanged(Some(existing)));
+        }
+
+        let tokens_used = existing.tokens_used.saturating_add(token_delta.max(0));
+        let budget_stopped = existing
+            .token_budget
+            .is_some_and(|budget| tokens_used >= budget);
+        let status = match existing.status {
+            crate::ThreadGoalStatus::Active if budget_stopped => {
+                crate::ThreadGoalStatus::BudgetStopped
+            }
+            crate::ThreadGoalStatus::Active => crate::ThreadGoalStatus::Active,
+            crate::ThreadGoalStatus::Complete => crate::ThreadGoalStatus::Complete,
+            crate::ThreadGoalStatus::Paused | crate::ThreadGoalStatus::BudgetStopped => {
+                existing.status
+            }
+        };
+        let now_ms = datetime_to_epoch_millis(Utc::now());
+
+        sqlx::query(
+            r#"
+UPDATE thread_goals
+SET
+    status = ?,
+    tokens_used = ?,
+    updated_at_ms = ?
+WHERE thread_id = ?
+  AND status IN ('active', 'complete')
+            "#,
+        )
+        .bind(status.as_str())
+        .bind(tokens_used)
+        .bind(now_ms)
+        .bind(thread_id.to_string())
+        .execute(self.pool.as_ref())
+        .await?;
+
+        let updated = self
+            .get_thread_goal(thread_id)
+            .await?
+            .ok_or_else(|| anyhow::anyhow!("thread goal disappeared after usage accounting"))?;
+        Ok(ThreadGoalAccountingOutcome::Updated(updated))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::runtime::test_support::unique_temp_dir;
+    use pretty_assertions::assert_eq;
+
+    async fn test_runtime() -> std::sync::Arc<StateRuntime> {
+        StateRuntime::init(unique_temp_dir(), "test-provider".to_string())
+            .await
+            .expect("state db should initialize")
+    }
+
+    fn test_thread_id() -> ThreadId {
+        ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id")
+    }
+
+    #[tokio::test]
+    async fn replace_update_and_get_thread_goal() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+
+        let goal = runtime
+            .replace_thread_goal(
+                thread_id,
+                "optimize the benchmark",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(100_000),
+            )
+            .await
+            .expect("goal replacement should succeed");
+        assert_eq!(
+            Some(goal.clone()),
+            runtime.get_thread_goal(thread_id).await.unwrap()
+        );
+
+        let updated = runtime
+            .update_thread_goal(
+                thread_id,
+                ThreadGoalUpdate {
+                    status: Some(crate::ThreadGoalStatus::Paused),
+                    token_budget: Some(Some(200_000)),
+                },
+            )
+            .await
+            .expect("goal update should succeed")
+            .expect("goal should exist");
+        let expected = crate::ThreadGoal {
+            status: crate::ThreadGoalStatus::Paused,
+            token_budget: Some(200_000),
+            updated_at: updated.updated_at,
+            ..goal.clone()
+        };
+        assert_eq!(expected, updated);
+
+        let replaced = runtime
+            .replace_thread_goal(
+                thread_id,
+                "ship the new result",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ None,
+            )
+            .await
+            .expect("goal replacement should succeed");
+        assert_eq!("ship the new result", replaced.objective);
+        assert_eq!(crate::ThreadGoalStatus::Active, replaced.status);
+        assert_eq!(None, replaced.token_budget);
+        assert_eq!(0, replaced.tokens_used);
+    }
+
+    #[tokio::test]
+    async fn usage_accounting_updates_active_goals_and_stops_on_budget() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "stay within budget",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(20),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        let outcome = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*token_delta*/ 5,
+                ThreadGoalAccountingMode::ActiveOnly,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Updated(goal) = outcome else {
+            panic!("active goal should be updated");
+        };
+        assert_eq!(crate::ThreadGoalStatus::Active, goal.status);
+        assert_eq!(5, goal.tokens_used);
+
+        let outcome = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*token_delta*/ 15,
+                ThreadGoalAccountingMode::ActiveOnly,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Updated(goal) = outcome else {
+            panic!("budget crossing should update the goal");
+        };
+        assert_eq!(crate::ThreadGoalStatus::BudgetStopped, goal.status);
+        assert_eq!(20, goal.tokens_used);
+
+        let outcome = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*token_delta*/ 5,
+                ThreadGoalAccountingMode::ActiveOnly,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else {
+            panic!("terminal goal should not continue accounting");
+        };
+        assert_eq!(crate::ThreadGoalStatus::BudgetStopped, goal.status);
+        assert_eq!(20, goal.tokens_used);
+    }
+
+    #[tokio::test]
+    async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "finish the report",
+                crate::ThreadGoalStatus::Complete,
+                /*token_budget*/ Some(1_000),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        let active_only = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*token_delta*/ 200,
+                ThreadGoalAccountingMode::ActiveOnly,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = active_only else {
+            panic!("completed goal should not be updated by active-only accounting");
+        };
+        assert_eq!(crate::ThreadGoalStatus::Complete, goal.status);
+        assert_eq!(0, goal.tokens_used);
+
+        let completing_turn = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*token_delta*/ 200,
+                ThreadGoalAccountingMode::ActiveOrComplete,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Updated(goal) = completing_turn else {
+            panic!("completed goal should be updated for final accounting");
+        };
+        assert_eq!(crate::ThreadGoalStatus::Complete, goal.status);
+        assert_eq!(200, goal.tokens_used);
+    }
+}

From 00ac0b1b3e17fd51388004f7f5611f952b94f554 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Wed, 15 Apr 2026 21:54:28 -0700
Subject: [PATCH 02/23] codex: address PR review feedback (#18073)

---
 .../state/migrations/0026_thread_goals.sql    |   2 +-
 codex-rs/state/src/model/thread_goal.rs       |   8 +-
 codex-rs/state/src/runtime/goals.rs           | 107 +++++++++++-------
 3 files changed, 71 insertions(+), 46 deletions(-)

diff --git a/codex-rs/state/migrations/0026_thread_goals.sql b/codex-rs/state/migrations/0026_thread_goals.sql
index e40d9dcadc06..35f2983ff174 100644
--- a/codex-rs/state/migrations/0026_thread_goals.sql
+++ b/codex-rs/state/migrations/0026_thread_goals.sql
@@ -1,7 +1,7 @@
 CREATE TABLE thread_goals (
     thread_id TEXT PRIMARY KEY NOT NULL,
     objective TEXT NOT NULL,
-    status TEXT NOT NULL CHECK(status IN ('active', 'paused', 'budget_stopped', 'complete')),
+    status TEXT NOT NULL CHECK(status IN ('active', 'paused', 'budget_limited', 'complete')),
     token_budget INTEGER,
     tokens_used INTEGER NOT NULL DEFAULT 0,
     created_at_ms INTEGER NOT NULL,
diff --git a/codex-rs/state/src/model/thread_goal.rs b/codex-rs/state/src/model/thread_goal.rs
index e93d6c5b4f09..523fe140c71a 100644
--- a/codex-rs/state/src/model/thread_goal.rs
+++ b/codex-rs/state/src/model/thread_goal.rs
@@ -12,7 +12,7 @@ use super::epoch_millis_to_datetime;
 pub enum ThreadGoalStatus {
     Active,
     Paused,
-    BudgetStopped,
+    BudgetLimited,
     Complete,
 }
 
@@ -21,7 +21,7 @@ impl ThreadGoalStatus {
         match self {
             Self::Active => "active",
             Self::Paused => "paused",
-            Self::BudgetStopped => "budget_stopped",
+            Self::BudgetLimited => "budget_limited",
             Self::Complete => "complete",
         }
     }
@@ -31,7 +31,7 @@ impl ThreadGoalStatus {
     }
 
     pub fn is_terminal(self) -> bool {
-        matches!(self, Self::BudgetStopped | Self::Complete)
+        matches!(self, Self::BudgetLimited | Self::Complete)
     }
 }
 
@@ -42,7 +42,7 @@ impl TryFrom<&str> for ThreadGoalStatus {
         match value {
             "active" => Ok(Self::Active),
             "paused" => Ok(Self::Paused),
-            "budget_stopped" | "budget_exceeded" => Ok(Self::BudgetStopped),
+            "budget_limited" => Ok(Self::BudgetLimited),
             "complete" => Ok(Self::Complete),
             other => Err(anyhow!("unknown thread goal status `{other}`")),
         }
diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index 936fb3b305b7..8c0971654fac 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -129,52 +129,41 @@ WHERE thread_id = ?
             ));
         }
 
-        let Some(existing) = self.get_thread_goal(thread_id).await? else {
-            return Ok(ThreadGoalAccountingOutcome::Unchanged(None));
-        };
-        let should_account = match mode {
-            ThreadGoalAccountingMode::ActiveOnly => existing.status.is_active(),
-            ThreadGoalAccountingMode::ActiveOrComplete => {
-                existing.status.is_active() || existing.status == crate::ThreadGoalStatus::Complete
-            }
-        };
-        if !should_account {
-            return Ok(ThreadGoalAccountingOutcome::Unchanged(Some(existing)));
-        }
-
-        let tokens_used = existing.tokens_used.saturating_add(token_delta.max(0));
-        let budget_stopped = existing
-            .token_budget
-            .is_some_and(|budget| tokens_used >= budget);
-        let status = match existing.status {
-            crate::ThreadGoalStatus::Active if budget_stopped => {
-                crate::ThreadGoalStatus::BudgetStopped
-            }
-            crate::ThreadGoalStatus::Active => crate::ThreadGoalStatus::Active,
-            crate::ThreadGoalStatus::Complete => crate::ThreadGoalStatus::Complete,
-            crate::ThreadGoalStatus::Paused | crate::ThreadGoalStatus::BudgetStopped => {
-                existing.status
-            }
-        };
         let now_ms = datetime_to_epoch_millis(Utc::now());
-
-        sqlx::query(
+        let status_filter = match mode {
+            ThreadGoalAccountingMode::ActiveOnly => "status = 'active'",
+            ThreadGoalAccountingMode::ActiveOrComplete => "status IN ('active', 'complete')",
+        };
+        let query = format!(
             r#"
 UPDATE thread_goals
 SET
-    status = ?,
-    tokens_used = ?,
+    tokens_used = tokens_used + ?,
+    status = CASE
+        WHEN status = 'active' AND token_budget IS NOT NULL AND tokens_used + ? >= token_budget
+            THEN ?
+        ELSE status
+    END,
     updated_at_ms = ?
 WHERE thread_id = ?
-  AND status IN ('active', 'complete')
+  AND {status_filter}
             "#,
-        )
-        .bind(status.as_str())
-        .bind(tokens_used)
-        .bind(now_ms)
-        .bind(thread_id.to_string())
-        .execute(self.pool.as_ref())
-        .await?;
+        );
+
+        let result = sqlx::query(&query)
+            .bind(token_delta)
+            .bind(token_delta)
+            .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
+            .bind(now_ms)
+            .bind(thread_id.to_string())
+            .execute(self.pool.as_ref())
+            .await?;
+
+        if result.rows_affected() == 0 {
+            return Ok(ThreadGoalAccountingOutcome::Unchanged(
+                self.get_thread_goal(thread_id).await?,
+            ));
+        }
 
         let updated = self
             .get_thread_goal(thread_id)
@@ -292,7 +281,7 @@ mod tests {
         let ThreadGoalAccountingOutcome::Updated(goal) = outcome else {
             panic!("budget crossing should update the goal");
         };
-        assert_eq!(crate::ThreadGoalStatus::BudgetStopped, goal.status);
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
         assert_eq!(20, goal.tokens_used);
 
         let outcome = runtime
@@ -306,7 +295,7 @@ mod tests {
         let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else {
             panic!("terminal goal should not continue accounting");
         };
-        assert_eq!(crate::ThreadGoalStatus::BudgetStopped, goal.status);
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
         assert_eq!(20, goal.tokens_used);
     }
 
@@ -352,4 +341,40 @@ mod tests {
         assert_eq!(crate::ThreadGoalStatus::Complete, goal.status);
         assert_eq!(200, goal.tokens_used);
     }
+
+    #[tokio::test]
+    async fn usage_accounting_adds_concurrent_token_deltas() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "count every token",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(1_000),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        let first = runtime.account_thread_goal_usage(
+            thread_id,
+            /*token_delta*/ 40,
+            ThreadGoalAccountingMode::ActiveOnly,
+        );
+        let second = runtime.account_thread_goal_usage(
+            thread_id,
+            /*token_delta*/ 60,
+            ThreadGoalAccountingMode::ActiveOnly,
+        );
+        let (first, second) = tokio::join!(first, second);
+        first.expect("first usage accounting should succeed");
+        second.expect("second usage accounting should succeed");
+
+        let goal = runtime
+            .get_thread_goal(thread_id)
+            .await
+            .expect("goal read should succeed")
+            .expect("goal should exist");
+        assert_eq!(100, goal.tokens_used);
+    }
 }

From a7897034eed4afea0393295499af2afcc1091a0d Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Wed, 15 Apr 2026 22:10:38 -0700
Subject: [PATCH 03/23] codex: address PR review feedback (#18073)

---
 codex-rs/state/src/runtime/goals.rs | 106 ++++++++++++++++++++++++----
 1 file changed, 91 insertions(+), 15 deletions(-)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index 8c0971654fac..285b0990fb7a 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -90,15 +90,11 @@ ON CONFLICT(thread_id) DO UPDATE SET
         thread_id: ThreadId,
         update: ThreadGoalUpdate,
     ) -> anyhow::Result<Option<crate::ThreadGoal>> {
-        let Some(existing) = self.get_thread_goal(thread_id).await? else {
-            return Ok(None);
-        };
-        let status = update.status.unwrap_or(existing.status);
-        let token_budget = update.token_budget.unwrap_or(existing.token_budget);
         let now_ms = datetime_to_epoch_millis(Utc::now());
-
-        sqlx::query(
-            r#"
+        let result = match (update.status, update.token_budget) {
+            (Some(status), Some(token_budget)) => {
+                sqlx::query(
+                    r#"
 UPDATE thread_goals
 SET
     status = ?,
@@ -106,13 +102,52 @@ SET
     updated_at_ms = ?
 WHERE thread_id = ?
             "#,
-        )
-        .bind(status.as_str())
-        .bind(token_budget)
-        .bind(now_ms)
-        .bind(thread_id.to_string())
-        .execute(self.pool.as_ref())
-        .await?;
+                )
+                .bind(status.as_str())
+                .bind(token_budget)
+                .bind(now_ms)
+                .bind(thread_id.to_string())
+                .execute(self.pool.as_ref())
+                .await?
+            }
+            (Some(status), None) => {
+                sqlx::query(
+                    r#"
+UPDATE thread_goals
+SET
+    status = ?,
+    updated_at_ms = ?
+WHERE thread_id = ?
+            "#,
+                )
+                .bind(status.as_str())
+                .bind(now_ms)
+                .bind(thread_id.to_string())
+                .execute(self.pool.as_ref())
+                .await?
+            }
+            (None, Some(token_budget)) => {
+                sqlx::query(
+                    r#"
+UPDATE thread_goals
+SET
+    token_budget = ?,
+    updated_at_ms = ?
+WHERE thread_id = ?
+            "#,
+                )
+                .bind(token_budget)
+                .bind(now_ms)
+                .bind(thread_id.to_string())
+                .execute(self.pool.as_ref())
+                .await?
+            }
+            (None, None) => return self.get_thread_goal(thread_id).await,
+        };
+
+        if result.rows_affected() == 0 {
+            return Ok(None);
+        }
 
         self.get_thread_goal(thread_id).await
     }
@@ -242,6 +277,47 @@ mod tests {
         assert_eq!(0, replaced.tokens_used);
     }
 
+    #[tokio::test]
+    async fn concurrent_partial_updates_preserve_independent_fields() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "optimize the benchmark",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(100_000),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        let status_update = runtime.update_thread_goal(
+            thread_id,
+            ThreadGoalUpdate {
+                status: Some(crate::ThreadGoalStatus::Paused),
+                token_budget: None,
+            },
+        );
+        let budget_update = runtime.update_thread_goal(
+            thread_id,
+            ThreadGoalUpdate {
+                status: None,
+                token_budget: Some(Some(200_000)),
+            },
+        );
+        let (status_update, budget_update) = tokio::join!(status_update, budget_update);
+        status_update.expect("status update should succeed");
+        budget_update.expect("budget update should succeed");
+
+        let goal = runtime
+            .get_thread_goal(thread_id)
+            .await
+            .expect("goal read should succeed")
+            .expect("goal should exist");
+        assert_eq!(crate::ThreadGoalStatus::Paused, goal.status);
+        assert_eq!(Some(200_000), goal.token_budget);
+    }
+
     #[tokio::test]
     async fn usage_accounting_updates_active_goals_and_stops_on_budget() {
         let runtime = test_runtime().await;

From e7ad22b86cbaa0502dc7d405dd91ee37ce7c0ee7 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Thu, 16 Apr 2026 11:07:24 -0700
Subject: [PATCH 04/23] Track elapsed goal time in state

---
 .../state/migrations/0026_thread_goals.sql    |  1 +
 codex-rs/state/src/model/thread_goal.rs       |  4 +++
 codex-rs/state/src/runtime/goals.rs           | 26 +++++++++++++++++--
 3 files changed, 29 insertions(+), 2 deletions(-)

diff --git a/codex-rs/state/migrations/0026_thread_goals.sql b/codex-rs/state/migrations/0026_thread_goals.sql
index 35f2983ff174..7b25d22b1726 100644
--- a/codex-rs/state/migrations/0026_thread_goals.sql
+++ b/codex-rs/state/migrations/0026_thread_goals.sql
@@ -4,6 +4,7 @@ CREATE TABLE thread_goals (
     status TEXT NOT NULL CHECK(status IN ('active', 'paused', 'budget_limited', 'complete')),
     token_budget INTEGER,
     tokens_used INTEGER NOT NULL DEFAULT 0,
+    time_used_seconds INTEGER NOT NULL DEFAULT 0,
     created_at_ms INTEGER NOT NULL,
     updated_at_ms INTEGER NOT NULL
 );
diff --git a/codex-rs/state/src/model/thread_goal.rs b/codex-rs/state/src/model/thread_goal.rs
index 523fe140c71a..5d1502862047 100644
--- a/codex-rs/state/src/model/thread_goal.rs
+++ b/codex-rs/state/src/model/thread_goal.rs
@@ -56,6 +56,7 @@ pub struct ThreadGoal {
     pub status: ThreadGoalStatus,
     pub token_budget: Option<i64>,
     pub tokens_used: i64,
+    pub time_used_seconds: i64,
     pub created_at: DateTime<Utc>,
     pub updated_at: DateTime<Utc>,
 }
@@ -66,6 +67,7 @@ pub(crate) struct ThreadGoalRow {
     pub status: String,
     pub token_budget: Option<i64>,
     pub tokens_used: i64,
+    pub time_used_seconds: i64,
     pub created_at_ms: i64,
     pub updated_at_ms: i64,
 }
@@ -78,6 +80,7 @@ impl ThreadGoalRow {
             status: row.try_get("status")?,
             token_budget: row.try_get("token_budget")?,
             tokens_used: row.try_get("tokens_used")?,
+            time_used_seconds: row.try_get("time_used_seconds")?,
             created_at_ms: row.try_get("created_at_ms")?,
             updated_at_ms: row.try_get("updated_at_ms")?,
         })
@@ -94,6 +97,7 @@ impl TryFrom<ThreadGoalRow> for ThreadGoal {
             status: ThreadGoalStatus::try_from(row.status.as_str())?,
             token_budget: row.token_budget,
             tokens_used: row.tokens_used,
+            time_used_seconds: row.time_used_seconds,
             created_at: epoch_millis_to_datetime(row.created_at_ms)?,
             updated_at: epoch_millis_to_datetime(row.updated_at_ms)?,
         })
diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index 285b0990fb7a..c21075b7b7c5 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -29,6 +29,7 @@ SELECT
     status,
     token_budget,
     tokens_used,
+    time_used_seconds,
     created_at_ms,
     updated_at_ms
 FROM thread_goals
@@ -59,14 +60,16 @@ INSERT INTO thread_goals (
     status,
     token_budget,
     tokens_used,
+    time_used_seconds,
     created_at_ms,
     updated_at_ms
-) VALUES (?, ?, ?, ?, 0, ?, ?)
+) VALUES (?, ?, ?, ?, 0, 0, ?, ?)
 ON CONFLICT(thread_id) DO UPDATE SET
     objective = excluded.objective,
     status = excluded.status,
     token_budget = excluded.token_budget,
     tokens_used = 0,
+    time_used_seconds = 0,
     created_at_ms = excluded.created_at_ms,
     updated_at_ms = excluded.updated_at_ms
             "#,
@@ -155,10 +158,13 @@ WHERE thread_id = ?
     pub async fn account_thread_goal_usage(
         &self,
         thread_id: ThreadId,
+        time_delta_seconds: i64,
         token_delta: i64,
         mode: ThreadGoalAccountingMode,
     ) -> anyhow::Result<ThreadGoalAccountingOutcome> {
-        if token_delta <= 0 {
+        let time_delta_seconds = time_delta_seconds.max(0);
+        let token_delta = token_delta.max(0);
+        if time_delta_seconds == 0 && token_delta == 0 {
             return Ok(ThreadGoalAccountingOutcome::Unchanged(
                 self.get_thread_goal(thread_id).await?,
             ));
@@ -173,6 +179,7 @@ WHERE thread_id = ?
             r#"
 UPDATE thread_goals
 SET
+    time_used_seconds = time_used_seconds + ?,
     tokens_used = tokens_used + ?,
     status = CASE
         WHEN status = 'active' AND token_budget IS NOT NULL AND tokens_used + ? >= token_budget
@@ -186,6 +193,7 @@ WHERE thread_id = ?
         );
 
         let result = sqlx::query(&query)
+            .bind(time_delta_seconds)
             .bind(token_delta)
             .bind(token_delta)
             .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
@@ -275,6 +283,7 @@ mod tests {
         assert_eq!(crate::ThreadGoalStatus::Active, replaced.status);
         assert_eq!(None, replaced.token_budget);
         assert_eq!(0, replaced.tokens_used);
+        assert_eq!(0, replaced.time_used_seconds);
     }
 
     #[tokio::test]
@@ -335,6 +344,7 @@ mod tests {
         let outcome = runtime
             .account_thread_goal_usage(
                 thread_id,
+                /*time_delta_seconds*/ 7,
                 /*token_delta*/ 5,
                 ThreadGoalAccountingMode::ActiveOnly,
             )
@@ -345,10 +355,12 @@ mod tests {
         };
         assert_eq!(crate::ThreadGoalStatus::Active, goal.status);
         assert_eq!(5, goal.tokens_used);
+        assert_eq!(7, goal.time_used_seconds);
 
         let outcome = runtime
             .account_thread_goal_usage(
                 thread_id,
+                /*time_delta_seconds*/ 3,
                 /*token_delta*/ 15,
                 ThreadGoalAccountingMode::ActiveOnly,
             )
@@ -359,10 +371,12 @@ mod tests {
         };
         assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
         assert_eq!(20, goal.tokens_used);
+        assert_eq!(10, goal.time_used_seconds);
 
         let outcome = runtime
             .account_thread_goal_usage(
                 thread_id,
+                /*time_delta_seconds*/ 5,
                 /*token_delta*/ 5,
                 ThreadGoalAccountingMode::ActiveOnly,
             )
@@ -373,6 +387,7 @@ mod tests {
         };
         assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
         assert_eq!(20, goal.tokens_used);
+        assert_eq!(10, goal.time_used_seconds);
     }
 
     #[tokio::test]
@@ -392,6 +407,7 @@ mod tests {
         let active_only = runtime
             .account_thread_goal_usage(
                 thread_id,
+                /*time_delta_seconds*/ 30,
                 /*token_delta*/ 200,
                 ThreadGoalAccountingMode::ActiveOnly,
             )
@@ -402,10 +418,12 @@ mod tests {
         };
         assert_eq!(crate::ThreadGoalStatus::Complete, goal.status);
         assert_eq!(0, goal.tokens_used);
+        assert_eq!(0, goal.time_used_seconds);
 
         let completing_turn = runtime
             .account_thread_goal_usage(
                 thread_id,
+                /*time_delta_seconds*/ 30,
                 /*token_delta*/ 200,
                 ThreadGoalAccountingMode::ActiveOrComplete,
             )
@@ -416,6 +434,7 @@ mod tests {
         };
         assert_eq!(crate::ThreadGoalStatus::Complete, goal.status);
         assert_eq!(200, goal.tokens_used);
+        assert_eq!(30, goal.time_used_seconds);
     }
 
     #[tokio::test]
@@ -434,11 +453,13 @@ mod tests {
 
         let first = runtime.account_thread_goal_usage(
             thread_id,
+            /*time_delta_seconds*/ 4,
             /*token_delta*/ 40,
             ThreadGoalAccountingMode::ActiveOnly,
         );
         let second = runtime.account_thread_goal_usage(
             thread_id,
+            /*time_delta_seconds*/ 6,
             /*token_delta*/ 60,
             ThreadGoalAccountingMode::ActiveOnly,
         );
@@ -452,5 +473,6 @@ mod tests {
             .expect("goal read should succeed")
             .expect("goal should exist");
         assert_eq!(100, goal.tokens_used);
+        assert_eq!(10, goal.time_used_seconds);
     }
 }

From cd7929f7961654fb5078a93108035861697ec20f Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Thu, 16 Apr 2026 14:39:55 -0700
Subject: [PATCH 05/23] codex: address PR review feedback (#18073)

---
 codex-rs/state/src/runtime/goals.rs | 103 +++++++++++++++++++++++++++-
 1 file changed, 101 insertions(+), 2 deletions(-)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index c21075b7b7c5..d13f97c0a0df 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -100,7 +100,10 @@ ON CONFLICT(thread_id) DO UPDATE SET
                     r#"
 UPDATE thread_goals
 SET
-    status = ?,
+    status = CASE
+        WHEN ? = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ?
+        ELSE ?
+    END,
     token_budget = ?,
     updated_at_ms = ?
 WHERE thread_id = ?
@@ -108,6 +111,10 @@ WHERE thread_id = ?
                 )
                 .bind(status.as_str())
                 .bind(token_budget)
+                .bind(token_budget)
+                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
+                .bind(status.as_str())
+                .bind(token_budget)
                 .bind(now_ms)
                 .bind(thread_id.to_string())
                 .execute(self.pool.as_ref())
@@ -118,12 +125,17 @@ WHERE thread_id = ?
                     r#"
 UPDATE thread_goals
 SET
-    status = ?,
+    status = CASE
+        WHEN ? = 'active' AND token_budget IS NOT NULL AND tokens_used >= token_budget THEN ?
+        ELSE ?
+    END,
     updated_at_ms = ?
 WHERE thread_id = ?
             "#,
                 )
                 .bind(status.as_str())
+                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
+                .bind(status.as_str())
                 .bind(now_ms)
                 .bind(thread_id.to_string())
                 .execute(self.pool.as_ref())
@@ -135,11 +147,18 @@ WHERE thread_id = ?
 UPDATE thread_goals
 SET
     token_budget = ?,
+    status = CASE
+        WHEN status = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ?
+        ELSE status
+    END,
     updated_at_ms = ?
 WHERE thread_id = ?
             "#,
                 )
                 .bind(token_budget)
+                .bind(token_budget)
+                .bind(token_budget)
+                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
                 .bind(now_ms)
                 .bind(thread_id.to_string())
                 .execute(self.pool.as_ref())
@@ -390,6 +409,86 @@ mod tests {
         assert_eq!(10, goal.time_used_seconds);
     }
 
+    #[tokio::test]
+    async fn budget_updates_immediately_stop_active_goals_already_over_budget() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "stay within budget",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(100),
+            )
+            .await
+            .expect("goal replacement should succeed");
+        runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*time_delta_seconds*/ 1,
+                /*token_delta*/ 50,
+                ThreadGoalAccountingMode::ActiveOnly,
+            )
+            .await
+            .expect("usage accounting should succeed");
+
+        let lowered = runtime
+            .update_thread_goal(
+                thread_id,
+                ThreadGoalUpdate {
+                    status: None,
+                    token_budget: Some(Some(40)),
+                },
+            )
+            .await
+            .expect("goal update should succeed")
+            .expect("goal should exist");
+
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, lowered.status);
+        assert_eq!(Some(40), lowered.token_budget);
+        assert_eq!(50, lowered.tokens_used);
+    }
+
+    #[tokio::test]
+    async fn activating_goal_already_over_budget_keeps_it_budget_limited() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "stay within budget",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(40),
+            )
+            .await
+            .expect("goal replacement should succeed");
+        runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*time_delta_seconds*/ 1,
+                /*token_delta*/ 50,
+                ThreadGoalAccountingMode::ActiveOnly,
+            )
+            .await
+            .expect("usage accounting should succeed");
+
+        let reactivated = runtime
+            .update_thread_goal(
+                thread_id,
+                ThreadGoalUpdate {
+                    status: Some(crate::ThreadGoalStatus::Active),
+                    token_budget: None,
+                },
+            )
+            .await
+            .expect("goal update should succeed")
+            .expect("goal should exist");
+
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, reactivated.status);
+        assert_eq!(Some(40), reactivated.token_budget);
+        assert_eq!(50, reactivated.tokens_used);
+    }
+
     #[tokio::test]
     async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() {
         let runtime = test_runtime().await;

From 59540e0a0a548af95062b948394977dd894c813b Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Thu, 16 Apr 2026 15:18:14 -0700
Subject: [PATCH 06/23] codex: enforce thread goal ownership (#18073)

---
 .../state/migrations/0026_thread_goals.sql    |  2 +-
 codex-rs/state/src/runtime/goals.rs           | 49 +++++++++++++++++++
 2 files changed, 50 insertions(+), 1 deletion(-)

diff --git a/codex-rs/state/migrations/0026_thread_goals.sql b/codex-rs/state/migrations/0026_thread_goals.sql
index 7b25d22b1726..44d68b7b0dc6 100644
--- a/codex-rs/state/migrations/0026_thread_goals.sql
+++ b/codex-rs/state/migrations/0026_thread_goals.sql
@@ -1,5 +1,5 @@
 CREATE TABLE thread_goals (
-    thread_id TEXT PRIMARY KEY NOT NULL,
+    thread_id TEXT PRIMARY KEY NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
     objective TEXT NOT NULL,
     status TEXT NOT NULL CHECK(status IN ('active', 'paused', 'budget_limited', 'complete')),
     token_budget INTEGER,
diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index d13f97c0a0df..ef8fd6ef862f 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -238,6 +238,7 @@ WHERE thread_id = ?
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::runtime::test_support::test_thread_metadata;
     use crate::runtime::test_support::unique_temp_dir;
     use pretty_assertions::assert_eq;
 
@@ -251,10 +252,23 @@ mod tests {
         ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id")
     }
 
+    async fn upsert_test_thread(runtime: &StateRuntime, thread_id: ThreadId) {
+        let metadata = test_thread_metadata(
+            runtime.codex_home(),
+            thread_id,
+            runtime.codex_home().join("workspace"),
+        );
+        runtime
+            .upsert_thread(&metadata)
+            .await
+            .expect("test thread should be upserted");
+    }
+
     #[tokio::test]
     async fn replace_update_and_get_thread_goal() {
         let runtime = test_runtime().await;
         let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
 
         let goal = runtime
             .replace_thread_goal(
@@ -309,6 +323,7 @@ mod tests {
     async fn concurrent_partial_updates_preserve_independent_fields() {
         let runtime = test_runtime().await;
         let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
         runtime
             .replace_thread_goal(
                 thread_id,
@@ -350,6 +365,7 @@ mod tests {
     async fn usage_accounting_updates_active_goals_and_stops_on_budget() {
         let runtime = test_runtime().await;
         let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
         runtime
             .replace_thread_goal(
                 thread_id,
@@ -413,6 +429,7 @@ mod tests {
     async fn budget_updates_immediately_stop_active_goals_already_over_budget() {
         let runtime = test_runtime().await;
         let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
         runtime
             .replace_thread_goal(
                 thread_id,
@@ -453,6 +470,7 @@ mod tests {
     async fn activating_goal_already_over_budget_keeps_it_budget_limited() {
         let runtime = test_runtime().await;
         let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
         runtime
             .replace_thread_goal(
                 thread_id,
@@ -493,6 +511,7 @@ mod tests {
     async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() {
         let runtime = test_runtime().await;
         let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
         runtime
             .replace_thread_goal(
                 thread_id,
@@ -540,6 +559,7 @@ mod tests {
     async fn usage_accounting_adds_concurrent_token_deltas() {
         let runtime = test_runtime().await;
         let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
         runtime
             .replace_thread_goal(
                 thread_id,
@@ -574,4 +594,33 @@ mod tests {
         assert_eq!(100, goal.tokens_used);
         assert_eq!(10, goal.time_used_seconds);
     }
+
+    #[tokio::test]
+    async fn deleting_thread_deletes_goal() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "clean up with the thread",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ None,
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        runtime
+            .delete_thread(thread_id)
+            .await
+            .expect("thread deletion should succeed");
+
+        assert_eq!(
+            None,
+            runtime
+                .get_thread_goal(thread_id)
+                .await
+                .expect("goal read should succeed")
+        );
+    }
 }

From 3e1b000bb7a7a015bef3d0d4065dd47307b2a229 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Sat, 18 Apr 2026 15:30:01 -0700
Subject: [PATCH 07/23] Add thread goal delete state helper

---
 codex-rs/state/src/runtime/goals.rs | 18 ++++++++++++++++++
 1 file changed, 18 insertions(+)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index ef8fd6ef862f..ad7ae7027c6b 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -174,6 +174,20 @@ WHERE thread_id = ?
         self.get_thread_goal(thread_id).await
     }
 
+    pub async fn delete_thread_goal(&self, thread_id: ThreadId) -> anyhow::Result<bool> {
+        let result = sqlx::query(
+            r#"
+DELETE FROM thread_goals
+WHERE thread_id = ?
+            "#,
+        )
+        .bind(thread_id.to_string())
+        .execute(self.pool.as_ref())
+        .await?;
+
+        Ok(result.rows_affected() > 0)
+    }
+
     pub async fn account_thread_goal_usage(
         &self,
         thread_id: ThreadId,
@@ -317,6 +331,10 @@ mod tests {
         assert_eq!(None, replaced.token_budget);
         assert_eq!(0, replaced.tokens_used);
         assert_eq!(0, replaced.time_used_seconds);
+
+        assert!(runtime.delete_thread_goal(thread_id).await.unwrap());
+        assert_eq!(None, runtime.get_thread_goal(thread_id).await.unwrap());
+        assert!(!runtime.delete_thread_goal(thread_id).await.unwrap());
     }
 
     #[tokio::test]

From 0f1a20c8ed304672d5147de6780cde931bd93704 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Sat, 18 Apr 2026 15:52:59 -0700
Subject: [PATCH 08/23] codex: address PR review feedback (#18073)

---
 codex-rs/features/src/tests.rs | 7 -------
 1 file changed, 7 deletions(-)

diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs
index e7111e02903c..18b030f88c6a 100644
--- a/codex-rs/features/src/tests.rs
+++ b/codex-rs/features/src/tests.rs
@@ -138,13 +138,6 @@ fn request_permissions_tool_is_under_development() {
     assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
 }
 
-#[test]
-fn goal_mode_is_under_development() {
-    assert_eq!(Feature::GoalMode.stage(), Stage::UnderDevelopment);
-    assert_eq!(Feature::GoalMode.default_enabled(), false);
-    assert_eq!(feature_for_key("goal_mode"), Some(Feature::GoalMode));
-}
-
 #[test]
 fn tool_suggest_is_stable_and_enabled_by_default() {
     assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable);

From 65169508b0160ff280564958b3448bb43b9f328e Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Sat, 18 Apr 2026 16:00:41 -0700
Subject: [PATCH 09/23] codex: address PR review feedback (#18073)

---
 codex-rs/state/src/runtime/goals.rs | 16 +++++++++-------
 1 file changed, 9 insertions(+), 7 deletions(-)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index ad7ae7027c6b..c8b477a7ae06 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -205,8 +205,10 @@ WHERE thread_id = ?
 
         let now_ms = datetime_to_epoch_millis(Utc::now());
         let status_filter = match mode {
-            ThreadGoalAccountingMode::ActiveOnly => "status = 'active'",
-            ThreadGoalAccountingMode::ActiveOrComplete => "status IN ('active', 'complete')",
+            ThreadGoalAccountingMode::ActiveOnly => "status IN ('active', 'budget_limited')",
+            ThreadGoalAccountingMode::ActiveOrComplete => {
+                "status IN ('active', 'budget_limited', 'complete')"
+            }
         };
         let query = format!(
             r#"
@@ -380,7 +382,7 @@ mod tests {
     }
 
     #[tokio::test]
-    async fn usage_accounting_updates_active_goals_and_stops_on_budget() {
+    async fn usage_accounting_updates_active_goals_and_accounts_budget_limited_in_flight_usage() {
         let runtime = test_runtime().await;
         let thread_id = test_thread_id();
         upsert_test_thread(&runtime, thread_id).await;
@@ -435,12 +437,12 @@ mod tests {
             )
             .await
             .expect("usage accounting should succeed");
-        let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else {
-            panic!("terminal goal should not continue accounting");
+        let ThreadGoalAccountingOutcome::Updated(goal) = outcome else {
+            panic!("budget-limited goal should still account in-flight active usage");
         };
         assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
-        assert_eq!(20, goal.tokens_used);
-        assert_eq!(10, goal.time_used_seconds);
+        assert_eq!(25, goal.tokens_used);
+        assert_eq!(15, goal.time_used_seconds);
     }
 
     #[tokio::test]

From b68a787b9953887c2992e21ce8b6c62dff8d27f6 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Sat, 18 Apr 2026 17:13:59 -0700
Subject: [PATCH 10/23] codex: mark goal mode feature experimental

---
 codex-rs/features/src/lib.rs | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs
index 91b8a994260b..0d8c7af73071 100644
--- a/codex-rs/features/src/lib.rs
+++ b/codex-rs/features/src/lib.rs
@@ -901,7 +901,11 @@ pub const FEATURES: &[FeatureSpec] = &[
     FeatureSpec {
         id: Feature::GoalMode,
         key: "goal_mode",
-        stage: Stage::UnderDevelopment,
+        stage: Stage::Experimental {
+            name: "Goal mode",
+            menu_description: "Let Codex keep working toward a persisted thread goal, including goal status, budgets, and continuation controls.",
+            announcement: "",
+        },
         default_enabled: false,
     },
     FeatureSpec {

From 310889983bf90c9e0c716fbf36ecd831fcff8d2b Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Sat, 18 Apr 2026 22:48:10 -0700
Subject: [PATCH 11/23] codex: rename goal feature flag

---
 codex-rs/core/config.schema.json | 6 +++---
 codex-rs/features/src/lib.rs     | 4 ++--
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json
index 1b825e96f214..21352dac5249 100644
--- a/codex-rs/core/config.schema.json
+++ b/codex-rs/core/config.schema.json
@@ -401,7 +401,7 @@
             "general_analytics": {
               "type": "boolean"
             },
-            "goal_mode": {
+            "goals": {
               "type": "boolean"
             },
             "guardian_approval": {
@@ -2350,7 +2350,7 @@
         "general_analytics": {
           "type": "boolean"
         },
-        "goal_mode": {
+        "goals": {
           "type": "boolean"
         },
         "guardian_approval": {
@@ -2925,4 +2925,4 @@
   },
   "title": "ConfigToml",
   "type": "object"
-}
+}
\ No newline at end of file
diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs
index 0d8c7af73071..e2710c4d28fe 100644
--- a/codex-rs/features/src/lib.rs
+++ b/codex-rs/features/src/lib.rs
@@ -900,9 +900,9 @@ pub const FEATURES: &[FeatureSpec] = &[
     },
     FeatureSpec {
         id: Feature::GoalMode,
-        key: "goal_mode",
+        key: "goals",
         stage: Stage::Experimental {
-            name: "Goal mode",
+            name: "Goals",
             menu_description: "Let Codex keep working toward a persisted thread goal, including goal status, budgets, and continuation controls.",
             announcement: "",
         },

From 6c8476bcb6beb7e7b18503f441c3328427c21676 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Sun, 19 Apr 2026 14:01:14 -0700
Subject: [PATCH 12/23] Rename goals feature variant

---
 codex-rs/features/src/lib.rs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs
index e2710c4d28fe..960df86fb114 100644
--- a/codex-rs/features/src/lib.rs
+++ b/codex-rs/features/src/lib.rs
@@ -172,7 +172,7 @@ pub enum Feature {
     /// Enable automatic review for approval prompts.
     GuardianApproval,
     /// Enable persisted thread goals and automatic goal continuation.
-    GoalMode,
+    Goals,
     /// Enable collaboration modes (Plan, Default).
     /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled.
     CollaborationModes,
@@ -899,7 +899,7 @@ pub const FEATURES: &[FeatureSpec] = &[
         default_enabled: false,
     },
     FeatureSpec {
-        id: Feature::GoalMode,
+        id: Feature::Goals,
         key: "goals",
         stage: Stage::Experimental {
             name: "Goals",

From 2c13a5aa095cacfb486d9c7ef3188a1cde9a9f10 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Sun, 19 Apr 2026 14:14:58 -0700
Subject: [PATCH 13/23] Apply goal budget on replacement

---
 codex-rs/state/src/runtime/goals.rs | 37 +++++++++++++++++++++++++++++
 1 file changed, 37 insertions(+)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index c8b477a7ae06..d00c3d7ce83d 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -52,6 +52,7 @@ WHERE thread_id = ?
         token_budget: Option<i64>,
     ) -> anyhow::Result<crate::ThreadGoal> {
         let now_ms = datetime_to_epoch_millis(Utc::now());
+        let status = status_after_budget_limit(status, /*tokens_used*/ 0, token_budget);
         sqlx::query(
             r#"
 INSERT INTO thread_goals (
@@ -251,6 +252,20 @@ WHERE thread_id = ?
     }
 }
 
+fn status_after_budget_limit(
+    status: crate::ThreadGoalStatus,
+    tokens_used: i64,
+    token_budget: Option<i64>,
+) -> crate::ThreadGoalStatus {
+    if status == crate::ThreadGoalStatus::Active
+        && token_budget.is_some_and(|budget| tokens_used >= budget)
+    {
+        crate::ThreadGoalStatus::BudgetLimited
+    } else {
+        status
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -339,6 +354,28 @@ mod tests {
         assert!(!runtime.delete_thread_goal(thread_id).await.unwrap());
     }
 
+    #[tokio::test]
+    async fn replace_thread_goal_applies_budget_limit_immediately() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+
+        let replaced = runtime
+            .replace_thread_goal(
+                thread_id,
+                "stay within budget",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(0),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, replaced.status);
+        assert_eq!(Some(0), replaced.token_budget);
+        assert_eq!(0, replaced.tokens_used);
+        assert_eq!(0, replaced.time_used_seconds);
+    }
+
     #[tokio::test]
     async fn concurrent_partial_updates_preserve_independent_fields() {
         let runtime = test_runtime().await;

From d1e5be7d97ee1f3d8e03a4098858d9ff0533d1c0 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Sun, 19 Apr 2026 14:28:54 -0700
Subject: [PATCH 14/23] Guard goal accounting by version

---
 codex-rs/state/src/runtime/goals.rs | 73 +++++++++++++++++++++++++++--
 1 file changed, 69 insertions(+), 4 deletions(-)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index d00c3d7ce83d..1537de76920c 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -195,6 +195,7 @@ WHERE thread_id = ?
         time_delta_seconds: i64,
         token_delta: i64,
         mode: ThreadGoalAccountingMode,
+        expected_created_at_ms: Option<i64>,
     ) -> anyhow::Result<ThreadGoalAccountingOutcome> {
         let time_delta_seconds = time_delta_seconds.max(0);
         let token_delta = token_delta.max(0);
@@ -211,6 +212,11 @@ WHERE thread_id = ?
                 "status IN ('active', 'budget_limited', 'complete')"
             }
         };
+        let created_at_filter = if expected_created_at_ms.is_some() {
+            "created_at_ms = ?"
+        } else {
+            "1 = 1"
+        };
         let query = format!(
             r#"
 UPDATE thread_goals
@@ -225,18 +231,22 @@ SET
     updated_at_ms = ?
 WHERE thread_id = ?
   AND {status_filter}
+  AND {created_at_filter}
             "#,
         );
 
-        let result = sqlx::query(&query)
+        let mut query = sqlx::query(&query)
             .bind(time_delta_seconds)
             .bind(token_delta)
             .bind(token_delta)
             .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
             .bind(now_ms)
-            .bind(thread_id.to_string())
-            .execute(self.pool.as_ref())
-            .await?;
+            .bind(thread_id.to_string());
+        if let Some(expected_created_at_ms) = expected_created_at_ms {
+            query = query.bind(expected_created_at_ms);
+        }
+
+        let result = query.execute(self.pool.as_ref()).await?;
 
         if result.rows_affected() == 0 {
             return Ok(ThreadGoalAccountingOutcome::Unchanged(
@@ -376,6 +386,52 @@ mod tests {
         assert_eq!(0, replaced.time_used_seconds);
     }
 
+    #[tokio::test]
+    async fn usage_accounting_ignores_replaced_goal_version() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+
+        let original = runtime
+            .replace_thread_goal(
+                thread_id,
+                "old objective",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(100),
+            )
+            .await
+            .expect("goal replacement should succeed");
+        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
+        let replacement = runtime
+            .replace_thread_goal(
+                thread_id,
+                "new objective",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(10),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        let outcome = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*time_delta_seconds*/ 5,
+                /*token_delta*/ 5,
+                ThreadGoalAccountingMode::ActiveOnly,
+                Some(original.created_at.timestamp_millis()),
+            )
+            .await
+            .expect("usage accounting should succeed");
+
+        let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else {
+            panic!("stale goal version should not be updated");
+        };
+        assert_eq!(replacement.created_at, goal.created_at);
+        assert_eq!("new objective", goal.objective);
+        assert_eq!(0, goal.tokens_used);
+        assert_eq!(0, goal.time_used_seconds);
+    }
+
     #[tokio::test]
     async fn concurrent_partial_updates_preserve_independent_fields() {
         let runtime = test_runtime().await;
@@ -439,6 +495,7 @@ mod tests {
                 /*time_delta_seconds*/ 7,
                 /*token_delta*/ 5,
                 ThreadGoalAccountingMode::ActiveOnly,
+                /*expected_created_at_ms*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -455,6 +512,7 @@ mod tests {
                 /*time_delta_seconds*/ 3,
                 /*token_delta*/ 15,
                 ThreadGoalAccountingMode::ActiveOnly,
+                /*expected_created_at_ms*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -471,6 +529,7 @@ mod tests {
                 /*time_delta_seconds*/ 5,
                 /*token_delta*/ 5,
                 ThreadGoalAccountingMode::ActiveOnly,
+                /*expected_created_at_ms*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -502,6 +561,7 @@ mod tests {
                 /*time_delta_seconds*/ 1,
                 /*token_delta*/ 50,
                 ThreadGoalAccountingMode::ActiveOnly,
+                /*expected_created_at_ms*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -543,6 +603,7 @@ mod tests {
                 /*time_delta_seconds*/ 1,
                 /*token_delta*/ 50,
                 ThreadGoalAccountingMode::ActiveOnly,
+                /*expected_created_at_ms*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -585,6 +646,7 @@ mod tests {
                 /*time_delta_seconds*/ 30,
                 /*token_delta*/ 200,
                 ThreadGoalAccountingMode::ActiveOnly,
+                /*expected_created_at_ms*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -601,6 +663,7 @@ mod tests {
                 /*time_delta_seconds*/ 30,
                 /*token_delta*/ 200,
                 ThreadGoalAccountingMode::ActiveOrComplete,
+                /*expected_created_at_ms*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -632,12 +695,14 @@ mod tests {
             /*time_delta_seconds*/ 4,
             /*token_delta*/ 40,
             ThreadGoalAccountingMode::ActiveOnly,
+            /*expected_created_at_ms*/ None,
         );
         let second = runtime.account_thread_goal_usage(
             thread_id,
             /*time_delta_seconds*/ 6,
             /*token_delta*/ 60,
             ThreadGoalAccountingMode::ActiveOnly,
+            /*expected_created_at_ms*/ None,
         );
         let (first, second) = tokio::join!(first, second);
         first.expect("first usage accounting should succeed");

From 6e9b05ad55b673f2ecbbc6c71a94398f25a338cd Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Mon, 20 Apr 2026 11:20:16 -0700
Subject: [PATCH 15/23] codex: address goal state review feedback (#18073)

---
 codex-rs/features/src/lib.rs                  |  6 +-
 .../state/migrations/0026_thread_goals.sql    |  1 +
 codex-rs/state/src/model/thread_goal.rs       |  4 +
 codex-rs/state/src/runtime/goals.rs           | 88 ++++++++++++-------
 4 files changed, 62 insertions(+), 37 deletions(-)

diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs
index 960df86fb114..bd18ad3f783e 100644
--- a/codex-rs/features/src/lib.rs
+++ b/codex-rs/features/src/lib.rs
@@ -901,11 +901,7 @@ pub const FEATURES: &[FeatureSpec] = &[
     FeatureSpec {
         id: Feature::Goals,
         key: "goals",
-        stage: Stage::Experimental {
-            name: "Goals",
-            menu_description: "Let Codex keep working toward a persisted thread goal, including goal status, budgets, and continuation controls.",
-            announcement: "",
-        },
+        stage: Stage::UnderDevelopment,
         default_enabled: false,
     },
     FeatureSpec {
diff --git a/codex-rs/state/migrations/0026_thread_goals.sql b/codex-rs/state/migrations/0026_thread_goals.sql
index 44d68b7b0dc6..6c6e86a19921 100644
--- a/codex-rs/state/migrations/0026_thread_goals.sql
+++ b/codex-rs/state/migrations/0026_thread_goals.sql
@@ -1,5 +1,6 @@
 CREATE TABLE thread_goals (
     thread_id TEXT PRIMARY KEY NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
+    goal_id TEXT NOT NULL,
     objective TEXT NOT NULL,
     status TEXT NOT NULL CHECK(status IN ('active', 'paused', 'budget_limited', 'complete')),
     token_budget INTEGER,
diff --git a/codex-rs/state/src/model/thread_goal.rs b/codex-rs/state/src/model/thread_goal.rs
index 5d1502862047..e943c145f837 100644
--- a/codex-rs/state/src/model/thread_goal.rs
+++ b/codex-rs/state/src/model/thread_goal.rs
@@ -52,6 +52,7 @@ impl TryFrom<&str> for ThreadGoalStatus {
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct ThreadGoal {
     pub thread_id: ThreadId,
+    pub goal_id: String,
     pub objective: String,
     pub status: ThreadGoalStatus,
     pub token_budget: Option<i64>,
@@ -63,6 +64,7 @@ pub struct ThreadGoal {
 
 pub(crate) struct ThreadGoalRow {
     pub thread_id: String,
+    pub goal_id: String,
     pub objective: String,
     pub status: String,
     pub token_budget: Option<i64>,
@@ -76,6 +78,7 @@ impl ThreadGoalRow {
     pub(crate) fn try_from_row(row: &SqliteRow) -> Result<Self> {
         Ok(Self {
             thread_id: row.try_get("thread_id")?,
+            goal_id: row.try_get("goal_id")?,
             objective: row.try_get("objective")?,
             status: row.try_get("status")?,
             token_budget: row.try_get("token_budget")?,
@@ -93,6 +96,7 @@ impl TryFrom<ThreadGoalRow> for ThreadGoal {
     fn try_from(row: ThreadGoalRow) -> Result<Self> {
         Ok(Self {
             thread_id: ThreadId::try_from(row.thread_id)?,
+            goal_id: row.goal_id,
             objective: row.objective,
             status: ThreadGoalStatus::try_from(row.status.as_str())?,
             token_budget: row.token_budget,
diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index 1537de76920c..65faac2dbd29 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -1,4 +1,5 @@
 use super::*;
+use uuid::Uuid;
 
 pub struct ThreadGoalUpdate {
     pub status: Option<crate::ThreadGoalStatus>,
@@ -25,6 +26,7 @@ impl StateRuntime {
             r#"
 SELECT
     thread_id,
+    goal_id,
     objective,
     status,
     token_budget,
@@ -40,8 +42,7 @@ WHERE thread_id = ?
         .fetch_optional(self.pool.as_ref())
         .await?;
 
-        row.map(|row| ThreadGoalRow::try_from_row(&row).and_then(crate::ThreadGoal::try_from))
-            .transpose()
+        row.map(|row| thread_goal_from_row(&row)).transpose()
     }
 
     pub async fn replace_thread_goal(
@@ -51,12 +52,14 @@ WHERE thread_id = ?
         status: crate::ThreadGoalStatus,
         token_budget: Option<i64>,
     ) -> anyhow::Result<crate::ThreadGoal> {
+        let goal_id = Uuid::new_v4().to_string();
         let now_ms = datetime_to_epoch_millis(Utc::now());
         let status = status_after_budget_limit(status, /*tokens_used*/ 0, token_budget);
-        sqlx::query(
+        let row = sqlx::query(
             r#"
 INSERT INTO thread_goals (
     thread_id,
+    goal_id,
     objective,
     status,
     token_budget,
@@ -64,8 +67,9 @@ INSERT INTO thread_goals (
     time_used_seconds,
     created_at_ms,
     updated_at_ms
-) VALUES (?, ?, ?, ?, 0, 0, ?, ?)
+) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)
 ON CONFLICT(thread_id) DO UPDATE SET
+    goal_id = excluded.goal_id,
     objective = excluded.objective,
     status = excluded.status,
     token_budget = excluded.token_budget,
@@ -73,20 +77,29 @@ ON CONFLICT(thread_id) DO UPDATE SET
     time_used_seconds = 0,
     created_at_ms = excluded.created_at_ms,
     updated_at_ms = excluded.updated_at_ms
+RETURNING
+    thread_id,
+    goal_id,
+    objective,
+    status,
+    token_budget,
+    tokens_used,
+    time_used_seconds,
+    created_at_ms,
+    updated_at_ms
             "#,
         )
         .bind(thread_id.to_string())
+        .bind(goal_id)
         .bind(objective)
         .bind(status.as_str())
         .bind(token_budget)
         .bind(now_ms)
         .bind(now_ms)
-        .execute(self.pool.as_ref())
+        .fetch_one(self.pool.as_ref())
         .await?;
 
-        self.get_thread_goal(thread_id)
-            .await?
-            .ok_or_else(|| anyhow::anyhow!("thread goal disappeared after replacement"))
+        thread_goal_from_row(&row)
     }
 
     pub async fn update_thread_goal(
@@ -195,7 +208,7 @@ WHERE thread_id = ?
         time_delta_seconds: i64,
         token_delta: i64,
         mode: ThreadGoalAccountingMode,
-        expected_created_at_ms: Option<i64>,
+        expected_goal_id: Option<&str>,
     ) -> anyhow::Result<ThreadGoalAccountingOutcome> {
         let time_delta_seconds = time_delta_seconds.max(0);
         let token_delta = token_delta.max(0);
@@ -212,8 +225,8 @@ WHERE thread_id = ?
                 "status IN ('active', 'budget_limited', 'complete')"
             }
         };
-        let created_at_filter = if expected_created_at_ms.is_some() {
-            "created_at_ms = ?"
+        let goal_id_filter = if expected_goal_id.is_some() {
+            "goal_id = ?"
         } else {
             "1 = 1"
         };
@@ -231,7 +244,17 @@ SET
     updated_at_ms = ?
 WHERE thread_id = ?
   AND {status_filter}
-  AND {created_at_filter}
+  AND {goal_id_filter}
+RETURNING
+    thread_id,
+    goal_id,
+    objective,
+    status,
+    token_budget,
+    tokens_used,
+    time_used_seconds,
+    created_at_ms,
+    updated_at_ms
             "#,
         );
 
@@ -242,26 +265,27 @@ WHERE thread_id = ?
             .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
             .bind(now_ms)
             .bind(thread_id.to_string());
-        if let Some(expected_created_at_ms) = expected_created_at_ms {
-            query = query.bind(expected_created_at_ms);
+        if let Some(expected_goal_id) = expected_goal_id {
+            query = query.bind(expected_goal_id);
         }
 
-        let result = query.execute(self.pool.as_ref()).await?;
+        let row = query.fetch_optional(self.pool.as_ref()).await?;
 
-        if result.rows_affected() == 0 {
+        let Some(row) = row else {
             return Ok(ThreadGoalAccountingOutcome::Unchanged(
                 self.get_thread_goal(thread_id).await?,
             ));
-        }
+        };
 
-        let updated = self
-            .get_thread_goal(thread_id)
-            .await?
-            .ok_or_else(|| anyhow::anyhow!("thread goal disappeared after usage accounting"))?;
+        let updated = thread_goal_from_row(&row)?;
         Ok(ThreadGoalAccountingOutcome::Updated(updated))
     }
 }
 
+fn thread_goal_from_row(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<crate::ThreadGoal> {
+    ThreadGoalRow::try_from_row(row).and_then(crate::ThreadGoal::try_from)
+}
+
 fn status_after_budget_limit(
     status: crate::ThreadGoalStatus,
     tokens_used: i64,
@@ -401,7 +425,6 @@ mod tests {
             )
             .await
             .expect("goal replacement should succeed");
-        tokio::time::sleep(std::time::Duration::from_millis(2)).await;
         let replacement = runtime
             .replace_thread_goal(
                 thread_id,
@@ -418,7 +441,7 @@ mod tests {
                 /*time_delta_seconds*/ 5,
                 /*token_delta*/ 5,
                 ThreadGoalAccountingMode::ActiveOnly,
-                Some(original.created_at.timestamp_millis()),
+                Some(original.goal_id.as_str()),
             )
             .await
             .expect("usage accounting should succeed");
@@ -426,6 +449,7 @@ mod tests {
         let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else {
             panic!("stale goal version should not be updated");
         };
+        assert_ne!(replacement.goal_id, original.goal_id);
         assert_eq!(replacement.created_at, goal.created_at);
         assert_eq!("new objective", goal.objective);
         assert_eq!(0, goal.tokens_used);
@@ -495,7 +519,7 @@ mod tests {
                 /*time_delta_seconds*/ 7,
                 /*token_delta*/ 5,
                 ThreadGoalAccountingMode::ActiveOnly,
-                /*expected_created_at_ms*/ None,
+                /*expected_goal_id*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -512,7 +536,7 @@ mod tests {
                 /*time_delta_seconds*/ 3,
                 /*token_delta*/ 15,
                 ThreadGoalAccountingMode::ActiveOnly,
-                /*expected_created_at_ms*/ None,
+                /*expected_goal_id*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -529,7 +553,7 @@ mod tests {
                 /*time_delta_seconds*/ 5,
                 /*token_delta*/ 5,
                 ThreadGoalAccountingMode::ActiveOnly,
-                /*expected_created_at_ms*/ None,
+                /*expected_goal_id*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -561,7 +585,7 @@ mod tests {
                 /*time_delta_seconds*/ 1,
                 /*token_delta*/ 50,
                 ThreadGoalAccountingMode::ActiveOnly,
-                /*expected_created_at_ms*/ None,
+                /*expected_goal_id*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -603,7 +627,7 @@ mod tests {
                 /*time_delta_seconds*/ 1,
                 /*token_delta*/ 50,
                 ThreadGoalAccountingMode::ActiveOnly,
-                /*expected_created_at_ms*/ None,
+                /*expected_goal_id*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -646,7 +670,7 @@ mod tests {
                 /*time_delta_seconds*/ 30,
                 /*token_delta*/ 200,
                 ThreadGoalAccountingMode::ActiveOnly,
-                /*expected_created_at_ms*/ None,
+                /*expected_goal_id*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -663,7 +687,7 @@ mod tests {
                 /*time_delta_seconds*/ 30,
                 /*token_delta*/ 200,
                 ThreadGoalAccountingMode::ActiveOrComplete,
-                /*expected_created_at_ms*/ None,
+                /*expected_goal_id*/ None,
             )
             .await
             .expect("usage accounting should succeed");
@@ -695,14 +719,14 @@ mod tests {
             /*time_delta_seconds*/ 4,
             /*token_delta*/ 40,
             ThreadGoalAccountingMode::ActiveOnly,
-            /*expected_created_at_ms*/ None,
+            /*expected_goal_id*/ None,
         );
         let second = runtime.account_thread_goal_usage(
             thread_id,
             /*time_delta_seconds*/ 6,
             /*token_delta*/ 60,
             ThreadGoalAccountingMode::ActiveOnly,
-            /*expected_created_at_ms*/ None,
+            /*expected_goal_id*/ None,
         );
         let (first, second) = tokio::join!(first, second);
         first.expect("first usage accounting should succeed");

From 1ea483e484d37444332083d69f82637aaaa7b33c Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Mon, 20 Apr 2026 13:14:28 -0700
Subject: [PATCH 16/23] codex: address PR review feedback (#18073)

---
 codex-rs/state/src/runtime/goals.rs | 65 +++++++++++++++++++++++++++++
 1 file changed, 65 insertions(+)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index 65faac2dbd29..6a25e8794a24 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -15,6 +15,7 @@ pub enum ThreadGoalAccountingOutcome {
 pub enum ThreadGoalAccountingMode {
     ActiveOnly,
     ActiveOrComplete,
+    ActiveOrStopped,
 }
 
 impl StateRuntime {
@@ -224,6 +225,9 @@ WHERE thread_id = ?
             ThreadGoalAccountingMode::ActiveOrComplete => {
                 "status IN ('active', 'budget_limited', 'complete')"
             }
+            ThreadGoalAccountingMode::ActiveOrStopped => {
+                "status IN ('active', 'paused', 'budget_limited')"
+            }
         };
         let goal_id_filter = if expected_goal_id.is_some() {
             "goal_id = ?"
@@ -699,6 +703,67 @@ mod tests {
         assert_eq!(30, goal.time_used_seconds);
     }
 
+    #[tokio::test]
+    async fn usage_accounting_can_finalize_stopped_goal_for_in_flight_turn() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "finish the report",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(1_000),
+            )
+            .await
+            .expect("goal replacement should succeed");
+        runtime
+            .update_thread_goal(
+                thread_id,
+                ThreadGoalUpdate {
+                    status: Some(crate::ThreadGoalStatus::Paused),
+                    token_budget: None,
+                },
+            )
+            .await
+            .expect("goal update should succeed")
+            .expect("goal should exist");
+
+        let active_only = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*time_delta_seconds*/ 30,
+                /*token_delta*/ 200,
+                ThreadGoalAccountingMode::ActiveOnly,
+                /*expected_goal_id*/ None,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = active_only else {
+            panic!("paused goal should not be updated by active-only accounting");
+        };
+        assert_eq!(crate::ThreadGoalStatus::Paused, goal.status);
+        assert_eq!(0, goal.tokens_used);
+        assert_eq!(0, goal.time_used_seconds);
+
+        let in_flight_turn = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*time_delta_seconds*/ 30,
+                /*token_delta*/ 200,
+                ThreadGoalAccountingMode::ActiveOrStopped,
+                /*expected_goal_id*/ None,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Updated(goal) = in_flight_turn else {
+            panic!("stopped goal should be updated for in-flight accounting");
+        };
+        assert_eq!(crate::ThreadGoalStatus::Paused, goal.status);
+        assert_eq!(200, goal.tokens_used);
+        assert_eq!(30, goal.time_used_seconds);
+    }
+
     #[tokio::test]
     async fn usage_accounting_adds_concurrent_token_deltas() {
         let runtime = test_runtime().await;

From e49f154731ca9d4cb670d517f022ea50a7cda149 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Mon, 20 Apr 2026 14:44:05 -0700
Subject: [PATCH 17/23] codex: fix stale model catalog tests (#18073)

---
 codex-rs/tui/src/app.rs                       | 105 ++++++++----------
 ...gration_prompt_shows_for_hidden_model.snap |   8 +-
 2 files changed, 51 insertions(+), 62 deletions(-)

diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index fe14c366300d..2caa6d1732b6 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -11510,32 +11510,20 @@ guardian_approval = true
     async fn model_migration_prompt_only_shows_for_deprecated_models() {
         let seen = BTreeMap::new();
         assert!(should_show_model_migration_prompt(
-            "gpt-5",
-            "gpt-5.2-codex",
+            "gpt-5.3-codex",
+            "gpt-5.4",
             &seen,
             &all_model_presets()
         ));
         assert!(should_show_model_migration_prompt(
-            "gpt-5-codex",
-            "gpt-5.2-codex",
-            &seen,
-            &all_model_presets()
-        ));
-        assert!(should_show_model_migration_prompt(
-            "gpt-5-codex-mini",
-            "gpt-5.2-codex",
-            &seen,
-            &all_model_presets()
-        ));
-        assert!(should_show_model_migration_prompt(
-            "gpt-5.1-codex",
-            "gpt-5.2-codex",
+            "gpt-5.2",
+            "gpt-5.4",
             &seen,
             &all_model_presets()
         ));
         assert!(!should_show_model_migration_prompt(
-            "gpt-5.1-codex",
-            "gpt-5.1-codex",
+            "gpt-5.3-codex",
+            "gpt-5.3-codex",
             &seen,
             &all_model_presets()
         ));
@@ -11549,10 +11537,10 @@ guardian_approval = true
         });
         let target = presets
             .iter_mut()
-            .find(|preset| preset.model == "gpt-5")
+            .find(|preset| preset.model == "gpt-5.4")
             .expect("target preset present");
         target.availability_nux = Some(ModelAvailabilityNux {
-            message: "gpt-5 is available".to_string(),
+            message: "gpt-5.4 is available".to_string(),
         });
 
         let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[]));
@@ -11560,8 +11548,8 @@ guardian_approval = true
         assert_eq!(
             selected,
             Some(StartupTooltipOverride {
-                model_slug: "gpt-5".to_string(),
-                message: "gpt-5 is available".to_string(),
+                model_slug: "gpt-5.4".to_string(),
+                message: "gpt-5.4 is available".to_string(),
             })
         );
     }
@@ -11572,31 +11560,31 @@ guardian_approval = true
         presets.iter_mut().for_each(|preset| {
             preset.availability_nux = None;
         });
-        let gpt_5 = presets
+        let gpt_5_4 = presets
             .iter_mut()
-            .find(|preset| preset.model == "gpt-5")
-            .expect("gpt-5 preset present");
-        gpt_5.availability_nux = Some(ModelAvailabilityNux {
-            message: "gpt-5 is available".to_string(),
+            .find(|preset| preset.model == "gpt-5.4")
+            .expect("gpt-5.4 preset present");
+        gpt_5_4.availability_nux = Some(ModelAvailabilityNux {
+            message: "gpt-5.4 is available".to_string(),
         });
-        let gpt_5_2 = presets
+        let gpt_5_4_mini = presets
             .iter_mut()
-            .find(|preset| preset.model == "gpt-5.2")
-            .expect("gpt-5.2 preset present");
-        gpt_5_2.availability_nux = Some(ModelAvailabilityNux {
-            message: "gpt-5.2 is available".to_string(),
+            .find(|preset| preset.model == "gpt-5.4-mini")
+            .expect("gpt-5.4-mini preset present");
+        gpt_5_4_mini.availability_nux = Some(ModelAvailabilityNux {
+            message: "gpt-5.4-mini is available".to_string(),
         });
 
         let selected = select_model_availability_nux(
             &presets,
-            &model_availability_nux_config(&[("gpt-5", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
+            &model_availability_nux_config(&[("gpt-5.4", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
         );
 
         assert_eq!(
             selected,
             Some(StartupTooltipOverride {
-                model_slug: "gpt-5.2".to_string(),
-                message: "gpt-5.2 is available".to_string(),
+                model_slug: "gpt-5.4-mini".to_string(),
+                message: "gpt-5.4-mini is available".to_string(),
             })
         );
     }
@@ -11671,8 +11659,8 @@ guardian_approval = true
         });
         let first = presets
             .iter_mut()
-            .find(|preset| preset.model == "gpt-5")
-            .expect("gpt-5 preset present");
+            .find(|preset| preset.model == "gpt-5.4-mini")
+            .expect("gpt-5.4-mini preset present");
         first.availability_nux = Some(ModelAvailabilityNux {
             message: "first".to_string(),
         });
@@ -11689,8 +11677,8 @@ guardian_approval = true
         assert_eq!(
             selected,
             Some(StartupTooltipOverride {
-                model_slug: "gpt-5.2".to_string(),
-                message: "second".to_string(),
+                model_slug: "gpt-5.4-mini".to_string(),
+                message: "first".to_string(),
             })
         );
     }
@@ -11703,15 +11691,15 @@ guardian_approval = true
         });
         let target = presets
             .iter_mut()
-            .find(|preset| preset.model == "gpt-5")
+            .find(|preset| preset.model == "gpt-5.4")
             .expect("target preset present");
         target.availability_nux = Some(ModelAvailabilityNux {
-            message: "gpt-5 is available".to_string(),
+            message: "gpt-5.4 is available".to_string(),
         });
 
         let selected = select_model_availability_nux(
             &presets,
-            &model_availability_nux_config(&[("gpt-5", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
+            &model_availability_nux_config(&[("gpt-5.4", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
         );
 
         assert_eq!(selected, None);
@@ -11720,16 +11708,16 @@ guardian_approval = true
     #[tokio::test]
     async fn model_migration_prompt_respects_hide_flag_and_self_target() {
         let mut seen = BTreeMap::new();
-        seen.insert("gpt-5".to_string(), "gpt-5.1".to_string());
+        seen.insert("gpt-5.3-codex".to_string(), "gpt-5.4".to_string());
         assert!(!should_show_model_migration_prompt(
-            "gpt-5",
-            "gpt-5.1",
+            "gpt-5.3-codex",
+            "gpt-5.4",
             &seen,
             &all_model_presets()
         ));
         assert!(!should_show_model_migration_prompt(
-            "gpt-5.1",
-            "gpt-5.1",
+            "gpt-5.4",
+            "gpt-5.4",
             &seen,
             &all_model_presets()
         ));
@@ -11740,7 +11728,7 @@ guardian_approval = true
         let mut available = all_model_presets();
         let mut current = available
             .iter()
-            .find(|preset| preset.model == "gpt-5-codex")
+            .find(|preset| preset.model == "gpt-5.3-codex")
             .cloned()
             .expect("preset present");
         current.upgrade = Some(ModelUpgrade {
@@ -11751,7 +11739,7 @@ guardian_approval = true
             upgrade_copy: None,
             migration_markdown: None,
         });
-        available.retain(|preset| preset.model != "gpt-5-codex");
+        available.retain(|preset| preset.model != "gpt-5.3-codex");
         available.push(current.clone());
 
         assert!(!should_show_model_migration_prompt(
@@ -11766,17 +11754,17 @@ guardian_approval = true
         let mut with_hidden_target = all_model_presets();
         let target = with_hidden_target
             .iter_mut()
-            .find(|preset| preset.model == "gpt-5.2-codex")
+            .find(|preset| preset.model == "gpt-5.4")
             .expect("target preset present");
         target.show_in_picker = false;
 
         assert!(!should_show_model_migration_prompt(
-            "gpt-5-codex",
-            "gpt-5.2-codex",
+            "gpt-5.3-codex",
+            "gpt-5.4",
             &BTreeMap::new(),
             &with_hidden_target,
         ));
-        assert!(target_preset_for_upgrade(&with_hidden_target, "gpt-5.2-codex").is_none());
+        assert!(target_preset_for_upgrade(&with_hidden_target, "gpt-5.4").is_none());
     }
 
     #[tokio::test]
@@ -11790,13 +11778,14 @@ guardian_approval = true
 
         let mut available_models = all_model_presets();
         let current = available_models
-            .iter()
-            .find(|preset| preset.model == "gpt-5.1-codex")
-            .cloned()
-            .expect("gpt-5.1-codex preset present");
+            .iter_mut()
+            .find(|preset| preset.model == "gpt-5.3-codex")
+            .expect("gpt-5.3-codex preset present");
+        current.show_in_picker = false;
+        let current = current.clone();
         assert!(
             !current.show_in_picker,
-            "expected gpt-5.1-codex to be hidden from picker for this test"
+            "expected current model to be hidden from picker for this test"
         );
 
         let upgrade = current.upgrade.as_ref().expect("upgrade configured");
diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap b/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap
index 23fb02d56384..9c25c63ed32b 100644
--- a/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap
+++ b/codex-rs/tui/src/snapshots/codex_tui__app__tests__model_migration_prompt_shows_for_hidden_model.snap
@@ -2,10 +2,10 @@
 source: tui/src/app.rs
 expression: model_migration_copy_to_plain_text(&copy)
 ---
-**Codex just got an upgrade. Introducing gpt-5.3-codex.**
+Introducing GPT-5.4
 
-Codex is now powered by gpt-5.3-codex, our most capable agentic coding model yet. It's built for long-running, project-scale work, with mid-turn steering + frequent progress updates so you can collaborate while it runs (and it's faster too).
+Codex just got an upgrade with GPT-5.4, our most capable model for professional work. It outperforms prior models while being more token efficient, with notable improvements on long-running tasks, tool calling, computer use, and frontend development.
 
-Learn more: https://openai.com/index/introducing-gpt-5-3-codex/
+Learn more: https://openai.com/index/introducing-gpt-5-4
 
-You can keep using gpt-5.1-codex if you prefer.
+You can always keep using GPT-5.3-Codex if you prefer.

From a0594d7da3c176c151b3ef2ee477abc6368b24a7 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Mon, 20 Apr 2026 14:50:56 -0700
Subject: [PATCH 18/23] codex: fix blob size policy on goal stack (#18073)

---
 codex-rs/tui/src/app.rs | 107 +++++++++++++++-------------------------
 1 file changed, 39 insertions(+), 68 deletions(-)

diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 2caa6d1732b6..1d13c8d519e1 100644
--- a/codex-rs/tui/src/app.rs
+++ b/codex-rs/tui/src/app.rs
@@ -11485,6 +11485,26 @@ guardian_approval = true
         }
     }
 
+    fn clear_nux(presets: &mut [ModelPreset]) {
+        for preset in presets {
+            preset.availability_nux = None;
+        }
+    }
+
+    fn preset_mut<'a>(presets: &'a mut [ModelPreset], model: &str) -> &'a mut ModelPreset {
+        presets
+            .iter_mut()
+            .find(|preset| preset.model == model)
+            .unwrap_or_else(|| panic!("{model} preset present"))
+    }
+
+    fn tooltip(model: &str, msg: &str) -> StartupTooltipOverride {
+        StartupTooltipOverride {
+            model_slug: model.into(),
+            message: msg.into(),
+        }
+    }
+
     fn model_migration_copy_to_plain_text(
         copy: &crate::model_migration::ModelMigrationCopy,
     ) -> String {
@@ -11532,47 +11552,28 @@ guardian_approval = true
     #[test]
     fn select_model_availability_nux_picks_only_eligible_model() {
         let mut presets = all_model_presets();
-        presets.iter_mut().for_each(|preset| {
-            preset.availability_nux = None;
-        });
-        let target = presets
-            .iter_mut()
-            .find(|preset| preset.model == "gpt-5.4")
-            .expect("target preset present");
+        clear_nux(&mut presets);
+        let target = preset_mut(&mut presets, "gpt-5.4");
         target.availability_nux = Some(ModelAvailabilityNux {
-            message: "gpt-5.4 is available".to_string(),
+            message: "available".to_string(),
         });
 
         let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[]));
 
-        assert_eq!(
-            selected,
-            Some(StartupTooltipOverride {
-                model_slug: "gpt-5.4".to_string(),
-                message: "gpt-5.4 is available".to_string(),
-            })
-        );
+        assert_eq!(selected, Some(tooltip("gpt-5.4", "available")));
     }
 
     #[test]
     fn select_model_availability_nux_skips_missing_and_exhausted_models() {
         let mut presets = all_model_presets();
-        presets.iter_mut().for_each(|preset| {
-            preset.availability_nux = None;
-        });
-        let gpt_5_4 = presets
-            .iter_mut()
-            .find(|preset| preset.model == "gpt-5.4")
-            .expect("gpt-5.4 preset present");
+        clear_nux(&mut presets);
+        let gpt_5_4 = preset_mut(&mut presets, "gpt-5.4");
         gpt_5_4.availability_nux = Some(ModelAvailabilityNux {
-            message: "gpt-5.4 is available".to_string(),
+            message: "exhausted".to_string(),
         });
-        let gpt_5_4_mini = presets
-            .iter_mut()
-            .find(|preset| preset.model == "gpt-5.4-mini")
-            .expect("gpt-5.4-mini preset present");
-        gpt_5_4_mini.availability_nux = Some(ModelAvailabilityNux {
-            message: "gpt-5.4-mini is available".to_string(),
+        let mini = preset_mut(&mut presets, "gpt-5.4-mini");
+        mini.availability_nux = Some(ModelAvailabilityNux {
+            message: "available".to_string(),
         });
 
         let selected = select_model_availability_nux(
@@ -11580,13 +11581,7 @@ guardian_approval = true
             &model_availability_nux_config(&[("gpt-5.4", MODEL_AVAILABILITY_NUX_MAX_SHOW_COUNT)]),
         );
 
-        assert_eq!(
-            selected,
-            Some(StartupTooltipOverride {
-                model_slug: "gpt-5.4-mini".to_string(),
-                message: "gpt-5.4-mini is available".to_string(),
-            })
-        );
+        assert_eq!(selected, Some(tooltip("gpt-5.4-mini", "available")));
     }
 
     #[test]
@@ -11654,47 +11649,28 @@ guardian_approval = true
     #[test]
     fn select_model_availability_nux_uses_existing_model_order_as_priority() {
         let mut presets = all_model_presets();
-        presets.iter_mut().for_each(|preset| {
-            preset.availability_nux = None;
-        });
-        let first = presets
-            .iter_mut()
-            .find(|preset| preset.model == "gpt-5.4-mini")
-            .expect("gpt-5.4-mini preset present");
+        clear_nux(&mut presets);
+        let first = preset_mut(&mut presets, "gpt-5.4-mini");
         first.availability_nux = Some(ModelAvailabilityNux {
             message: "first".to_string(),
         });
-        let second = presets
-            .iter_mut()
-            .find(|preset| preset.model == "gpt-5.2")
-            .expect("gpt-5.2 preset present");
+        let second = preset_mut(&mut presets, "gpt-5.2");
         second.availability_nux = Some(ModelAvailabilityNux {
             message: "second".to_string(),
         });
 
         let selected = select_model_availability_nux(&presets, &model_availability_nux_config(&[]));
 
-        assert_eq!(
-            selected,
-            Some(StartupTooltipOverride {
-                model_slug: "gpt-5.4-mini".to_string(),
-                message: "first".to_string(),
-            })
-        );
+        assert_eq!(selected, Some(tooltip("gpt-5.4-mini", "first")));
     }
 
     #[test]
     fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() {
         let mut presets = all_model_presets();
-        presets.iter_mut().for_each(|preset| {
-            preset.availability_nux = None;
-        });
-        let target = presets
-            .iter_mut()
-            .find(|preset| preset.model == "gpt-5.4")
-            .expect("target preset present");
+        clear_nux(&mut presets);
+        let target = preset_mut(&mut presets, "gpt-5.4");
         target.availability_nux = Some(ModelAvailabilityNux {
-            message: "gpt-5.4 is available".to_string(),
+            message: "available".to_string(),
         });
 
         let selected = select_model_availability_nux(
@@ -11783,14 +11759,9 @@ guardian_approval = true
             .expect("gpt-5.3-codex preset present");
         current.show_in_picker = false;
         let current = current.clone();
-        assert!(
-            !current.show_in_picker,
-            "expected current model to be hidden from picker for this test"
-        );
+        assert!(!current.show_in_picker);
 
         let upgrade = current.upgrade.as_ref().expect("upgrade configured");
-        // Test "hidden current model still prompts" even if bundled
-        // catalog data changes the target model's picker visibility.
         available_models
             .iter_mut()
             .find(|preset| preset.model == upgrade.id)

From 13559e033d985db6eb73738378648d57e32e5360 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Tue, 21 Apr 2026 00:48:19 -0700
Subject: [PATCH 19/23] Move goal accounting state APIs into state PR

---
 ...thread_goals.sql => 0027_thread_goals.sql} |   0
 codex-rs/state/src/runtime/goals.rs           | 218 +++++++++++++++++-
 2 files changed, 217 insertions(+), 1 deletion(-)
 rename codex-rs/state/migrations/{0026_thread_goals.sql => 0027_thread_goals.sql} (100%)

diff --git a/codex-rs/state/migrations/0026_thread_goals.sql b/codex-rs/state/migrations/0027_thread_goals.sql
similarity index 100%
rename from codex-rs/state/migrations/0026_thread_goals.sql
rename to codex-rs/state/migrations/0027_thread_goals.sql
diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index 6a25e8794a24..7e32b08b1642 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -13,6 +13,7 @@ pub enum ThreadGoalAccountingOutcome {
 
 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
 pub enum ThreadGoalAccountingMode {
+    ActiveStatusOnly,
     ActiveOnly,
     ActiveOrComplete,
     ActiveOrStopped,
@@ -116,6 +117,7 @@ RETURNING
 UPDATE thread_goals
 SET
     status = CASE
+        WHEN status = ? AND ? = ? THEN status
         WHEN ? = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ?
         ELSE ?
     END,
@@ -124,6 +126,9 @@ SET
 WHERE thread_id = ?
             "#,
                 )
+                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
+                .bind(status.as_str())
+                .bind(crate::ThreadGoalStatus::Paused.as_str())
                 .bind(status.as_str())
                 .bind(token_budget)
                 .bind(token_budget)
@@ -141,6 +146,7 @@ WHERE thread_id = ?
 UPDATE thread_goals
 SET
     status = CASE
+        WHEN status = ? AND ? = ? THEN status
         WHEN ? = 'active' AND token_budget IS NOT NULL AND tokens_used >= token_budget THEN ?
         ELSE ?
     END,
@@ -148,6 +154,9 @@ SET
 WHERE thread_id = ?
             "#,
                 )
+                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
+                .bind(status.as_str())
+                .bind(crate::ThreadGoalStatus::Paused.as_str())
                 .bind(status.as_str())
                 .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
                 .bind(status.as_str())
@@ -189,6 +198,34 @@ WHERE thread_id = ?
         self.get_thread_goal(thread_id).await
     }
 
+    pub async fn pause_active_thread_goal(
+        &self,
+        thread_id: ThreadId,
+    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
+        let now_ms = datetime_to_epoch_millis(Utc::now());
+        let result = sqlx::query(
+            r#"
+UPDATE thread_goals
+SET
+    status = ?,
+    updated_at_ms = ?
+WHERE thread_id = ?
+  AND status = 'active'
+            "#,
+        )
+        .bind(crate::ThreadGoalStatus::Paused.as_str())
+        .bind(now_ms)
+        .bind(thread_id.to_string())
+        .execute(self.pool.as_ref())
+        .await?;
+
+        if result.rows_affected() == 0 {
+            return Ok(None);
+        }
+
+        self.get_thread_goal(thread_id).await
+    }
+
     pub async fn delete_thread_goal(&self, thread_id: ThreadId) -> anyhow::Result<bool> {
         let result = sqlx::query(
             r#"
@@ -221,6 +258,7 @@ WHERE thread_id = ?
 
         let now_ms = datetime_to_epoch_millis(Utc::now());
         let status_filter = match mode {
+            ThreadGoalAccountingMode::ActiveStatusOnly => "status = 'active'",
             ThreadGoalAccountingMode::ActiveOnly => "status IN ('active', 'budget_limited')",
             ThreadGoalAccountingMode::ActiveOrComplete => {
                 "status IN ('active', 'budget_limited', 'complete')"
@@ -229,6 +267,14 @@ WHERE thread_id = ?
                 "status IN ('active', 'paused', 'budget_limited')"
             }
         };
+        let budget_limit_status_filter = match mode {
+            ThreadGoalAccountingMode::ActiveStatusOnly
+            | ThreadGoalAccountingMode::ActiveOnly
+            | ThreadGoalAccountingMode::ActiveOrComplete => "status = 'active'",
+            ThreadGoalAccountingMode::ActiveOrStopped => {
+                "status IN ('active', 'paused', 'budget_limited')"
+            }
+        };
         let goal_id_filter = if expected_goal_id.is_some() {
             "goal_id = ?"
         } else {
@@ -241,7 +287,7 @@ SET
     time_used_seconds = time_used_seconds + ?,
     tokens_used = tokens_used + ?,
     status = CASE
-        WHEN status = 'active' AND token_budget IS NOT NULL AND tokens_used + ? >= token_budget
+        WHEN {budget_limit_status_filter} AND token_budget IS NOT NULL AND tokens_used + ? >= token_budget
             THEN ?
         ELSE status
     END,
@@ -502,6 +548,58 @@ mod tests {
         assert_eq!(Some(200_000), goal.token_budget);
     }
 
+    #[tokio::test]
+    async fn pause_active_thread_goal_does_not_clobber_terminal_status() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+        let goal = runtime
+            .replace_thread_goal(
+                thread_id,
+                "optimize the benchmark",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(100_000),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        let paused = runtime
+            .pause_active_thread_goal(thread_id)
+            .await
+            .expect("active pause should succeed")
+            .expect("active goal should be paused");
+        let expected = crate::ThreadGoal {
+            status: crate::ThreadGoalStatus::Paused,
+            updated_at: paused.updated_at,
+            ..goal
+        };
+        assert_eq!(expected, paused);
+
+        let complete = runtime
+            .update_thread_goal(
+                thread_id,
+                ThreadGoalUpdate {
+                    status: Some(crate::ThreadGoalStatus::Complete),
+                    token_budget: None,
+                },
+            )
+            .await
+            .expect("goal update should succeed")
+            .expect("goal should exist");
+        let pause_result = runtime
+            .pause_active_thread_goal(thread_id)
+            .await
+            .expect("terminal pause attempt should succeed");
+        assert_eq!(None, pause_result);
+        assert_eq!(
+            Some(complete),
+            runtime
+                .get_thread_goal(thread_id)
+                .await
+                .expect("goal read should succeed")
+        );
+    }
+
     #[tokio::test]
     async fn usage_accounting_updates_active_goals_and_accounts_budget_limited_in_flight_usage() {
         let runtime = test_runtime().await;
@@ -569,6 +667,82 @@ mod tests {
         assert_eq!(15, goal.time_used_seconds);
     }
 
+    #[tokio::test]
+    async fn active_status_only_usage_accounting_does_not_update_budget_limited_goals() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "stay stopped",
+                crate::ThreadGoalStatus::BudgetLimited,
+                /*token_budget*/ Some(20),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        let outcome = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*time_delta_seconds*/ 5,
+                /*token_delta*/ 5,
+                ThreadGoalAccountingMode::ActiveStatusOnly,
+                /*expected_goal_id*/ None,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else {
+            panic!("budget-limited goal should not be updated");
+        };
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
+        assert_eq!(0, goal.tokens_used);
+        assert_eq!(0, goal.time_used_seconds);
+    }
+
+    #[tokio::test]
+    async fn stopped_usage_accounting_promotes_paused_goal_over_budget() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+        runtime
+            .replace_thread_goal(
+                thread_id,
+                "stop before overrun",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(20),
+            )
+            .await
+            .expect("goal replacement should succeed");
+        runtime
+            .update_thread_goal(
+                thread_id,
+                crate::ThreadGoalUpdate {
+                    status: Some(crate::ThreadGoalStatus::Paused),
+                    token_budget: None,
+                },
+            )
+            .await
+            .expect("goal update should succeed");
+
+        let outcome = runtime
+            .account_thread_goal_usage(
+                thread_id,
+                /*time_delta_seconds*/ 3,
+                /*token_delta*/ 25,
+                ThreadGoalAccountingMode::ActiveOrStopped,
+                /*expected_goal_id*/ None,
+            )
+            .await
+            .expect("usage accounting should succeed");
+        let ThreadGoalAccountingOutcome::Updated(goal) = outcome else {
+            panic!("stopped goal should account final usage");
+        };
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
+        assert_eq!(25, goal.tokens_used);
+        assert_eq!(3, goal.time_used_seconds);
+    }
+
     #[tokio::test]
     async fn budget_updates_immediately_stop_active_goals_already_over_budget() {
         let runtime = test_runtime().await;
@@ -653,6 +827,48 @@ mod tests {
         assert_eq!(50, reactivated.tokens_used);
     }
 
+    #[tokio::test]
+    async fn pausing_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
+            .replace_thread_goal(
+                thread_id,
+                "stay within budget",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(40),
+            )
+            .await
+            .expect("goal replacement should succeed");
+        runtime
+            .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 paused = runtime
+            .update_thread_goal(
+                thread_id,
+                ThreadGoalUpdate {
+                    status: Some(crate::ThreadGoalStatus::Paused),
+                    token_budget: None,
+                },
+            )
+            .await
+            .expect("goal update should succeed")
+            .expect("goal should exist");
+
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, paused.status);
+        assert_eq!(Some(40), paused.token_budget);
+        assert_eq!(50, paused.tokens_used);
+    }
+
     #[tokio::test]
     async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() {
         let runtime = test_runtime().await;

From d32c5d32ace4039fb02aa459de6f791544ec6ab6 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Tue, 21 Apr 2026 01:39:35 -0700
Subject: [PATCH 20/23] codex: address PR review feedback (#18073)

---
 codex-rs/state/src/runtime/goals.rs | 98 ++++++++++++++++++++++++++++-
 1 file changed, 96 insertions(+), 2 deletions(-)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index 7e32b08b1642..b806dc53f670 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -4,6 +4,7 @@ use uuid::Uuid;
 pub struct ThreadGoalUpdate {
     pub status: Option<crate::ThreadGoalStatus>,
     pub token_budget: Option<Option<i64>>,
+    pub expected_goal_id: Option<String>,
 }
 
 pub enum ThreadGoalAccountingOutcome {
@@ -109,8 +110,14 @@ RETURNING
         thread_id: ThreadId,
         update: ThreadGoalUpdate,
     ) -> anyhow::Result<Option<crate::ThreadGoal>> {
+        let ThreadGoalUpdate {
+            status,
+            token_budget,
+            expected_goal_id,
+        } = update;
+        let expected_goal_id = expected_goal_id.as_deref();
         let now_ms = datetime_to_epoch_millis(Utc::now());
-        let result = match (update.status, update.token_budget) {
+        let result = match (status, token_budget) {
             (Some(status), Some(token_budget)) => {
                 sqlx::query(
                     r#"
@@ -124,6 +131,7 @@ SET
     token_budget = ?,
     updated_at_ms = ?
 WHERE thread_id = ?
+  AND (? IS NULL OR goal_id = ?)
             "#,
                 )
                 .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
@@ -137,6 +145,8 @@ WHERE thread_id = ?
                 .bind(token_budget)
                 .bind(now_ms)
                 .bind(thread_id.to_string())
+                .bind(expected_goal_id)
+                .bind(expected_goal_id)
                 .execute(self.pool.as_ref())
                 .await?
             }
@@ -152,6 +162,7 @@ SET
     END,
     updated_at_ms = ?
 WHERE thread_id = ?
+  AND (? IS NULL OR goal_id = ?)
             "#,
                 )
                 .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
@@ -162,6 +173,8 @@ WHERE thread_id = ?
                 .bind(status.as_str())
                 .bind(now_ms)
                 .bind(thread_id.to_string())
+                .bind(expected_goal_id)
+                .bind(expected_goal_id)
                 .execute(self.pool.as_ref())
                 .await?
             }
@@ -177,6 +190,7 @@ SET
     END,
     updated_at_ms = ?
 WHERE thread_id = ?
+  AND (? IS NULL OR goal_id = ?)
             "#,
                 )
                 .bind(token_budget)
@@ -185,10 +199,20 @@ WHERE thread_id = ?
                 .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
                 .bind(now_ms)
                 .bind(thread_id.to_string())
+                .bind(expected_goal_id)
+                .bind(expected_goal_id)
                 .execute(self.pool.as_ref())
                 .await?
             }
-            (None, None) => return self.get_thread_goal(thread_id).await,
+            (None, None) => {
+                let goal = self.get_thread_goal(thread_id).await?;
+                return Ok(match (goal, expected_goal_id) {
+                    (Some(goal), Some(expected_goal_id)) if goal.goal_id != expected_goal_id => {
+                        None
+                    }
+                    (goal, _) => goal,
+                });
+            }
         };
 
         if result.rows_affected() == 0 {
@@ -405,6 +429,7 @@ mod tests {
                 ThreadGoalUpdate {
                     status: Some(crate::ThreadGoalStatus::Paused),
                     token_budget: Some(Some(200_000)),
+                    expected_goal_id: None,
                 },
             )
             .await
@@ -460,6 +485,67 @@ mod tests {
         assert_eq!(0, replaced.time_used_seconds);
     }
 
+    #[tokio::test]
+    async fn update_thread_goal_ignores_replaced_goal_version() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+
+        let original = runtime
+            .replace_thread_goal(
+                thread_id,
+                "old objective",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(100),
+            )
+            .await
+            .expect("goal replacement should succeed");
+        let replacement = runtime
+            .replace_thread_goal(
+                thread_id,
+                "new objective",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(10),
+            )
+            .await
+            .expect("goal replacement should succeed");
+
+        let stale_update = runtime
+            .update_thread_goal(
+                thread_id,
+                ThreadGoalUpdate {
+                    status: Some(crate::ThreadGoalStatus::Complete),
+                    token_budget: None,
+                    expected_goal_id: Some(original.goal_id),
+                },
+            )
+            .await
+            .expect("goal update should succeed");
+
+        assert_eq!(None, stale_update);
+        assert_eq!(
+            Some(replacement.clone()),
+            runtime
+                .get_thread_goal(thread_id)
+                .await
+                .expect("goal read should succeed")
+        );
+
+        let fresh_update = runtime
+            .update_thread_goal(
+                thread_id,
+                ThreadGoalUpdate {
+                    status: Some(crate::ThreadGoalStatus::Complete),
+                    token_budget: None,
+                    expected_goal_id: Some(replacement.goal_id),
+                },
+            )
+            .await
+            .expect("goal update should succeed")
+            .expect("fresh update should match the replacement goal");
+        assert_eq!(crate::ThreadGoalStatus::Complete, fresh_update.status);
+    }
+
     #[tokio::test]
     async fn usage_accounting_ignores_replaced_goal_version() {
         let runtime = test_runtime().await;
@@ -526,6 +612,7 @@ mod tests {
             ThreadGoalUpdate {
                 status: Some(crate::ThreadGoalStatus::Paused),
                 token_budget: None,
+                expected_goal_id: None,
             },
         );
         let budget_update = runtime.update_thread_goal(
@@ -533,6 +620,7 @@ mod tests {
             ThreadGoalUpdate {
                 status: None,
                 token_budget: Some(Some(200_000)),
+                expected_goal_id: None,
             },
         );
         let (status_update, budget_update) = tokio::join!(status_update, budget_update);
@@ -581,6 +669,7 @@ mod tests {
                 ThreadGoalUpdate {
                     status: Some(crate::ThreadGoalStatus::Complete),
                     token_budget: None,
+                    expected_goal_id: None,
                 },
             )
             .await
@@ -720,6 +809,7 @@ mod tests {
                 crate::ThreadGoalUpdate {
                     status: Some(crate::ThreadGoalStatus::Paused),
                     token_budget: None,
+                    expected_goal_id: None,
                 },
             )
             .await
@@ -774,6 +864,7 @@ mod tests {
                 ThreadGoalUpdate {
                     status: None,
                     token_budget: Some(Some(40)),
+                    expected_goal_id: None,
                 },
             )
             .await
@@ -816,6 +907,7 @@ mod tests {
                 ThreadGoalUpdate {
                     status: Some(crate::ThreadGoalStatus::Active),
                     token_budget: None,
+                    expected_goal_id: None,
                 },
             )
             .await
@@ -858,6 +950,7 @@ mod tests {
                 ThreadGoalUpdate {
                     status: Some(crate::ThreadGoalStatus::Paused),
                     token_budget: None,
+                    expected_goal_id: None,
                 },
             )
             .await
@@ -939,6 +1032,7 @@ mod tests {
                 ThreadGoalUpdate {
                     status: Some(crate::ThreadGoalStatus::Paused),
                     token_budget: None,
+                    expected_goal_id: None,
                 },
             )
             .await

From 6fad2475be74e713a6a219b0ce91a635ebc8a5c4 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Wed, 22 Apr 2026 11:03:31 -0700
Subject: [PATCH 21/23] goals: return inserted goal atomically

---
 codex-rs/state/src/runtime/goals.rs | 106 ++++++++++++++++++++++++++++
 1 file changed, 106 insertions(+)

diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs
index b806dc53f670..56613b7d92da 100644
--- a/codex-rs/state/src/runtime/goals.rs
+++ b/codex-rs/state/src/runtime/goals.rs
@@ -105,6 +105,55 @@ RETURNING
         thread_goal_from_row(&row)
     }
 
+    pub async fn insert_thread_goal(
+        &self,
+        thread_id: ThreadId,
+        objective: &str,
+        status: crate::ThreadGoalStatus,
+        token_budget: Option<i64>,
+    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
+        let goal_id = Uuid::new_v4().to_string();
+        let now_ms = datetime_to_epoch_millis(Utc::now());
+        let status = status_after_budget_limit(status, /*tokens_used*/ 0, token_budget);
+        let row = sqlx::query(
+            r#"
+INSERT INTO thread_goals (
+    thread_id,
+    goal_id,
+    objective,
+    status,
+    token_budget,
+    tokens_used,
+    time_used_seconds,
+    created_at_ms,
+    updated_at_ms
+) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)
+ON CONFLICT(thread_id) DO NOTHING
+RETURNING
+    thread_id,
+    goal_id,
+    objective,
+    status,
+    token_budget,
+    tokens_used,
+    time_used_seconds,
+    created_at_ms,
+    updated_at_ms
+            "#,
+        )
+        .bind(thread_id.to_string())
+        .bind(goal_id)
+        .bind(objective)
+        .bind(status.as_str())
+        .bind(token_budget)
+        .bind(now_ms)
+        .bind(now_ms)
+        .fetch_optional(self.pool.as_ref())
+        .await?;
+
+        row.map(|row| thread_goal_from_row(&row)).transpose()
+    }
+
     pub async fn update_thread_goal(
         &self,
         thread_id: ThreadId,
@@ -485,6 +534,63 @@ mod tests {
         assert_eq!(0, replaced.time_used_seconds);
     }
 
+    #[tokio::test]
+    async fn insert_thread_goal_does_not_replace_existing_goal() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+
+        let inserted = runtime
+            .insert_thread_goal(
+                thread_id,
+                "optimize the benchmark",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(100_000),
+            )
+            .await
+            .expect("goal insertion should succeed")
+            .expect("goal should be inserted");
+
+        let duplicate = runtime
+            .insert_thread_goal(
+                thread_id,
+                "replace the benchmark",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(200_000),
+            )
+            .await
+            .expect("duplicate insert should not fail");
+
+        assert_eq!(None, duplicate);
+        assert_eq!(
+            Some(inserted),
+            runtime.get_thread_goal(thread_id).await.unwrap()
+        );
+    }
+
+    #[tokio::test]
+    async fn insert_thread_goal_applies_budget_limit_immediately() {
+        let runtime = test_runtime().await;
+        let thread_id = test_thread_id();
+        upsert_test_thread(&runtime, thread_id).await;
+
+        let inserted = runtime
+            .insert_thread_goal(
+                thread_id,
+                "stay within budget",
+                crate::ThreadGoalStatus::Active,
+                /*token_budget*/ Some(0),
+            )
+            .await
+            .expect("goal insertion should succeed")
+            .expect("goal should be inserted");
+
+        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, inserted.status);
+        assert_eq!(Some(0), inserted.token_budget);
+        assert_eq!(0, inserted.tokens_used);
+        assert_eq!(0, inserted.time_used_seconds);
+    }
+
     #[tokio::test]
     async fn update_thread_goal_ignores_replaced_goal_version() {
         let runtime = test_runtime().await;

From b4858b5b6df385fbd659942f81b2d6fe8f4ba08e Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Wed, 22 Apr 2026 11:04:50 -0700
Subject: [PATCH 22/23] goals: renumber state migration after main merge

---
 .../migrations/{0027_thread_goals.sql => 0028_thread_goals.sql}   | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename codex-rs/state/migrations/{0027_thread_goals.sql => 0028_thread_goals.sql} (100%)

diff --git a/codex-rs/state/migrations/0027_thread_goals.sql b/codex-rs/state/migrations/0028_thread_goals.sql
similarity index 100%
rename from codex-rs/state/migrations/0027_thread_goals.sql
rename to codex-rs/state/migrations/0028_thread_goals.sql

From 1a52141db76d1867a9ce3ff94a15607dd6665d98 Mon Sep 17 00:00:00 2001
From: Eric Traut <etraut@openai.com>
Date: Fri, 24 Apr 2026 00:31:10 -0700
Subject: [PATCH 23/23] Renumber goal state migration

---
 .../migrations/{0028_thread_goals.sql => 0029_thread_goals.sql}   | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 rename codex-rs/state/migrations/{0028_thread_goals.sql => 0029_thread_goals.sql} (100%)

diff --git a/codex-rs/state/migrations/0028_thread_goals.sql b/codex-rs/state/migrations/0029_thread_goals.sql
similarity index 100%
rename from codex-rs/state/migrations/0028_thread_goals.sql
rename to codex-rs/state/migrations/0029_thread_goals.sql
