Expose experimental durable thread read state in the app server (#52350)

## What changed

- Add experimental `readState` to `thread/read` and a thread-ID-keyed `readStates` map to `thread/list` for eligible durable local user threads. Each receipt contains `firstUnread` and an opaque `revision`.
- Publish new visible terminal results before `turn/completed`, after flushing the rollout. Reading snapshots leaves activity unread; replayed history does not publish new activity.
- Clear unread positions removed by a paginated thread revert while preserving explicit thread-start marks and concurrent receipt updates.

## Testing

Add an integration test covering completed-turn receipts, repeated reads without acknowledgement, matching list receipts, and revert cleanup with a changed revision.

GitOrigin-RevId: e7f6ddfbb1fc2fa95d1ef2876d7f0f7b9f99ff65
This commit is contained in:
Brent Traut
2026-10-09 01:22:30 +00:00
committed by copyberry
parent b6e8a2e207
commit 0ada5d8806
29 changed files with 711 additions and 18 deletions
@@ -2218,6 +2218,53 @@ impl ThreadRequestProcessor {
thread_state.lock().await.take_shutdown_drain_waiter();
return Err(err);
}
// The listener is drained: even an interrupted turn is durable now. Inspect only the
// suffix being reverted and remember the revision, not a collection of turn IDs.
let receipt_to_retire = async {
let Some(db) = self.state_db.as_ref() else {
return anyhow::Ok(None);
};
let Some(state) = db
.thread_read_states(&[thread_id])
.await?
.remove(&thread_id)
else {
return Ok(None);
};
let Some(turn_id) = state.first_unread_turn.as_ref().filter(|id| !id.is_empty()) else {
return Ok(None);
};
let mut cursor = None;
loop {
let page = self
.thread_store
.list_turns(codex_thread_store::ListTurnsParams {
thread_id,
include_archived: false,
cursor: cursor.clone(),
page_size: THREAD_TURNS_MAX_LIMIT,
sort_direction: codex_thread_store::SortDirection::Desc,
items_view: codex_thread_store::StoredTurnItemsView::NotLoaded,
})
.await?;
for turn in page.turns {
if turn.turn_id == *turn_id {
return Ok(Some(state.revision));
}
if turn.turn_id == before_turn_id {
return Ok(None);
}
}
let Some(next) = page.next_cursor else {
return Ok(None);
};
if cursor.as_ref() == Some(&next) {
anyhow::bail!("thread/revert turn cursor did not advance");
}
cursor = Some(next);
}
}
.await;
if self
.thread_manager
.remove_thread(&thread_id)
@@ -2243,6 +2290,27 @@ impl ThreadRequestProcessor {
})
.await
.map_err(|err| thread_store_mutation_error("revert", err));
// Receipt lookup or update errors cannot prevent reload or misreport a committed revert.
// A concurrent mark wins over this cleanup, even while the thread was being truncated.
if revert_result.is_ok()
&& let Some(db) = self.state_db.as_ref()
{
let retirement = match receipt_to_retire {
Ok(Some(revision)) => db
.update_thread_read_state(
thread_id,
&revision,
codex_state::ReadStateOperation::Read,
)
.await
.map(|_| ()),
Ok(None) => Ok(()),
Err(err) => Err(err),
};
if let Err(err) = retirement {
tracing::warn!("reverted history but could not update unread position: {err}")
}
}
let response = self
.reload_paginated_thread(
request_id,
@@ -2662,8 +2730,10 @@ impl ThreadRequestProcessor {
|thread| thread,
)
.await;
let read_states = super::thread_read_state::snapshots(self.state_db.as_ref(), &data).await;
Ok(ThreadListResponse {
data,
read_states,
next_cursor,
backwards_cursor,
})
@@ -2854,7 +2924,13 @@ impl ThreadRequestProcessor {
.read_thread_view(thread_uuid, include_turns)
.await
.map_err(thread_read_view_error)?;
Ok(ThreadReadResponse { thread })
let read_state = super::thread_read_state::snapshots(
self.state_db.as_ref(),
std::slice::from_ref(&thread),
)
.await
.and_then(|mut states| states.remove(&thread.id));
Ok(ThreadReadResponse { thread, read_state })
}
/// Builds the API view for `thread/read` from persisted metadata plus optional live state.