Bug: UpdatedAt pagination skips valid sessions — sort key (mtime) and cursor comparison key diverge in should_skip()

Resolved 💬 8 comments Opened Apr 3, 2026 by aamodbhatt Closed Apr 3, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

When listing threads sorted by UpdatedAt, the pagination logic in codex-rs/rollout/src/list.rs can silently skip valid sessions on page 2+. The root cause is a mismatch between the sort key used to order candidates (file mtime) and the key used in the cursor anchor comparison (should_skip()), which breaks the transitivity assumption the comparison relies on.

Root Cause

File: codex-rs/rollout/src/list.rs
Functions: traverse_directories_for_paths_updated(), traverse_flat_paths_updated(), AnchorState::should_skip(), build_next_cursor()

Candidates are sorted by mtime descending:

candidates.sort_by_key(|candidate| {
    let ts = candidate.updated_at.unwrap_or(OffsetDateTime::UNIX_EPOCH);
    (Reverse(ts), Reverse(candidate.id))
});

should_skip() then compares each candidate's (ts, id) against the cursor anchor, assuming that once a candidate with ts < cursor.ts is seen, all subsequent candidates also satisfy that condition (i.e. the sequence is transitive). This holds only if the sort key and the comparison key are the same value.

But build_next_cursor() for UpdatedAt builds the cursor from last.updated_at parsed from the file contents — not directly from the mtime used during sorting. If those two values differ for any session (e.g. metadata written slightly after the file was last touched, clock skew, or a backfill operation), the cursor anchor and the sort order are no longer aligned.

Concrete Example

  • Session A: mtime = 2025-01-03T15:30:00Z, updated_at in file = 2025-01-03T15:29:58Z
  • Session B: mtime = 2025-01-02T14:00:00Z, updated_at in file = 2025-01-02T14:00:00Z

Page 1 returns [A]. Cursor is built from A's updated_at: (2025-01-03T15:29:58Z, A_uuid).

Page 2 anchor scan hits B:

  • should_skip(2025-01-02T14:00:00Z, B_uuid)ts < cursor.tstrue, skip
  • But B has never been returned — it should be on this page.

B is permanently invisible in paginated results.

Impact

  • Paginated thread/list with sort=UpdatedAt can return incomplete results
  • Sessions are silently dropped — no error, no indication pages are incomplete
  • Affects both traverse_directories_for_paths_updated() and traverse_flat_paths_updated()
  • Related but distinct from #16545 (scan cap bug, same file)

Proposed Fix

The sort key and cursor comparison key must be the same value. Two options:

  1. Build the cursor from the same mtime used during sort — store the raw mtime on the candidate struct and use it in build_next_cursor() instead of re-parsing updated_at from file contents
  2. Sort by the parsed updated_at field and ensure that field is always populated before sorting, so the sort key and cursor key are guaranteed identical

View original on GitHub ↗

8 Comments

github-actions[bot] contributor · 3 months ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #16624

Powered by Codex Action

aamodbhatt · 3 months ago

Not a duplicate. #16624 is a user report about the VS Code extension task picker dropping sessions client-side. This issue is about a server-side logic bug in codex-rs/rollout/src/list.rs — specifically that should_skip() in the UpdatedAt pagination path can skip valid sessions because the sort key (mtime) and the cursor anchor comparison key (updated_at parsed from file contents) are not guaranteed to be the same value, breaking the transitivity assumption the anchor scan relies on. The bug exists in the Rust pagination code regardless of which client is consuming the API.

etraut-openai contributor · 3 months ago

Can you provide more details as requested in the bug report template? Which Codex client are you using (TUI, app, extension)? Which version? What are the repro steps?

I appreciate your analysis of the problem, but it's useful if you start with the actual description of the problem so we can assess user impact (and therefore priority).

aamodbhatt · 3 months ago

Version: codex-cli 0.118.0
Platform: Darwin 25.3.0 arm64 arm

What issue are you seeing?
When paginating thread/list sorted by UpdatedAt, valid sessions are silently skipped on page 2+. No error is returned — the page just comes back missing sessions that should appear.

What steps can reproduce the bug?

  1. Create two sessions A and B where A has a newer mtime than B
  2. Trigger a metadata backfill or any write that causes A's updated_at value in the file to differ slightly from its mtime (this happens naturally via reconcile_rollout())
  3. Fetch page 1 of thread/list with sort=UpdatedAt and limit=1 — A is returned, cursor is built from A's updated_at value
  4. Fetch page 2 with that cursor
  5. B gets evaluated by should_skip() using its mtime. Since B.mtime < cursor.ts (A's updated_at), it is skipped even though it was never returned

What is the expected behavior?
B should appear on page 2.

Additional information:
Root cause is in codex-rs/rollout/src/list.rs — the sort key (mtime) and cursor comparison key (updated_at parsed from file) are not always the same value, breaking the transitivity assumption in should_skip(). Same applies to traverse_flat_paths_updated().

etraut-openai contributor · 3 months ago

Are you using the TUI, or are you using some other custom app server client?

aamodbhatt · 3 months ago

TUI, no custom client.

etraut-openai contributor · 3 months ago

Can you provide a set of steps to reproduce the bug in the TUI? The steps you list above reference internal implementation details and code paths, but I'm interested in understanding what steps a user would take to see this bug.

etraut-openai contributor · 3 months ago

I've looked into your analysis, and I don't think it pans out.

UpdatedAt sorting, anchor checks, and the fallback ThreadItem.updated_at all come from file mtime in list.rs:500, and read_head_summary() never fills summary.updated_at, so the claimed “mtime sort vs parsed updated_at cursor” split isn’t actually there. There's also a test (test_updated_at_uses_file_mtime) that specifically validates this condition.