Distinguish thread listing failures from exhausted history (#51602)

## Why

DB-only listing and pagination failures must not look like exhausted history. This hardens the DB-only listing added in https://github.com/openai/codex/pull/18502 and page refilling added in https://github.com/openai/codex/pull/7509.

## What changed

- Return JSON-RPC error `-32603` when `useStateDbOnly: true` cannot query the local state database, including requests with an empty `cwd` array.
- Track all cursors encountered while filling a page and return `-32603` for repeated cursors, including on full pages.
- Document that callers can retry these errors; a successful response with `nextCursor: null` still indicates exhaustion. Default scan-and-repair requests retain their filesystem fallback.

## Testing

Add regression tests for missing or mismatched state databases versus healthy empty history, with active and archived listings and null or empty `cwd` filters. Cover repeated cursors during page refilling and on full pages using the in-memory thread store.

GitOrigin-RevId: 09653a84fc65f47316d8bbae9e4cf3dd8ce9948a
This commit is contained in:
Brent Traut
2026-10-07 06:34:22 +00:00
committed by copyberry
parent 1fbe15c962
commit 406eb53c07
6 changed files with 239 additions and 15 deletions
@@ -5503,7 +5503,7 @@ impl ThreadRequestProcessor {
excluded_thread_ids,
} = filters;
let mut cursor_obj = cursor;
let mut last_cursor = cursor_obj.clone();
let mut seen_cursors: HashSet<String> = cursor_obj.iter().cloned().collect();
let mut remaining = requested_page_size;
let mut items = Vec::with_capacity(requested_page_size);
let mut next_cursor: Option<String> = None;
@@ -5580,20 +5580,16 @@ impl ThreadRequestProcessor {
remaining = requested_page_size.saturating_sub(items.len());
next_cursor = page.next_cursor;
if remaining == 0 {
break;
}
let Some(cursor_val) = next_cursor.clone() else {
break;
};
// Break if our pagination would reuse the same cursor again; this avoids
// an infinite loop when filtering drops everything on the page.
if last_cursor.as_ref() == Some(&cursor_val) {
next_cursor = None;
// Check full pages too: returning a repeated cursor would loop on the next request.
if !seen_cursors.insert(cursor_val.clone()) {
return Err(internal_error("thread listing returned a repeated cursor"));
}
if remaining == 0 {
break;
}
last_cursor = Some(cursor_val.clone());
cursor_obj = Some(cursor_val);
}