Add thread recencyAt for sidebar ordering (#27910)

## Summary

Add a server-owned `recencyAt` timestamp and `recency_at` thread-list
sort key for product recency ordering while preserving the existing
meaning of `updatedAt` as the latest persisted thread mutation.

This is the server-side alternative to #27697. Rather than narrowing
`updatedAt`, clients can sort the sidebar by `recency_at` and continue
treating `updatedAt` as mutation time.

Paired Codex Apps PR:
[openai/openai#1024599](https://github.com/openai/openai/pull/1024599)

## Contract

- `recencyAt` initializes when a thread is created.
- A turn start advances `recencyAt` monotonically.
- Commentary, agent output, tool results, token/accounting updates, turn
completion, archive, unarchive, resume, and generic metadata writes do
not advance it.
- `updatedAt` retains its existing behavior and continues to advance for
persisted thread mutations.
- Current servers populate `recencyAt`; the response field is optional
in generated TypeScript so clients connected to older servers can fall
back to `updatedAt`.
- Filesystem-only fallback uses existing updated/mtime ordering when
SQLite is unavailable.

## Persistence and compatibility

Migration 0038 adds second- and millisecond-precision recency columns,
backfills them from the existing updated timestamp, creates list
indexes, and includes an insert trigger so older binaries writing to a
migrated database seed recency without causing later mutations to
advance it.

Generic metadata upserts preserve existing recency values. Turn-start
updates use a dedicated monotonic touch, and process-local allocation
keeps millisecond cursor values unique. State DB list, search, read,
filtered-list repair, rollout fallback propagation, and app-server
conversions all carry the new field.

## API

`Thread` responses include:

```ts
recencyAt?: number
```

`thread/list` and `thread/search` accept:

```json
{ "sortKey": "recency_at" }
```

Generated TypeScript and JSON schemas are included.

## Validation

- `just test -p codex-state` — 146 passed
- `just test -p codex-rollout` — 69 passed
- `just test -p codex-thread-store` — 81 passed
- `just test -p codex-app-server-protocol` — 231 passed
- Focused app-server list ordering, response mapping, archive/unarchive,
and resume lifecycle tests passed
- Scoped `just fix` for state, rollout, thread-store,
app-server-protocol, and app-server
- `just fmt`
- `git diff --check`
- Independent correctness, simplicity, elegance, security, and
test-quality reviews; actionable ordering, lifecycle, query-projection,
and timestamp-uniqueness findings were addressed
This commit is contained in:
Jeremy Rose
2026-06-16 17:06:22 -07:00
committed by GitHub
parent f0cb96bcb1
commit fac3158c2a
67 changed files with 1153 additions and 99 deletions
@@ -2501,6 +2501,7 @@ mod tests {
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 2,
recency_at: Some(3),
status: v2::ThreadStatus::Idle,
path: None,
cwd: cwd.clone(),
@@ -2544,6 +2545,7 @@ mod tests {
"modelProvider": "openai",
"createdAt": 1,
"updatedAt": 2,
"recencyAt": 3,
"status": {
"type": "idle"
},
@@ -174,6 +174,7 @@ fn thread_resume_response_round_trips_initial_turns_page() {
model_provider: "openai".to_string(),
created_at: 1,
updated_at: 1,
recency_at: Some(1),
status: ThreadStatus::Idle,
path: None,
cwd: absolute_path("tmp"),
@@ -3600,6 +3601,7 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
assert_eq!(start.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.thread.parent_thread_id, None);
assert_eq!(start.thread.recency_at, None);
assert_eq!(resume.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<AbsolutePathBuf>::new());
assert_eq!(start.active_permission_profile, None);
@@ -3608,6 +3610,14 @@ fn thread_lifecycle_responses_default_missing_optional_fields() {
assert_eq!(fork.active_permission_profile, None);
}
#[test]
fn thread_recency_sort_key_serializes_as_snake_case() {
assert_eq!(
serde_json::to_value(ThreadSortKey::RecencyAt).expect("sort key should serialize"),
json!("recency_at")
);
}
#[test]
fn turn_start_params_preserve_explicit_null_service_tier() {
let params: TurnStartParams = serde_json::from_value(json!({
@@ -1086,6 +1086,7 @@ pub enum ThreadSourceKind {
pub enum ThreadSortKey {
CreatedAt,
UpdatedAt,
RecencyAt,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)]
@@ -152,6 +152,9 @@ pub struct Thread {
/// Unix timestamp (in seconds) when the thread was last updated.
#[ts(type = "number")]
pub updated_at: i64,
/// Unix timestamp (in seconds) used for thread recency ordering.
#[ts(type = "number | null")]
pub recency_at: Option<i64>,
/// Current runtime status for the thread.
pub status: ThreadStatus,
/// [UNSTABLE] Path to the thread on disk.