1use super::threads::ThreadFilterOptions;
2use super::threads::push_thread_filters;
3use super::*;
4use crate::SortDirection;
5use crate::model::Phase2JobClaimOutcome;
6use crate::model::Stage1JobClaim;
7use crate::model::Stage1JobClaimOutcome;
8use crate::model::Stage1Output;
9use crate::model::Stage1StartupClaimParams;
10use crate::model::ThreadRow;
11use chrono::DateTime;
12use chrono::Duration;
13use sqlx::Executor;
14use sqlx::QueryBuilder;
15use sqlx::Sqlite;
16use uuid::Uuid;
17
18const JOB_KIND_MEMORY_STAGE1: &str = "memory_stage1";
19const JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL: &str = "memory_consolidate_global";
20const MEMORY_CONSOLIDATION_JOB_KEY: &str = "global";
21const PHASE2_SUCCESS_COOLDOWN_SECONDS: i64 = 6 * 60 * 60;
22const PHASE2_INPUT_SELECTION_PAGE_SIZE: usize = 512;
23
24const DEFAULT_RETRY_REMAINING: i64 = 3;
25
26#[derive(Clone)]
28pub struct MemoryStore {
29 pool: Arc<SqlitePool>,
30 state_pool: Arc<SqlitePool>,
31}
32
33impl MemoryStore {
34 pub(crate) fn new(pool: Arc<SqlitePool>, state_pool: Arc<SqlitePool>) -> Self {
35 Self { pool, state_pool }
36 }
37
38 pub(crate) async fn close(&self) {
39 self.pool.close().await;
40 }
41
42 pub async fn clear_memory_data(&self) -> anyhow::Result<()> {
48 clear_memory_data_in_pool(self.pool.as_ref()).await
49 }
50
51 pub async fn record_stage1_output_usage(
56 &self,
57 thread_ids: &[ThreadId],
58 ) -> anyhow::Result<usize> {
59 if thread_ids.is_empty() {
60 return Ok(0);
61 }
62
63 let now = Utc::now().timestamp();
64 let mut tx = self.pool.begin().await?;
65 let mut updated_rows = 0;
66
67 for thread_id in thread_ids {
68 updated_rows += sqlx::query(
69 r#"
70UPDATE stage1_outputs
71SET
72 usage_count = COALESCE(usage_count, 0) + 1,
73 last_usage = ?
74WHERE thread_id = ?
75 "#,
76 )
77 .bind(now)
78 .bind(thread_id.to_string())
79 .execute(&mut *tx)
80 .await?
81 .rows_affected() as usize;
82 }
83
84 tx.commit().await?;
85 Ok(updated_rows)
86 }
87
88 async fn stage1_source_needs_update(
89 &self,
90 thread_id: ThreadId,
91 source_updated_at: i64,
92 ) -> anyhow::Result<bool> {
93 let thread_id = thread_id.to_string();
94 let existing_output = sqlx::query(
95 r#"
96SELECT source_updated_at
97FROM stage1_outputs
98WHERE thread_id = ?
99 "#,
100 )
101 .bind(thread_id.as_str())
102 .fetch_optional(self.pool.as_ref())
103 .await?;
104 if let Some(existing_output) = existing_output {
105 let existing_source_updated_at: i64 = existing_output.try_get("source_updated_at")?;
106 if existing_source_updated_at >= source_updated_at {
107 return Ok(false);
108 }
109 }
110
111 let existing_job = sqlx::query(
112 r#"
113SELECT last_success_watermark
114FROM jobs
115WHERE kind = ? AND job_key = ?
116 "#,
117 )
118 .bind(JOB_KIND_MEMORY_STAGE1)
119 .bind(thread_id.as_str())
120 .fetch_optional(self.pool.as_ref())
121 .await?;
122 if let Some(existing_job) = existing_job {
123 let last_success_watermark =
124 existing_job.try_get::<Option<i64>, _>("last_success_watermark")?;
125 if last_success_watermark.is_some_and(|watermark| watermark >= source_updated_at) {
126 return Ok(false);
127 }
128 }
129
130 Ok(true)
131 }
132
133 pub async fn claim_stage1_jobs_for_startup(
149 &self,
150 current_thread_id: ThreadId,
151 params: Stage1StartupClaimParams<'_>,
152 ) -> anyhow::Result<Vec<Stage1JobClaim>> {
153 let Stage1StartupClaimParams {
154 scan_limit,
155 max_claimed,
156 max_age_days,
157 min_rollout_idle_hours,
158 allowed_sources,
159 lease_seconds,
160 } = params;
161 if scan_limit == 0 || max_claimed == 0 {
162 return Ok(Vec::new());
163 }
164
165 let worker_id = current_thread_id;
166 let current_thread_id = worker_id.to_string();
167 let max_age_cutoff = (Utc::now() - Duration::days(max_age_days.max(0))).timestamp_millis();
168 let idle_cutoff =
169 (Utc::now() - Duration::hours(min_rollout_idle_hours.max(0))).timestamp_millis();
170
171 let mut builder = QueryBuilder::<Sqlite>::new(
172 r#"
173SELECT
174 threads.id,
175 threads.rollout_path,
176 threads.created_at_ms AS created_at,
177 threads.updated_at_ms AS updated_at,
178 threads.recency_at_ms AS recency_at,
179 threads.source,
180 threads.history_mode,
181 threads.thread_source,
182 threads.agent_path,
183 threads.agent_nickname,
184 threads.agent_role,
185 threads.model_provider,
186 threads.model,
187 threads.reasoning_effort,
188 threads.cwd,
189 threads.cli_version,
190 threads.title,
191 threads.name,
192 threads.preview,
193 threads.sandbox_policy,
194 threads.approval_mode,
195 threads.tokens_used,
196 threads.first_user_message,
197 threads.archived_at,
198 threads.git_sha,
199 threads.git_branch,
200 threads.git_origin_url
201FROM threads
202 "#,
203 );
204 push_thread_filters(
205 &mut builder,
206 ThreadFilterOptions {
207 archived_only: false,
208 allowed_sources,
209 model_providers: None,
210 cwd_filters: None,
211 anchor: None,
212 sort_key: SortKey::UpdatedAt,
213 sort_direction: SortDirection::Desc,
214 search_term: None,
215 },
216 false,
217 );
218 builder.push(" AND threads.memory_mode = 'enabled'");
219 builder
220 .push(" AND threads.id != ")
221 .push_bind(current_thread_id.as_str());
222 builder
223 .push(" AND ")
224 .push("threads.updated_at_ms")
225 .push(" >= ")
226 .push_bind(max_age_cutoff);
227 builder
228 .push(" AND ")
229 .push("threads.updated_at_ms")
230 .push(" <= ")
231 .push_bind(idle_cutoff);
232 let scan_limit_i64 = i64::try_from(scan_limit).unwrap_or(i64::MAX);
233 builder.push(" ORDER BY threads.updated_at_ms DESC LIMIT ");
234 builder.push_bind(scan_limit_i64);
235
236 let items = builder
237 .build()
238 .fetch_all(self.state_pool.as_ref())
239 .await?
240 .into_iter()
241 .map(|row| ThreadRow::try_from_row(&row).and_then(ThreadMetadata::try_from))
242 .collect::<Result<Vec<_>, _>>()?;
243
244 let mut claimed = Vec::new();
245 for item in items {
246 if claimed.len() >= max_claimed {
247 break;
248 }
249 if !self
250 .stage1_source_needs_update(item.id, item.updated_at.timestamp())
251 .await?
252 {
253 continue;
254 }
255
256 if let Stage1JobClaimOutcome::Claimed { ownership_token } = self
257 .try_claim_stage1_job(
258 item.id,
259 worker_id,
260 item.updated_at.timestamp(),
261 lease_seconds,
262 max_claimed,
263 )
264 .await?
265 {
266 claimed.push(Stage1JobClaim {
267 thread: item,
268 ownership_token,
269 });
270 }
271 }
272
273 Ok(claimed)
274 }
275
276 pub(super) async fn delete_thread_memory(&self, thread_id: ThreadId) -> anyhow::Result<()> {
277 let now = Utc::now().timestamp();
278 let thread_id = thread_id.to_string();
279 let mut tx = self.pool.begin().await?;
280
281 let existing_output = sqlx::query(
282 r#"
283SELECT selected_for_phase2
284FROM stage1_outputs
285WHERE thread_id = ?
286 "#,
287 )
288 .bind(thread_id.as_str())
289 .fetch_optional(&mut *tx)
290 .await?;
291 let was_selected_for_phase2 = existing_output
292 .map(|row| row.try_get::<i64, _>("selected_for_phase2"))
293 .transpose()?
294 .is_some_and(|selected| selected != 0);
295
296 let deleted_rows = sqlx::query(
297 r#"
298DELETE FROM stage1_outputs
299WHERE thread_id = ?
300 "#,
301 )
302 .bind(thread_id.as_str())
303 .execute(&mut *tx)
304 .await?
305 .rows_affected();
306
307 sqlx::query(
308 r#"
309DELETE FROM jobs
310WHERE kind = ? AND job_key = ?
311 "#,
312 )
313 .bind(JOB_KIND_MEMORY_STAGE1)
314 .bind(thread_id.as_str())
315 .execute(&mut *tx)
316 .await?;
317
318 if deleted_rows > 0 && was_selected_for_phase2 {
319 enqueue_global_consolidation_with_executor(&mut *tx, now).await?;
320 }
321
322 tx.commit().await?;
323 Ok(())
324 }
325
326 pub async fn list_stage1_outputs_for_global(
335 &self,
336 n: usize,
337 ) -> anyhow::Result<Vec<Stage1Output>> {
338 if n == 0 {
339 return Ok(Vec::new());
340 }
341
342 let rows = sqlx::query(
343 r#"
344SELECT
345 so.thread_id,
346 so.source_updated_at,
347 so.raw_memory,
348 so.rollout_summary,
349 so.rollout_slug,
350 so.generated_at
351FROM stage1_outputs AS so
352WHERE length(trim(so.raw_memory)) > 0 OR length(trim(so.rollout_summary)) > 0
353ORDER BY so.source_updated_at DESC, so.thread_id DESC
354 "#,
355 )
356 .fetch_all(self.pool.as_ref())
357 .await?;
358
359 let mut outputs = Vec::new();
360 for row in rows {
361 if let Some(output) = self.stage1_output_from_row_if_thread_enabled(&row).await? {
362 outputs.push(output);
363 if outputs.len() >= n {
364 break;
365 }
366 }
367 }
368
369 Ok(outputs)
370 }
371
372 pub async fn prune_stage1_outputs_for_retention(
381 &self,
382 max_unused_days: i64,
383 limit: usize,
384 ) -> anyhow::Result<usize> {
385 if limit == 0 {
386 return Ok(0);
387 }
388
389 let cutoff = (Utc::now() - Duration::days(max_unused_days.max(0))).timestamp();
390 let rows_affected = sqlx::query(
391 r#"
392DELETE FROM stage1_outputs
393WHERE thread_id IN (
394 SELECT thread_id
395 FROM stage1_outputs
396 WHERE selected_for_phase2 = 0
397 AND COALESCE(last_usage, source_updated_at) < ?
398 ORDER BY
399 COALESCE(last_usage, source_updated_at) ASC,
400 source_updated_at ASC,
401 thread_id ASC
402 LIMIT ?
403)
404 "#,
405 )
406 .bind(cutoff)
407 .bind(limit as i64)
408 .execute(self.pool.as_ref())
409 .await?
410 .rows_affected();
411
412 Ok(rows_affected as usize)
413 }
414
415 pub async fn get_phase2_input_selection(
431 &self,
432 n: usize,
433 max_unused_days: i64,
434 ) -> anyhow::Result<Vec<Stage1Output>> {
435 if n == 0 {
436 return Ok(Vec::new());
437 }
438 let cutoff = (Utc::now() - Duration::days(max_unused_days.max(0))).timestamp();
439
440 let page_size = n.clamp(1, PHASE2_INPUT_SELECTION_PAGE_SIZE);
441 let page_size_i64 = i64::try_from(page_size).unwrap_or(i64::MAX);
442 let mut offset = 0_i64;
443 let mut selected_keys = Vec::with_capacity(n);
444
445 while selected_keys.len() < n {
446 let candidate_rows = sqlx::query(
447 r#"
448SELECT
449 so.thread_id,
450 so.source_updated_at
451FROM stage1_outputs AS so
452WHERE (length(trim(so.raw_memory)) > 0 OR length(trim(so.rollout_summary)) > 0)
453 AND (
454 (so.last_usage IS NOT NULL AND so.last_usage >= ?)
455 OR (so.last_usage IS NULL AND so.source_updated_at >= ?)
456 )
457ORDER BY
458 COALESCE(so.usage_count, 0) DESC,
459 COALESCE(so.last_usage, so.source_updated_at) DESC,
460 so.source_updated_at DESC,
461 so.thread_id DESC
462LIMIT ? OFFSET ?
463 "#,
464 )
465 .bind(cutoff)
466 .bind(cutoff)
467 .bind(page_size_i64)
468 .bind(offset)
469 .fetch_all(self.pool.as_ref())
470 .await?;
471
472 if candidate_rows.is_empty() {
473 break;
474 }
475
476 let candidate_count = i64::try_from(candidate_rows.len()).unwrap_or(i64::MAX);
477 for row in candidate_rows {
478 let thread_id: String = row.try_get("thread_id")?;
479 let source_updated_at: i64 = row.try_get("source_updated_at")?;
480 if self
481 .enabled_thread_metadata(ThreadId::try_from(thread_id.as_str())?)
482 .await?
483 .is_some()
484 {
485 selected_keys.push((thread_id, source_updated_at));
486 if selected_keys.len() >= n {
487 break;
488 }
489 }
490 }
491
492 offset = offset.saturating_add(candidate_count);
493 }
494
495 let mut selected = Vec::with_capacity(selected_keys.len());
496 for (thread_id, source_updated_at) in selected_keys {
497 let Some(row) = sqlx::query(
498 r#"
499SELECT
500 so.thread_id,
501 so.source_updated_at,
502 so.raw_memory,
503 so.rollout_summary,
504 so.rollout_slug,
505 so.generated_at
506FROM stage1_outputs AS so
507WHERE so.thread_id = ? AND so.source_updated_at = ?
508 "#,
509 )
510 .bind(thread_id.as_str())
511 .bind(source_updated_at)
512 .fetch_optional(self.pool.as_ref())
513 .await?
514 else {
515 continue;
516 };
517 if let Some(output) = self.stage1_output_from_row_if_thread_enabled(&row).await? {
518 selected.push(output);
519 }
520 }
521
522 selected.sort_by_key(|entry| entry.thread_id.to_string());
523
524 Ok(selected)
525 }
526
527 async fn stage1_output_from_row_if_thread_enabled(
528 &self,
529 row: &sqlx::sqlite::SqliteRow,
530 ) -> anyhow::Result<Option<Stage1Output>> {
531 let thread_id: String = row.try_get("thread_id")?;
532 let Some(thread) = self
533 .enabled_thread_metadata(ThreadId::try_from(thread_id.as_str())?)
534 .await?
535 else {
536 return Ok(None);
537 };
538 Ok(Some(stage1_output_from_row_and_thread(row, thread)?))
539 }
540
541 async fn enabled_thread_metadata(
542 &self,
543 thread_id: ThreadId,
544 ) -> anyhow::Result<Option<ThreadMetadata>> {
545 let row = sqlx::query(
546 r#"
547SELECT
548 threads.id,
549 threads.rollout_path,
550 threads.created_at_ms AS created_at,
551 threads.updated_at_ms AS updated_at,
552 threads.recency_at_ms AS recency_at,
553 threads.source,
554 threads.history_mode,
555 threads.thread_source,
556 threads.agent_nickname,
557 threads.agent_role,
558 threads.agent_path,
559 threads.model_provider,
560 threads.model,
561 threads.reasoning_effort,
562 threads.cwd,
563 threads.cli_version,
564 threads.title,
565 threads.name,
566 threads.preview,
567 threads.sandbox_policy,
568 threads.approval_mode,
569 threads.tokens_used,
570 threads.first_user_message,
571 threads.archived_at,
572 threads.git_sha,
573 threads.git_branch,
574 threads.git_origin_url
575FROM threads
576WHERE threads.id = ? AND threads.memory_mode = 'enabled'
577 "#,
578 )
579 .bind(thread_id.to_string())
580 .fetch_optional(self.state_pool.as_ref())
581 .await?;
582
583 row.map(|row| ThreadRow::try_from_row(&row).and_then(ThreadMetadata::try_from))
584 .transpose()
585 }
586
587 pub async fn mark_thread_memory_mode_polluted(
590 &self,
591 thread_id: ThreadId,
592 ) -> anyhow::Result<bool> {
593 let now = Utc::now().timestamp();
594 let thread_id = thread_id.to_string();
595 let selected_for_phase2 = sqlx::query_scalar::<_, i64>(
596 r#"
597SELECT selected_for_phase2
598FROM stage1_outputs
599WHERE thread_id = ?
600 "#,
601 )
602 .bind(thread_id.as_str())
603 .fetch_optional(self.pool.as_ref())
604 .await?
605 .unwrap_or(0);
606 let rows_affected = sqlx::query(
607 r#"
608UPDATE threads
609SET memory_mode = 'polluted'
610WHERE id = ? AND memory_mode != 'polluted'
611 "#,
612 )
613 .bind(thread_id.as_str())
614 .execute(self.state_pool.as_ref())
615 .await?
616 .rows_affected();
617
618 if selected_for_phase2 != 0 {
619 self.enqueue_global_consolidation(now).await?;
620 }
621
622 Ok(rows_affected > 0)
623 }
624
625 pub async fn try_claim_stage1_job(
642 &self,
643 thread_id: ThreadId,
644 worker_id: ThreadId,
645 source_updated_at: i64,
646 lease_seconds: i64,
647 max_running_jobs: usize,
648 ) -> anyhow::Result<Stage1JobClaimOutcome> {
649 let now = Utc::now().timestamp();
650 let lease_until = now.saturating_add(lease_seconds.max(0));
651 let max_running_jobs = max_running_jobs as i64;
652 let ownership_token = Uuid::new_v4().to_string();
653 let thread_id = thread_id.to_string();
654 let worker_id = worker_id.to_string();
655
656 let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
657
658 let existing_output = sqlx::query(
659 r#"
660SELECT source_updated_at
661FROM stage1_outputs
662WHERE thread_id = ?
663 "#,
664 )
665 .bind(thread_id.as_str())
666 .fetch_optional(&mut *tx)
667 .await?;
668 if let Some(existing_output) = existing_output {
669 let existing_source_updated_at: i64 = existing_output.try_get("source_updated_at")?;
670 if existing_source_updated_at >= source_updated_at {
671 tx.commit().await?;
672 return Ok(Stage1JobClaimOutcome::SkippedUpToDate);
673 }
674 }
675 let existing_job = sqlx::query(
676 r#"
677SELECT last_success_watermark
678FROM jobs
679WHERE kind = ? AND job_key = ?
680 "#,
681 )
682 .bind(JOB_KIND_MEMORY_STAGE1)
683 .bind(thread_id.as_str())
684 .fetch_optional(&mut *tx)
685 .await?;
686 if let Some(existing_job) = existing_job {
687 let last_success_watermark =
688 existing_job.try_get::<Option<i64>, _>("last_success_watermark")?;
689 if last_success_watermark.is_some_and(|watermark| watermark >= source_updated_at) {
690 tx.commit().await?;
691 return Ok(Stage1JobClaimOutcome::SkippedUpToDate);
692 }
693 }
694
695 let rows_affected = sqlx::query(
696 r#"
697INSERT INTO jobs (
698 kind,
699 job_key,
700 status,
701 worker_id,
702 ownership_token,
703 started_at,
704 finished_at,
705 lease_until,
706 retry_at,
707 retry_remaining,
708 last_error,
709 input_watermark,
710 last_success_watermark
711)
712SELECT ?, ?, 'running', ?, ?, ?, NULL, ?, NULL, ?, NULL, ?, NULL
713WHERE (
714 SELECT COUNT(*)
715 FROM jobs
716 WHERE kind = ?
717 AND status = 'running'
718 AND lease_until IS NOT NULL
719 AND lease_until > ?
720) < ?
721ON CONFLICT(kind, job_key) DO UPDATE SET
722 status = 'running',
723 worker_id = excluded.worker_id,
724 ownership_token = excluded.ownership_token,
725 started_at = excluded.started_at,
726 finished_at = NULL,
727 lease_until = excluded.lease_until,
728 retry_at = NULL,
729 retry_remaining = CASE
730 WHEN excluded.input_watermark > COALESCE(jobs.input_watermark, -1) THEN ?
731 ELSE jobs.retry_remaining
732 END,
733 last_error = NULL,
734 input_watermark = excluded.input_watermark
735WHERE
736 (jobs.status != 'running' OR jobs.lease_until IS NULL OR jobs.lease_until <= excluded.started_at)
737 AND (
738 jobs.retry_at IS NULL
739 OR jobs.retry_at <= excluded.started_at
740 OR excluded.input_watermark > COALESCE(jobs.input_watermark, -1)
741 )
742 AND (
743 jobs.retry_remaining > 0
744 OR excluded.input_watermark > COALESCE(jobs.input_watermark, -1)
745 )
746 AND (
747 SELECT COUNT(*)
748 FROM jobs AS running_jobs
749 WHERE running_jobs.kind = excluded.kind
750 AND running_jobs.status = 'running'
751 AND running_jobs.lease_until IS NOT NULL
752 AND running_jobs.lease_until > excluded.started_at
753 AND running_jobs.job_key != excluded.job_key
754 ) < ?
755 "#,
756 )
757 .bind(JOB_KIND_MEMORY_STAGE1)
758 .bind(thread_id.as_str())
759 .bind(worker_id.as_str())
760 .bind(ownership_token.as_str())
761 .bind(now)
762 .bind(lease_until)
763 .bind(DEFAULT_RETRY_REMAINING)
764 .bind(source_updated_at)
765 .bind(JOB_KIND_MEMORY_STAGE1)
766 .bind(now)
767 .bind(max_running_jobs)
768 .bind(DEFAULT_RETRY_REMAINING)
769 .bind(max_running_jobs)
770 .execute(&mut *tx)
771 .await?
772 .rows_affected();
773
774 if rows_affected > 0 {
775 tx.commit().await?;
776 return Ok(Stage1JobClaimOutcome::Claimed { ownership_token });
777 }
778
779 let existing_job = sqlx::query(
780 r#"
781SELECT status, lease_until, retry_at, retry_remaining
782FROM jobs
783WHERE kind = ? AND job_key = ?
784 "#,
785 )
786 .bind(JOB_KIND_MEMORY_STAGE1)
787 .bind(thread_id.as_str())
788 .fetch_optional(&mut *tx)
789 .await?;
790
791 tx.commit().await?;
792
793 if let Some(existing_job) = existing_job {
794 let status: String = existing_job.try_get("status")?;
795 let existing_lease_until: Option<i64> = existing_job.try_get("lease_until")?;
796 let retry_at: Option<i64> = existing_job.try_get("retry_at")?;
797 let retry_remaining: i64 = existing_job.try_get("retry_remaining")?;
798
799 if retry_remaining <= 0 {
800 return Ok(Stage1JobClaimOutcome::SkippedRetryExhausted);
801 }
802 if retry_at.is_some_and(|retry_at| retry_at > now) {
803 return Ok(Stage1JobClaimOutcome::SkippedRetryBackoff);
804 }
805 if status == "running"
806 && existing_lease_until.is_some_and(|lease_until| lease_until > now)
807 {
808 return Ok(Stage1JobClaimOutcome::SkippedRunning);
809 }
810 }
811
812 Ok(Stage1JobClaimOutcome::SkippedRunning)
813 }
814
815 pub async fn mark_stage1_job_succeeded(
829 &self,
830 thread_id: ThreadId,
831 ownership_token: &str,
832 source_updated_at: i64,
833 raw_memory: &str,
834 rollout_summary: &str,
835 rollout_slug: Option<&str>,
836 ) -> anyhow::Result<bool> {
837 let now = Utc::now().timestamp();
838 let thread_id = thread_id.to_string();
839
840 let mut tx = self.pool.begin().await?;
841 let rows_affected = sqlx::query(
842 r#"
843UPDATE jobs
844SET
845 status = 'done',
846 finished_at = ?,
847 lease_until = NULL,
848 last_error = NULL,
849 last_success_watermark = input_watermark
850WHERE kind = ? AND job_key = ?
851 AND status = 'running' AND ownership_token = ?
852 "#,
853 )
854 .bind(now)
855 .bind(JOB_KIND_MEMORY_STAGE1)
856 .bind(thread_id.as_str())
857 .bind(ownership_token)
858 .execute(&mut *tx)
859 .await?
860 .rows_affected();
861
862 if rows_affected == 0 {
863 tx.commit().await?;
864 return Ok(false);
865 }
866
867 sqlx::query(
868 r#"
869INSERT INTO stage1_outputs (
870 thread_id,
871 source_updated_at,
872 raw_memory,
873 rollout_summary,
874 rollout_slug,
875 generated_at
876) VALUES (?, ?, ?, ?, ?, ?)
877ON CONFLICT(thread_id) DO UPDATE SET
878 source_updated_at = excluded.source_updated_at,
879 raw_memory = excluded.raw_memory,
880 rollout_summary = excluded.rollout_summary,
881 rollout_slug = excluded.rollout_slug,
882 generated_at = excluded.generated_at
883WHERE excluded.source_updated_at >= stage1_outputs.source_updated_at
884 "#,
885 )
886 .bind(thread_id.as_str())
887 .bind(source_updated_at)
888 .bind(raw_memory)
889 .bind(rollout_summary)
890 .bind(rollout_slug)
891 .bind(now)
892 .execute(&mut *tx)
893 .await?;
894
895 enqueue_global_consolidation_with_executor(&mut *tx, source_updated_at).await?;
896
897 tx.commit().await?;
898 Ok(true)
899 }
900
901 pub async fn mark_stage1_job_succeeded_no_output(
910 &self,
911 thread_id: ThreadId,
912 ownership_token: &str,
913 ) -> anyhow::Result<bool> {
914 let now = Utc::now().timestamp();
915 let thread_id = thread_id.to_string();
916
917 let mut tx = self.pool.begin().await?;
918 let rows_affected = sqlx::query(
919 r#"
920UPDATE jobs
921SET
922 status = 'done',
923 finished_at = ?,
924 lease_until = NULL,
925 last_error = NULL,
926 last_success_watermark = input_watermark
927WHERE kind = ? AND job_key = ?
928 AND status = 'running' AND ownership_token = ?
929 "#,
930 )
931 .bind(now)
932 .bind(JOB_KIND_MEMORY_STAGE1)
933 .bind(thread_id.as_str())
934 .bind(ownership_token)
935 .execute(&mut *tx)
936 .await?
937 .rows_affected();
938
939 if rows_affected == 0 {
940 tx.commit().await?;
941 return Ok(false);
942 }
943
944 let source_updated_at = sqlx::query(
945 r#"
946SELECT input_watermark
947FROM jobs
948WHERE kind = ? AND job_key = ? AND ownership_token = ?
949 "#,
950 )
951 .bind(JOB_KIND_MEMORY_STAGE1)
952 .bind(thread_id.as_str())
953 .bind(ownership_token)
954 .fetch_one(&mut *tx)
955 .await?
956 .try_get::<i64, _>("input_watermark")?;
957
958 let deleted_rows = sqlx::query(
959 r#"
960DELETE FROM stage1_outputs
961WHERE thread_id = ?
962 "#,
963 )
964 .bind(thread_id.as_str())
965 .execute(&mut *tx)
966 .await?
967 .rows_affected();
968
969 if deleted_rows > 0 {
970 enqueue_global_consolidation_with_executor(&mut *tx, source_updated_at).await?;
971 }
972
973 tx.commit().await?;
974 Ok(true)
975 }
976
977 pub async fn mark_stage1_job_failed(
985 &self,
986 thread_id: ThreadId,
987 ownership_token: &str,
988 failure_reason: &str,
989 retry_delay_seconds: i64,
990 ) -> anyhow::Result<bool> {
991 let now = Utc::now().timestamp();
992 let retry_at = now.saturating_add(retry_delay_seconds.max(0));
993 let thread_id = thread_id.to_string();
994
995 let rows_affected = sqlx::query(
996 r#"
997UPDATE jobs
998SET
999 status = 'error',
1000 finished_at = ?,
1001 lease_until = NULL,
1002 retry_at = ?,
1003 retry_remaining = retry_remaining - 1,
1004 last_error = ?
1005WHERE kind = ? AND job_key = ?
1006 AND status = 'running' AND ownership_token = ?
1007 "#,
1008 )
1009 .bind(now)
1010 .bind(retry_at)
1011 .bind(failure_reason)
1012 .bind(JOB_KIND_MEMORY_STAGE1)
1013 .bind(thread_id.as_str())
1014 .bind(ownership_token)
1015 .execute(self.pool.as_ref())
1016 .await?
1017 .rows_affected();
1018
1019 Ok(rows_affected > 0)
1020 }
1021
1022 pub async fn enqueue_global_consolidation(&self, input_watermark: i64) -> anyhow::Result<()> {
1030 enqueue_global_consolidation_with_executor(self.pool.as_ref(), input_watermark).await
1031 }
1032
1033 pub async fn try_claim_global_phase2_job(
1047 &self,
1048 worker_id: ThreadId,
1049 lease_seconds: i64,
1050 ) -> anyhow::Result<Phase2JobClaimOutcome> {
1051 let now = Utc::now().timestamp();
1052 let lease_until = now.saturating_add(lease_seconds.max(0));
1053 let cooldown_cutoff = now.saturating_sub(PHASE2_SUCCESS_COOLDOWN_SECONDS);
1054 let ownership_token = Uuid::new_v4().to_string();
1055 let worker_id = worker_id.to_string();
1056
1057 let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?;
1058
1059 let existing_job = sqlx::query(
1060 r#"
1061SELECT status, lease_until, retry_at, input_watermark, finished_at, last_error
1062FROM jobs
1063WHERE kind = ? AND job_key = ?
1064 "#,
1065 )
1066 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1067 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1068 .fetch_optional(&mut *tx)
1069 .await?;
1070
1071 let Some(existing_job) = existing_job else {
1072 let rows_affected = sqlx::query(
1073 r#"
1074INSERT INTO jobs (
1075 kind,
1076 job_key,
1077 status,
1078 worker_id,
1079 ownership_token,
1080 started_at,
1081 finished_at,
1082 lease_until,
1083 retry_at,
1084 retry_remaining,
1085 last_error,
1086 input_watermark,
1087 last_success_watermark
1088) VALUES (?, ?, 'running', ?, ?, ?, NULL, ?, NULL, ?, NULL, 0, 0)
1089 "#,
1090 )
1091 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1092 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1093 .bind(worker_id.as_str())
1094 .bind(ownership_token.as_str())
1095 .bind(now)
1096 .bind(lease_until)
1097 .bind(DEFAULT_RETRY_REMAINING)
1098 .execute(&mut *tx)
1099 .await?
1100 .rows_affected();
1101
1102 tx.commit().await?;
1103 return if rows_affected == 0 {
1104 Ok(Phase2JobClaimOutcome::SkippedRunning)
1105 } else {
1106 Ok(Phase2JobClaimOutcome::Claimed {
1107 ownership_token,
1108 input_watermark: 0,
1109 })
1110 };
1111 };
1112
1113 let input_watermark: Option<i64> = existing_job.try_get("input_watermark")?;
1114 let input_watermark_value = input_watermark.unwrap_or(0);
1115 let status: String = existing_job.try_get("status")?;
1116 let existing_lease_until: Option<i64> = existing_job.try_get("lease_until")?;
1117 let retry_at: Option<i64> = existing_job.try_get("retry_at")?;
1118 let finished_at: Option<i64> = existing_job.try_get("finished_at")?;
1119 let last_error: Option<String> = existing_job.try_get("last_error")?;
1120 if retry_at.is_some_and(|retry_at| retry_at > now) {
1121 tx.commit().await?;
1122 return Ok(Phase2JobClaimOutcome::SkippedRetryUnavailable);
1123 }
1124 if status == "running" && existing_lease_until.is_some_and(|lease_until| lease_until > now)
1125 {
1126 tx.commit().await?;
1127 return Ok(Phase2JobClaimOutcome::SkippedRunning);
1128 }
1129 if last_error.is_none()
1130 && finished_at.is_some_and(|finished_at| finished_at > cooldown_cutoff)
1131 {
1132 tx.commit().await?;
1133 return Ok(Phase2JobClaimOutcome::SkippedCooldown);
1134 }
1135
1136 let rows_affected = sqlx::query(
1137 r#"
1138UPDATE jobs
1139SET
1140 status = 'running',
1141 worker_id = ?,
1142 ownership_token = ?,
1143 started_at = ?,
1144 finished_at = NULL,
1145 lease_until = ?,
1146 retry_at = NULL,
1147 last_error = NULL
1148WHERE kind = ? AND job_key = ?
1149 AND (status != 'running' OR lease_until IS NULL OR lease_until <= ?)
1150 AND (retry_at IS NULL OR retry_at <= ?)
1151 AND (last_error IS NOT NULL OR finished_at IS NULL OR finished_at <= ?)
1152 "#,
1153 )
1154 .bind(worker_id.as_str())
1155 .bind(ownership_token.as_str())
1156 .bind(now)
1157 .bind(lease_until)
1158 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1159 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1160 .bind(now)
1161 .bind(now)
1162 .bind(cooldown_cutoff)
1163 .execute(&mut *tx)
1164 .await?
1165 .rows_affected();
1166
1167 tx.commit().await?;
1168 if rows_affected == 0 {
1169 Ok(Phase2JobClaimOutcome::SkippedRunning)
1170 } else {
1171 Ok(Phase2JobClaimOutcome::Claimed {
1172 ownership_token,
1173 input_watermark: input_watermark_value,
1174 })
1175 }
1176 }
1177
1178 pub async fn heartbeat_global_phase2_job(
1184 &self,
1185 ownership_token: &str,
1186 lease_seconds: i64,
1187 ) -> anyhow::Result<bool> {
1188 let now = Utc::now().timestamp();
1189 let lease_until = now.saturating_add(lease_seconds.max(0));
1190 let rows_affected = sqlx::query(
1191 r#"
1192UPDATE jobs
1193SET lease_until = ?
1194WHERE kind = ? AND job_key = ?
1195 AND status = 'running' AND ownership_token = ?
1196 "#,
1197 )
1198 .bind(lease_until)
1199 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1200 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1201 .bind(ownership_token)
1202 .execute(self.pool.as_ref())
1203 .await?
1204 .rows_affected();
1205
1206 Ok(rows_affected > 0)
1207 }
1208
1209 pub async fn mark_global_phase2_job_succeeded(
1220 &self,
1221 ownership_token: &str,
1222 completed_watermark: i64,
1223 selected_outputs: &[Stage1Output],
1224 ) -> anyhow::Result<bool> {
1225 let mut tx = self.pool.begin().await?;
1226 let rows_affected =
1227 mark_global_phase2_job_succeeded_row(&mut *tx, ownership_token, completed_watermark)
1228 .await?;
1229
1230 if rows_affected == 0 {
1231 tx.commit().await?;
1232 return Ok(false);
1233 }
1234
1235 sqlx::query(
1236 r#"
1237UPDATE stage1_outputs
1238SET
1239 selected_for_phase2 = 0,
1240 selected_for_phase2_source_updated_at = NULL
1241WHERE selected_for_phase2 != 0 OR selected_for_phase2_source_updated_at IS NOT NULL
1242 "#,
1243 )
1244 .execute(&mut *tx)
1245 .await?;
1246
1247 for output in selected_outputs {
1248 sqlx::query(
1249 r#"
1250UPDATE stage1_outputs
1251SET
1252 selected_for_phase2 = 1,
1253 selected_for_phase2_source_updated_at = ?
1254WHERE thread_id = ? AND source_updated_at = ?
1255 "#,
1256 )
1257 .bind(output.source_updated_at.timestamp())
1258 .bind(output.thread_id.to_string())
1259 .bind(output.source_updated_at.timestamp())
1260 .execute(&mut *tx)
1261 .await?;
1262 }
1263
1264 tx.commit().await?;
1265 Ok(true)
1266 }
1267
1268 pub async fn mark_global_phase2_job_failed(
1276 &self,
1277 ownership_token: &str,
1278 failure_reason: &str,
1279 retry_delay_seconds: i64,
1280 ) -> anyhow::Result<bool> {
1281 let now = Utc::now().timestamp();
1282 let retry_at = now.saturating_add(retry_delay_seconds.max(0));
1283 let rows_affected = sqlx::query(
1284 r#"
1285UPDATE jobs
1286SET
1287 status = 'error',
1288 finished_at = ?,
1289 lease_until = NULL,
1290 retry_at = ?,
1291 retry_remaining = max(retry_remaining - 1, 0),
1292 last_error = ?
1293WHERE kind = ? AND job_key = ?
1294 AND status = 'running' AND ownership_token = ?
1295 "#,
1296 )
1297 .bind(now)
1298 .bind(retry_at)
1299 .bind(failure_reason)
1300 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1301 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1302 .bind(ownership_token)
1303 .execute(self.pool.as_ref())
1304 .await?
1305 .rows_affected();
1306
1307 Ok(rows_affected > 0)
1308 }
1309
1310 pub async fn mark_global_phase2_job_failed_if_unowned(
1317 &self,
1318 ownership_token: &str,
1319 failure_reason: &str,
1320 retry_delay_seconds: i64,
1321 ) -> anyhow::Result<bool> {
1322 let now = Utc::now().timestamp();
1323 let retry_at = now.saturating_add(retry_delay_seconds.max(0));
1324 let rows_affected = sqlx::query(
1325 r#"
1326UPDATE jobs
1327SET
1328 status = 'error',
1329 finished_at = ?,
1330 lease_until = NULL,
1331 retry_at = ?,
1332 retry_remaining = max(retry_remaining - 1, 0),
1333 last_error = ?
1334WHERE kind = ? AND job_key = ?
1335 AND status = 'running'
1336 AND (ownership_token = ? OR ownership_token IS NULL)
1337 "#,
1338 )
1339 .bind(now)
1340 .bind(retry_at)
1341 .bind(failure_reason)
1342 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1343 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1344 .bind(ownership_token)
1345 .execute(self.pool.as_ref())
1346 .await?
1347 .rows_affected();
1348
1349 Ok(rows_affected > 0)
1350 }
1351}
1352
1353async fn mark_global_phase2_job_succeeded_row<'e, E>(
1354 executor: E,
1355 ownership_token: &str,
1356 completed_watermark: i64,
1357) -> anyhow::Result<u64>
1358where
1359 E: Executor<'e, Database = Sqlite>,
1360{
1361 let now = Utc::now().timestamp();
1362 let rows_affected = sqlx::query(
1363 r#"
1364UPDATE jobs
1365SET
1366 status = 'done',
1367 finished_at = ?,
1368 lease_until = NULL,
1369 last_error = NULL,
1370 last_success_watermark = max(COALESCE(last_success_watermark, 0), ?)
1371WHERE kind = ? AND job_key = ?
1372 AND status = 'running' AND ownership_token = ?
1373 "#,
1374 )
1375 .bind(now)
1376 .bind(completed_watermark)
1377 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1378 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1379 .bind(ownership_token)
1380 .execute(executor)
1381 .await?
1382 .rows_affected();
1383
1384 Ok(rows_affected)
1385}
1386
1387pub(super) async fn clear_memory_data_in_pool(pool: &SqlitePool) -> anyhow::Result<()> {
1388 let mut tx = pool.begin().await?;
1389
1390 sqlx::query(
1391 r#"
1392DELETE FROM stage1_outputs
1393 "#,
1394 )
1395 .execute(&mut *tx)
1396 .await?;
1397
1398 sqlx::query(
1399 r#"
1400DELETE FROM jobs
1401WHERE kind = ? OR kind = ?
1402 "#,
1403 )
1404 .bind(JOB_KIND_MEMORY_STAGE1)
1405 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1406 .execute(&mut *tx)
1407 .await?;
1408
1409 tx.commit().await?;
1410 Ok(())
1411}
1412
1413fn stage1_output_from_row_and_thread(
1414 row: &sqlx::sqlite::SqliteRow,
1415 thread: ThreadMetadata,
1416) -> anyhow::Result<Stage1Output> {
1417 let source_updated_at: i64 = row.try_get("source_updated_at")?;
1418 let generated_at: i64 = row.try_get("generated_at")?;
1419 let source_updated_at = datetime_from_epoch_seconds(source_updated_at)?;
1420 let generated_at = datetime_from_epoch_seconds(generated_at)?;
1421 Ok(Stage1Output {
1422 thread_id: thread.id,
1423 rollout_path: thread.rollout_path,
1424 source_updated_at,
1425 raw_memory: row.try_get("raw_memory")?,
1426 rollout_summary: row.try_get("rollout_summary")?,
1427 rollout_slug: row.try_get("rollout_slug")?,
1428 cwd: thread.cwd,
1429 git_branch: thread.git_branch,
1430 generated_at,
1431 })
1432}
1433
1434fn datetime_from_epoch_seconds(secs: i64) -> anyhow::Result<DateTime<Utc>> {
1435 DateTime::<Utc>::from_timestamp(secs, 0)
1436 .ok_or_else(|| anyhow::anyhow!("invalid unix timestamp: {secs}"))
1437}
1438
1439async fn enqueue_global_consolidation_with_executor<'e, E>(
1440 executor: E,
1441 input_watermark: i64,
1442) -> anyhow::Result<()>
1443where
1444 E: Executor<'e, Database = Sqlite>,
1445{
1446 sqlx::query(
1447 r#"
1448INSERT INTO jobs (
1449 kind,
1450 job_key,
1451 status,
1452 worker_id,
1453 ownership_token,
1454 started_at,
1455 finished_at,
1456 lease_until,
1457 retry_at,
1458 retry_remaining,
1459 last_error,
1460 input_watermark,
1461 last_success_watermark
1462) VALUES (?, ?, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, ?, NULL, ?, 0)
1463ON CONFLICT(kind, job_key) DO UPDATE SET
1464 status = CASE
1465 WHEN jobs.status = 'running' THEN 'running'
1466 ELSE 'pending'
1467 END,
1468 retry_at = CASE
1469 WHEN jobs.status = 'running' THEN jobs.retry_at
1470 ELSE NULL
1471 END,
1472 retry_remaining = max(jobs.retry_remaining, excluded.retry_remaining),
1473 input_watermark = CASE
1474 WHEN excluded.input_watermark > COALESCE(jobs.input_watermark, 0)
1475 THEN excluded.input_watermark
1476 ELSE COALESCE(jobs.input_watermark, 0) + 1
1477 END
1478 "#,
1479 )
1480 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1481 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1482 .bind(DEFAULT_RETRY_REMAINING)
1483 .bind(input_watermark)
1484 .execute(executor)
1485 .await?;
1486
1487 Ok(())
1488}
1489
1490#[cfg(test)]
1491impl StateRuntime {
1492 async fn clear_memory_data(&self) -> anyhow::Result<()> {
1493 self.memories.clear_memory_data().await
1494 }
1495
1496 async fn record_stage1_output_usage(&self, thread_ids: &[ThreadId]) -> anyhow::Result<usize> {
1497 self.memories.record_stage1_output_usage(thread_ids).await
1498 }
1499
1500 async fn claim_stage1_jobs_for_startup(
1501 &self,
1502 current_thread_id: ThreadId,
1503 params: Stage1StartupClaimParams<'_>,
1504 ) -> anyhow::Result<Vec<Stage1JobClaim>> {
1505 self.memories
1506 .claim_stage1_jobs_for_startup(current_thread_id, params)
1507 .await
1508 }
1509
1510 async fn list_stage1_outputs_for_global(&self, n: usize) -> anyhow::Result<Vec<Stage1Output>> {
1511 self.memories.list_stage1_outputs_for_global(n).await
1512 }
1513
1514 async fn prune_stage1_outputs_for_retention(
1515 &self,
1516 max_unused_days: i64,
1517 limit: usize,
1518 ) -> anyhow::Result<usize> {
1519 self.memories
1520 .prune_stage1_outputs_for_retention(max_unused_days, limit)
1521 .await
1522 }
1523
1524 async fn get_phase2_input_selection(
1525 &self,
1526 n: usize,
1527 max_unused_days: i64,
1528 ) -> anyhow::Result<Vec<Stage1Output>> {
1529 self.memories
1530 .get_phase2_input_selection(n, max_unused_days)
1531 .await
1532 }
1533
1534 async fn mark_thread_memory_mode_polluted(&self, thread_id: ThreadId) -> anyhow::Result<bool> {
1535 self.memories
1536 .mark_thread_memory_mode_polluted(thread_id)
1537 .await
1538 }
1539
1540 async fn try_claim_stage1_job(
1541 &self,
1542 thread_id: ThreadId,
1543 worker_id: ThreadId,
1544 source_updated_at: i64,
1545 lease_seconds: i64,
1546 max_running_jobs: usize,
1547 ) -> anyhow::Result<Stage1JobClaimOutcome> {
1548 self.memories
1549 .try_claim_stage1_job(
1550 thread_id,
1551 worker_id,
1552 source_updated_at,
1553 lease_seconds,
1554 max_running_jobs,
1555 )
1556 .await
1557 }
1558
1559 async fn mark_stage1_job_succeeded(
1560 &self,
1561 thread_id: ThreadId,
1562 ownership_token: &str,
1563 source_updated_at: i64,
1564 raw_memory: &str,
1565 rollout_summary: &str,
1566 rollout_slug: Option<&str>,
1567 ) -> anyhow::Result<bool> {
1568 self.memories
1569 .mark_stage1_job_succeeded(
1570 thread_id,
1571 ownership_token,
1572 source_updated_at,
1573 raw_memory,
1574 rollout_summary,
1575 rollout_slug,
1576 )
1577 .await
1578 }
1579
1580 async fn mark_stage1_job_succeeded_no_output(
1581 &self,
1582 thread_id: ThreadId,
1583 ownership_token: &str,
1584 ) -> anyhow::Result<bool> {
1585 self.memories
1586 .mark_stage1_job_succeeded_no_output(thread_id, ownership_token)
1587 .await
1588 }
1589
1590 async fn mark_stage1_job_failed(
1591 &self,
1592 thread_id: ThreadId,
1593 ownership_token: &str,
1594 failure_reason: &str,
1595 retry_delay_seconds: i64,
1596 ) -> anyhow::Result<bool> {
1597 self.memories
1598 .mark_stage1_job_failed(
1599 thread_id,
1600 ownership_token,
1601 failure_reason,
1602 retry_delay_seconds,
1603 )
1604 .await
1605 }
1606
1607 async fn enqueue_global_consolidation(&self, input_watermark: i64) -> anyhow::Result<()> {
1608 self.memories
1609 .enqueue_global_consolidation(input_watermark)
1610 .await
1611 }
1612
1613 async fn try_claim_global_phase2_job(
1614 &self,
1615 worker_id: ThreadId,
1616 lease_seconds: i64,
1617 ) -> anyhow::Result<Phase2JobClaimOutcome> {
1618 self.memories
1619 .try_claim_global_phase2_job(worker_id, lease_seconds)
1620 .await
1621 }
1622
1623 async fn mark_global_phase2_job_succeeded(
1624 &self,
1625 ownership_token: &str,
1626 completed_watermark: i64,
1627 selected_outputs: &[Stage1Output],
1628 ) -> anyhow::Result<bool> {
1629 self.memories
1630 .mark_global_phase2_job_succeeded(
1631 ownership_token,
1632 completed_watermark,
1633 selected_outputs,
1634 )
1635 .await
1636 }
1637
1638 async fn mark_global_phase2_job_failed(
1639 &self,
1640 ownership_token: &str,
1641 failure_reason: &str,
1642 retry_delay_seconds: i64,
1643 ) -> anyhow::Result<bool> {
1644 self.memories
1645 .mark_global_phase2_job_failed(ownership_token, failure_reason, retry_delay_seconds)
1646 .await
1647 }
1648
1649 async fn mark_global_phase2_job_failed_if_unowned(
1650 &self,
1651 ownership_token: &str,
1652 failure_reason: &str,
1653 retry_delay_seconds: i64,
1654 ) -> anyhow::Result<bool> {
1655 self.memories
1656 .mark_global_phase2_job_failed_if_unowned(
1657 ownership_token,
1658 failure_reason,
1659 retry_delay_seconds,
1660 )
1661 .await
1662 }
1663}
1664
1665#[cfg(test)]
1666mod tests {
1667 use super::JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL;
1668 use super::JOB_KIND_MEMORY_STAGE1;
1669 use super::MEMORY_CONSOLIDATION_JOB_KEY;
1670 use super::PHASE2_SUCCESS_COOLDOWN_SECONDS;
1671 use super::StateRuntime;
1672 use super::test_support::test_thread_metadata;
1673 use super::test_support::unique_temp_dir;
1674 use crate::model::Phase2JobClaimOutcome;
1675 use crate::model::Stage1JobClaimOutcome;
1676 use crate::model::Stage1StartupClaimParams;
1677 use chrono::Duration;
1678 use chrono::Utc;
1679 use codex_protocol::ThreadId;
1680 use codex_protocol::protocol::ThreadHistoryMode;
1681 use pretty_assertions::assert_eq;
1682 use sqlx::Row;
1683 use std::sync::Arc;
1684 use uuid::Uuid;
1685
1686 fn stable_thread_id(value: &str) -> ThreadId {
1687 ThreadId::from_string(value).expect("thread id")
1688 }
1689
1690 fn memory_pool(runtime: &StateRuntime) -> &sqlx::SqlitePool {
1691 runtime.memories().pool.as_ref()
1692 }
1693
1694 async fn age_phase2_success_beyond_cooldown(runtime: &StateRuntime) {
1695 sqlx::query("UPDATE jobs SET finished_at = ? WHERE kind = ? AND job_key = ?")
1696 .bind(Utc::now().timestamp() - PHASE2_SUCCESS_COOLDOWN_SECONDS - 1)
1697 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
1698 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
1699 .execute(memory_pool(runtime))
1700 .await
1701 .expect("age phase2 success beyond cooldown");
1702 }
1703
1704 #[tokio::test]
1705 async fn stage1_claim_skips_when_up_to_date() {
1706 let codex_home = unique_temp_dir();
1707 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1708 .await
1709 .expect("initialize runtime");
1710
1711 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
1712 let metadata = test_thread_metadata(&codex_home, thread_id, codex_home.join("a"));
1713 runtime
1714 .upsert_thread(&metadata)
1715 .await
1716 .expect("upsert thread");
1717
1718 let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
1719 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
1720
1721 let claim = runtime
1722 .try_claim_stage1_job(
1723 thread_id, owner_a, 100, 3600,
1724 64,
1725 )
1726 .await
1727 .expect("claim stage1 job");
1728 let ownership_token = match claim {
1729 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
1730 other => panic!("unexpected claim outcome: {other:?}"),
1731 };
1732
1733 assert!(
1734 runtime
1735 .mark_stage1_job_succeeded(
1736 thread_id,
1737 ownership_token.as_str(),
1738 100,
1739 "raw",
1740 "sum",
1741 None,
1742 )
1743 .await
1744 .expect("mark stage1 succeeded"),
1745 "stage1 success should finalize for current token"
1746 );
1747
1748 let up_to_date = runtime
1749 .try_claim_stage1_job(
1750 thread_id, owner_b, 100, 3600,
1751 64,
1752 )
1753 .await
1754 .expect("claim stage1 up-to-date");
1755 assert_eq!(up_to_date, Stage1JobClaimOutcome::SkippedUpToDate);
1756
1757 let needs_rerun = runtime
1758 .try_claim_stage1_job(
1759 thread_id, owner_b, 101, 3600,
1760 64,
1761 )
1762 .await
1763 .expect("claim stage1 newer source");
1764 assert!(
1765 matches!(needs_rerun, Stage1JobClaimOutcome::Claimed { .. }),
1766 "newer source_updated_at should be claimable"
1767 );
1768
1769 let _ = tokio::fs::remove_dir_all(codex_home).await;
1770 }
1771
1772 #[tokio::test]
1773 async fn stage1_running_stale_can_be_stolen_but_fresh_running_is_skipped() {
1774 let codex_home = unique_temp_dir();
1775 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1776 .await
1777 .expect("initialize runtime");
1778
1779 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
1780 let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
1781 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
1782 let cwd = codex_home.join("workspace");
1783 runtime
1784 .upsert_thread(&test_thread_metadata(&codex_home, thread_id, cwd))
1785 .await
1786 .expect("upsert thread");
1787
1788 let claim_a = runtime
1789 .try_claim_stage1_job(
1790 thread_id, owner_a, 100, 3600,
1791 64,
1792 )
1793 .await
1794 .expect("claim a");
1795 assert!(matches!(claim_a, Stage1JobClaimOutcome::Claimed { .. }));
1796
1797 let claim_b_fresh = runtime
1798 .try_claim_stage1_job(
1799 thread_id, owner_b, 100, 3600,
1800 64,
1801 )
1802 .await
1803 .expect("claim b fresh");
1804 assert_eq!(claim_b_fresh, Stage1JobClaimOutcome::SkippedRunning);
1805
1806 sqlx::query("UPDATE jobs SET lease_until = 0 WHERE kind = 'memory_stage1' AND job_key = ?")
1807 .bind(thread_id.to_string())
1808 .execute(memory_pool(&runtime))
1809 .await
1810 .expect("force stale lease");
1811
1812 let claim_b_stale = runtime
1813 .try_claim_stage1_job(
1814 thread_id, owner_b, 100, 3600,
1815 64,
1816 )
1817 .await
1818 .expect("claim b stale");
1819 assert!(matches!(
1820 claim_b_stale,
1821 Stage1JobClaimOutcome::Claimed { .. }
1822 ));
1823
1824 let _ = tokio::fs::remove_dir_all(codex_home).await;
1825 }
1826
1827 #[tokio::test]
1828 async fn stage1_concurrent_claim_for_same_thread_is_conflict_safe() {
1829 let codex_home = unique_temp_dir();
1830 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1831 .await
1832 .expect("initialize runtime");
1833
1834 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
1835 runtime
1836 .upsert_thread(&test_thread_metadata(
1837 &codex_home,
1838 thread_id,
1839 codex_home.join("workspace"),
1840 ))
1841 .await
1842 .expect("upsert thread");
1843
1844 let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
1845 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
1846 let thread_id_a = thread_id;
1847 let thread_id_b = thread_id;
1848 let runtime_a = Arc::clone(&runtime);
1849 let runtime_b = Arc::clone(&runtime);
1850 let claim_with_retry = |runtime: Arc<StateRuntime>,
1851 thread_id: ThreadId,
1852 owner: ThreadId| async move {
1853 for attempt in 0..5 {
1854 match runtime
1855 .try_claim_stage1_job(
1856 thread_id, owner, 100,
1857 3_600, 64,
1858 )
1859 .await
1860 {
1861 Ok(outcome) => return outcome,
1862 Err(err) if err.to_string().contains("database is locked") && attempt < 4 => {
1863 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1864 }
1865 Err(err) => panic!("claim stage1 should not fail: {err}"),
1866 }
1867 }
1868 panic!("claim stage1 should have returned within retry budget")
1869 };
1870
1871 let (claim_a, claim_b) = tokio::join!(
1872 claim_with_retry(runtime_a, thread_id_a, owner_a),
1873 claim_with_retry(runtime_b, thread_id_b, owner_b),
1874 );
1875
1876 let claim_outcomes = vec![claim_a, claim_b];
1877 let claimed_count = claim_outcomes
1878 .iter()
1879 .filter(|outcome| matches!(outcome, Stage1JobClaimOutcome::Claimed { .. }))
1880 .count();
1881 assert_eq!(claimed_count, 1);
1882 assert!(
1883 claim_outcomes.iter().all(|outcome| {
1884 matches!(
1885 outcome,
1886 Stage1JobClaimOutcome::Claimed { .. } | Stage1JobClaimOutcome::SkippedRunning
1887 )
1888 }),
1889 "unexpected claim outcomes: {claim_outcomes:?}"
1890 );
1891
1892 let _ = tokio::fs::remove_dir_all(codex_home).await;
1893 }
1894
1895 #[tokio::test]
1896 async fn stage1_concurrent_claims_respect_running_cap() {
1897 let codex_home = unique_temp_dir();
1898 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1899 .await
1900 .expect("initialize runtime");
1901
1902 let thread_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
1903 let thread_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
1904 runtime
1905 .upsert_thread(&test_thread_metadata(
1906 &codex_home,
1907 thread_a,
1908 codex_home.join("workspace-a"),
1909 ))
1910 .await
1911 .expect("upsert thread a");
1912 runtime
1913 .upsert_thread(&test_thread_metadata(
1914 &codex_home,
1915 thread_b,
1916 codex_home.join("workspace-b"),
1917 ))
1918 .await
1919 .expect("upsert thread b");
1920
1921 let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
1922 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
1923 let runtime_a = Arc::clone(&runtime);
1924 let runtime_b = Arc::clone(&runtime);
1925
1926 let (claim_a, claim_b) = tokio::join!(
1927 async move {
1928 runtime_a
1929 .try_claim_stage1_job(
1930 thread_a, owner_a, 100,
1931 3_600, 1,
1932 )
1933 .await
1934 .expect("claim stage1 thread a")
1935 },
1936 async move {
1937 runtime_b
1938 .try_claim_stage1_job(
1939 thread_b, owner_b, 101,
1940 3_600, 1,
1941 )
1942 .await
1943 .expect("claim stage1 thread b")
1944 },
1945 );
1946
1947 let claim_outcomes = vec![claim_a, claim_b];
1948 let claimed_count = claim_outcomes
1949 .iter()
1950 .filter(|outcome| matches!(outcome, Stage1JobClaimOutcome::Claimed { .. }))
1951 .count();
1952 assert_eq!(claimed_count, 1);
1953 assert!(
1954 claim_outcomes
1955 .iter()
1956 .any(|outcome| { matches!(outcome, Stage1JobClaimOutcome::SkippedRunning) }),
1957 "one concurrent claim should be throttled by running cap: {claim_outcomes:?}"
1958 );
1959
1960 let _ = tokio::fs::remove_dir_all(codex_home).await;
1961 }
1962
1963 #[tokio::test]
1964 async fn claim_stage1_jobs_filters_by_age_idle_and_current_thread() {
1965 let codex_home = unique_temp_dir();
1966 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
1967 .await
1968 .expect("initialize runtime");
1969
1970 let now = Utc::now();
1971 let fresh_at = now - Duration::hours(1);
1972 let just_under_idle_at = now - Duration::hours(12) + Duration::minutes(1);
1973 let eligible_idle_at = now - Duration::hours(12) - Duration::minutes(1);
1974 let old_at = now - Duration::days(31);
1975
1976 let current_thread_id =
1977 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("current thread id");
1978 let fresh_thread_id =
1979 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("fresh thread id");
1980 let just_under_idle_thread_id =
1981 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("just under idle thread id");
1982 let eligible_idle_thread_id =
1983 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("eligible idle thread id");
1984 let old_thread_id =
1985 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("old thread id");
1986
1987 let mut current =
1988 test_thread_metadata(&codex_home, current_thread_id, codex_home.join("current"));
1989 current.created_at = now;
1990 current.updated_at = now;
1991 runtime
1992 .upsert_thread(¤t)
1993 .await
1994 .expect("upsert current");
1995
1996 let mut fresh =
1997 test_thread_metadata(&codex_home, fresh_thread_id, codex_home.join("fresh"));
1998 fresh.created_at = fresh_at;
1999 fresh.updated_at = fresh_at;
2000 runtime.upsert_thread(&fresh).await.expect("upsert fresh");
2001
2002 let mut just_under_idle = test_thread_metadata(
2003 &codex_home,
2004 just_under_idle_thread_id,
2005 codex_home.join("just-under-idle"),
2006 );
2007 just_under_idle.created_at = just_under_idle_at;
2008 just_under_idle.updated_at = just_under_idle_at;
2009 runtime
2010 .upsert_thread(&just_under_idle)
2011 .await
2012 .expect("upsert just-under-idle");
2013
2014 let mut eligible_idle = test_thread_metadata(
2015 &codex_home,
2016 eligible_idle_thread_id,
2017 codex_home.join("eligible-idle"),
2018 );
2019 eligible_idle.created_at = eligible_idle_at;
2020 eligible_idle.updated_at = eligible_idle_at;
2021 runtime
2022 .upsert_thread(&eligible_idle)
2023 .await
2024 .expect("upsert eligible-idle");
2025
2026 let mut old = test_thread_metadata(&codex_home, old_thread_id, codex_home.join("old"));
2027 old.created_at = old_at;
2028 old.updated_at = old_at;
2029 runtime.upsert_thread(&old).await.expect("upsert old");
2030
2031 let allowed_sources = vec!["cli".to_string()];
2032 let claims = runtime
2033 .claim_stage1_jobs_for_startup(
2034 current_thread_id,
2035 Stage1StartupClaimParams {
2036 scan_limit: 1,
2037 max_claimed: 5,
2038 max_age_days: 30,
2039 min_rollout_idle_hours: 12,
2040 allowed_sources: allowed_sources.as_slice(),
2041 lease_seconds: 3600,
2042 },
2043 )
2044 .await
2045 .expect("claim stage1 jobs");
2046
2047 assert_eq!(claims.len(), 1);
2048 assert_eq!(claims[0].thread.id, eligible_idle_thread_id);
2049
2050 let _ = tokio::fs::remove_dir_all(codex_home).await;
2051 }
2052
2053 #[tokio::test]
2054 async fn claim_stage1_jobs_bounds_state_scan_before_memory_probes() {
2055 let codex_home = unique_temp_dir();
2056 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2057 .await
2058 .expect("initialize runtime");
2059
2060 let now = Utc::now();
2061 let eligible_newer_at = now - Duration::hours(13);
2062 let eligible_older_at = now - Duration::hours(14);
2063
2064 let current_thread_id =
2065 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("current thread id");
2066 let up_to_date_thread_id =
2067 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("up-to-date thread id");
2068 let stale_thread_id =
2069 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("stale thread id");
2070 let worker_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("worker id");
2071
2072 let mut current =
2073 test_thread_metadata(&codex_home, current_thread_id, codex_home.join("current"));
2074 current.created_at = now;
2075 current.updated_at = now;
2076 runtime
2077 .upsert_thread(¤t)
2078 .await
2079 .expect("upsert current thread");
2080
2081 let mut up_to_date = test_thread_metadata(
2082 &codex_home,
2083 up_to_date_thread_id,
2084 codex_home.join("up-to-date"),
2085 );
2086 up_to_date.created_at = eligible_newer_at;
2087 up_to_date.updated_at = eligible_newer_at;
2088 runtime
2089 .upsert_thread(&up_to_date)
2090 .await
2091 .expect("upsert up-to-date thread");
2092
2093 let up_to_date_claim = runtime
2094 .try_claim_stage1_job(
2095 up_to_date_thread_id,
2096 worker_id,
2097 up_to_date.updated_at.timestamp(),
2098 3600,
2099 64,
2100 )
2101 .await
2102 .expect("claim up-to-date thread for seed");
2103 let up_to_date_token = match up_to_date_claim {
2104 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
2105 other => panic!("unexpected seed claim outcome: {other:?}"),
2106 };
2107 assert!(
2108 runtime
2109 .mark_stage1_job_succeeded(
2110 up_to_date_thread_id,
2111 up_to_date_token.as_str(),
2112 up_to_date.updated_at.timestamp(),
2113 "raw",
2114 "summary",
2115 None,
2116 )
2117 .await
2118 .expect("mark up-to-date thread succeeded"),
2119 "seed stage1 success should complete for up-to-date thread"
2120 );
2121
2122 let mut stale =
2123 test_thread_metadata(&codex_home, stale_thread_id, codex_home.join("stale"));
2124 stale.created_at = eligible_older_at;
2125 stale.updated_at = eligible_older_at;
2126 runtime
2127 .upsert_thread(&stale)
2128 .await
2129 .expect("upsert stale thread");
2130
2131 let allowed_sources = vec!["cli".to_string()];
2132 let claims_with_one_scanned_thread = runtime
2133 .claim_stage1_jobs_for_startup(
2134 current_thread_id,
2135 Stage1StartupClaimParams {
2136 scan_limit: 1,
2137 max_claimed: 1,
2138 max_age_days: 30,
2139 min_rollout_idle_hours: 12,
2140 allowed_sources: allowed_sources.as_slice(),
2141 lease_seconds: 3600,
2142 },
2143 )
2144 .await
2145 .expect("claim stage1 startup jobs");
2146 assert_eq!(claims_with_one_scanned_thread.len(), 0);
2147
2148 let claims = runtime
2149 .claim_stage1_jobs_for_startup(
2150 current_thread_id,
2151 Stage1StartupClaimParams {
2152 scan_limit: 2,
2153 max_claimed: 1,
2154 max_age_days: 30,
2155 min_rollout_idle_hours: 12,
2156 allowed_sources: allowed_sources.as_slice(),
2157 lease_seconds: 3600,
2158 },
2159 )
2160 .await
2161 .expect("claim stage1 startup jobs with wider scan");
2162 assert_eq!(claims.len(), 1);
2163 assert_eq!(claims[0].thread.id, stale_thread_id);
2164
2165 let _ = tokio::fs::remove_dir_all(codex_home).await;
2166 }
2167
2168 #[tokio::test]
2169 async fn claim_stage1_jobs_skips_threads_without_enabled_memory() {
2170 let codex_home = unique_temp_dir();
2171 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2172 .await
2173 .expect("initialize runtime");
2174
2175 let now = Utc::now();
2176 let eligible_at = now - Duration::hours(13);
2177
2178 let current_thread_id =
2179 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("current thread id");
2180 let disabled_thread_id =
2181 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("disabled thread id");
2182 let paginated_thread_id =
2183 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("paginated thread id");
2184 let enabled_thread_id =
2185 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("enabled thread id");
2186
2187 let mut current =
2188 test_thread_metadata(&codex_home, current_thread_id, codex_home.join("current"));
2189 current.created_at = now;
2190 current.updated_at = now;
2191 runtime
2192 .upsert_thread(¤t)
2193 .await
2194 .expect("upsert current thread");
2195
2196 let mut disabled =
2197 test_thread_metadata(&codex_home, disabled_thread_id, codex_home.join("disabled"));
2198 disabled.created_at = eligible_at;
2199 disabled.updated_at = eligible_at;
2200 disabled.history_mode = ThreadHistoryMode::Paginated;
2201 runtime
2202 .upsert_thread(&disabled)
2203 .await
2204 .expect("upsert disabled thread");
2205 sqlx::query("UPDATE threads SET memory_mode = 'disabled' WHERE id = ?")
2206 .bind(disabled_thread_id.to_string())
2207 .execute(runtime.pool.as_ref())
2208 .await
2209 .expect("disable thread memory mode");
2210
2211 let mut paginated = test_thread_metadata(
2212 &codex_home,
2213 paginated_thread_id,
2214 codex_home.join("paginated"),
2215 );
2216 paginated.created_at = eligible_at;
2217 paginated.updated_at = eligible_at;
2218 paginated.history_mode = ThreadHistoryMode::Paginated;
2219 runtime
2220 .upsert_thread(&paginated)
2221 .await
2222 .expect("upsert paginated thread");
2223
2224 let mut enabled =
2225 test_thread_metadata(&codex_home, enabled_thread_id, codex_home.join("enabled"));
2226 enabled.created_at = eligible_at;
2227 enabled.updated_at = eligible_at;
2228 runtime
2229 .upsert_thread(&enabled)
2230 .await
2231 .expect("upsert enabled thread");
2232
2233 let allowed_sources = vec!["cli".to_string()];
2234 let claims = runtime
2235 .claim_stage1_jobs_for_startup(
2236 current_thread_id,
2237 Stage1StartupClaimParams {
2238 scan_limit: 10,
2239 max_claimed: 10,
2240 max_age_days: 30,
2241 min_rollout_idle_hours: 12,
2242 allowed_sources: allowed_sources.as_slice(),
2243 lease_seconds: 3600,
2244 },
2245 )
2246 .await
2247 .expect("claim stage1 startup jobs");
2248
2249 let mut claimed_ids = claims
2250 .iter()
2251 .map(|claim| claim.thread.id.to_string())
2252 .collect::<Vec<_>>();
2253 claimed_ids.sort();
2254 let mut expected_ids = vec![
2255 enabled_thread_id.to_string(),
2256 paginated_thread_id.to_string(),
2257 ];
2258 expected_ids.sort();
2259 assert_eq!(claimed_ids, expected_ids);
2260
2261 let _ = tokio::fs::remove_dir_all(codex_home).await;
2262 }
2263
2264 #[tokio::test]
2265 async fn clear_memory_data_clears_rows_and_preserves_thread_memory_modes() {
2266 let codex_home = unique_temp_dir();
2267 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2268 .await
2269 .expect("initialize runtime");
2270
2271 let now = Utc::now() - Duration::hours(13);
2272 let worker_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("worker id");
2273 let enabled_thread_id =
2274 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("enabled thread id");
2275 let disabled_thread_id =
2276 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("disabled thread id");
2277
2278 let mut enabled =
2279 test_thread_metadata(&codex_home, enabled_thread_id, codex_home.join("enabled"));
2280 enabled.created_at = now;
2281 enabled.updated_at = now;
2282 runtime
2283 .upsert_thread(&enabled)
2284 .await
2285 .expect("upsert enabled thread");
2286
2287 let claim = runtime
2288 .try_claim_stage1_job(
2289 enabled_thread_id,
2290 worker_id,
2291 enabled.updated_at.timestamp(),
2292 3600,
2293 64,
2294 )
2295 .await
2296 .expect("claim enabled thread");
2297 let ownership_token = match claim {
2298 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
2299 other => panic!("unexpected claim outcome: {other:?}"),
2300 };
2301 assert!(
2302 runtime
2303 .mark_stage1_job_succeeded(
2304 enabled_thread_id,
2305 ownership_token.as_str(),
2306 enabled.updated_at.timestamp(),
2307 "raw",
2308 "summary",
2309 None,
2310 )
2311 .await
2312 .expect("mark enabled thread succeeded"),
2313 "stage1 success should be recorded"
2314 );
2315 runtime
2316 .enqueue_global_consolidation(enabled.updated_at.timestamp())
2317 .await
2318 .expect("enqueue global consolidation");
2319
2320 let mut disabled =
2321 test_thread_metadata(&codex_home, disabled_thread_id, codex_home.join("disabled"));
2322 disabled.created_at = now;
2323 disabled.updated_at = now;
2324 runtime
2325 .upsert_thread(&disabled)
2326 .await
2327 .expect("upsert disabled thread");
2328 sqlx::query("UPDATE threads SET memory_mode = 'disabled' WHERE id = ?")
2329 .bind(disabled_thread_id.to_string())
2330 .execute(runtime.pool.as_ref())
2331 .await
2332 .expect("disable existing thread");
2333
2334 runtime
2335 .clear_memory_data()
2336 .await
2337 .expect("clear memory data");
2338
2339 let stage1_outputs_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stage1_outputs")
2340 .fetch_one(memory_pool(&runtime))
2341 .await
2342 .expect("count stage1 outputs");
2343 assert_eq!(stage1_outputs_count, 0);
2344
2345 let memory_jobs_count: i64 =
2346 sqlx::query_scalar("SELECT COUNT(*) FROM jobs WHERE kind = ? OR kind = ?")
2347 .bind(JOB_KIND_MEMORY_STAGE1)
2348 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
2349 .fetch_one(memory_pool(&runtime))
2350 .await
2351 .expect("count memory jobs");
2352 assert_eq!(memory_jobs_count, 0);
2353
2354 let enabled_memory_mode: String =
2355 sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
2356 .bind(enabled_thread_id.to_string())
2357 .fetch_one(runtime.pool.as_ref())
2358 .await
2359 .expect("read enabled thread memory mode");
2360 assert_eq!(enabled_memory_mode, "enabled");
2361
2362 let disabled_memory_mode: String =
2363 sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
2364 .bind(disabled_thread_id.to_string())
2365 .fetch_one(runtime.pool.as_ref())
2366 .await
2367 .expect("read disabled thread memory mode");
2368 assert_eq!(disabled_memory_mode, "disabled");
2369
2370 let _ = tokio::fs::remove_dir_all(codex_home).await;
2371 }
2372
2373 #[tokio::test]
2374 async fn claim_stage1_jobs_enforces_global_running_cap() {
2375 let codex_home = unique_temp_dir();
2376 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2377 .await
2378 .expect("initialize runtime");
2379
2380 let current_thread_id =
2381 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("current thread id");
2382 runtime
2383 .upsert_thread(&test_thread_metadata(
2384 &codex_home,
2385 current_thread_id,
2386 codex_home.join("current"),
2387 ))
2388 .await
2389 .expect("upsert current");
2390
2391 let now = Utc::now();
2392 let started_at = now.timestamp();
2393 let lease_until = started_at + 3600;
2394 let eligible_at = now - Duration::hours(13);
2395 let existing_running = 10usize;
2396 let total_candidates = 80usize;
2397
2398 for idx in 0..total_candidates {
2399 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
2400 let mut metadata = test_thread_metadata(
2401 &codex_home,
2402 thread_id,
2403 codex_home.join(format!("thread-{idx}")),
2404 );
2405 metadata.created_at = eligible_at - Duration::seconds(idx as i64);
2406 metadata.updated_at = eligible_at - Duration::seconds(idx as i64);
2407 runtime
2408 .upsert_thread(&metadata)
2409 .await
2410 .expect("upsert thread");
2411
2412 if idx < existing_running {
2413 sqlx::query(
2414 r#"
2415INSERT INTO jobs (
2416 kind,
2417 job_key,
2418 status,
2419 worker_id,
2420 ownership_token,
2421 started_at,
2422 finished_at,
2423 lease_until,
2424 retry_at,
2425 retry_remaining,
2426 last_error,
2427 input_watermark,
2428 last_success_watermark
2429) VALUES (?, ?, 'running', ?, ?, ?, NULL, ?, NULL, ?, NULL, ?, NULL)
2430 "#,
2431 )
2432 .bind("memory_stage1")
2433 .bind(thread_id.to_string())
2434 .bind(current_thread_id.to_string())
2435 .bind(Uuid::new_v4().to_string())
2436 .bind(started_at)
2437 .bind(lease_until)
2438 .bind(3)
2439 .bind(metadata.updated_at.timestamp())
2440 .execute(memory_pool(&runtime))
2441 .await
2442 .expect("seed running stage1 job");
2443 }
2444 }
2445
2446 let allowed_sources = vec!["cli".to_string()];
2447 let claims = runtime
2448 .claim_stage1_jobs_for_startup(
2449 current_thread_id,
2450 Stage1StartupClaimParams {
2451 scan_limit: 200,
2452 max_claimed: 64,
2453 max_age_days: 30,
2454 min_rollout_idle_hours: 12,
2455 allowed_sources: allowed_sources.as_slice(),
2456 lease_seconds: 3600,
2457 },
2458 )
2459 .await
2460 .expect("claim stage1 jobs");
2461 assert_eq!(claims.len(), 54);
2462
2463 let running_count = sqlx::query(
2464 r#"
2465SELECT COUNT(*) AS count
2466FROM jobs
2467WHERE kind = 'memory_stage1'
2468 AND status = 'running'
2469 AND lease_until IS NOT NULL
2470 AND lease_until > ?
2471 "#,
2472 )
2473 .bind(Utc::now().timestamp())
2474 .fetch_one(memory_pool(&runtime))
2475 .await
2476 .expect("count running stage1 jobs")
2477 .try_get::<i64, _>("count")
2478 .expect("running count value");
2479 assert_eq!(running_count, 64);
2480
2481 let more_claims = runtime
2482 .claim_stage1_jobs_for_startup(
2483 current_thread_id,
2484 Stage1StartupClaimParams {
2485 scan_limit: 200,
2486 max_claimed: 64,
2487 max_age_days: 30,
2488 min_rollout_idle_hours: 12,
2489 allowed_sources: allowed_sources.as_slice(),
2490 lease_seconds: 3600,
2491 },
2492 )
2493 .await
2494 .expect("claim stage1 jobs with cap reached");
2495 assert_eq!(more_claims.len(), 0);
2496
2497 let _ = tokio::fs::remove_dir_all(codex_home).await;
2498 }
2499
2500 #[tokio::test]
2501 async fn claim_stage1_jobs_processes_two_full_batches_across_startup_passes() {
2502 let codex_home = unique_temp_dir();
2503 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2504 .await
2505 .expect("initialize runtime");
2506
2507 let current_thread_id =
2508 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("current thread id");
2509 let mut current =
2510 test_thread_metadata(&codex_home, current_thread_id, codex_home.join("current"));
2511 current.created_at = Utc::now();
2512 current.updated_at = Utc::now();
2513 runtime
2514 .upsert_thread(¤t)
2515 .await
2516 .expect("upsert current");
2517
2518 let eligible_at = Utc::now() - Duration::hours(13);
2519 for idx in 0..200 {
2520 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
2521 let mut metadata = test_thread_metadata(
2522 &codex_home,
2523 thread_id,
2524 codex_home.join(format!("thread-{idx}")),
2525 );
2526 metadata.created_at = eligible_at - Duration::seconds(idx as i64);
2527 metadata.updated_at = eligible_at - Duration::seconds(idx as i64);
2528 runtime
2529 .upsert_thread(&metadata)
2530 .await
2531 .expect("upsert eligible thread");
2532 }
2533
2534 let allowed_sources = vec!["cli".to_string()];
2535 let first_claims = runtime
2536 .claim_stage1_jobs_for_startup(
2537 current_thread_id,
2538 Stage1StartupClaimParams {
2539 scan_limit: 5_000,
2540 max_claimed: 64,
2541 max_age_days: 30,
2542 min_rollout_idle_hours: 12,
2543 allowed_sources: allowed_sources.as_slice(),
2544 lease_seconds: 3_600,
2545 },
2546 )
2547 .await
2548 .expect("first stage1 startup claim");
2549 assert_eq!(first_claims.len(), 64);
2550
2551 for claim in first_claims {
2552 assert!(
2553 runtime
2554 .mark_stage1_job_succeeded(
2555 claim.thread.id,
2556 claim.ownership_token.as_str(),
2557 claim.thread.updated_at.timestamp(),
2558 "raw",
2559 "summary",
2560 None,
2561 )
2562 .await
2563 .expect("mark first-batch stage1 success"),
2564 "first batch stage1 completion should succeed"
2565 );
2566 }
2567
2568 let second_claims = runtime
2569 .claim_stage1_jobs_for_startup(
2570 current_thread_id,
2571 Stage1StartupClaimParams {
2572 scan_limit: 5_000,
2573 max_claimed: 64,
2574 max_age_days: 30,
2575 min_rollout_idle_hours: 12,
2576 allowed_sources: allowed_sources.as_slice(),
2577 lease_seconds: 3_600,
2578 },
2579 )
2580 .await
2581 .expect("second stage1 startup claim");
2582 assert_eq!(second_claims.len(), 64);
2583
2584 let _ = tokio::fs::remove_dir_all(codex_home).await;
2585 }
2586
2587 #[tokio::test]
2588 async fn delete_thread_removes_stage1_output_and_enqueues_phase2_when_selected() {
2589 let codex_home = unique_temp_dir();
2590 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2591 .await
2592 .expect("initialize runtime");
2593
2594 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
2595 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
2596 let cwd = codex_home.join("workspace");
2597 runtime
2598 .upsert_thread(&test_thread_metadata(&codex_home, thread_id, cwd))
2599 .await
2600 .expect("upsert thread");
2601
2602 let claim = runtime
2603 .try_claim_stage1_job(
2604 thread_id, owner, 100, 3600,
2605 64,
2606 )
2607 .await
2608 .expect("claim stage1");
2609 let ownership_token = match claim {
2610 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
2611 other => panic!("unexpected claim outcome: {other:?}"),
2612 };
2613 assert!(
2614 runtime
2615 .mark_stage1_job_succeeded(
2616 thread_id,
2617 ownership_token.as_str(),
2618 100,
2619 "raw",
2620 "sum",
2621 None,
2622 )
2623 .await
2624 .expect("mark stage1 succeeded"),
2625 "mark stage1 succeeded should write stage1_outputs"
2626 );
2627
2628 let count_before =
2629 sqlx::query("SELECT COUNT(*) AS count FROM stage1_outputs WHERE thread_id = ?")
2630 .bind(thread_id.to_string())
2631 .fetch_one(memory_pool(&runtime))
2632 .await
2633 .expect("count before delete")
2634 .try_get::<i64, _>("count")
2635 .expect("count value");
2636 assert_eq!(count_before, 1);
2637
2638 let phase2_claim = runtime
2639 .try_claim_global_phase2_job(owner, 3600)
2640 .await
2641 .expect("claim phase2");
2642 let (phase2_token, input_watermark) = match phase2_claim {
2643 Phase2JobClaimOutcome::Claimed {
2644 ownership_token,
2645 input_watermark,
2646 } => (ownership_token, input_watermark),
2647 other => panic!("unexpected phase2 claim outcome: {other:?}"),
2648 };
2649 let selected_outputs = runtime
2650 .list_stage1_outputs_for_global(10)
2651 .await
2652 .expect("list stage1 outputs");
2653 assert!(
2654 runtime
2655 .mark_global_phase2_job_succeeded(
2656 phase2_token.as_str(),
2657 input_watermark,
2658 &selected_outputs,
2659 )
2660 .await
2661 .expect("mark phase2 succeeded"),
2662 "phase2 success should mark selected stage1 output"
2663 );
2664
2665 let before_delete = Utc::now().timestamp();
2666 assert_eq!(
2667 runtime
2668 .delete_thread(thread_id)
2669 .await
2670 .expect("delete thread"),
2671 1
2672 );
2673
2674 let count_after =
2675 sqlx::query("SELECT COUNT(*) AS count FROM stage1_outputs WHERE thread_id = ?")
2676 .bind(thread_id.to_string())
2677 .fetch_one(memory_pool(&runtime))
2678 .await
2679 .expect("count after delete")
2680 .try_get::<i64, _>("count")
2681 .expect("count value");
2682 assert_eq!(count_after, 0);
2683
2684 let phase2_job = sqlx::query(
2685 r#"
2686SELECT status, input_watermark
2687FROM jobs
2688WHERE kind = ? AND job_key = ?
2689 "#,
2690 )
2691 .bind(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL)
2692 .bind(MEMORY_CONSOLIDATION_JOB_KEY)
2693 .fetch_one(memory_pool(&runtime))
2694 .await
2695 .expect("load phase2 job after delete");
2696 let status: String = phase2_job.try_get("status").expect("status");
2697 let input_watermark: i64 = phase2_job
2698 .try_get("input_watermark")
2699 .expect("input watermark");
2700 assert_eq!(status, "pending");
2701 assert!(input_watermark >= before_delete);
2702
2703 let visible_outputs = runtime
2704 .list_stage1_outputs_for_global(10)
2705 .await
2706 .expect("list stage1 outputs after thread delete");
2707 assert_eq!(visible_outputs.len(), 0);
2708
2709 let _ = tokio::fs::remove_dir_all(codex_home).await;
2710 }
2711
2712 #[tokio::test]
2713 async fn mark_stage1_job_succeeded_no_output_skips_phase2_when_output_was_already_absent() {
2714 let codex_home = unique_temp_dir();
2715 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2716 .await
2717 .expect("initialize runtime");
2718
2719 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
2720 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
2721 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
2722 runtime
2723 .upsert_thread(&test_thread_metadata(
2724 &codex_home,
2725 thread_id,
2726 codex_home.join("workspace"),
2727 ))
2728 .await
2729 .expect("upsert thread");
2730
2731 let claim = runtime
2732 .try_claim_stage1_job(
2733 thread_id, owner, 100, 3600,
2734 64,
2735 )
2736 .await
2737 .expect("claim stage1");
2738 let ownership_token = match claim {
2739 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
2740 other => panic!("unexpected claim outcome: {other:?}"),
2741 };
2742 assert!(
2743 runtime
2744 .mark_stage1_job_succeeded_no_output(thread_id, ownership_token.as_str())
2745 .await
2746 .expect("mark stage1 succeeded without output"),
2747 "stage1 no-output success should complete the job"
2748 );
2749
2750 let output_row_count =
2751 sqlx::query("SELECT COUNT(*) AS count FROM stage1_outputs WHERE thread_id = ?")
2752 .bind(thread_id.to_string())
2753 .fetch_one(memory_pool(&runtime))
2754 .await
2755 .expect("load stage1 output count")
2756 .try_get::<i64, _>("count")
2757 .expect("stage1 output count");
2758 assert_eq!(
2759 output_row_count, 0,
2760 "stage1 no-output success should not persist empty stage1 outputs"
2761 );
2762
2763 let up_to_date = runtime
2764 .try_claim_stage1_job(
2765 thread_id, owner_b, 100, 3600,
2766 64,
2767 )
2768 .await
2769 .expect("claim stage1 up-to-date");
2770 assert_eq!(up_to_date, Stage1JobClaimOutcome::SkippedUpToDate);
2771
2772 let global_job_row_count = sqlx::query("SELECT COUNT(*) AS count FROM jobs WHERE kind = ?")
2773 .bind("memory_consolidate_global")
2774 .fetch_one(memory_pool(&runtime))
2775 .await
2776 .expect("load phase2 job row count")
2777 .try_get::<i64, _>("count")
2778 .expect("phase2 job row count");
2779 assert_eq!(
2780 global_job_row_count, 0,
2781 "no-output without an existing stage1 output should not enqueue phase2"
2782 );
2783
2784 let _ = tokio::fs::remove_dir_all(codex_home).await;
2785 }
2786
2787 #[tokio::test]
2788 async fn mark_stage1_job_succeeded_no_output_enqueues_phase2_when_deleting_output() {
2789 let codex_home = unique_temp_dir();
2790 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2791 .await
2792 .expect("initialize runtime");
2793
2794 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
2795 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
2796 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
2797 runtime
2798 .upsert_thread(&test_thread_metadata(
2799 &codex_home,
2800 thread_id,
2801 codex_home.join("workspace"),
2802 ))
2803 .await
2804 .expect("upsert thread");
2805
2806 let first_claim = runtime
2807 .try_claim_stage1_job(
2808 thread_id, owner, 100, 3600,
2809 64,
2810 )
2811 .await
2812 .expect("claim initial stage1");
2813 let first_token = match first_claim {
2814 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
2815 other => panic!("unexpected initial stage1 claim outcome: {other:?}"),
2816 };
2817 assert!(
2818 runtime
2819 .mark_stage1_job_succeeded(
2820 thread_id,
2821 first_token.as_str(),
2822 100,
2823 "raw",
2824 "sum",
2825 None
2826 )
2827 .await
2828 .expect("mark initial stage1 succeeded"),
2829 "initial stage1 success should create stage1 output"
2830 );
2831
2832 let phase2_claim = runtime
2833 .try_claim_global_phase2_job(owner, 3600)
2834 .await
2835 .expect("claim phase2 after initial output");
2836 let (phase2_token, phase2_input_watermark) = match phase2_claim {
2837 Phase2JobClaimOutcome::Claimed {
2838 ownership_token,
2839 input_watermark,
2840 } => (ownership_token, input_watermark),
2841 other => panic!("unexpected phase2 claim after initial output: {other:?}"),
2842 };
2843 assert_eq!(phase2_input_watermark, 100);
2844 assert!(
2845 runtime
2846 .mark_global_phase2_job_succeeded(
2847 phase2_token.as_str(),
2848 phase2_input_watermark,
2849 &[],
2850 )
2851 .await
2852 .expect("mark initial phase2 succeeded"),
2853 "initial phase2 success should finalize the global job"
2854 );
2855
2856 let no_output_claim = runtime
2857 .try_claim_stage1_job(
2858 thread_id, owner_b, 101, 3600,
2859 64,
2860 )
2861 .await
2862 .expect("claim stage1 for no-output delete");
2863 let no_output_token = match no_output_claim {
2864 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
2865 other => panic!("unexpected no-output stage1 claim outcome: {other:?}"),
2866 };
2867 assert!(
2868 runtime
2869 .mark_stage1_job_succeeded_no_output(thread_id, no_output_token.as_str())
2870 .await
2871 .expect("mark stage1 no-output after existing output"),
2872 "no-output should succeed when deleting an existing stage1 output"
2873 );
2874
2875 let output_row_count =
2876 sqlx::query("SELECT COUNT(*) AS count FROM stage1_outputs WHERE thread_id = ?")
2877 .bind(thread_id.to_string())
2878 .fetch_one(memory_pool(&runtime))
2879 .await
2880 .expect("load stage1 output count after delete")
2881 .try_get::<i64, _>("count")
2882 .expect("stage1 output count");
2883 assert_eq!(output_row_count, 0);
2884
2885 age_phase2_success_beyond_cooldown(&runtime).await;
2886 let claim_phase2 = runtime
2887 .try_claim_global_phase2_job(owner, 3600)
2888 .await
2889 .expect("claim phase2 after no-output deletion");
2890 let (phase2_token, phase2_input_watermark) = match claim_phase2 {
2891 Phase2JobClaimOutcome::Claimed {
2892 ownership_token,
2893 input_watermark,
2894 } => (ownership_token, input_watermark),
2895 other => panic!("unexpected phase2 claim after no-output deletion: {other:?}"),
2896 };
2897 assert_eq!(phase2_input_watermark, 101);
2898 assert!(
2899 runtime
2900 .mark_global_phase2_job_succeeded(
2901 phase2_token.as_str(),
2902 phase2_input_watermark,
2903 &[],
2904 )
2905 .await
2906 .expect("mark phase2 succeeded after no-output delete")
2907 );
2908
2909 let _ = tokio::fs::remove_dir_all(codex_home).await;
2910 }
2911
2912 #[tokio::test]
2913 async fn stage1_retry_exhaustion_does_not_block_newer_watermark() {
2914 let codex_home = unique_temp_dir();
2915 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
2916 .await
2917 .expect("initialize runtime");
2918
2919 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
2920 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
2921 runtime
2922 .upsert_thread(&test_thread_metadata(
2923 &codex_home,
2924 thread_id,
2925 codex_home.join("workspace"),
2926 ))
2927 .await
2928 .expect("upsert thread");
2929
2930 for attempt in 0..3 {
2931 let claim = runtime
2932 .try_claim_stage1_job(
2933 thread_id, owner, 100, 3_600,
2934 64,
2935 )
2936 .await
2937 .expect("claim stage1 for retry exhaustion");
2938 let ownership_token = match claim {
2939 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
2940 other => panic!(
2941 "attempt {} should claim stage1 before retries are exhausted: {other:?}",
2942 attempt + 1
2943 ),
2944 };
2945 assert!(
2946 runtime
2947 .mark_stage1_job_failed(
2948 thread_id,
2949 ownership_token.as_str(),
2950 "boom",
2951 0
2952 )
2953 .await
2954 .expect("mark stage1 failed"),
2955 "attempt {} should decrement retry budget",
2956 attempt + 1
2957 );
2958 }
2959
2960 let exhausted_claim = runtime
2961 .try_claim_stage1_job(
2962 thread_id, owner, 100, 3_600,
2963 64,
2964 )
2965 .await
2966 .expect("claim stage1 after retry exhaustion");
2967 assert_eq!(
2968 exhausted_claim,
2969 Stage1JobClaimOutcome::SkippedRetryExhausted
2970 );
2971
2972 let newer_source_claim = runtime
2973 .try_claim_stage1_job(
2974 thread_id, owner, 101, 3_600,
2975 64,
2976 )
2977 .await
2978 .expect("claim stage1 with newer source watermark");
2979 assert!(
2980 matches!(newer_source_claim, Stage1JobClaimOutcome::Claimed { .. }),
2981 "newer source watermark should reset retry budget and be claimable"
2982 );
2983
2984 let job_row = sqlx::query(
2985 "SELECT retry_remaining, input_watermark FROM jobs WHERE kind = ? AND job_key = ?",
2986 )
2987 .bind("memory_stage1")
2988 .bind(thread_id.to_string())
2989 .fetch_one(memory_pool(&runtime))
2990 .await
2991 .expect("load stage1 job row after newer-source claim");
2992 assert_eq!(
2993 job_row
2994 .try_get::<i64, _>("retry_remaining")
2995 .expect("retry_remaining"),
2996 3
2997 );
2998 assert_eq!(
2999 job_row
3000 .try_get::<i64, _>("input_watermark")
3001 .expect("input_watermark"),
3002 101
3003 );
3004
3005 let _ = tokio::fs::remove_dir_all(codex_home).await;
3006 }
3007
3008 #[tokio::test]
3009 async fn phase2_global_lock_respects_success_cooldown() {
3010 let codex_home = unique_temp_dir();
3011 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3012 .await
3013 .expect("initialize runtime");
3014
3015 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3016
3017 runtime
3018 .enqueue_global_consolidation(100)
3019 .await
3020 .expect("enqueue global consolidation");
3021
3022 let claim = runtime
3023 .try_claim_global_phase2_job(owner, 3600)
3024 .await
3025 .expect("claim phase2");
3026 let (ownership_token, input_watermark) = match claim {
3027 Phase2JobClaimOutcome::Claimed {
3028 ownership_token,
3029 input_watermark,
3030 } => (ownership_token, input_watermark),
3031 other => panic!("unexpected phase2 claim outcome: {other:?}"),
3032 };
3033 assert!(
3034 runtime
3035 .mark_global_phase2_job_succeeded(ownership_token.as_str(), input_watermark, &[],)
3036 .await
3037 .expect("mark phase2 succeeded"),
3038 "phase2 success should finalize for current token"
3039 );
3040
3041 let claim_after_success = runtime
3042 .try_claim_global_phase2_job(owner, 3600)
3043 .await
3044 .expect("claim phase2 after success");
3045 assert_eq!(claim_after_success, Phase2JobClaimOutcome::SkippedCooldown);
3046
3047 runtime
3048 .enqueue_global_consolidation(101)
3049 .await
3050 .expect("enqueue global consolidation after success");
3051 let claim_after_enqueue = runtime
3052 .try_claim_global_phase2_job(owner, 3600)
3053 .await
3054 .expect("claim phase2 after enqueue");
3055 assert_eq!(claim_after_enqueue, Phase2JobClaimOutcome::SkippedCooldown);
3056
3057 age_phase2_success_beyond_cooldown(&runtime).await;
3058 let claim_after_cooldown = runtime
3059 .try_claim_global_phase2_job(owner, 3600)
3060 .await
3061 .expect("claim phase2 after cooldown");
3062 assert!(matches!(
3063 claim_after_cooldown,
3064 Phase2JobClaimOutcome::Claimed { .. }
3065 ));
3066
3067 let _ = tokio::fs::remove_dir_all(codex_home).await;
3068 }
3069
3070 #[tokio::test]
3071 async fn phase2_global_lock_can_be_claimed_after_retry_budget_is_exhausted() {
3072 let codex_home = unique_temp_dir();
3073 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3074 .await
3075 .expect("initialize runtime");
3076
3077 runtime
3078 .enqueue_global_consolidation(100)
3079 .await
3080 .expect("enqueue global consolidation");
3081
3082 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3083 for attempt in 0..3 {
3084 let claim = runtime
3085 .try_claim_global_phase2_job(owner, 3_600)
3086 .await
3087 .expect("claim phase2 before retry exhaustion");
3088 let ownership_token = match claim {
3089 Phase2JobClaimOutcome::Claimed {
3090 ownership_token, ..
3091 } => ownership_token,
3092 other => panic!(
3093 "attempt {} should claim phase2 before retries are exhausted: {other:?}",
3094 attempt + 1
3095 ),
3096 };
3097 assert!(
3098 runtime
3099 .mark_global_phase2_job_failed(
3100 ownership_token.as_str(),
3101 "boom",
3102 0,
3103 )
3104 .await
3105 .expect("mark phase2 failed"),
3106 "attempt {} should decrement retry budget",
3107 attempt + 1
3108 );
3109 }
3110
3111 let job_row =
3112 sqlx::query("SELECT retry_remaining FROM jobs WHERE kind = ? AND job_key = ?")
3113 .bind("memory_consolidate_global")
3114 .bind("global")
3115 .fetch_one(memory_pool(&runtime))
3116 .await
3117 .expect("load phase2 job row after retry exhaustion");
3118 assert_eq!(
3119 job_row
3120 .try_get::<i64, _>("retry_remaining")
3121 .expect("retry_remaining"),
3122 0
3123 );
3124
3125 let claim_after_exhaustion = runtime
3126 .try_claim_global_phase2_job(owner, 3_600)
3127 .await
3128 .expect("claim phase2 after retry exhaustion");
3129 assert!(
3130 matches!(
3131 claim_after_exhaustion,
3132 Phase2JobClaimOutcome::Claimed { .. }
3133 ),
3134 "phase2 claim should only lock; workspace diffing decides whether there is work"
3135 );
3136
3137 let _ = tokio::fs::remove_dir_all(codex_home).await;
3138 }
3139
3140 #[tokio::test]
3141 async fn list_stage1_outputs_for_global_returns_latest_outputs() {
3142 let codex_home = unique_temp_dir();
3143 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3144 .await
3145 .expect("initialize runtime");
3146
3147 let thread_id_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3148 let thread_id_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3149 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3150 runtime
3151 .upsert_thread(&test_thread_metadata(
3152 &codex_home,
3153 thread_id_a,
3154 codex_home.join("workspace-a"),
3155 ))
3156 .await
3157 .expect("upsert thread a");
3158 let mut metadata_b =
3159 test_thread_metadata(&codex_home, thread_id_b, codex_home.join("workspace-b"));
3160 metadata_b.git_branch = Some("feature/stage1-b".to_string());
3161 runtime
3162 .upsert_thread(&metadata_b)
3163 .await
3164 .expect("upsert thread b");
3165
3166 let claim = runtime
3167 .try_claim_stage1_job(
3168 thread_id_a,
3169 owner,
3170 100,
3171 3600,
3172 64,
3173 )
3174 .await
3175 .expect("claim stage1 a");
3176 let ownership_token = match claim {
3177 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3178 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3179 };
3180 assert!(
3181 runtime
3182 .mark_stage1_job_succeeded(
3183 thread_id_a,
3184 ownership_token.as_str(),
3185 100,
3186 "raw memory a",
3187 "summary a",
3188 None,
3189 )
3190 .await
3191 .expect("mark stage1 succeeded a"),
3192 "stage1 success should persist output a"
3193 );
3194
3195 let claim = runtime
3196 .try_claim_stage1_job(
3197 thread_id_b,
3198 owner,
3199 101,
3200 3600,
3201 64,
3202 )
3203 .await
3204 .expect("claim stage1 b");
3205 let ownership_token = match claim {
3206 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3207 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3208 };
3209 assert!(
3210 runtime
3211 .mark_stage1_job_succeeded(
3212 thread_id_b,
3213 ownership_token.as_str(),
3214 101,
3215 "raw memory b",
3216 "summary b",
3217 Some("rollout-b"),
3218 )
3219 .await
3220 .expect("mark stage1 succeeded b"),
3221 "stage1 success should persist output b"
3222 );
3223
3224 let outputs = runtime
3225 .list_stage1_outputs_for_global(10)
3226 .await
3227 .expect("list stage1 outputs for global");
3228 assert_eq!(outputs.len(), 2);
3229 assert_eq!(outputs[0].thread_id, thread_id_b);
3230 assert_eq!(outputs[0].rollout_summary, "summary b");
3231 assert_eq!(outputs[0].rollout_slug.as_deref(), Some("rollout-b"));
3232 assert_eq!(outputs[0].cwd, codex_home.join("workspace-b"));
3233 assert_eq!(outputs[0].git_branch.as_deref(), Some("feature/stage1-b"));
3234 assert_eq!(outputs[1].thread_id, thread_id_a);
3235 assert_eq!(outputs[1].rollout_summary, "summary a");
3236 assert_eq!(outputs[1].rollout_slug, None);
3237 assert_eq!(outputs[1].cwd, codex_home.join("workspace-a"));
3238 assert_eq!(outputs[1].git_branch, None);
3239
3240 let _ = tokio::fs::remove_dir_all(codex_home).await;
3241 }
3242
3243 #[tokio::test]
3244 async fn list_stage1_outputs_for_global_skips_empty_payloads() {
3245 let codex_home = unique_temp_dir();
3246 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3247 .await
3248 .expect("initialize runtime");
3249
3250 let thread_id_non_empty =
3251 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3252 let thread_id_empty =
3253 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3254 runtime
3255 .upsert_thread(&test_thread_metadata(
3256 &codex_home,
3257 thread_id_non_empty,
3258 codex_home.join("workspace-non-empty"),
3259 ))
3260 .await
3261 .expect("upsert non-empty thread");
3262 runtime
3263 .upsert_thread(&test_thread_metadata(
3264 &codex_home,
3265 thread_id_empty,
3266 codex_home.join("workspace-empty"),
3267 ))
3268 .await
3269 .expect("upsert empty thread");
3270
3271 sqlx::query(
3272 r#"
3273INSERT INTO stage1_outputs (thread_id, source_updated_at, raw_memory, rollout_summary, generated_at)
3274VALUES (?, ?, ?, ?, ?)
3275 "#,
3276 )
3277 .bind(thread_id_non_empty.to_string())
3278 .bind(100_i64)
3279 .bind("raw memory")
3280 .bind("summary")
3281 .bind(100_i64)
3282 .execute(memory_pool(&runtime))
3283 .await
3284 .expect("insert non-empty stage1 output");
3285 sqlx::query(
3286 r#"
3287INSERT INTO stage1_outputs (thread_id, source_updated_at, raw_memory, rollout_summary, generated_at)
3288VALUES (?, ?, ?, ?, ?)
3289 "#,
3290 )
3291 .bind(thread_id_empty.to_string())
3292 .bind(101_i64)
3293 .bind("")
3294 .bind("")
3295 .bind(101_i64)
3296 .execute(memory_pool(&runtime))
3297 .await
3298 .expect("insert empty stage1 output");
3299
3300 let outputs = runtime
3301 .list_stage1_outputs_for_global(1)
3302 .await
3303 .expect("list stage1 outputs for global");
3304 assert_eq!(outputs.len(), 1);
3305 assert_eq!(outputs[0].thread_id, thread_id_non_empty);
3306 assert_eq!(outputs[0].rollout_summary, "summary");
3307 assert_eq!(outputs[0].cwd, codex_home.join("workspace-non-empty"));
3308
3309 let _ = tokio::fs::remove_dir_all(codex_home).await;
3310 }
3311
3312 #[tokio::test]
3313 async fn list_stage1_outputs_for_global_includes_paginated_and_skips_polluted_threads() {
3314 let codex_home = unique_temp_dir();
3315 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3316 .await
3317 .expect("initialize runtime");
3318
3319 let thread_id_enabled =
3320 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3321 let thread_id_polluted =
3322 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3323 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3324
3325 for (thread_id, workspace) in [
3326 (thread_id_enabled, "workspace-enabled"),
3327 (thread_id_polluted, "workspace-polluted"),
3328 ] {
3329 let mut metadata =
3330 test_thread_metadata(&codex_home, thread_id, codex_home.join(workspace));
3331 metadata.history_mode = ThreadHistoryMode::Paginated;
3332 runtime
3333 .upsert_thread(&metadata)
3334 .await
3335 .expect("upsert thread");
3336
3337 let claim = runtime
3338 .try_claim_stage1_job(
3339 thread_id, owner, 100, 3600,
3340 64,
3341 )
3342 .await
3343 .expect("claim stage1");
3344 let ownership_token = match claim {
3345 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3346 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3347 };
3348 assert!(
3349 runtime
3350 .mark_stage1_job_succeeded(
3351 thread_id,
3352 ownership_token.as_str(),
3353 100,
3354 "raw memory",
3355 "summary",
3356 None,
3357 )
3358 .await
3359 .expect("mark stage1 succeeded"),
3360 "stage1 success should persist output"
3361 );
3362 }
3363
3364 runtime
3365 .set_thread_memory_mode(thread_id_polluted, "polluted")
3366 .await
3367 .expect("mark thread polluted");
3368
3369 let outputs = runtime
3370 .list_stage1_outputs_for_global(10)
3371 .await
3372 .expect("list stage1 outputs for global");
3373 assert_eq!(outputs.len(), 1);
3374 assert_eq!(outputs[0].thread_id, thread_id_enabled);
3375
3376 let _ = tokio::fs::remove_dir_all(codex_home).await;
3377 }
3378
3379 #[tokio::test]
3380 async fn get_phase2_input_selection_returns_current_selected_rows() {
3381 let codex_home = unique_temp_dir();
3382 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3383 .await
3384 .expect("initialize runtime");
3385
3386 let thread_id_a = stable_thread_id("00000000-0000-4000-8000-000000000001");
3387 let thread_id_b = stable_thread_id("00000000-0000-4000-8000-000000000002");
3388 let thread_id_c = stable_thread_id("00000000-0000-4000-8000-000000000003");
3389 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3390
3391 for (thread_id, workspace) in [
3392 (thread_id_a, "workspace-a"),
3393 (thread_id_b, "workspace-b"),
3394 (thread_id_c, "workspace-c"),
3395 ] {
3396 runtime
3397 .upsert_thread(&test_thread_metadata(
3398 &codex_home,
3399 thread_id,
3400 codex_home.join(workspace),
3401 ))
3402 .await
3403 .expect("upsert thread");
3404 }
3405
3406 for (thread_id, updated_at, slug) in [
3407 (thread_id_a, 100, Some("rollout-a")),
3408 (thread_id_b, 101, Some("rollout-b")),
3409 (thread_id_c, 102, Some("rollout-c")),
3410 ] {
3411 let claim = runtime
3412 .try_claim_stage1_job(
3413 thread_id, owner, updated_at, 3600,
3414 64,
3415 )
3416 .await
3417 .expect("claim stage1");
3418 let ownership_token = match claim {
3419 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3420 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3421 };
3422 assert!(
3423 runtime
3424 .mark_stage1_job_succeeded(
3425 thread_id,
3426 ownership_token.as_str(),
3427 updated_at,
3428 &format!("raw-{updated_at}"),
3429 &format!("summary-{updated_at}"),
3430 slug,
3431 )
3432 .await
3433 .expect("mark stage1 succeeded"),
3434 "stage1 success should persist output"
3435 );
3436 }
3437
3438 let claim = runtime
3439 .try_claim_global_phase2_job(owner, 3600)
3440 .await
3441 .expect("claim phase2");
3442 let (ownership_token, input_watermark) = match claim {
3443 Phase2JobClaimOutcome::Claimed {
3444 ownership_token,
3445 input_watermark,
3446 } => (ownership_token, input_watermark),
3447 other => panic!("unexpected phase2 claim outcome: {other:?}"),
3448 };
3449 assert_eq!(input_watermark, 102);
3450 let selected_outputs = runtime
3451 .list_stage1_outputs_for_global(10)
3452 .await
3453 .expect("list stage1 outputs for global")
3454 .into_iter()
3455 .filter(|output| output.thread_id == thread_id_c || output.thread_id == thread_id_a)
3456 .collect::<Vec<_>>();
3457 assert!(
3458 runtime
3459 .mark_global_phase2_job_succeeded(
3460 ownership_token.as_str(),
3461 input_watermark,
3462 &selected_outputs,
3463 )
3464 .await
3465 .expect("mark phase2 success with selection"),
3466 "phase2 success should persist selected rows"
3467 );
3468
3469 let selection = runtime
3470 .get_phase2_input_selection(2, 36_500)
3471 .await
3472 .expect("load phase2 input selection");
3473
3474 assert_eq!(selection.len(), 2);
3475 assert_eq!(
3476 selection
3477 .iter()
3478 .map(|output| output.thread_id)
3479 .collect::<Vec<_>>(),
3480 vec![thread_id_b, thread_id_c]
3481 );
3482 let selected_c = selection
3483 .iter()
3484 .find(|output| output.thread_id == thread_id_c)
3485 .expect("thread c should be selected");
3486 assert_eq!(
3487 selected_c.rollout_path,
3488 codex_home.join(format!("rollout-{thread_id_c}.jsonl"))
3489 );
3490
3491 let _ = tokio::fs::remove_dir_all(codex_home).await;
3492 }
3493
3494 #[tokio::test]
3495 async fn get_phase2_input_selection_excludes_polluted_previous_selection() {
3496 let codex_home = unique_temp_dir();
3497 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3498 .await
3499 .expect("initialize runtime");
3500
3501 let thread_id_enabled =
3502 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3503 let thread_id_polluted =
3504 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3505 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3506
3507 for (thread_id, updated_at) in [(thread_id_enabled, 100), (thread_id_polluted, 101)] {
3508 runtime
3509 .upsert_thread(&test_thread_metadata(
3510 &codex_home,
3511 thread_id,
3512 codex_home.join(thread_id.to_string()),
3513 ))
3514 .await
3515 .expect("upsert thread");
3516
3517 let claim = runtime
3518 .try_claim_stage1_job(
3519 thread_id, owner, updated_at, 3600,
3520 64,
3521 )
3522 .await
3523 .expect("claim stage1");
3524 let ownership_token = match claim {
3525 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3526 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3527 };
3528 assert!(
3529 runtime
3530 .mark_stage1_job_succeeded(
3531 thread_id,
3532 ownership_token.as_str(),
3533 updated_at,
3534 &format!("raw-{updated_at}"),
3535 &format!("summary-{updated_at}"),
3536 None,
3537 )
3538 .await
3539 .expect("mark stage1 succeeded"),
3540 "stage1 success should persist output"
3541 );
3542 }
3543
3544 let claim = runtime
3545 .try_claim_global_phase2_job(owner, 3600)
3546 .await
3547 .expect("claim phase2");
3548 let (ownership_token, input_watermark) = match claim {
3549 Phase2JobClaimOutcome::Claimed {
3550 ownership_token,
3551 input_watermark,
3552 } => (ownership_token, input_watermark),
3553 other => panic!("unexpected phase2 claim outcome: {other:?}"),
3554 };
3555 let selected_outputs = runtime
3556 .list_stage1_outputs_for_global(10)
3557 .await
3558 .expect("list stage1 outputs for global");
3559 assert!(
3560 runtime
3561 .mark_global_phase2_job_succeeded(
3562 ownership_token.as_str(),
3563 input_watermark,
3564 &selected_outputs,
3565 )
3566 .await
3567 .expect("mark phase2 success"),
3568 "phase2 success should persist selected rows"
3569 );
3570
3571 runtime
3572 .set_thread_memory_mode(thread_id_polluted, "polluted")
3573 .await
3574 .expect("mark thread polluted");
3575
3576 let selection = runtime
3577 .get_phase2_input_selection(2, 36_500)
3578 .await
3579 .expect("load phase2 input selection");
3580
3581 assert_eq!(selection.len(), 1);
3582 assert_eq!(selection[0].thread_id, thread_id_enabled);
3583
3584 let _ = tokio::fs::remove_dir_all(codex_home).await;
3585 }
3586
3587 #[tokio::test]
3588 async fn mark_thread_memory_mode_polluted_enqueues_phase2_for_selected_threads() {
3589 let codex_home = unique_temp_dir();
3590 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3591 .await
3592 .expect("initialize runtime");
3593
3594 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3595 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3596 runtime
3597 .upsert_thread(&test_thread_metadata(
3598 &codex_home,
3599 thread_id,
3600 codex_home.join("workspace"),
3601 ))
3602 .await
3603 .expect("upsert thread");
3604
3605 let claim = runtime
3606 .try_claim_stage1_job(
3607 thread_id, owner, 100, 3600,
3608 64,
3609 )
3610 .await
3611 .expect("claim stage1");
3612 let ownership_token = match claim {
3613 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3614 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3615 };
3616 assert!(
3617 runtime
3618 .mark_stage1_job_succeeded(
3619 thread_id,
3620 ownership_token.as_str(),
3621 100,
3622 "raw",
3623 "summary",
3624 None,
3625 )
3626 .await
3627 .expect("mark stage1 succeeded"),
3628 "stage1 success should persist output"
3629 );
3630
3631 let phase2_claim = runtime
3632 .try_claim_global_phase2_job(owner, 3600)
3633 .await
3634 .expect("claim phase2");
3635 let (phase2_token, input_watermark) = match phase2_claim {
3636 Phase2JobClaimOutcome::Claimed {
3637 ownership_token,
3638 input_watermark,
3639 } => (ownership_token, input_watermark),
3640 other => panic!("unexpected phase2 claim outcome: {other:?}"),
3641 };
3642 let selected_outputs = runtime
3643 .list_stage1_outputs_for_global(10)
3644 .await
3645 .expect("list stage1 outputs");
3646 assert!(
3647 runtime
3648 .mark_global_phase2_job_succeeded(
3649 phase2_token.as_str(),
3650 input_watermark,
3651 &selected_outputs,
3652 )
3653 .await
3654 .expect("mark phase2 success"),
3655 "phase2 success should persist selected rows"
3656 );
3657
3658 assert!(
3659 runtime
3660 .mark_thread_memory_mode_polluted(thread_id)
3661 .await
3662 .expect("mark thread polluted"),
3663 "thread should transition to polluted"
3664 );
3665
3666 age_phase2_success_beyond_cooldown(&runtime).await;
3667 let next_claim = runtime
3668 .try_claim_global_phase2_job(owner, 3600)
3669 .await
3670 .expect("claim phase2 after pollution");
3671 assert!(matches!(next_claim, Phase2JobClaimOutcome::Claimed { .. }));
3672
3673 let _ = tokio::fs::remove_dir_all(codex_home).await;
3674 }
3675
3676 #[tokio::test]
3677 async fn mark_thread_memory_mode_polluted_enqueues_phase2_when_already_polluted() {
3678 let codex_home = unique_temp_dir();
3679 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3680 .await
3681 .expect("initialize runtime");
3682
3683 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3684 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3685 runtime
3686 .upsert_thread(&test_thread_metadata(
3687 &codex_home,
3688 thread_id,
3689 codex_home.join("workspace"),
3690 ))
3691 .await
3692 .expect("upsert thread");
3693
3694 let claim = runtime
3695 .try_claim_stage1_job(
3696 thread_id, owner, 100, 3600,
3697 64,
3698 )
3699 .await
3700 .expect("claim stage1");
3701 let ownership_token = match claim {
3702 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3703 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3704 };
3705 assert!(
3706 runtime
3707 .mark_stage1_job_succeeded(
3708 thread_id,
3709 ownership_token.as_str(),
3710 100,
3711 "raw",
3712 "summary",
3713 None,
3714 )
3715 .await
3716 .expect("mark stage1 succeeded"),
3717 "stage1 success should persist output"
3718 );
3719
3720 let phase2_claim = runtime
3721 .try_claim_global_phase2_job(owner, 3600)
3722 .await
3723 .expect("claim phase2");
3724 let (phase2_token, input_watermark) = match phase2_claim {
3725 Phase2JobClaimOutcome::Claimed {
3726 ownership_token,
3727 input_watermark,
3728 } => (ownership_token, input_watermark),
3729 other => panic!("unexpected phase2 claim outcome: {other:?}"),
3730 };
3731 let selected_outputs = runtime
3732 .list_stage1_outputs_for_global(10)
3733 .await
3734 .expect("list stage1 outputs");
3735 assert!(
3736 runtime
3737 .mark_global_phase2_job_succeeded(
3738 phase2_token.as_str(),
3739 input_watermark,
3740 &selected_outputs,
3741 )
3742 .await
3743 .expect("mark phase2 success"),
3744 "phase2 success should persist selected rows"
3745 );
3746
3747 sqlx::query("UPDATE threads SET memory_mode = 'polluted' WHERE id = ?")
3748 .bind(thread_id.to_string())
3749 .execute(runtime.pool.as_ref())
3750 .await
3751 .expect("mark thread polluted before memory enqueue");
3752
3753 assert!(
3754 !runtime
3755 .mark_thread_memory_mode_polluted(thread_id)
3756 .await
3757 .expect("mark already polluted thread"),
3758 "already polluted thread should not report a state transition"
3759 );
3760
3761 age_phase2_success_beyond_cooldown(&runtime).await;
3762 let next_claim = runtime
3763 .try_claim_global_phase2_job(owner, 3600)
3764 .await
3765 .expect("claim phase2 after already-polluted enqueue");
3766 assert!(matches!(next_claim, Phase2JobClaimOutcome::Claimed { .. }));
3767
3768 let _ = tokio::fs::remove_dir_all(codex_home).await;
3769 }
3770
3771 #[tokio::test]
3772 async fn get_phase2_input_selection_returns_regenerated_selected_rows() {
3773 let codex_home = unique_temp_dir();
3774 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3775 .await
3776 .expect("initialize runtime");
3777
3778 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
3779 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3780 runtime
3781 .upsert_thread(&test_thread_metadata(
3782 &codex_home,
3783 thread_id,
3784 codex_home.join("workspace"),
3785 ))
3786 .await
3787 .expect("upsert thread");
3788
3789 let first_claim = runtime
3790 .try_claim_stage1_job(
3791 thread_id, owner, 100, 3600,
3792 64,
3793 )
3794 .await
3795 .expect("claim initial stage1");
3796 let first_token = match first_claim {
3797 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3798 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3799 };
3800 assert!(
3801 runtime
3802 .mark_stage1_job_succeeded(
3803 thread_id,
3804 first_token.as_str(),
3805 100,
3806 "raw-100",
3807 "summary-100",
3808 Some("rollout-100"),
3809 )
3810 .await
3811 .expect("mark initial stage1 success"),
3812 "initial stage1 success should persist output"
3813 );
3814
3815 let phase2_claim = runtime
3816 .try_claim_global_phase2_job(owner, 3600)
3817 .await
3818 .expect("claim phase2");
3819 let (phase2_token, input_watermark) = match phase2_claim {
3820 Phase2JobClaimOutcome::Claimed {
3821 ownership_token,
3822 input_watermark,
3823 } => (ownership_token, input_watermark),
3824 other => panic!("unexpected phase2 claim outcome: {other:?}"),
3825 };
3826 let selected_outputs = runtime
3827 .list_stage1_outputs_for_global(1)
3828 .await
3829 .expect("list selected outputs");
3830 assert!(
3831 runtime
3832 .mark_global_phase2_job_succeeded(
3833 phase2_token.as_str(),
3834 input_watermark,
3835 &selected_outputs,
3836 )
3837 .await
3838 .expect("mark phase2 success"),
3839 "phase2 success should persist selected rows"
3840 );
3841
3842 let refreshed_claim = runtime
3843 .try_claim_stage1_job(
3844 thread_id, owner, 101, 3600,
3845 64,
3846 )
3847 .await
3848 .expect("claim refreshed stage1");
3849 let refreshed_token = match refreshed_claim {
3850 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3851 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3852 };
3853 assert!(
3854 runtime
3855 .mark_stage1_job_succeeded(
3856 thread_id,
3857 refreshed_token.as_str(),
3858 101,
3859 "raw-101",
3860 "summary-101",
3861 Some("rollout-101"),
3862 )
3863 .await
3864 .expect("mark refreshed stage1 success"),
3865 "refreshed stage1 success should persist output"
3866 );
3867
3868 let selection = runtime
3869 .get_phase2_input_selection(1, 36_500)
3870 .await
3871 .expect("load phase2 input selection");
3872 assert_eq!(selection.len(), 1);
3873 assert_eq!(selection[0].thread_id, thread_id);
3874 assert_eq!(selection[0].source_updated_at.timestamp(), 101);
3875
3876 let (selected_for_phase2, selected_for_phase2_source_updated_at) =
3877 sqlx::query_as::<_, (i64, Option<i64>)>(
3878 "SELECT selected_for_phase2, selected_for_phase2_source_updated_at FROM stage1_outputs WHERE thread_id = ?",
3879 )
3880 .bind(thread_id.to_string())
3881 .fetch_one(memory_pool(&runtime))
3882 .await
3883 .expect("load selected_for_phase2");
3884 assert_eq!(selected_for_phase2, 1);
3885 assert_eq!(selected_for_phase2_source_updated_at, Some(100));
3886
3887 let _ = tokio::fs::remove_dir_all(codex_home).await;
3888 }
3889
3890 #[tokio::test]
3891 async fn get_phase2_input_selection_uses_current_ranking_after_refreshes() {
3892 let codex_home = unique_temp_dir();
3893 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
3894 .await
3895 .expect("initialize runtime");
3896
3897 let thread_id_a = stable_thread_id("00000000-0000-4000-8000-000000000001");
3898 let thread_id_b = stable_thread_id("00000000-0000-4000-8000-000000000002");
3899 let thread_id_c = stable_thread_id("00000000-0000-4000-8000-000000000003");
3900 let thread_id_d = stable_thread_id("00000000-0000-4000-8000-000000000004");
3901 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
3902
3903 for (thread_id, workspace) in [
3904 (thread_id_a, "workspace-a"),
3905 (thread_id_b, "workspace-b"),
3906 (thread_id_c, "workspace-c"),
3907 (thread_id_d, "workspace-d"),
3908 ] {
3909 runtime
3910 .upsert_thread(&test_thread_metadata(
3911 &codex_home,
3912 thread_id,
3913 codex_home.join(workspace),
3914 ))
3915 .await
3916 .expect("upsert thread");
3917 }
3918
3919 for (thread_id, updated_at, slug) in [
3920 (thread_id_a, 100, Some("rollout-a-100")),
3921 (thread_id_b, 101, Some("rollout-b-101")),
3922 (thread_id_c, 99, Some("rollout-c-99")),
3923 (thread_id_d, 98, Some("rollout-d-98")),
3924 ] {
3925 let claim = runtime
3926 .try_claim_stage1_job(
3927 thread_id, owner, updated_at, 3600,
3928 64,
3929 )
3930 .await
3931 .expect("claim initial stage1");
3932 let ownership_token = match claim {
3933 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
3934 other => panic!("unexpected stage1 claim outcome: {other:?}"),
3935 };
3936 assert!(
3937 runtime
3938 .mark_stage1_job_succeeded(
3939 thread_id,
3940 ownership_token.as_str(),
3941 updated_at,
3942 &format!("raw-{updated_at}"),
3943 &format!("summary-{updated_at}"),
3944 slug,
3945 )
3946 .await
3947 .expect("mark stage1 succeeded"),
3948 "stage1 success should persist output"
3949 );
3950 }
3951
3952 let phase2_claim = runtime
3953 .try_claim_global_phase2_job(owner, 3600)
3954 .await
3955 .expect("claim phase2");
3956 let (phase2_token, input_watermark) = match phase2_claim {
3957 Phase2JobClaimOutcome::Claimed {
3958 ownership_token,
3959 input_watermark,
3960 } => (ownership_token, input_watermark),
3961 other => panic!("unexpected phase2 claim outcome: {other:?}"),
3962 };
3963 let selected_outputs = runtime
3964 .list_stage1_outputs_for_global(2)
3965 .await
3966 .expect("list selected outputs");
3967 assert_eq!(
3968 selected_outputs
3969 .iter()
3970 .map(|output| output.thread_id)
3971 .collect::<Vec<_>>(),
3972 vec![thread_id_b, thread_id_a]
3973 );
3974 assert!(
3975 runtime
3976 .mark_global_phase2_job_succeeded(
3977 phase2_token.as_str(),
3978 input_watermark,
3979 &selected_outputs,
3980 )
3981 .await
3982 .expect("mark phase2 success"),
3983 "phase2 success should persist selected rows"
3984 );
3985
3986 for (thread_id, updated_at, slug) in [
3987 (thread_id_a, 102, Some("rollout-a-102")),
3988 (thread_id_c, 103, Some("rollout-c-103")),
3989 (thread_id_d, 104, Some("rollout-d-104")),
3990 ] {
3991 let claim = runtime
3992 .try_claim_stage1_job(
3993 thread_id, owner, updated_at, 3600,
3994 64,
3995 )
3996 .await
3997 .expect("claim refreshed stage1");
3998 let ownership_token = match claim {
3999 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4000 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4001 };
4002 assert!(
4003 runtime
4004 .mark_stage1_job_succeeded(
4005 thread_id,
4006 ownership_token.as_str(),
4007 updated_at,
4008 &format!("raw-{updated_at}"),
4009 &format!("summary-{updated_at}"),
4010 slug,
4011 )
4012 .await
4013 .expect("mark refreshed stage1 success"),
4014 "refreshed stage1 success should persist output"
4015 );
4016 }
4017
4018 let selection = runtime
4019 .get_phase2_input_selection(2, 36_500)
4020 .await
4021 .expect("load phase2 input selection");
4022 assert_eq!(
4023 selection
4024 .iter()
4025 .map(|output| output.thread_id)
4026 .collect::<Vec<_>>(),
4027 vec![thread_id_c, thread_id_d]
4028 );
4029
4030 let _ = tokio::fs::remove_dir_all(codex_home).await;
4031 }
4032
4033 #[tokio::test]
4034 async fn mark_global_phase2_job_succeeded_updates_selected_snapshot_timestamp() {
4035 let codex_home = unique_temp_dir();
4036 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4037 .await
4038 .expect("initialize runtime");
4039
4040 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
4041 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4042 runtime
4043 .upsert_thread(&test_thread_metadata(
4044 &codex_home,
4045 thread_id,
4046 codex_home.join("workspace"),
4047 ))
4048 .await
4049 .expect("upsert thread");
4050
4051 let initial_claim = runtime
4052 .try_claim_stage1_job(
4053 thread_id, owner, 100, 3600,
4054 64,
4055 )
4056 .await
4057 .expect("claim initial stage1");
4058 let initial_token = match initial_claim {
4059 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4060 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4061 };
4062 assert!(
4063 runtime
4064 .mark_stage1_job_succeeded(
4065 thread_id,
4066 initial_token.as_str(),
4067 100,
4068 "raw-100",
4069 "summary-100",
4070 Some("rollout-100"),
4071 )
4072 .await
4073 .expect("mark initial stage1 success"),
4074 "initial stage1 success should persist output"
4075 );
4076
4077 let first_phase2_claim = runtime
4078 .try_claim_global_phase2_job(owner, 3600)
4079 .await
4080 .expect("claim first phase2");
4081 let (first_phase2_token, first_input_watermark) = match first_phase2_claim {
4082 Phase2JobClaimOutcome::Claimed {
4083 ownership_token,
4084 input_watermark,
4085 } => (ownership_token, input_watermark),
4086 other => panic!("unexpected first phase2 claim outcome: {other:?}"),
4087 };
4088 let first_selected_outputs = runtime
4089 .list_stage1_outputs_for_global(1)
4090 .await
4091 .expect("list first selected outputs");
4092 assert!(
4093 runtime
4094 .mark_global_phase2_job_succeeded(
4095 first_phase2_token.as_str(),
4096 first_input_watermark,
4097 &first_selected_outputs,
4098 )
4099 .await
4100 .expect("mark first phase2 success"),
4101 "first phase2 success should persist selected rows"
4102 );
4103
4104 let refreshed_claim = runtime
4105 .try_claim_stage1_job(
4106 thread_id, owner, 101, 3600,
4107 64,
4108 )
4109 .await
4110 .expect("claim refreshed stage1");
4111 let refreshed_token = match refreshed_claim {
4112 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4113 other => panic!("unexpected refreshed stage1 claim outcome: {other:?}"),
4114 };
4115 assert!(
4116 runtime
4117 .mark_stage1_job_succeeded(
4118 thread_id,
4119 refreshed_token.as_str(),
4120 101,
4121 "raw-101",
4122 "summary-101",
4123 Some("rollout-101"),
4124 )
4125 .await
4126 .expect("mark refreshed stage1 success"),
4127 "refreshed stage1 success should persist output"
4128 );
4129
4130 age_phase2_success_beyond_cooldown(&runtime).await;
4131 let second_phase2_claim = runtime
4132 .try_claim_global_phase2_job(owner, 3600)
4133 .await
4134 .expect("claim second phase2");
4135 let (second_phase2_token, second_input_watermark) = match second_phase2_claim {
4136 Phase2JobClaimOutcome::Claimed {
4137 ownership_token,
4138 input_watermark,
4139 } => (ownership_token, input_watermark),
4140 other => panic!("unexpected second phase2 claim outcome: {other:?}"),
4141 };
4142 let second_selected_outputs = runtime
4143 .list_stage1_outputs_for_global(1)
4144 .await
4145 .expect("list second selected outputs");
4146 assert_eq!(
4147 second_selected_outputs[0].source_updated_at.timestamp(),
4148 101
4149 );
4150 assert!(
4151 runtime
4152 .mark_global_phase2_job_succeeded(
4153 second_phase2_token.as_str(),
4154 second_input_watermark,
4155 &second_selected_outputs,
4156 )
4157 .await
4158 .expect("mark second phase2 success"),
4159 "second phase2 success should persist selected rows"
4160 );
4161
4162 let selection = runtime
4163 .get_phase2_input_selection(1, 36_500)
4164 .await
4165 .expect("load phase2 input selection after refresh");
4166 assert_eq!(selection.len(), 1);
4167 assert_eq!(selection[0].thread_id, thread_id);
4168
4169 let (selected_for_phase2, selected_for_phase2_source_updated_at) =
4170 sqlx::query_as::<_, (i64, Option<i64>)>(
4171 "SELECT selected_for_phase2, selected_for_phase2_source_updated_at FROM stage1_outputs WHERE thread_id = ?",
4172 )
4173 .bind(thread_id.to_string())
4174 .fetch_one(memory_pool(&runtime))
4175 .await
4176 .expect("load selected snapshot after phase2");
4177 assert_eq!(selected_for_phase2, 1);
4178 assert_eq!(selected_for_phase2_source_updated_at, Some(101));
4179
4180 let _ = tokio::fs::remove_dir_all(codex_home).await;
4181 }
4182
4183 #[tokio::test]
4184 async fn mark_global_phase2_job_succeeded_only_marks_exact_selected_snapshots() {
4185 let codex_home = unique_temp_dir();
4186 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4187 .await
4188 .expect("initialize runtime");
4189
4190 let thread_id = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id");
4191 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4192 runtime
4193 .upsert_thread(&test_thread_metadata(
4194 &codex_home,
4195 thread_id,
4196 codex_home.join("workspace"),
4197 ))
4198 .await
4199 .expect("upsert thread");
4200
4201 let initial_claim = runtime
4202 .try_claim_stage1_job(
4203 thread_id, owner, 100, 3600,
4204 64,
4205 )
4206 .await
4207 .expect("claim initial stage1");
4208 let initial_token = match initial_claim {
4209 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4210 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4211 };
4212 assert!(
4213 runtime
4214 .mark_stage1_job_succeeded(
4215 thread_id,
4216 initial_token.as_str(),
4217 100,
4218 "raw-100",
4219 "summary-100",
4220 Some("rollout-100"),
4221 )
4222 .await
4223 .expect("mark initial stage1 success"),
4224 "initial stage1 success should persist output"
4225 );
4226
4227 let phase2_claim = runtime
4228 .try_claim_global_phase2_job(owner, 3600)
4229 .await
4230 .expect("claim phase2");
4231 let (phase2_token, input_watermark) = match phase2_claim {
4232 Phase2JobClaimOutcome::Claimed {
4233 ownership_token,
4234 input_watermark,
4235 } => (ownership_token, input_watermark),
4236 other => panic!("unexpected phase2 claim outcome: {other:?}"),
4237 };
4238 let selected_outputs = runtime
4239 .list_stage1_outputs_for_global(1)
4240 .await
4241 .expect("list selected outputs");
4242 assert_eq!(selected_outputs[0].source_updated_at.timestamp(), 100);
4243
4244 let refreshed_claim = runtime
4245 .try_claim_stage1_job(
4246 thread_id, owner, 101, 3600,
4247 64,
4248 )
4249 .await
4250 .expect("claim refreshed stage1");
4251 let refreshed_token = match refreshed_claim {
4252 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4253 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4254 };
4255 assert!(
4256 runtime
4257 .mark_stage1_job_succeeded(
4258 thread_id,
4259 refreshed_token.as_str(),
4260 101,
4261 "raw-101",
4262 "summary-101",
4263 Some("rollout-101"),
4264 )
4265 .await
4266 .expect("mark refreshed stage1 success"),
4267 "refreshed stage1 success should persist output"
4268 );
4269
4270 assert!(
4271 runtime
4272 .mark_global_phase2_job_succeeded(
4273 phase2_token.as_str(),
4274 input_watermark,
4275 &selected_outputs,
4276 )
4277 .await
4278 .expect("mark phase2 success"),
4279 "phase2 success should still complete"
4280 );
4281
4282 let (selected_for_phase2, selected_for_phase2_source_updated_at) =
4283 sqlx::query_as::<_, (i64, Option<i64>)>(
4284 "SELECT selected_for_phase2, selected_for_phase2_source_updated_at FROM stage1_outputs WHERE thread_id = ?",
4285 )
4286 .bind(thread_id.to_string())
4287 .fetch_one(memory_pool(&runtime))
4288 .await
4289 .expect("load selected_for_phase2");
4290 assert_eq!(selected_for_phase2, 0);
4291 assert_eq!(selected_for_phase2_source_updated_at, None);
4292
4293 let selection = runtime
4294 .get_phase2_input_selection(1, 36_500)
4295 .await
4296 .expect("load phase2 input selection");
4297 assert_eq!(selection.len(), 1);
4298 assert_eq!(selection[0].source_updated_at.timestamp(), 101);
4299
4300 let _ = tokio::fs::remove_dir_all(codex_home).await;
4301 }
4302
4303 #[tokio::test]
4304 async fn record_stage1_output_usage_updates_usage_metadata() {
4305 let codex_home = unique_temp_dir();
4306 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4307 .await
4308 .expect("initialize runtime");
4309
4310 let thread_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id a");
4311 let thread_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id b");
4312 let missing = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("missing id");
4313 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4314
4315 runtime
4316 .upsert_thread(&test_thread_metadata(
4317 &codex_home,
4318 thread_a,
4319 codex_home.join("workspace-a"),
4320 ))
4321 .await
4322 .expect("upsert thread a");
4323 runtime
4324 .upsert_thread(&test_thread_metadata(
4325 &codex_home,
4326 thread_b,
4327 codex_home.join("workspace-b"),
4328 ))
4329 .await
4330 .expect("upsert thread b");
4331
4332 let claim_a = runtime
4333 .try_claim_stage1_job(
4334 thread_a, owner, 100, 3600,
4335 64,
4336 )
4337 .await
4338 .expect("claim stage1 a");
4339 let token_a = match claim_a {
4340 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4341 other => panic!("unexpected stage1 claim outcome for a: {other:?}"),
4342 };
4343 assert!(
4344 runtime
4345 .mark_stage1_job_succeeded(
4346 thread_a,
4347 token_a.as_str(),
4348 100,
4349 "raw a",
4350 "sum a",
4351 None
4352 )
4353 .await
4354 .expect("mark stage1 succeeded a")
4355 );
4356
4357 let claim_b = runtime
4358 .try_claim_stage1_job(
4359 thread_b, owner, 101, 3600,
4360 64,
4361 )
4362 .await
4363 .expect("claim stage1 b");
4364 let token_b = match claim_b {
4365 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4366 other => panic!("unexpected stage1 claim outcome for b: {other:?}"),
4367 };
4368 assert!(
4369 runtime
4370 .mark_stage1_job_succeeded(
4371 thread_b,
4372 token_b.as_str(),
4373 101,
4374 "raw b",
4375 "sum b",
4376 None
4377 )
4378 .await
4379 .expect("mark stage1 succeeded b")
4380 );
4381
4382 let updated_rows = runtime
4383 .record_stage1_output_usage(&[thread_a, thread_a, thread_b, missing])
4384 .await
4385 .expect("record stage1 output usage");
4386 assert_eq!(updated_rows, 3);
4387
4388 let row_a =
4389 sqlx::query("SELECT usage_count, last_usage FROM stage1_outputs WHERE thread_id = ?")
4390 .bind(thread_a.to_string())
4391 .fetch_one(memory_pool(&runtime))
4392 .await
4393 .expect("load stage1 usage row a");
4394 let row_b =
4395 sqlx::query("SELECT usage_count, last_usage FROM stage1_outputs WHERE thread_id = ?")
4396 .bind(thread_b.to_string())
4397 .fetch_one(memory_pool(&runtime))
4398 .await
4399 .expect("load stage1 usage row b");
4400
4401 assert_eq!(
4402 row_a
4403 .try_get::<i64, _>("usage_count")
4404 .expect("usage_count a"),
4405 2
4406 );
4407 assert_eq!(
4408 row_b
4409 .try_get::<i64, _>("usage_count")
4410 .expect("usage_count b"),
4411 1
4412 );
4413
4414 let last_usage_a = row_a.try_get::<i64, _>("last_usage").expect("last_usage a");
4415 let last_usage_b = row_b.try_get::<i64, _>("last_usage").expect("last_usage b");
4416 assert_eq!(last_usage_a, last_usage_b);
4417 assert!(last_usage_a > 0);
4418
4419 let _ = tokio::fs::remove_dir_all(codex_home).await;
4420 }
4421
4422 #[tokio::test]
4423 async fn get_phase2_input_selection_prioritizes_usage_count_then_recent_usage() {
4424 let codex_home = unique_temp_dir();
4425 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4426 .await
4427 .expect("initialize runtime");
4428
4429 let now = Utc::now();
4430 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4431 let thread_a = stable_thread_id("00000000-0000-4000-8000-000000000001");
4432 let thread_b = stable_thread_id("00000000-0000-4000-8000-000000000002");
4433 let thread_c = stable_thread_id("00000000-0000-4000-8000-000000000003");
4434
4435 for (thread_id, workspace) in [
4436 (thread_a, "workspace-a"),
4437 (thread_b, "workspace-b"),
4438 (thread_c, "workspace-c"),
4439 ] {
4440 runtime
4441 .upsert_thread(&test_thread_metadata(
4442 &codex_home,
4443 thread_id,
4444 codex_home.join(workspace),
4445 ))
4446 .await
4447 .expect("upsert thread");
4448 }
4449
4450 for (thread_id, generated_at, summary) in [
4451 (thread_a, now - Duration::days(3), "summary-a"),
4452 (thread_b, now - Duration::days(2), "summary-b"),
4453 (thread_c, now - Duration::days(1), "summary-c"),
4454 ] {
4455 let source_updated_at = generated_at.timestamp();
4456 let claim = runtime
4457 .try_claim_stage1_job(
4458 thread_id,
4459 owner,
4460 source_updated_at,
4461 3600,
4462 64,
4463 )
4464 .await
4465 .expect("claim stage1");
4466 let ownership_token = match claim {
4467 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4468 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4469 };
4470 assert!(
4471 runtime
4472 .mark_stage1_job_succeeded(
4473 thread_id,
4474 ownership_token.as_str(),
4475 source_updated_at,
4476 &format!("raw-{summary}"),
4477 summary,
4478 None,
4479 )
4480 .await
4481 .expect("mark stage1 success"),
4482 "stage1 success should persist output"
4483 );
4484 }
4485
4486 for (thread_id, usage_count, last_usage) in [
4487 (thread_a, 5_i64, now - Duration::days(10)),
4488 (thread_b, 5_i64, now - Duration::days(1)),
4489 (thread_c, 1_i64, now - Duration::hours(1)),
4490 ] {
4491 sqlx::query(
4492 "UPDATE stage1_outputs SET usage_count = ?, last_usage = ? WHERE thread_id = ?",
4493 )
4494 .bind(usage_count)
4495 .bind(last_usage.timestamp())
4496 .bind(thread_id.to_string())
4497 .execute(memory_pool(&runtime))
4498 .await
4499 .expect("update usage metadata");
4500 }
4501
4502 let selection = runtime
4503 .get_phase2_input_selection(1, 30)
4504 .await
4505 .expect("load phase2 input selection");
4506
4507 assert_eq!(
4508 selection
4509 .iter()
4510 .map(|output| output.thread_id)
4511 .collect::<Vec<_>>(),
4512 vec![thread_b]
4513 );
4514
4515 let _ = tokio::fs::remove_dir_all(codex_home).await;
4516 }
4517
4518 #[tokio::test]
4519 async fn get_phase2_input_selection_excludes_stale_used_memories_but_keeps_fresh_never_used() {
4520 let codex_home = unique_temp_dir();
4521 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4522 .await
4523 .expect("initialize runtime");
4524
4525 let now = Utc::now();
4526 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4527 let thread_a = stable_thread_id("00000000-0000-4000-8000-000000000001");
4528 let thread_b = stable_thread_id("00000000-0000-4000-8000-000000000002");
4529 let thread_c = stable_thread_id("00000000-0000-4000-8000-000000000003");
4530
4531 for (thread_id, workspace) in [
4532 (thread_a, "workspace-a"),
4533 (thread_b, "workspace-b"),
4534 (thread_c, "workspace-c"),
4535 ] {
4536 runtime
4537 .upsert_thread(&test_thread_metadata(
4538 &codex_home,
4539 thread_id,
4540 codex_home.join(workspace),
4541 ))
4542 .await
4543 .expect("upsert thread");
4544 }
4545
4546 for (thread_id, generated_at, summary) in [
4547 (thread_a, now - Duration::days(40), "summary-a"),
4548 (thread_b, now - Duration::days(2), "summary-b"),
4549 (thread_c, now - Duration::days(50), "summary-c"),
4550 ] {
4551 let source_updated_at = generated_at.timestamp();
4552 let claim = runtime
4553 .try_claim_stage1_job(
4554 thread_id,
4555 owner,
4556 source_updated_at,
4557 3600,
4558 64,
4559 )
4560 .await
4561 .expect("claim stage1");
4562 let ownership_token = match claim {
4563 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4564 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4565 };
4566 assert!(
4567 runtime
4568 .mark_stage1_job_succeeded(
4569 thread_id,
4570 ownership_token.as_str(),
4571 source_updated_at,
4572 &format!("raw-{summary}"),
4573 summary,
4574 None,
4575 )
4576 .await
4577 .expect("mark stage1 success"),
4578 "stage1 success should persist output"
4579 );
4580 }
4581
4582 for (thread_id, usage_count, last_usage) in [
4583 (thread_a, Some(9_i64), Some(now - Duration::days(31))),
4584 (thread_b, None, None),
4585 (thread_c, Some(1_i64), Some(now - Duration::days(1))),
4586 ] {
4587 sqlx::query(
4588 "UPDATE stage1_outputs SET usage_count = ?, last_usage = ? WHERE thread_id = ?",
4589 )
4590 .bind(usage_count)
4591 .bind(last_usage.map(|value| value.timestamp()))
4592 .bind(thread_id.to_string())
4593 .execute(memory_pool(&runtime))
4594 .await
4595 .expect("update usage metadata");
4596 }
4597
4598 let selection = runtime
4599 .get_phase2_input_selection(3, 30)
4600 .await
4601 .expect("load phase2 input selection");
4602
4603 assert_eq!(
4604 selection
4605 .iter()
4606 .map(|output| output.thread_id)
4607 .collect::<Vec<_>>(),
4608 vec![thread_b, thread_c]
4609 );
4610
4611 let _ = tokio::fs::remove_dir_all(codex_home).await;
4612 }
4613
4614 #[tokio::test]
4615 async fn get_phase2_input_selection_prefers_recent_thread_updates_over_recent_generation() {
4616 let codex_home = unique_temp_dir();
4617 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4618 .await
4619 .expect("initialize runtime");
4620
4621 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4622 let older_thread =
4623 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("older thread id");
4624 let newer_thread =
4625 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("newer thread id");
4626
4627 for (thread_id, workspace) in [
4628 (older_thread, "workspace-older"),
4629 (newer_thread, "workspace-newer"),
4630 ] {
4631 runtime
4632 .upsert_thread(&test_thread_metadata(
4633 &codex_home,
4634 thread_id,
4635 codex_home.join(workspace),
4636 ))
4637 .await
4638 .expect("upsert thread");
4639 }
4640
4641 for (thread_id, source_updated_at, summary) in [
4642 (older_thread, 100_i64, "summary-older"),
4643 (newer_thread, 200_i64, "summary-newer"),
4644 ] {
4645 let claim = runtime
4646 .try_claim_stage1_job(
4647 thread_id,
4648 owner,
4649 source_updated_at,
4650 3600,
4651 64,
4652 )
4653 .await
4654 .expect("claim stage1");
4655 let ownership_token = match claim {
4656 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4657 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4658 };
4659 assert!(
4660 runtime
4661 .mark_stage1_job_succeeded(
4662 thread_id,
4663 ownership_token.as_str(),
4664 source_updated_at,
4665 &format!("raw-{summary}"),
4666 summary,
4667 None,
4668 )
4669 .await
4670 .expect("mark stage1 success"),
4671 "stage1 success should persist output"
4672 );
4673 }
4674
4675 sqlx::query("UPDATE stage1_outputs SET generated_at = ? WHERE thread_id = ?")
4676 .bind(300_i64)
4677 .bind(older_thread.to_string())
4678 .execute(memory_pool(&runtime))
4679 .await
4680 .expect("update older generated_at");
4681 sqlx::query("UPDATE stage1_outputs SET generated_at = ? WHERE thread_id = ?")
4682 .bind(150_i64)
4683 .bind(newer_thread.to_string())
4684 .execute(memory_pool(&runtime))
4685 .await
4686 .expect("update newer generated_at");
4687
4688 let selection = runtime
4689 .get_phase2_input_selection(1, 36_500)
4690 .await
4691 .expect("load phase2 input selection");
4692
4693 assert_eq!(selection.len(), 1);
4694 assert_eq!(selection[0].thread_id, newer_thread);
4695 assert_eq!(selection[0].source_updated_at.timestamp(), 200);
4696
4697 let _ = tokio::fs::remove_dir_all(codex_home).await;
4698 }
4699
4700 #[tokio::test]
4701 async fn prune_stage1_outputs_for_retention_prunes_stale_unselected_rows_only() {
4702 let codex_home = unique_temp_dir();
4703 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4704 .await
4705 .expect("initialize runtime");
4706
4707 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4708 let stale_unused =
4709 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("stale unused");
4710 let stale_used = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("stale used");
4711 let stale_selected =
4712 ThreadId::from_string(&Uuid::new_v4().to_string()).expect("stale selected");
4713 let fresh_used = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("fresh used");
4714
4715 for (thread_id, workspace) in [
4716 (stale_unused, "workspace-stale-unused"),
4717 (stale_used, "workspace-stale-used"),
4718 (stale_selected, "workspace-stale-selected"),
4719 (fresh_used, "workspace-fresh-used"),
4720 ] {
4721 runtime
4722 .upsert_thread(&test_thread_metadata(
4723 &codex_home,
4724 thread_id,
4725 codex_home.join(workspace),
4726 ))
4727 .await
4728 .expect("upsert thread");
4729 }
4730
4731 let now = Utc::now().timestamp();
4732 for (thread_id, source_updated_at, summary) in [
4733 (
4734 stale_unused,
4735 now - Duration::days(60).num_seconds(),
4736 "stale-unused",
4737 ),
4738 (
4739 stale_used,
4740 now - Duration::days(50).num_seconds(),
4741 "stale-used",
4742 ),
4743 (
4744 stale_selected,
4745 now - Duration::days(45).num_seconds(),
4746 "stale-selected",
4747 ),
4748 (
4749 fresh_used,
4750 now - Duration::days(10).num_seconds(),
4751 "fresh-used",
4752 ),
4753 ] {
4754 let claim = runtime
4755 .try_claim_stage1_job(
4756 thread_id,
4757 owner,
4758 source_updated_at,
4759 3600,
4760 64,
4761 )
4762 .await
4763 .expect("claim stage1");
4764 let ownership_token = match claim {
4765 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4766 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4767 };
4768 assert!(
4769 runtime
4770 .mark_stage1_job_succeeded(
4771 thread_id,
4772 ownership_token.as_str(),
4773 source_updated_at,
4774 &format!("raw-{summary}"),
4775 summary,
4776 None,
4777 )
4778 .await
4779 .expect("mark stage1 success"),
4780 "stage1 success should persist output"
4781 );
4782 }
4783
4784 sqlx::query(
4785 "UPDATE stage1_outputs SET usage_count = ?, last_usage = ? WHERE thread_id = ?",
4786 )
4787 .bind(3_i64)
4788 .bind(now - Duration::days(40).num_seconds())
4789 .bind(stale_used.to_string())
4790 .execute(memory_pool(&runtime))
4791 .await
4792 .expect("set stale used metadata");
4793 sqlx::query(
4794 "UPDATE stage1_outputs SET selected_for_phase2 = 1, selected_for_phase2_source_updated_at = source_updated_at WHERE thread_id = ?",
4795 )
4796 .bind(stale_selected.to_string())
4797 .execute(memory_pool(&runtime))
4798 .await
4799 .expect("mark selected for phase2");
4800 sqlx::query(
4801 "UPDATE stage1_outputs SET usage_count = ?, last_usage = ? WHERE thread_id = ?",
4802 )
4803 .bind(8_i64)
4804 .bind(now - Duration::days(2).num_seconds())
4805 .bind(fresh_used.to_string())
4806 .execute(memory_pool(&runtime))
4807 .await
4808 .expect("set fresh used metadata");
4809
4810 let before_jobs_count =
4811 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM jobs WHERE kind = 'memory_stage1'")
4812 .fetch_one(memory_pool(&runtime))
4813 .await
4814 .expect("count stage1 jobs before prune");
4815
4816 let pruned = runtime
4817 .prune_stage1_outputs_for_retention(30, 100)
4818 .await
4819 .expect("prune stage1 outputs");
4820 assert_eq!(pruned, 2);
4821
4822 let remaining = sqlx::query_scalar::<_, String>(
4823 "SELECT thread_id FROM stage1_outputs ORDER BY thread_id",
4824 )
4825 .fetch_all(memory_pool(&runtime))
4826 .await
4827 .expect("load remaining stage1 outputs");
4828 let mut expected_remaining = vec![fresh_used.to_string(), stale_selected.to_string()];
4829 expected_remaining.sort();
4830 assert_eq!(remaining, expected_remaining);
4831
4832 let after_jobs_count =
4833 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM jobs WHERE kind = 'memory_stage1'")
4834 .fetch_one(memory_pool(&runtime))
4835 .await
4836 .expect("count stage1 jobs after prune");
4837 assert_eq!(after_jobs_count, before_jobs_count);
4838
4839 let _ = tokio::fs::remove_dir_all(codex_home).await;
4840 }
4841
4842 #[tokio::test]
4843 async fn prune_stage1_outputs_for_retention_respects_batch_limit() {
4844 let codex_home = unique_temp_dir();
4845 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4846 .await
4847 .expect("initialize runtime");
4848
4849 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4850 let thread_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread a");
4851 let thread_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread b");
4852 let thread_c = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread c");
4853
4854 for (thread_id, workspace) in [
4855 (thread_a, "workspace-a"),
4856 (thread_b, "workspace-b"),
4857 (thread_c, "workspace-c"),
4858 ] {
4859 runtime
4860 .upsert_thread(&test_thread_metadata(
4861 &codex_home,
4862 thread_id,
4863 codex_home.join(workspace),
4864 ))
4865 .await
4866 .expect("upsert thread");
4867 }
4868
4869 let now = Utc::now().timestamp();
4870 for (thread_id, source_updated_at, summary) in [
4871 (thread_a, now - Duration::days(60).num_seconds(), "stale-a"),
4872 (thread_b, now - Duration::days(50).num_seconds(), "stale-b"),
4873 (thread_c, now - Duration::days(40).num_seconds(), "stale-c"),
4874 ] {
4875 let claim = runtime
4876 .try_claim_stage1_job(
4877 thread_id,
4878 owner,
4879 source_updated_at,
4880 3600,
4881 64,
4882 )
4883 .await
4884 .expect("claim stage1");
4885 let ownership_token = match claim {
4886 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4887 other => panic!("unexpected stage1 claim outcome: {other:?}"),
4888 };
4889 assert!(
4890 runtime
4891 .mark_stage1_job_succeeded(
4892 thread_id,
4893 ownership_token.as_str(),
4894 source_updated_at,
4895 &format!("raw-{summary}"),
4896 summary,
4897 None,
4898 )
4899 .await
4900 .expect("mark stage1 success"),
4901 "stage1 success should persist output"
4902 );
4903 }
4904
4905 let pruned = runtime
4906 .prune_stage1_outputs_for_retention(30, 2)
4907 .await
4908 .expect("prune stage1 outputs with limit");
4909 assert_eq!(pruned, 2);
4910
4911 let remaining_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stage1_outputs")
4912 .fetch_one(memory_pool(&runtime))
4913 .await
4914 .expect("count remaining stage1 outputs");
4915 assert_eq!(remaining_count, 1);
4916
4917 let _ = tokio::fs::remove_dir_all(codex_home).await;
4918 }
4919
4920 #[tokio::test]
4921 async fn mark_stage1_job_succeeded_enqueues_global_consolidation() {
4922 let codex_home = unique_temp_dir();
4923 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
4924 .await
4925 .expect("initialize runtime");
4926
4927 let thread_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id a");
4928 let thread_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id b");
4929 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
4930
4931 runtime
4932 .upsert_thread(&test_thread_metadata(
4933 &codex_home,
4934 thread_a,
4935 codex_home.join("workspace-a"),
4936 ))
4937 .await
4938 .expect("upsert thread a");
4939 runtime
4940 .upsert_thread(&test_thread_metadata(
4941 &codex_home,
4942 thread_b,
4943 codex_home.join("workspace-b"),
4944 ))
4945 .await
4946 .expect("upsert thread b");
4947
4948 let claim_a = runtime
4949 .try_claim_stage1_job(
4950 thread_a, owner, 100, 3600,
4951 64,
4952 )
4953 .await
4954 .expect("claim stage1 a");
4955 let token_a = match claim_a {
4956 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4957 other => panic!("unexpected stage1 claim outcome for thread a: {other:?}"),
4958 };
4959 assert!(
4960 runtime
4961 .mark_stage1_job_succeeded(
4962 thread_a,
4963 token_a.as_str(),
4964 100,
4965 "raw-a",
4966 "summary-a",
4967 None,
4968 )
4969 .await
4970 .expect("mark stage1 succeeded a"),
4971 "stage1 success should persist output for thread a"
4972 );
4973
4974 let claim_b = runtime
4975 .try_claim_stage1_job(
4976 thread_b, owner, 101, 3600,
4977 64,
4978 )
4979 .await
4980 .expect("claim stage1 b");
4981 let token_b = match claim_b {
4982 Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
4983 other => panic!("unexpected stage1 claim outcome for thread b: {other:?}"),
4984 };
4985 assert!(
4986 runtime
4987 .mark_stage1_job_succeeded(
4988 thread_b,
4989 token_b.as_str(),
4990 101,
4991 "raw-b",
4992 "summary-b",
4993 None,
4994 )
4995 .await
4996 .expect("mark stage1 succeeded b"),
4997 "stage1 success should persist output for thread b"
4998 );
4999
5000 let claim = runtime
5001 .try_claim_global_phase2_job(owner, 3600)
5002 .await
5003 .expect("claim global consolidation");
5004 let input_watermark = match claim {
5005 Phase2JobClaimOutcome::Claimed {
5006 input_watermark, ..
5007 } => input_watermark,
5008 other => panic!("unexpected global consolidation claim outcome: {other:?}"),
5009 };
5010 assert_eq!(input_watermark, 101);
5011
5012 let _ = tokio::fs::remove_dir_all(codex_home).await;
5013 }
5014
5015 #[tokio::test]
5016 async fn phase2_global_lock_allows_only_one_fresh_runner() {
5017 let codex_home = unique_temp_dir();
5018 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
5019 .await
5020 .expect("initialize runtime");
5021
5022 runtime
5023 .enqueue_global_consolidation(200)
5024 .await
5025 .expect("enqueue global consolidation");
5026
5027 let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner a");
5028 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner b");
5029
5030 let running_claim = runtime
5031 .try_claim_global_phase2_job(owner_a, 3600)
5032 .await
5033 .expect("claim global lock");
5034 assert!(
5035 matches!(running_claim, Phase2JobClaimOutcome::Claimed { .. }),
5036 "first owner should claim global lock"
5037 );
5038
5039 let second_claim = runtime
5040 .try_claim_global_phase2_job(owner_b, 3600)
5041 .await
5042 .expect("claim global lock from second owner");
5043 assert_eq!(second_claim, Phase2JobClaimOutcome::SkippedRunning);
5044
5045 let _ = tokio::fs::remove_dir_all(codex_home).await;
5046 }
5047
5048 #[tokio::test]
5049 async fn phase2_global_lock_creates_missing_job_row() {
5050 let codex_home = unique_temp_dir();
5051 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
5052 .await
5053 .expect("initialize runtime");
5054
5055 let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner a");
5056 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner b");
5057
5058 let claim = runtime
5059 .try_claim_global_phase2_job(owner_a, 3_600)
5060 .await
5061 .expect("claim global phase2 lock");
5062 let ownership_token = match claim {
5063 Phase2JobClaimOutcome::Claimed {
5064 ownership_token,
5065 input_watermark,
5066 } => {
5067 assert_eq!(input_watermark, 0);
5068 ownership_token
5069 }
5070 other => panic!("unexpected phase2 lock claim outcome: {other:?}"),
5071 };
5072
5073 let second_claim = runtime
5074 .try_claim_global_phase2_job(owner_b, 3_600)
5075 .await
5076 .expect("claim global phase2 lock from second owner");
5077 assert_eq!(second_claim, Phase2JobClaimOutcome::SkippedRunning);
5078
5079 assert!(
5080 runtime
5081 .mark_global_phase2_job_succeeded(
5082 ownership_token.as_str(),
5083 0,
5084 &[]
5085 )
5086 .await
5087 .expect("mark phase2 lock success")
5088 );
5089 let claim_after_success = runtime
5090 .try_claim_global_phase2_job(owner_b, 3_600)
5091 .await
5092 .expect("claim global phase2 lock after success");
5093 assert_eq!(claim_after_success, Phase2JobClaimOutcome::SkippedCooldown);
5094
5095 let _ = tokio::fs::remove_dir_all(codex_home).await;
5096 }
5097
5098 #[tokio::test]
5099 async fn phase2_global_lock_stale_lease_allows_takeover() {
5100 let codex_home = unique_temp_dir();
5101 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
5102 .await
5103 .expect("initialize runtime");
5104
5105 runtime
5106 .enqueue_global_consolidation(300)
5107 .await
5108 .expect("enqueue global consolidation");
5109
5110 let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner a");
5111 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner b");
5112
5113 let initial_claim = runtime
5114 .try_claim_global_phase2_job(owner_a, 3600)
5115 .await
5116 .expect("claim initial global lock");
5117 let token_a = match initial_claim {
5118 Phase2JobClaimOutcome::Claimed {
5119 ownership_token, ..
5120 } => ownership_token,
5121 other => panic!("unexpected initial claim outcome: {other:?}"),
5122 };
5123
5124 sqlx::query("UPDATE jobs SET lease_until = ? WHERE kind = ? AND job_key = ?")
5125 .bind(Utc::now().timestamp() - 1)
5126 .bind("memory_consolidate_global")
5127 .bind("global")
5128 .execute(memory_pool(&runtime))
5129 .await
5130 .expect("expire global consolidation lease");
5131
5132 let takeover_claim = runtime
5133 .try_claim_global_phase2_job(owner_b, 3600)
5134 .await
5135 .expect("claim stale global lock");
5136 let (token_b, input_watermark) = match takeover_claim {
5137 Phase2JobClaimOutcome::Claimed {
5138 ownership_token,
5139 input_watermark,
5140 } => (ownership_token, input_watermark),
5141 other => panic!("unexpected takeover claim outcome: {other:?}"),
5142 };
5143 assert_ne!(token_a, token_b);
5144 assert_eq!(input_watermark, 300);
5145
5146 assert_eq!(
5147 runtime
5148 .mark_global_phase2_job_succeeded(
5149 token_a.as_str(),
5150 300,
5151 &[]
5152 )
5153 .await
5154 .expect("mark stale owner success result"),
5155 false,
5156 "stale owner should lose finalization ownership after takeover"
5157 );
5158 assert!(
5159 runtime
5160 .mark_global_phase2_job_succeeded(
5161 token_b.as_str(),
5162 300,
5163 &[]
5164 )
5165 .await
5166 .expect("mark takeover owner success"),
5167 "takeover owner should finalize consolidation"
5168 );
5169
5170 let _ = tokio::fs::remove_dir_all(codex_home).await;
5171 }
5172
5173 #[tokio::test]
5174 async fn enqueue_global_consolidation_keeps_phase2_input_watermark_monotonic() {
5175 let codex_home = unique_temp_dir();
5176 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
5177 .await
5178 .expect("initialize runtime");
5179
5180 runtime
5181 .enqueue_global_consolidation(500)
5182 .await
5183 .expect("enqueue initial consolidation");
5184 let owner_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner a");
5185 let claim_a = runtime
5186 .try_claim_global_phase2_job(owner_a, 3_600)
5187 .await
5188 .expect("claim initial consolidation");
5189 let token_a = match claim_a {
5190 Phase2JobClaimOutcome::Claimed {
5191 ownership_token,
5192 input_watermark,
5193 } => {
5194 assert_eq!(input_watermark, 500);
5195 ownership_token
5196 }
5197 other => panic!("unexpected initial phase2 claim outcome: {other:?}"),
5198 };
5199 assert!(
5200 runtime
5201 .mark_global_phase2_job_succeeded(
5202 token_a.as_str(),
5203 500,
5204 &[]
5205 )
5206 .await
5207 .expect("mark initial phase2 success"),
5208 "initial phase2 success should finalize"
5209 );
5210
5211 runtime
5212 .enqueue_global_consolidation(400)
5213 .await
5214 .expect("enqueue lower-watermark consolidation");
5215
5216 age_phase2_success_beyond_cooldown(&runtime).await;
5217 let owner_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner b");
5218 let claim_b = runtime
5219 .try_claim_global_phase2_job(owner_b, 3_600)
5220 .await
5221 .expect("claim lower-watermark consolidation");
5222 match claim_b {
5223 Phase2JobClaimOutcome::Claimed {
5224 input_watermark, ..
5225 } => {
5226 assert!(
5227 input_watermark > 500,
5228 "lower-watermark enqueue should still advance the bookkeeping watermark"
5229 );
5230 }
5231 other => panic!("unexpected lower-watermark phase2 claim outcome: {other:?}"),
5232 }
5233
5234 let _ = tokio::fs::remove_dir_all(codex_home).await;
5235 }
5236
5237 #[tokio::test]
5238 async fn phase2_failure_fallback_updates_unowned_running_job() {
5239 let codex_home = unique_temp_dir();
5240 let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string())
5241 .await
5242 .expect("initialize runtime");
5243
5244 runtime
5245 .enqueue_global_consolidation(400)
5246 .await
5247 .expect("enqueue global consolidation");
5248
5249 let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner");
5250 let claim = runtime
5251 .try_claim_global_phase2_job(owner, 3_600)
5252 .await
5253 .expect("claim global consolidation");
5254 let ownership_token = match claim {
5255 Phase2JobClaimOutcome::Claimed {
5256 ownership_token, ..
5257 } => ownership_token,
5258 other => panic!("unexpected claim outcome: {other:?}"),
5259 };
5260
5261 sqlx::query("UPDATE jobs SET ownership_token = NULL WHERE kind = ? AND job_key = ?")
5262 .bind("memory_consolidate_global")
5263 .bind("global")
5264 .execute(memory_pool(&runtime))
5265 .await
5266 .expect("clear ownership token");
5267
5268 assert_eq!(
5269 runtime
5270 .mark_global_phase2_job_failed(
5271 ownership_token.as_str(),
5272 "lost",
5273 3_600
5274 )
5275 .await
5276 .expect("mark phase2 failed with strict ownership"),
5277 false,
5278 "strict failure update should not match unowned running job"
5279 );
5280 assert!(
5281 runtime
5282 .mark_global_phase2_job_failed_if_unowned(
5283 ownership_token.as_str(),
5284 "lost",
5285 3_600
5286 )
5287 .await
5288 .expect("fallback failure update should match unowned running job"),
5289 "fallback failure update should transition the unowned running job"
5290 );
5291
5292 let claim = runtime
5293 .try_claim_global_phase2_job(ThreadId::new(), 3_600)
5294 .await
5295 .expect("claim after fallback failure");
5296 assert_eq!(claim, Phase2JobClaimOutcome::SkippedRetryUnavailable);
5297
5298 let _ = tokio::fs::remove_dir_all(codex_home).await;
5299 }
5300}