mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user