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, 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, 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 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 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 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 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 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 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 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 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 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 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 5,
943 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 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 12,
981 30,
982 GoalAccountingMode::ActiveOnly,
983 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 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 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 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 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 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 7,
1203 5,
1204 GoalAccountingMode::ActiveOnly,
1205 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 3,
1221 15,
1222 GoalAccountingMode::ActiveOnly,
1223 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 5,
1239 5,
1240 GoalAccountingMode::ActiveOnly,
1241 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 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 5,
1274 5,
1275 GoalAccountingMode::ActiveStatusOnly,
1276 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 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 3,
1322 25,
1323 GoalAccountingMode::ActiveOrStopped,
1324 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 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 1,
1356 50,
1357 GoalAccountingMode::ActiveOnly,
1358 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 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 1,
1403 50,
1404 GoalAccountingMode::ActiveOnly,
1405 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 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 1,
1454 50,
1455 GoalAccountingMode::ActiveOnly,
1456 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 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 1,
1501 50,
1502 GoalAccountingMode::ActiveOnly,
1503 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 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 30,
1554 200,
1555 GoalAccountingMode::ActiveOnly,
1556 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 30,
1572 200,
1573 GoalAccountingMode::ActiveOrComplete,
1574 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 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 30,
1621 200,
1622 GoalAccountingMode::ActiveOnly,
1623 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 30,
1639 200,
1640 GoalAccountingMode::ActiveOrStopped,
1641 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 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 4,
1672 40,
1673 GoalAccountingMode::ActiveOnly,
1674 None,
1675 );
1676 let second = runtime.thread_goals().account_thread_goal_usage(
1677 thread_id,
1678 6,
1679 60,
1680 GoalAccountingMode::ActiveOnly,
1681 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 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}