Skip to main content

codex_state/runtime/
memories.rs

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/// Store for generated memory state and memory extraction/consolidation jobs.
27#[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    /// Deletes all persisted memory state in one transaction.
43    ///
44    /// This removes every `stage1_outputs` row and all `jobs` rows for the
45    /// stage-1 (`memory_stage1`) and phase-2 (`memory_consolidate_global`)
46    /// memory pipelines.
47    pub async fn clear_memory_data(&self) -> anyhow::Result<()> {
48        clear_memory_data_in_pool(self.pool.as_ref()).await
49    }
50
51    /// Record usage for cited stage-1 outputs.
52    ///
53    /// Each thread id increments `usage_count` by one and sets `last_usage` to
54    /// the current Unix timestamp. Missing rows are ignored.
55    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    /// Selects and claims stage-1 startup jobs for stale threads.
134    ///
135    /// Query behavior:
136    /// - starts from `threads` filtered to active threads and allowed sources
137    ///   (`push_thread_filters`)
138    /// - excludes threads with `memory_mode != 'enabled'`
139    /// - excludes the current thread id
140    /// - keeps only threads whose millisecond `updated_at` is in the age window
141    /// - checks memory staleness against the memories DB
142    /// - orders by `updated_at_ms DESC` and applies `scan_limit` to bound
143    ///   state-DB work before probing the memories DB
144    ///
145    /// For each selected thread, this function calls [`Self::try_claim_stage1_job`]
146    /// with `source_updated_at = thread.updated_at.timestamp()` and returns up to
147    /// `max_claimed` successful claims.
148    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            /*include_thread_id_tiebreaker*/ 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    /// Lists the most recent non-empty stage-1 outputs for global consolidation.
327    ///
328    /// Query behavior:
329    /// - filters out rows where both `raw_memory` and `rollout_summary` are blank
330    /// - hydrates thread `cwd`, `rollout_path`, and `git_branch` from the state DB
331    /// - filters out missing or non-enabled threads
332    /// - orders by `source_updated_at DESC, thread_id DESC`
333    /// - returns the first `n` visible outputs
334    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    /// Prunes stale stage-1 outputs while preserving the latest phase-2
373    /// baseline and stage-1 job watermarks.
374    ///
375    /// Query behavior:
376    /// - considers only rows with `selected_for_phase2 = 0`
377    /// - keeps recency as `COALESCE(last_usage, source_updated_at)`
378    /// - removes rows older than `max_unused_days`
379    /// - prunes at most `limit` rows ordered from stalest to newest
380    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    /// Returns the current phase-2 input set.
416    ///
417    /// Query behavior:
418    /// - current selection keeps only non-empty stage-1 outputs whose
419    ///   `last_usage` is within `max_unused_days`, or whose
420    ///   `source_updated_at` is within that window when the memory has never
421    ///   been used
422    /// - eligible rows are ranked by `usage_count DESC`,
423    ///   `COALESCE(last_usage, source_updated_at) DESC`, `source_updated_at DESC`,
424    ///   `thread_id DESC`
425    /// - the selected top-N rows are returned in stable `thread_id ASC` order
426    ///
427    /// The returned rows are the complete Phase 2 filesystem input. Phase 2
428    /// syncs these rows directly; deletions are represented by the workspace
429    /// diff against the previous successful memory baseline.
430    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    /// Marks a thread as polluted and enqueues phase-2 forgetting when the
588    /// thread participated in the last successful phase-2 baseline.
589    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    /// Attempts to claim a stage-1 job for a thread at `source_updated_at`.
626    ///
627    /// Claim semantics:
628    /// - skips as up-to-date when either:
629    ///   - `stage1_outputs.source_updated_at >= source_updated_at`, or
630    ///   - `jobs.last_success_watermark >= source_updated_at`
631    /// - inserts or updates a `jobs` row to `running` only when:
632    ///   - global running job count for `memory_stage1` is below `max_running_jobs`
633    ///   - existing row is not actively running with a valid lease
634    ///   - retry backoff (if present) has elapsed, or `source_updated_at` advanced
635    ///   - retries remain, or `source_updated_at` advanced (which resets retries)
636    ///
637    /// The update path refreshes ownership token, lease, and `input_watermark`.
638    /// If claiming fails, a follow-up read maps current row state to a precise
639    /// skip outcome (`SkippedRunning`, `SkippedRetryBackoff`, or
640    /// `SkippedRetryExhausted`).
641    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    /// Marks a claimed stage-1 job successful and upserts generated output.
816    ///
817    /// Transaction behavior:
818    /// - updates `jobs` only for the currently owned running row
819    /// - sets `status='done'` and `last_success_watermark = input_watermark`
820    /// - upserts `stage1_outputs` for the thread, replacing existing output only
821    ///   when `source_updated_at` is newer or equal
822    /// - preserves any existing `selected_for_phase2` baseline until the next
823    ///   successful phase-2 run rewrites the baseline selection, including the
824    ///   snapshot timestamp chosen during that run
825    /// - persists optional `rollout_slug` for rollout summary artifact naming
826    /// - enqueues/advances the global phase-2 job watermark using
827    ///   `source_updated_at`
828    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    /// Marks a claimed stage-1 job successful when extraction produced no output.
902    ///
903    /// Transaction behavior:
904    /// - updates `jobs` only for the currently owned running row
905    /// - sets `status='done'` and `last_success_watermark = input_watermark`
906    /// - deletes any existing `stage1_outputs` row for the thread
907    /// - enqueues/advances the global phase-2 job watermark using the claimed
908    ///   `input_watermark` only when deleting an existing `stage1_outputs` row
909    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    /// Marks a claimed stage-1 job as failed and schedules retry backoff.
978    ///
979    /// Query behavior:
980    /// - updates only the owned running row for `(kind='memory_stage1', job_key)`
981    /// - sets `status='error'`, clears lease, writes `last_error`
982    /// - decrements `retry_remaining`
983    /// - sets `retry_at = now + retry_delay_seconds`
984    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    /// Enqueues or advances the global phase-2 consolidation job watermark.
1023    ///
1024    /// The underlying upsert keeps the job `running` when already running, resets
1025    /// `pending/error` jobs to `pending`, and advances `input_watermark` as
1026    /// bookkeeping even when `source_updated_at` is older than prior maxima.
1027    /// Phase 2 does not use this watermark as a dirty check; git workspace diffing
1028    /// decides whether consolidation work exists after the lock is claimed.
1029    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    /// Attempts to claim the global phase-2 consolidation lock.
1034    ///
1035    /// Claim semantics:
1036    /// - reads the singleton global job row (`kind='memory_consolidate_global'`)
1037    /// - creates and claims the singleton row when it does not exist yet
1038    /// - does not use DB watermarks to decide whether Phase 2 has work; git workspace
1039    ///   dirtiness is the source of truth after the caller materializes inputs
1040    /// - returns `SkippedRetryUnavailable` when retry backoff is active
1041    /// - returns `SkippedRunning` when an active running lease exists
1042    /// - returns `SkippedCooldown` when the latest successful run finished
1043    ///   within the phase-2 success cooldown
1044    /// - otherwise updates the row to `running`, sets ownership + lease, and
1045    ///   returns `Claimed`
1046    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    /// Extends the lease for an owned running phase-2 global job.
1179    ///
1180    /// Query behavior:
1181    /// - `UPDATE jobs SET lease_until = ?` for the singleton global row
1182    /// - requires `status='running'` and matching `ownership_token`
1183    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    /// Marks the owned running global phase-2 job as succeeded.
1210    ///
1211    /// Query behavior:
1212    /// - updates only the owned running singleton global row
1213    /// - sets `status='done'`, clears lease/errors
1214    /// - advances `last_success_watermark` to
1215    ///   `max(existing_last_success_watermark, completed_watermark)`
1216    /// - rewrites `selected_for_phase2` so only the exact selected stage-1
1217    ///   snapshots remain marked as part of the latest successful phase-2
1218    ///   selection, and persists each selected snapshot's `source_updated_at`
1219    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    /// Marks the owned running global phase-2 job as failed and schedules retry.
1269    ///
1270    /// Query behavior:
1271    /// - updates only the owned running singleton global row
1272    /// - sets `status='error'`, clears lease
1273    /// - writes failure reason and retry time
1274    /// - decrements `retry_remaining` without going below zero
1275    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    /// Fallback failure finalization when ownership may have been lost.
1311    ///
1312    /// Query behavior:
1313    /// - same state transition as [`Self::mark_global_phase2_job_failed`]
1314    /// - matches rows where `ownership_token = ? OR ownership_token IS NULL`
1315    /// - allows recovering a stuck unowned running row
1316    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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
1724                /*max_running_jobs*/ 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                    /*source_updated_at*/ 100,
1739                    "raw",
1740                    "sum",
1741                    /*rollout_slug*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
1751                /*max_running_jobs*/ 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, /*source_updated_at*/ 101, /*lease_seconds*/ 3600,
1760                /*max_running_jobs*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
1791                /*max_running_jobs*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
1800                /*max_running_jobs*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
1815                /*max_running_jobs*/ 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, /*source_updated_at*/ 100,
1857                        /*lease_seconds*/ 3_600, /*max_running_jobs*/ 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, /*source_updated_at*/ 100,
1931                        /*lease_seconds*/ 3_600, /*max_running_jobs*/ 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, /*source_updated_at*/ 101,
1940                        /*lease_seconds*/ 3_600, /*max_running_jobs*/ 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(&current)
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(&current)
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                /*lease_seconds*/ 3600,
2099                /*max_running_jobs*/ 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                    /*rollout_slug*/ 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(&current)
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                /*lease_seconds*/ 3600,
2293                /*max_running_jobs*/ 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                    /*rollout_slug*/ 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(&current)
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                        /*rollout_slug*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
2605                /*max_running_jobs*/ 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                    /*source_updated_at*/ 100,
2619                    "raw",
2620                    "sum",
2621                    /*rollout_slug*/ 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, /*lease_seconds*/ 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(/*n*/ 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(/*n*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
2734                /*max_running_jobs*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
2766                /*max_running_jobs*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
2809                /*max_running_jobs*/ 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                    /*source_updated_at*/ 100,
2823                    "raw",
2824                    "sum",
2825                    /*rollout_slug*/ 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, /*lease_seconds*/ 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, /*source_updated_at*/ 101, /*lease_seconds*/ 3600,
2859                /*max_running_jobs*/ 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, /*lease_seconds*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3_600,
2934                    /*max_running_jobs*/ 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                        /*retry_delay_seconds*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3_600,
2963                /*max_running_jobs*/ 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, /*source_updated_at*/ 101, /*lease_seconds*/ 3_600,
2975                /*max_running_jobs*/ 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(/*input_watermark*/ 100)
3019            .await
3020            .expect("enqueue global consolidation");
3021
3022        let claim = runtime
3023            .try_claim_global_phase2_job(owner, /*lease_seconds*/ 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, /*lease_seconds*/ 3600)
3043            .await
3044            .expect("claim phase2 after success");
3045        assert_eq!(claim_after_success, Phase2JobClaimOutcome::SkippedCooldown);
3046
3047        runtime
3048            .enqueue_global_consolidation(/*input_watermark*/ 101)
3049            .await
3050            .expect("enqueue global consolidation after success");
3051        let claim_after_enqueue = runtime
3052            .try_claim_global_phase2_job(owner, /*lease_seconds*/ 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, /*lease_seconds*/ 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(/*input_watermark*/ 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, /*lease_seconds*/ 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                        /*retry_delay_seconds*/ 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, /*lease_seconds*/ 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                /*source_updated_at*/ 100,
3171                /*lease_seconds*/ 3600,
3172                /*max_running_jobs*/ 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                    /*source_updated_at*/ 100,
3186                    "raw memory a",
3187                    "summary a",
3188                    /*rollout_slug*/ 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                /*source_updated_at*/ 101,
3200                /*lease_seconds*/ 3600,
3201                /*max_running_jobs*/ 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                    /*source_updated_at*/ 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(/*n*/ 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(/*n*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
3340                    /*max_running_jobs*/ 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                        /*source_updated_at*/ 100,
3354                        "raw memory",
3355                        "summary",
3356                        /*rollout_slug*/ 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(/*n*/ 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, /*lease_seconds*/ 3600,
3414                    /*max_running_jobs*/ 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, /*lease_seconds*/ 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(/*n*/ 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(/*n*/ 2, /*max_unused_days*/ 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, /*lease_seconds*/ 3600,
3520                    /*max_running_jobs*/ 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                        /*rollout_slug*/ 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, /*lease_seconds*/ 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(/*n*/ 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(/*n*/ 2, /*max_unused_days*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
3608                /*max_running_jobs*/ 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                    /*source_updated_at*/ 100,
3622                    "raw",
3623                    "summary",
3624                    /*rollout_slug*/ 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, /*lease_seconds*/ 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(/*n*/ 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, /*lease_seconds*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
3697                /*max_running_jobs*/ 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                    /*source_updated_at*/ 100,
3711                    "raw",
3712                    "summary",
3713                    /*rollout_slug*/ 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, /*lease_seconds*/ 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(/*n*/ 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, /*lease_seconds*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
3792                /*max_running_jobs*/ 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                    /*source_updated_at*/ 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, /*lease_seconds*/ 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(/*n*/ 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, /*source_updated_at*/ 101, /*lease_seconds*/ 3600,
3845                /*max_running_jobs*/ 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                    /*source_updated_at*/ 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(/*n*/ 1, /*max_unused_days*/ 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, /*lease_seconds*/ 3600,
3928                    /*max_running_jobs*/ 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, /*lease_seconds*/ 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(/*n*/ 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, /*lease_seconds*/ 3600,
3994                    /*max_running_jobs*/ 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(/*n*/ 2, /*max_unused_days*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
4054                /*max_running_jobs*/ 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                    /*source_updated_at*/ 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, /*lease_seconds*/ 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(/*n*/ 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, /*source_updated_at*/ 101, /*lease_seconds*/ 3600,
4107                /*max_running_jobs*/ 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                    /*source_updated_at*/ 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, /*lease_seconds*/ 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(/*n*/ 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(/*n*/ 1, /*max_unused_days*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
4204                /*max_running_jobs*/ 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                    /*source_updated_at*/ 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, /*lease_seconds*/ 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(/*n*/ 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, /*source_updated_at*/ 101, /*lease_seconds*/ 3600,
4247                /*max_running_jobs*/ 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                    /*source_updated_at*/ 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(/*n*/ 1, /*max_unused_days*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
4335                /*max_running_jobs*/ 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                    /*source_updated_at*/ 100,
4349                    "raw a",
4350                    "sum a",
4351                    /*rollout_slug*/ 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, /*source_updated_at*/ 101, /*lease_seconds*/ 3600,
4360                /*max_running_jobs*/ 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                    /*source_updated_at*/ 101,
4374                    "raw b",
4375                    "sum b",
4376                    /*rollout_slug*/ 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                    /*lease_seconds*/ 3600,
4462                    /*max_running_jobs*/ 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                        /*rollout_slug*/ 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(/*n*/ 1, /*max_unused_days*/ 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                    /*lease_seconds*/ 3600,
4558                    /*max_running_jobs*/ 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                        /*rollout_slug*/ 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(/*n*/ 3, /*max_unused_days*/ 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                    /*lease_seconds*/ 3600,
4651                    /*max_running_jobs*/ 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                        /*rollout_slug*/ 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(/*n*/ 1, /*max_unused_days*/ 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                    /*lease_seconds*/ 3600,
4760                    /*max_running_jobs*/ 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                        /*rollout_slug*/ 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(/*max_unused_days*/ 30, /*limit*/ 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                    /*lease_seconds*/ 3600,
4881                    /*max_running_jobs*/ 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                        /*rollout_slug*/ 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(/*max_unused_days*/ 30, /*limit*/ 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, /*source_updated_at*/ 100, /*lease_seconds*/ 3600,
4951                /*max_running_jobs*/ 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                    /*source_updated_at*/ 100,
4965                    "raw-a",
4966                    "summary-a",
4967                    /*rollout_slug*/ 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, /*source_updated_at*/ 101, /*lease_seconds*/ 3600,
4977                /*max_running_jobs*/ 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                    /*source_updated_at*/ 101,
4991                    "raw-b",
4992                    "summary-b",
4993                    /*rollout_slug*/ 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, /*lease_seconds*/ 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(/*input_watermark*/ 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, /*lease_seconds*/ 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, /*lease_seconds*/ 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, /*lease_seconds*/ 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, /*lease_seconds*/ 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                    /*completed_watermark*/ 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, /*lease_seconds*/ 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(/*input_watermark*/ 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, /*lease_seconds*/ 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, /*lease_seconds*/ 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                    /*completed_watermark*/ 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                    /*completed_watermark*/ 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(/*input_watermark*/ 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, /*lease_seconds*/ 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                    /*completed_watermark*/ 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(/*input_watermark*/ 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, /*lease_seconds*/ 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(/*input_watermark*/ 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, /*lease_seconds*/ 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                    /*retry_delay_seconds*/ 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                    /*retry_delay_seconds*/ 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(), /*lease_seconds*/ 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}