Skip to main content

codex_state/runtime/
goals.rs

1use super::*;
2use crate::model::ThreadGoalRow;
3use uuid::Uuid;
4
5#[derive(Clone)]
6pub struct GoalStore {
7    pool: Arc<SqlitePool>,
8}
9
10impl GoalStore {
11    pub(crate) fn new(pool: Arc<SqlitePool>) -> Self {
12        Self { pool }
13    }
14
15    pub(crate) async fn close(&self) {
16        self.pool.close().await;
17    }
18}
19
20pub struct GoalUpdate {
21    pub objective: Option<String>,
22    pub status: Option<crate::ThreadGoalStatus>,
23    pub token_budget: Option<Option<i64>>,
24    pub expected_goal_id: Option<String>,
25}
26
27pub enum GoalAccountingOutcome {
28    Unchanged(Option<crate::ThreadGoal>),
29    Updated(crate::ThreadGoal),
30}
31
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum GoalAccountingMode {
34    ActiveStatusOnly,
35    ActiveOnly,
36    ActiveOrComplete,
37    ActiveOrStopped,
38}
39
40impl GoalStore {
41    pub async fn get_thread_goal(
42        &self,
43        thread_id: ThreadId,
44    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
45        let row = sqlx::query(
46            r#"
47SELECT
48    thread_id,
49    goal_id,
50    objective,
51    status,
52    token_budget,
53    tokens_used,
54    time_used_seconds,
55    created_at_ms,
56    updated_at_ms
57FROM thread_goals
58WHERE thread_id = ?
59            "#,
60        )
61        .bind(thread_id.to_string())
62        .fetch_optional(self.pool.as_ref())
63        .await?;
64
65        row.map(|row| thread_goal_from_row(&row)).transpose()
66    }
67
68    pub async fn replace_thread_goal_snapshot(
69        &self,
70        goal: &crate::ThreadGoal,
71    ) -> anyhow::Result<()> {
72        let mut transaction = self.pool.begin().await?;
73        sqlx::query(
74            r#"
75INSERT INTO thread_goals (
76    thread_id,
77    goal_id,
78    objective,
79    status,
80    token_budget,
81    tokens_used,
82    time_used_seconds,
83    created_at_ms,
84    updated_at_ms
85) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
86ON CONFLICT(thread_id) DO UPDATE SET
87    goal_id = excluded.goal_id,
88    objective = excluded.objective,
89    status = excluded.status,
90    token_budget = excluded.token_budget,
91    tokens_used = excluded.tokens_used,
92    time_used_seconds = excluded.time_used_seconds,
93    created_at_ms = excluded.created_at_ms,
94    updated_at_ms = excluded.updated_at_ms
95            "#,
96        )
97        .bind(goal.thread_id.to_string())
98        .bind(&goal.goal_id)
99        .bind(&goal.objective)
100        .bind(goal.status.as_str())
101        .bind(goal.token_budget)
102        .bind(goal.tokens_used)
103        .bind(goal.time_used_seconds)
104        .bind(datetime_to_epoch_millis(goal.created_at))
105        .bind(datetime_to_epoch_millis(goal.updated_at))
106        .execute(&mut *transaction)
107        .await?;
108
109        sqlx::query(
110            r#"
111INSERT INTO thread_goal_continuation_deferrals (thread_id)
112VALUES (?)
113ON CONFLICT(thread_id) DO NOTHING
114            "#,
115        )
116        .bind(goal.thread_id.to_string())
117        .execute(&mut *transaction)
118        .await?;
119
120        transaction.commit().await?;
121
122        Ok(())
123    }
124
125    pub async fn has_thread_goal_continuation_deferral(
126        &self,
127        thread_id: ThreadId,
128    ) -> anyhow::Result<bool> {
129        sqlx::query_scalar(
130            r#"
131SELECT EXISTS(
132    SELECT 1
133    FROM thread_goal_continuation_deferrals
134    WHERE thread_id = ?
135)
136            "#,
137        )
138        .bind(thread_id.to_string())
139        .fetch_one(self.pool.as_ref())
140        .await
141        .map_err(Into::into)
142    }
143
144    pub async fn clear_thread_goal_continuation_deferral(
145        &self,
146        thread_id: ThreadId,
147    ) -> anyhow::Result<()> {
148        sqlx::query("DELETE FROM thread_goal_continuation_deferrals WHERE thread_id = ?")
149            .bind(thread_id.to_string())
150            .execute(self.pool.as_ref())
151            .await?;
152
153        Ok(())
154    }
155
156    pub async fn replace_thread_goal(
157        &self,
158        thread_id: ThreadId,
159        objective: &str,
160        status: crate::ThreadGoalStatus,
161        token_budget: Option<i64>,
162    ) -> anyhow::Result<crate::ThreadGoal> {
163        let goal_id = Uuid::new_v4().to_string();
164        let now_ms = datetime_to_epoch_millis(Utc::now());
165        let status = status_after_budget_limit(status, /*tokens_used*/ 0, token_budget);
166        let row = sqlx::query(
167            r#"
168INSERT INTO thread_goals (
169    thread_id,
170    goal_id,
171    objective,
172    status,
173    token_budget,
174    tokens_used,
175    time_used_seconds,
176    created_at_ms,
177    updated_at_ms
178) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)
179ON CONFLICT(thread_id) DO UPDATE SET
180    goal_id = excluded.goal_id,
181    objective = excluded.objective,
182    status = excluded.status,
183    token_budget = excluded.token_budget,
184    tokens_used = 0,
185    time_used_seconds = 0,
186    created_at_ms = excluded.created_at_ms,
187    updated_at_ms = excluded.updated_at_ms
188RETURNING
189    thread_id,
190    goal_id,
191    objective,
192    status,
193    token_budget,
194    tokens_used,
195    time_used_seconds,
196    created_at_ms,
197    updated_at_ms
198            "#,
199        )
200        .bind(thread_id.to_string())
201        .bind(goal_id)
202        .bind(objective)
203        .bind(status.as_str())
204        .bind(token_budget)
205        .bind(now_ms)
206        .bind(now_ms)
207        .fetch_one(self.pool.as_ref())
208        .await?;
209
210        thread_goal_from_row(&row)
211    }
212
213    pub async fn insert_thread_goal(
214        &self,
215        thread_id: ThreadId,
216        objective: &str,
217        status: crate::ThreadGoalStatus,
218        token_budget: Option<i64>,
219    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
220        let goal_id = Uuid::new_v4().to_string();
221        let now_ms = datetime_to_epoch_millis(Utc::now());
222        let status = status_after_budget_limit(status, /*tokens_used*/ 0, token_budget);
223        let row = sqlx::query(
224            r#"
225INSERT INTO thread_goals (
226    thread_id,
227    goal_id,
228    objective,
229    status,
230    token_budget,
231    tokens_used,
232    time_used_seconds,
233    created_at_ms,
234    updated_at_ms
235) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?)
236ON CONFLICT(thread_id) DO UPDATE SET
237    goal_id = excluded.goal_id,
238    objective = excluded.objective,
239    status = excluded.status,
240    token_budget = excluded.token_budget,
241    tokens_used = 0,
242    time_used_seconds = 0,
243    created_at_ms = excluded.created_at_ms,
244    updated_at_ms = excluded.updated_at_ms
245WHERE thread_goals.status = 'complete'
246RETURNING
247    thread_id,
248    goal_id,
249    objective,
250    status,
251    token_budget,
252    tokens_used,
253    time_used_seconds,
254    created_at_ms,
255    updated_at_ms
256            "#,
257        )
258        .bind(thread_id.to_string())
259        .bind(goal_id)
260        .bind(objective)
261        .bind(status.as_str())
262        .bind(token_budget)
263        .bind(now_ms)
264        .bind(now_ms)
265        .fetch_optional(self.pool.as_ref())
266        .await?;
267
268        row.map(|row| thread_goal_from_row(&row)).transpose()
269    }
270
271    pub async fn update_thread_goal(
272        &self,
273        thread_id: ThreadId,
274        update: GoalUpdate,
275    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
276        let GoalUpdate {
277            objective,
278            status,
279            token_budget,
280            expected_goal_id,
281        } = update;
282        let objective = objective.as_deref();
283        let expected_goal_id = expected_goal_id.as_deref();
284        let now_ms = datetime_to_epoch_millis(Utc::now());
285        let result = match (status, token_budget) {
286            (Some(status), Some(token_budget)) => {
287                sqlx::query(
288                    r#"
289UPDATE thread_goals
290SET
291    objective = COALESCE(?, objective),
292    status = CASE
293        WHEN status = ? AND ? IN (?, ?) THEN status
294        WHEN ? = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ?
295        ELSE ?
296    END,
297    token_budget = ?,
298    updated_at_ms = ?
299WHERE thread_id = ?
300  AND (? IS NULL OR goal_id = ?)
301            "#,
302                )
303                .bind(objective)
304                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
305                .bind(status.as_str())
306                .bind(crate::ThreadGoalStatus::Paused.as_str())
307                .bind(crate::ThreadGoalStatus::Blocked.as_str())
308                .bind(status.as_str())
309                .bind(token_budget)
310                .bind(token_budget)
311                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
312                .bind(status.as_str())
313                .bind(token_budget)
314                .bind(now_ms)
315                .bind(thread_id.to_string())
316                .bind(expected_goal_id)
317                .bind(expected_goal_id)
318                .execute(self.pool.as_ref())
319                .await?
320            }
321            (Some(status), None) => {
322                sqlx::query(
323                    r#"
324UPDATE thread_goals
325SET
326    objective = COALESCE(?, objective),
327    status = CASE
328        WHEN status = ? AND ? IN (?, ?) THEN status
329        WHEN ? = 'active' AND token_budget IS NOT NULL AND tokens_used >= token_budget THEN ?
330        ELSE ?
331    END,
332    updated_at_ms = ?
333WHERE thread_id = ?
334  AND (? IS NULL OR goal_id = ?)
335            "#,
336                )
337                .bind(objective)
338                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
339                .bind(status.as_str())
340                .bind(crate::ThreadGoalStatus::Paused.as_str())
341                .bind(crate::ThreadGoalStatus::Blocked.as_str())
342                .bind(status.as_str())
343                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
344                .bind(status.as_str())
345                .bind(now_ms)
346                .bind(thread_id.to_string())
347                .bind(expected_goal_id)
348                .bind(expected_goal_id)
349                .execute(self.pool.as_ref())
350                .await?
351            }
352            (None, Some(token_budget)) => {
353                sqlx::query(
354                    r#"
355UPDATE thread_goals
356SET
357    objective = COALESCE(?, objective),
358    token_budget = ?,
359    status = CASE
360        WHEN status = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ?
361        ELSE status
362    END,
363    updated_at_ms = ?
364WHERE thread_id = ?
365  AND (? IS NULL OR goal_id = ?)
366            "#,
367                )
368                .bind(objective)
369                .bind(token_budget)
370                .bind(token_budget)
371                .bind(token_budget)
372                .bind(crate::ThreadGoalStatus::BudgetLimited.as_str())
373                .bind(now_ms)
374                .bind(thread_id.to_string())
375                .bind(expected_goal_id)
376                .bind(expected_goal_id)
377                .execute(self.pool.as_ref())
378                .await?
379            }
380            (None, None) => {
381                if let Some(objective) = objective {
382                    sqlx::query(
383                        r#"
384UPDATE thread_goals
385SET
386    objective = ?,
387    updated_at_ms = ?
388WHERE thread_id = ?
389  AND (? IS NULL OR goal_id = ?)
390            "#,
391                    )
392                    .bind(objective)
393                    .bind(now_ms)
394                    .bind(thread_id.to_string())
395                    .bind(expected_goal_id)
396                    .bind(expected_goal_id)
397                    .execute(self.pool.as_ref())
398                    .await?
399                } else {
400                    let goal = self.get_thread_goal(thread_id).await?;
401                    return Ok(match (goal, expected_goal_id) {
402                        (Some(goal), Some(expected_goal_id))
403                            if goal.goal_id != expected_goal_id =>
404                        {
405                            None
406                        }
407                        (goal, _) => goal,
408                    });
409                }
410            }
411        };
412
413        if result.rows_affected() == 0 {
414            return Ok(None);
415        }
416
417        self.get_thread_goal(thread_id).await
418    }
419
420    pub async fn pause_active_thread_goal(
421        &self,
422        thread_id: ThreadId,
423    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
424        self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::Paused)
425            .await
426    }
427
428    pub async fn usage_limit_active_thread_goal(
429        &self,
430        thread_id: ThreadId,
431    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
432        self.update_active_thread_goal_status(thread_id, crate::ThreadGoalStatus::UsageLimited)
433            .await
434    }
435
436    async fn update_active_thread_goal_status(
437        &self,
438        thread_id: ThreadId,
439        status: crate::ThreadGoalStatus,
440    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
441        let now_ms = datetime_to_epoch_millis(Utc::now());
442        let result = sqlx::query(
443            r#"
444UPDATE thread_goals
445SET
446    status = ?,
447    updated_at_ms = ?
448WHERE thread_id = ?
449  AND (
450      status = 'active'
451      OR (
452          ? = 'usage_limited'
453          AND status = 'budget_limited'
454      )
455  )
456            "#,
457        )
458        .bind(status.as_str())
459        .bind(now_ms)
460        .bind(thread_id.to_string())
461        .bind(status.as_str())
462        .execute(self.pool.as_ref())
463        .await?;
464
465        if result.rows_affected() == 0 {
466            return Ok(None);
467        }
468
469        self.get_thread_goal(thread_id).await
470    }
471
472    pub async fn delete_thread_goal(
473        &self,
474        thread_id: ThreadId,
475    ) -> anyhow::Result<Option<crate::ThreadGoal>> {
476        let row = sqlx::query(
477            r#"
478DELETE FROM thread_goals
479WHERE thread_id = ?
480RETURNING
481    thread_id,
482    goal_id,
483    objective,
484    status,
485    token_budget,
486    tokens_used,
487    time_used_seconds,
488    created_at_ms,
489    updated_at_ms
490            "#,
491        )
492        .bind(thread_id.to_string())
493        .fetch_optional(self.pool.as_ref())
494        .await?;
495
496        row.map(|row| thread_goal_from_row(&row)).transpose()
497    }
498
499    pub async fn account_thread_goal_usage(
500        &self,
501        thread_id: ThreadId,
502        time_delta_seconds: i64,
503        token_delta: i64,
504        mode: GoalAccountingMode,
505        expected_goal_id: Option<&str>,
506    ) -> anyhow::Result<GoalAccountingOutcome> {
507        let time_delta_seconds = time_delta_seconds.max(0);
508        let token_delta = token_delta.max(0);
509        if time_delta_seconds == 0 && token_delta == 0 {
510            return Ok(GoalAccountingOutcome::Unchanged(
511                self.get_thread_goal(thread_id).await?,
512            ));
513        }
514
515        let now_ms = datetime_to_epoch_millis(Utc::now());
516        let active_or_stopped_status_filter =
517            "status IN ('active', 'paused', 'blocked', 'usage_limited', 'budget_limited')";
518        let status_filter = match mode {
519            GoalAccountingMode::ActiveStatusOnly => "status = 'active'",
520            GoalAccountingMode::ActiveOnly => "status IN ('active', 'budget_limited')",
521            GoalAccountingMode::ActiveOrComplete => {
522                "status IN ('active', 'budget_limited', 'complete')"
523            }
524            GoalAccountingMode::ActiveOrStopped => active_or_stopped_status_filter,
525        };
526        let budget_limit_status_filter = match mode {
527            GoalAccountingMode::ActiveStatusOnly
528            | GoalAccountingMode::ActiveOnly
529            | GoalAccountingMode::ActiveOrComplete => "status = 'active'",
530            GoalAccountingMode::ActiveOrStopped => active_or_stopped_status_filter,
531        };
532        let mut builder = QueryBuilder::<Sqlite>::new(
533            r#"
534UPDATE thread_goals
535SET
536    time_used_seconds = time_used_seconds +
537            "#,
538        );
539        builder.push_bind(time_delta_seconds);
540        builder.push(
541            r#",
542    tokens_used = tokens_used +
543            "#,
544        );
545        builder.push_bind(token_delta);
546        builder.push(
547            r#",
548    status = CASE
549        WHEN
550            "#,
551        );
552        builder.push(budget_limit_status_filter);
553        builder.push(
554            r#"
555            AND token_budget IS NOT NULL
556            AND tokens_used +
557            "#,
558        );
559        builder.push_bind(token_delta);
560        builder.push(
561            r#"
562                >= token_budget
563            THEN
564            "#,
565        );
566        builder.push_bind(crate::ThreadGoalStatus::BudgetLimited.as_str());
567        builder.push(
568            r#"
569        ELSE status
570    END,
571    updated_at_ms =
572            "#,
573        );
574        builder.push_bind(now_ms);
575        builder.push(
576            r#"
577WHERE thread_id =
578            "#,
579        );
580        builder.push_bind(thread_id.to_string());
581        builder.push(" AND ");
582        builder.push(status_filter);
583        if let Some(expected_goal_id) = expected_goal_id {
584            builder.push(" AND goal_id = ").push_bind(expected_goal_id);
585        }
586        builder.push(
587            r#"
588RETURNING
589    thread_id,
590    goal_id,
591    objective,
592    status,
593    token_budget,
594    tokens_used,
595    time_used_seconds,
596    created_at_ms,
597    updated_at_ms
598            "#,
599        );
600
601        let row = builder.build().fetch_optional(self.pool.as_ref()).await?;
602
603        let Some(row) = row else {
604            return Ok(GoalAccountingOutcome::Unchanged(
605                self.get_thread_goal(thread_id).await?,
606            ));
607        };
608
609        let updated = thread_goal_from_row(&row)?;
610        Ok(GoalAccountingOutcome::Updated(updated))
611    }
612}
613
614fn thread_goal_from_row(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<crate::ThreadGoal> {
615    ThreadGoalRow::try_from_row(row).and_then(crate::ThreadGoal::try_from)
616}
617
618fn status_after_budget_limit(
619    status: crate::ThreadGoalStatus,
620    tokens_used: i64,
621    token_budget: Option<i64>,
622) -> crate::ThreadGoalStatus {
623    if status == crate::ThreadGoalStatus::Active
624        && token_budget.is_some_and(|budget| tokens_used >= budget)
625    {
626        crate::ThreadGoalStatus::BudgetLimited
627    } else {
628        status
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::runtime::test_support::test_thread_metadata;
636    use crate::runtime::test_support::unique_temp_dir;
637    use pretty_assertions::assert_eq;
638
639    async fn test_runtime() -> std::sync::Arc<StateRuntime> {
640        StateRuntime::init(unique_temp_dir(), "test-provider".to_string())
641            .await
642            .expect("state db should initialize")
643    }
644
645    fn test_thread_id() -> ThreadId {
646        ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id")
647    }
648
649    async fn upsert_test_thread(runtime: &StateRuntime, thread_id: ThreadId) {
650        let metadata = test_thread_metadata(
651            runtime.codex_home(),
652            thread_id,
653            runtime.codex_home().join("workspace"),
654        );
655        runtime
656            .upsert_thread(&metadata)
657            .await
658            .expect("test thread should be upserted");
659    }
660
661    #[tokio::test]
662    async fn replace_update_and_get_thread_goal() {
663        let runtime = test_runtime().await;
664        let thread_id = test_thread_id();
665        upsert_test_thread(&runtime, thread_id).await;
666
667        let goal = runtime
668            .thread_goals()
669            .replace_thread_goal(
670                thread_id,
671                "optimize the benchmark",
672                crate::ThreadGoalStatus::Active,
673                /*token_budget*/ Some(100_000),
674            )
675            .await
676            .expect("goal replacement should succeed");
677        assert_eq!(
678            Some(goal.clone()),
679            runtime
680                .thread_goals()
681                .get_thread_goal(thread_id)
682                .await
683                .unwrap()
684        );
685        let metadata = runtime
686            .get_thread(thread_id)
687            .await
688            .expect("thread metadata should load")
689            .expect("thread should exist");
690        assert_eq!(metadata.preview.as_deref(), Some("hello"));
691
692        let updated = runtime
693            .thread_goals()
694            .update_thread_goal(
695                thread_id,
696                GoalUpdate {
697                    objective: None,
698                    status: Some(crate::ThreadGoalStatus::Paused),
699                    token_budget: Some(Some(200_000)),
700                    expected_goal_id: None,
701                },
702            )
703            .await
704            .expect("goal update should succeed")
705            .expect("goal should exist");
706        let expected = crate::ThreadGoal {
707            status: crate::ThreadGoalStatus::Paused,
708            token_budget: Some(200_000),
709            updated_at: updated.updated_at,
710            ..goal.clone()
711        };
712        assert_eq!(expected, updated);
713
714        let replaced = runtime
715            .thread_goals()
716            .replace_thread_goal(
717                thread_id,
718                "ship the new result",
719                crate::ThreadGoalStatus::Active,
720                /*token_budget*/ None,
721            )
722            .await
723            .expect("goal replacement should succeed");
724        assert_eq!("ship the new result", replaced.objective);
725        assert_eq!(crate::ThreadGoalStatus::Active, replaced.status);
726        assert_eq!(None, replaced.token_budget);
727        assert_eq!(0, replaced.tokens_used);
728        assert_eq!(0, replaced.time_used_seconds);
729
730        assert_eq!(
731            Some(replaced),
732            runtime
733                .thread_goals()
734                .delete_thread_goal(thread_id)
735                .await
736                .unwrap()
737        );
738        assert_eq!(
739            None,
740            runtime
741                .thread_goals()
742                .get_thread_goal(thread_id)
743                .await
744                .unwrap()
745        );
746        assert_eq!(
747            None,
748            runtime
749                .thread_goals()
750                .delete_thread_goal(thread_id)
751                .await
752                .unwrap()
753        );
754    }
755
756    #[tokio::test]
757    async fn replace_thread_goal_applies_budget_limit_immediately() {
758        let runtime = test_runtime().await;
759        let thread_id = test_thread_id();
760        upsert_test_thread(&runtime, thread_id).await;
761
762        let replaced = runtime
763            .thread_goals()
764            .replace_thread_goal(
765                thread_id,
766                "stay within budget",
767                crate::ThreadGoalStatus::Active,
768                /*token_budget*/ Some(0),
769            )
770            .await
771            .expect("goal replacement should succeed");
772
773        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, replaced.status);
774        assert_eq!(Some(0), replaced.token_budget);
775        assert_eq!(0, replaced.tokens_used);
776        assert_eq!(0, replaced.time_used_seconds);
777    }
778
779    #[tokio::test]
780    async fn insert_thread_goal_does_not_replace_existing_goal() {
781        let runtime = test_runtime().await;
782        let thread_id = test_thread_id();
783        upsert_test_thread(&runtime, thread_id).await;
784
785        let inserted = runtime
786            .thread_goals()
787            .insert_thread_goal(
788                thread_id,
789                "optimize the benchmark",
790                crate::ThreadGoalStatus::Active,
791                /*token_budget*/ Some(100_000),
792            )
793            .await
794            .expect("goal insertion should succeed")
795            .expect("goal should be inserted");
796
797        let duplicate = runtime
798            .thread_goals()
799            .insert_thread_goal(
800                thread_id,
801                "replace the benchmark",
802                crate::ThreadGoalStatus::Active,
803                /*token_budget*/ Some(200_000),
804            )
805            .await
806            .expect("duplicate insert should not fail");
807
808        assert_eq!(None, duplicate);
809        assert_eq!(
810            Some(inserted),
811            runtime
812                .thread_goals()
813                .get_thread_goal(thread_id)
814                .await
815                .unwrap()
816        );
817    }
818
819    #[tokio::test]
820    async fn insert_thread_goal_applies_budget_limit_immediately() {
821        let runtime = test_runtime().await;
822        let thread_id = test_thread_id();
823        upsert_test_thread(&runtime, thread_id).await;
824
825        let inserted = runtime
826            .thread_goals()
827            .insert_thread_goal(
828                thread_id,
829                "stay within budget",
830                crate::ThreadGoalStatus::Active,
831                /*token_budget*/ Some(0),
832            )
833            .await
834            .expect("goal insertion should succeed")
835            .expect("goal should be inserted");
836
837        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, inserted.status);
838        assert_eq!(Some(0), inserted.token_budget);
839        assert_eq!(0, inserted.tokens_used);
840        assert_eq!(0, inserted.time_used_seconds);
841    }
842
843    #[tokio::test]
844    async fn update_thread_goal_ignores_replaced_goal_version() {
845        let runtime = test_runtime().await;
846        let thread_id = test_thread_id();
847        upsert_test_thread(&runtime, thread_id).await;
848
849        let original = runtime
850            .thread_goals()
851            .replace_thread_goal(
852                thread_id,
853                "old objective",
854                crate::ThreadGoalStatus::Active,
855                /*token_budget*/ Some(100),
856            )
857            .await
858            .expect("goal replacement should succeed");
859        let replacement = runtime
860            .thread_goals()
861            .replace_thread_goal(
862                thread_id,
863                "new objective",
864                crate::ThreadGoalStatus::Active,
865                /*token_budget*/ Some(10),
866            )
867            .await
868            .expect("goal replacement should succeed");
869
870        let stale_update = runtime
871            .thread_goals()
872            .update_thread_goal(
873                thread_id,
874                GoalUpdate {
875                    objective: None,
876                    status: Some(crate::ThreadGoalStatus::Complete),
877                    token_budget: None,
878                    expected_goal_id: Some(original.goal_id),
879                },
880            )
881            .await
882            .expect("goal update should succeed");
883
884        assert_eq!(None, stale_update);
885        assert_eq!(
886            Some(replacement.clone()),
887            runtime
888                .thread_goals()
889                .get_thread_goal(thread_id)
890                .await
891                .expect("goal read should succeed")
892        );
893
894        let fresh_update = runtime
895            .thread_goals()
896            .update_thread_goal(
897                thread_id,
898                GoalUpdate {
899                    objective: None,
900                    status: Some(crate::ThreadGoalStatus::Complete),
901                    token_budget: None,
902                    expected_goal_id: Some(replacement.goal_id),
903                },
904            )
905            .await
906            .expect("goal update should succeed")
907            .expect("fresh update should match the replacement goal");
908        assert_eq!(crate::ThreadGoalStatus::Complete, fresh_update.status);
909    }
910
911    #[tokio::test]
912    async fn usage_accounting_ignores_replaced_goal_version() {
913        let runtime = test_runtime().await;
914        let thread_id = test_thread_id();
915        upsert_test_thread(&runtime, thread_id).await;
916
917        let original = runtime
918            .thread_goals()
919            .replace_thread_goal(
920                thread_id,
921                "old objective",
922                crate::ThreadGoalStatus::Active,
923                /*token_budget*/ Some(100),
924            )
925            .await
926            .expect("goal replacement should succeed");
927        let replacement = runtime
928            .thread_goals()
929            .replace_thread_goal(
930                thread_id,
931                "new objective",
932                crate::ThreadGoalStatus::Active,
933                /*token_budget*/ Some(10),
934            )
935            .await
936            .expect("goal replacement should succeed");
937
938        let outcome = runtime
939            .thread_goals()
940            .account_thread_goal_usage(
941                thread_id,
942                /*time_delta_seconds*/ 5,
943                /*token_delta*/ 5,
944                GoalAccountingMode::ActiveOnly,
945                Some(original.goal_id.as_str()),
946            )
947            .await
948            .expect("usage accounting should succeed");
949
950        let GoalAccountingOutcome::Unchanged(Some(goal)) = outcome else {
951            panic!("stale goal version should not be updated");
952        };
953        assert_ne!(replacement.goal_id, original.goal_id);
954        assert_eq!(replacement.created_at, goal.created_at);
955        assert_eq!("new objective", goal.objective);
956        assert_eq!(0, goal.tokens_used);
957        assert_eq!(0, goal.time_used_seconds);
958    }
959
960    #[tokio::test]
961    async fn update_thread_goal_objective_preserves_usage_and_created_at() {
962        let runtime = test_runtime().await;
963        let thread_id = test_thread_id();
964        upsert_test_thread(&runtime, thread_id).await;
965
966        runtime
967            .thread_goals()
968            .replace_thread_goal(
969                thread_id,
970                "draft the report",
971                crate::ThreadGoalStatus::Active,
972                /*token_budget*/ Some(100),
973            )
974            .await
975            .expect("goal replacement should succeed");
976        let outcome = runtime
977            .thread_goals()
978            .account_thread_goal_usage(
979                thread_id,
980                /*time_delta_seconds*/ 12,
981                /*token_delta*/ 30,
982                GoalAccountingMode::ActiveOnly,
983                /*expected_goal_id*/ None,
984            )
985            .await
986            .expect("usage accounting should succeed");
987        let GoalAccountingOutcome::Updated(accounted) = outcome else {
988            panic!("active goal should account usage");
989        };
990
991        let updated = runtime
992            .thread_goals()
993            .update_thread_goal(
994                thread_id,
995                GoalUpdate {
996                    objective: Some("draft the report clearly".to_string()),
997                    status: Some(crate::ThreadGoalStatus::Paused),
998                    token_budget: Some(Some(200)),
999                    expected_goal_id: Some(accounted.goal_id.clone()),
1000                },
1001            )
1002            .await
1003            .expect("goal update should succeed")
1004            .expect("goal should exist");
1005        let expected = crate::ThreadGoal {
1006            objective: "draft the report clearly".to_string(),
1007            status: crate::ThreadGoalStatus::Paused,
1008            token_budget: Some(200),
1009            updated_at: updated.updated_at,
1010            ..accounted
1011        };
1012        assert_eq!(expected, updated);
1013    }
1014
1015    #[tokio::test]
1016    async fn concurrent_partial_updates_preserve_independent_fields() {
1017        let runtime = test_runtime().await;
1018        let thread_id = test_thread_id();
1019        upsert_test_thread(&runtime, thread_id).await;
1020        runtime
1021            .thread_goals()
1022            .replace_thread_goal(
1023                thread_id,
1024                "optimize the benchmark",
1025                crate::ThreadGoalStatus::Active,
1026                /*token_budget*/ Some(100_000),
1027            )
1028            .await
1029            .expect("goal replacement should succeed");
1030
1031        let status_update = runtime.thread_goals().update_thread_goal(
1032            thread_id,
1033            GoalUpdate {
1034                objective: None,
1035                status: Some(crate::ThreadGoalStatus::Paused),
1036                token_budget: None,
1037                expected_goal_id: None,
1038            },
1039        );
1040        let budget_update = runtime.thread_goals().update_thread_goal(
1041            thread_id,
1042            GoalUpdate {
1043                objective: None,
1044                status: None,
1045                token_budget: Some(Some(200_000)),
1046                expected_goal_id: None,
1047            },
1048        );
1049        let (status_update, budget_update) = tokio::join!(status_update, budget_update);
1050        status_update.expect("status update should succeed");
1051        budget_update.expect("budget update should succeed");
1052
1053        let goal = runtime
1054            .thread_goals()
1055            .get_thread_goal(thread_id)
1056            .await
1057            .expect("goal read should succeed")
1058            .expect("goal should exist");
1059        assert_eq!(crate::ThreadGoalStatus::Paused, goal.status);
1060        assert_eq!(Some(200_000), goal.token_budget);
1061    }
1062
1063    #[tokio::test]
1064    async fn pause_active_thread_goal_does_not_clobber_terminal_status() {
1065        let runtime = test_runtime().await;
1066        let thread_id = test_thread_id();
1067        upsert_test_thread(&runtime, thread_id).await;
1068        let goal = runtime
1069            .thread_goals()
1070            .replace_thread_goal(
1071                thread_id,
1072                "optimize the benchmark",
1073                crate::ThreadGoalStatus::Active,
1074                /*token_budget*/ Some(100_000),
1075            )
1076            .await
1077            .expect("goal replacement should succeed");
1078
1079        let paused = runtime
1080            .thread_goals()
1081            .pause_active_thread_goal(thread_id)
1082            .await
1083            .expect("active pause should succeed")
1084            .expect("active goal should be paused");
1085        let expected = crate::ThreadGoal {
1086            status: crate::ThreadGoalStatus::Paused,
1087            updated_at: paused.updated_at,
1088            ..goal
1089        };
1090        assert_eq!(expected, paused);
1091
1092        let complete = runtime
1093            .thread_goals()
1094            .update_thread_goal(
1095                thread_id,
1096                GoalUpdate {
1097                    objective: None,
1098                    status: Some(crate::ThreadGoalStatus::Complete),
1099                    token_budget: None,
1100                    expected_goal_id: None,
1101                },
1102            )
1103            .await
1104            .expect("goal update should succeed")
1105            .expect("goal should exist");
1106        let pause_result = runtime
1107            .thread_goals()
1108            .pause_active_thread_goal(thread_id)
1109            .await
1110            .expect("terminal pause attempt should succeed");
1111        assert_eq!(None, pause_result);
1112        assert_eq!(
1113            Some(complete),
1114            runtime
1115                .thread_goals()
1116                .get_thread_goal(thread_id)
1117                .await
1118                .expect("goal read should succeed")
1119        );
1120    }
1121
1122    #[tokio::test]
1123    async fn usage_limit_active_thread_goal_updates_active_or_budget_limited_goals() {
1124        let runtime = test_runtime().await;
1125        let thread_id = test_thread_id();
1126        upsert_test_thread(&runtime, thread_id).await;
1127        let goal = runtime
1128            .thread_goals()
1129            .replace_thread_goal(
1130                thread_id,
1131                "optimize the benchmark",
1132                crate::ThreadGoalStatus::Active,
1133                /*token_budget*/ None,
1134            )
1135            .await
1136            .expect("goal replacement should succeed");
1137
1138        let usage_limited = runtime
1139            .thread_goals()
1140            .usage_limit_active_thread_goal(thread_id)
1141            .await
1142            .expect("usage limiting should succeed")
1143            .expect("active goal should become usage limited");
1144        let expected = crate::ThreadGoal {
1145            status: crate::ThreadGoalStatus::UsageLimited,
1146            updated_at: usage_limited.updated_at,
1147            ..goal
1148        };
1149        assert_eq!(expected, usage_limited);
1150
1151        let second_update = runtime
1152            .thread_goals()
1153            .usage_limit_active_thread_goal(thread_id)
1154            .await
1155            .expect("repeated usage limiting should succeed");
1156        assert_eq!(None, second_update);
1157
1158        let budget_limited = runtime
1159            .thread_goals()
1160            .replace_thread_goal(
1161                thread_id,
1162                "keep the usage failure visible",
1163                crate::ThreadGoalStatus::BudgetLimited,
1164                /*token_budget*/ Some(1),
1165            )
1166            .await
1167            .expect("goal replacement should succeed");
1168        let usage_limited = runtime
1169            .thread_goals()
1170            .usage_limit_active_thread_goal(thread_id)
1171            .await
1172            .expect("usage limiting should succeed")
1173            .expect("budget-limited goal should become usage limited");
1174        let expected = crate::ThreadGoal {
1175            status: crate::ThreadGoalStatus::UsageLimited,
1176            updated_at: usage_limited.updated_at,
1177            ..budget_limited
1178        };
1179        assert_eq!(expected, usage_limited);
1180    }
1181
1182    #[tokio::test]
1183    async fn usage_accounting_updates_active_goals_and_accounts_budget_limited_in_flight_usage() {
1184        let runtime = test_runtime().await;
1185        let thread_id = test_thread_id();
1186        upsert_test_thread(&runtime, thread_id).await;
1187        runtime
1188            .thread_goals()
1189            .replace_thread_goal(
1190                thread_id,
1191                "stay within budget",
1192                crate::ThreadGoalStatus::Active,
1193                /*token_budget*/ Some(20),
1194            )
1195            .await
1196            .expect("goal replacement should succeed");
1197
1198        let outcome = runtime
1199            .thread_goals()
1200            .account_thread_goal_usage(
1201                thread_id,
1202                /*time_delta_seconds*/ 7,
1203                /*token_delta*/ 5,
1204                GoalAccountingMode::ActiveOnly,
1205                /*expected_goal_id*/ None,
1206            )
1207            .await
1208            .expect("usage accounting should succeed");
1209        let GoalAccountingOutcome::Updated(goal) = outcome else {
1210            panic!("active goal should be updated");
1211        };
1212        assert_eq!(crate::ThreadGoalStatus::Active, goal.status);
1213        assert_eq!(5, goal.tokens_used);
1214        assert_eq!(7, goal.time_used_seconds);
1215
1216        let outcome = runtime
1217            .thread_goals()
1218            .account_thread_goal_usage(
1219                thread_id,
1220                /*time_delta_seconds*/ 3,
1221                /*token_delta*/ 15,
1222                GoalAccountingMode::ActiveOnly,
1223                /*expected_goal_id*/ None,
1224            )
1225            .await
1226            .expect("usage accounting should succeed");
1227        let GoalAccountingOutcome::Updated(goal) = outcome else {
1228            panic!("budget crossing should update the goal");
1229        };
1230        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
1231        assert_eq!(20, goal.tokens_used);
1232        assert_eq!(10, goal.time_used_seconds);
1233
1234        let outcome = runtime
1235            .thread_goals()
1236            .account_thread_goal_usage(
1237                thread_id,
1238                /*time_delta_seconds*/ 5,
1239                /*token_delta*/ 5,
1240                GoalAccountingMode::ActiveOnly,
1241                /*expected_goal_id*/ None,
1242            )
1243            .await
1244            .expect("usage accounting should succeed");
1245        let GoalAccountingOutcome::Updated(goal) = outcome else {
1246            panic!("budget-limited goal should still account in-flight active usage");
1247        };
1248        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
1249        assert_eq!(25, goal.tokens_used);
1250        assert_eq!(15, goal.time_used_seconds);
1251    }
1252
1253    #[tokio::test]
1254    async fn active_status_only_usage_accounting_does_not_update_budget_limited_goals() {
1255        let runtime = test_runtime().await;
1256        let thread_id = test_thread_id();
1257        upsert_test_thread(&runtime, thread_id).await;
1258        runtime
1259            .thread_goals()
1260            .replace_thread_goal(
1261                thread_id,
1262                "stay stopped",
1263                crate::ThreadGoalStatus::BudgetLimited,
1264                /*token_budget*/ Some(20),
1265            )
1266            .await
1267            .expect("goal replacement should succeed");
1268
1269        let outcome = runtime
1270            .thread_goals()
1271            .account_thread_goal_usage(
1272                thread_id,
1273                /*time_delta_seconds*/ 5,
1274                /*token_delta*/ 5,
1275                GoalAccountingMode::ActiveStatusOnly,
1276                /*expected_goal_id*/ None,
1277            )
1278            .await
1279            .expect("usage accounting should succeed");
1280        let GoalAccountingOutcome::Unchanged(Some(goal)) = outcome else {
1281            panic!("budget-limited goal should not be updated");
1282        };
1283        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
1284        assert_eq!(0, goal.tokens_used);
1285        assert_eq!(0, goal.time_used_seconds);
1286    }
1287
1288    #[tokio::test]
1289    async fn stopped_usage_accounting_promotes_paused_goal_over_budget() {
1290        let runtime = test_runtime().await;
1291        let thread_id = test_thread_id();
1292        upsert_test_thread(&runtime, thread_id).await;
1293        runtime
1294            .thread_goals()
1295            .replace_thread_goal(
1296                thread_id,
1297                "stop before overrun",
1298                crate::ThreadGoalStatus::Active,
1299                /*token_budget*/ Some(20),
1300            )
1301            .await
1302            .expect("goal replacement should succeed");
1303        runtime
1304            .thread_goals()
1305            .update_thread_goal(
1306                thread_id,
1307                crate::GoalUpdate {
1308                    objective: None,
1309                    status: Some(crate::ThreadGoalStatus::Paused),
1310                    token_budget: None,
1311                    expected_goal_id: None,
1312                },
1313            )
1314            .await
1315            .expect("goal update should succeed");
1316
1317        let outcome = runtime
1318            .thread_goals()
1319            .account_thread_goal_usage(
1320                thread_id,
1321                /*time_delta_seconds*/ 3,
1322                /*token_delta*/ 25,
1323                GoalAccountingMode::ActiveOrStopped,
1324                /*expected_goal_id*/ None,
1325            )
1326            .await
1327            .expect("usage accounting should succeed");
1328        let GoalAccountingOutcome::Updated(goal) = outcome else {
1329            panic!("stopped goal should account final usage");
1330        };
1331        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status);
1332        assert_eq!(25, goal.tokens_used);
1333        assert_eq!(3, goal.time_used_seconds);
1334    }
1335
1336    #[tokio::test]
1337    async fn budget_updates_immediately_stop_active_goals_already_over_budget() {
1338        let runtime = test_runtime().await;
1339        let thread_id = test_thread_id();
1340        upsert_test_thread(&runtime, thread_id).await;
1341        runtime
1342            .thread_goals()
1343            .replace_thread_goal(
1344                thread_id,
1345                "stay within budget",
1346                crate::ThreadGoalStatus::Active,
1347                /*token_budget*/ Some(100),
1348            )
1349            .await
1350            .expect("goal replacement should succeed");
1351        runtime
1352            .thread_goals()
1353            .account_thread_goal_usage(
1354                thread_id,
1355                /*time_delta_seconds*/ 1,
1356                /*token_delta*/ 50,
1357                GoalAccountingMode::ActiveOnly,
1358                /*expected_goal_id*/ None,
1359            )
1360            .await
1361            .expect("usage accounting should succeed");
1362
1363        let lowered = runtime
1364            .thread_goals()
1365            .update_thread_goal(
1366                thread_id,
1367                GoalUpdate {
1368                    objective: None,
1369                    status: None,
1370                    token_budget: Some(Some(40)),
1371                    expected_goal_id: None,
1372                },
1373            )
1374            .await
1375            .expect("goal update should succeed")
1376            .expect("goal should exist");
1377
1378        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, lowered.status);
1379        assert_eq!(Some(40), lowered.token_budget);
1380        assert_eq!(50, lowered.tokens_used);
1381    }
1382
1383    #[tokio::test]
1384    async fn activating_goal_already_over_budget_keeps_it_budget_limited() {
1385        let runtime = test_runtime().await;
1386        let thread_id = test_thread_id();
1387        upsert_test_thread(&runtime, thread_id).await;
1388        runtime
1389            .thread_goals()
1390            .replace_thread_goal(
1391                thread_id,
1392                "stay within budget",
1393                crate::ThreadGoalStatus::Active,
1394                /*token_budget*/ Some(40),
1395            )
1396            .await
1397            .expect("goal replacement should succeed");
1398        runtime
1399            .thread_goals()
1400            .account_thread_goal_usage(
1401                thread_id,
1402                /*time_delta_seconds*/ 1,
1403                /*token_delta*/ 50,
1404                GoalAccountingMode::ActiveOnly,
1405                /*expected_goal_id*/ None,
1406            )
1407            .await
1408            .expect("usage accounting should succeed");
1409
1410        let reactivated = runtime
1411            .thread_goals()
1412            .update_thread_goal(
1413                thread_id,
1414                GoalUpdate {
1415                    objective: Some("stay within budget, with clearer wording".to_string()),
1416                    status: Some(crate::ThreadGoalStatus::Active),
1417                    token_budget: None,
1418                    expected_goal_id: None,
1419                },
1420            )
1421            .await
1422            .expect("goal update should succeed")
1423            .expect("goal should exist");
1424
1425        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, reactivated.status);
1426        assert_eq!(
1427            "stay within budget, with clearer wording",
1428            reactivated.objective
1429        );
1430        assert_eq!(Some(40), reactivated.token_budget);
1431        assert_eq!(50, reactivated.tokens_used);
1432    }
1433
1434    #[tokio::test]
1435    async fn pausing_budget_limited_goal_preserves_terminal_status() {
1436        let runtime = test_runtime().await;
1437        let thread_id = test_thread_id();
1438        upsert_test_thread(&runtime, thread_id).await;
1439        runtime
1440            .thread_goals()
1441            .replace_thread_goal(
1442                thread_id,
1443                "stay within budget",
1444                crate::ThreadGoalStatus::Active,
1445                /*token_budget*/ Some(40),
1446            )
1447            .await
1448            .expect("goal replacement should succeed");
1449        runtime
1450            .thread_goals()
1451            .account_thread_goal_usage(
1452                thread_id,
1453                /*time_delta_seconds*/ 1,
1454                /*token_delta*/ 50,
1455                GoalAccountingMode::ActiveOnly,
1456                /*expected_goal_id*/ None,
1457            )
1458            .await
1459            .expect("usage accounting should succeed");
1460
1461        let paused = runtime
1462            .thread_goals()
1463            .update_thread_goal(
1464                thread_id,
1465                GoalUpdate {
1466                    objective: None,
1467                    status: Some(crate::ThreadGoalStatus::Paused),
1468                    token_budget: None,
1469                    expected_goal_id: None,
1470                },
1471            )
1472            .await
1473            .expect("goal update should succeed")
1474            .expect("goal should exist");
1475
1476        assert_eq!(crate::ThreadGoalStatus::BudgetLimited, paused.status);
1477        assert_eq!(Some(40), paused.token_budget);
1478        assert_eq!(50, paused.tokens_used);
1479    }
1480
1481    #[tokio::test]
1482    async fn blocking_budget_limited_goal_preserves_terminal_status() {
1483        let runtime = test_runtime().await;
1484        let thread_id = test_thread_id();
1485        upsert_test_thread(&runtime, thread_id).await;
1486        runtime
1487            .thread_goals()
1488            .replace_thread_goal(
1489                thread_id,
1490                "stay within budget",
1491                crate::ThreadGoalStatus::Active,
1492                /*token_budget*/ Some(40),
1493            )
1494            .await
1495            .expect("goal replacement should succeed");
1496        let outcome = runtime
1497            .thread_goals()
1498            .account_thread_goal_usage(
1499                thread_id,
1500                /*time_delta_seconds*/ 1,
1501                /*token_delta*/ 50,
1502                GoalAccountingMode::ActiveOnly,
1503                /*expected_goal_id*/ None,
1504            )
1505            .await
1506            .expect("usage accounting should succeed");
1507        let GoalAccountingOutcome::Updated(budget_limited) = outcome else {
1508            panic!("budget crossing should update the goal");
1509        };
1510
1511        let blocked = runtime
1512            .thread_goals()
1513            .update_thread_goal(
1514                thread_id,
1515                GoalUpdate {
1516                    objective: None,
1517                    status: Some(crate::ThreadGoalStatus::Blocked),
1518                    token_budget: None,
1519                    expected_goal_id: None,
1520                },
1521            )
1522            .await
1523            .expect("goal update should succeed")
1524            .expect("goal should exist");
1525
1526        let expected = crate::ThreadGoal {
1527            updated_at: blocked.updated_at,
1528            ..budget_limited
1529        };
1530        assert_eq!(expected, blocked);
1531    }
1532
1533    #[tokio::test]
1534    async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() {
1535        let runtime = test_runtime().await;
1536        let thread_id = test_thread_id();
1537        upsert_test_thread(&runtime, thread_id).await;
1538        runtime
1539            .thread_goals()
1540            .replace_thread_goal(
1541                thread_id,
1542                "finish the report",
1543                crate::ThreadGoalStatus::Complete,
1544                /*token_budget*/ Some(1_000),
1545            )
1546            .await
1547            .expect("goal replacement should succeed");
1548
1549        let active_only = runtime
1550            .thread_goals()
1551            .account_thread_goal_usage(
1552                thread_id,
1553                /*time_delta_seconds*/ 30,
1554                /*token_delta*/ 200,
1555                GoalAccountingMode::ActiveOnly,
1556                /*expected_goal_id*/ None,
1557            )
1558            .await
1559            .expect("usage accounting should succeed");
1560        let GoalAccountingOutcome::Unchanged(Some(goal)) = active_only else {
1561            panic!("completed goal should not be updated by active-only accounting");
1562        };
1563        assert_eq!(crate::ThreadGoalStatus::Complete, goal.status);
1564        assert_eq!(0, goal.tokens_used);
1565        assert_eq!(0, goal.time_used_seconds);
1566
1567        let completing_turn = runtime
1568            .thread_goals()
1569            .account_thread_goal_usage(
1570                thread_id,
1571                /*time_delta_seconds*/ 30,
1572                /*token_delta*/ 200,
1573                GoalAccountingMode::ActiveOrComplete,
1574                /*expected_goal_id*/ None,
1575            )
1576            .await
1577            .expect("usage accounting should succeed");
1578        let GoalAccountingOutcome::Updated(goal) = completing_turn else {
1579            panic!("completed goal should be updated for final accounting");
1580        };
1581        assert_eq!(crate::ThreadGoalStatus::Complete, goal.status);
1582        assert_eq!(200, goal.tokens_used);
1583        assert_eq!(30, goal.time_used_seconds);
1584    }
1585
1586    #[tokio::test]
1587    async fn usage_accounting_can_finalize_stopped_goal_for_in_flight_turn() {
1588        let runtime = test_runtime().await;
1589        let thread_id = test_thread_id();
1590        upsert_test_thread(&runtime, thread_id).await;
1591        runtime
1592            .thread_goals()
1593            .replace_thread_goal(
1594                thread_id,
1595                "finish the report",
1596                crate::ThreadGoalStatus::Active,
1597                /*token_budget*/ Some(1_000),
1598            )
1599            .await
1600            .expect("goal replacement should succeed");
1601        runtime
1602            .thread_goals()
1603            .update_thread_goal(
1604                thread_id,
1605                GoalUpdate {
1606                    objective: None,
1607                    status: Some(crate::ThreadGoalStatus::Paused),
1608                    token_budget: None,
1609                    expected_goal_id: None,
1610                },
1611            )
1612            .await
1613            .expect("goal update should succeed")
1614            .expect("goal should exist");
1615
1616        let active_only = runtime
1617            .thread_goals()
1618            .account_thread_goal_usage(
1619                thread_id,
1620                /*time_delta_seconds*/ 30,
1621                /*token_delta*/ 200,
1622                GoalAccountingMode::ActiveOnly,
1623                /*expected_goal_id*/ None,
1624            )
1625            .await
1626            .expect("usage accounting should succeed");
1627        let GoalAccountingOutcome::Unchanged(Some(goal)) = active_only else {
1628            panic!("paused goal should not be updated by active-only accounting");
1629        };
1630        assert_eq!(crate::ThreadGoalStatus::Paused, goal.status);
1631        assert_eq!(0, goal.tokens_used);
1632        assert_eq!(0, goal.time_used_seconds);
1633
1634        let in_flight_turn = runtime
1635            .thread_goals()
1636            .account_thread_goal_usage(
1637                thread_id,
1638                /*time_delta_seconds*/ 30,
1639                /*token_delta*/ 200,
1640                GoalAccountingMode::ActiveOrStopped,
1641                /*expected_goal_id*/ None,
1642            )
1643            .await
1644            .expect("usage accounting should succeed");
1645        let GoalAccountingOutcome::Updated(goal) = in_flight_turn else {
1646            panic!("stopped goal should be updated for in-flight accounting");
1647        };
1648        assert_eq!(crate::ThreadGoalStatus::Paused, goal.status);
1649        assert_eq!(200, goal.tokens_used);
1650        assert_eq!(30, goal.time_used_seconds);
1651    }
1652
1653    #[tokio::test]
1654    async fn usage_accounting_adds_concurrent_token_deltas() {
1655        let runtime = test_runtime().await;
1656        let thread_id = test_thread_id();
1657        upsert_test_thread(&runtime, thread_id).await;
1658        runtime
1659            .thread_goals()
1660            .replace_thread_goal(
1661                thread_id,
1662                "count every token",
1663                crate::ThreadGoalStatus::Active,
1664                /*token_budget*/ Some(1_000),
1665            )
1666            .await
1667            .expect("goal replacement should succeed");
1668
1669        let first = runtime.thread_goals().account_thread_goal_usage(
1670            thread_id,
1671            /*time_delta_seconds*/ 4,
1672            /*token_delta*/ 40,
1673            GoalAccountingMode::ActiveOnly,
1674            /*expected_goal_id*/ None,
1675        );
1676        let second = runtime.thread_goals().account_thread_goal_usage(
1677            thread_id,
1678            /*time_delta_seconds*/ 6,
1679            /*token_delta*/ 60,
1680            GoalAccountingMode::ActiveOnly,
1681            /*expected_goal_id*/ None,
1682        );
1683        let (first, second) = tokio::join!(first, second);
1684        first.expect("first usage accounting should succeed");
1685        second.expect("second usage accounting should succeed");
1686
1687        let goal = runtime
1688            .thread_goals()
1689            .get_thread_goal(thread_id)
1690            .await
1691            .expect("goal read should succeed")
1692            .expect("goal should exist");
1693        assert_eq!(100, goal.tokens_used);
1694        assert_eq!(10, goal.time_used_seconds);
1695    }
1696
1697    #[tokio::test]
1698    async fn deleting_thread_deletes_goal() {
1699        let runtime = test_runtime().await;
1700        let thread_id = test_thread_id();
1701        upsert_test_thread(&runtime, thread_id).await;
1702        runtime
1703            .thread_goals()
1704            .replace_thread_goal(
1705                thread_id,
1706                "clean up with the thread",
1707                crate::ThreadGoalStatus::Active,
1708                /*token_budget*/ None,
1709            )
1710            .await
1711            .expect("goal replacement should succeed");
1712
1713        runtime
1714            .delete_thread(thread_id)
1715            .await
1716            .expect("thread deletion should succeed");
1717
1718        assert_eq!(
1719            None,
1720            runtime
1721                .thread_goals()
1722                .get_thread_goal(thread_id)
1723                .await
1724                .expect("goal read should succeed")
1725        );
1726    }
1727}