mirror of
https://github.com/openai/codex.git
synced 2026-11-13 22:47:20 +00:00
Support paginated thread forks (#35220)
## Why `thread/fork` rejected threads using paginated history, so they could not use the same fork workflow as legacy threads. ## What changed - Create paginated forks by referencing a frozen source-history prefix while persisting only child-owned records in the new rollout. - Support latest, `lastTurnId`, and `beforeTurnId` boundaries, including inherited lineage, model-context reconstruction, interruption markers, and approval-reviewer inheritance. - Make paginated reads and occurrence search traverse referenced history while excluding source records added after the fork. - Coordinate fork preparation with archive and delete operations, and materialize compressed rollouts before they become fork references. ## Testing Added app-server and thread-store coverage for boundary selection, active turns, inherited reads and search, compressed lineage, and concurrent source lifecycle operations. GitOrigin-RevId: 66052d69517d7fb97a3b7817ab77a39c5ad995fd
This commit is contained in:
1 parent
58b4277228
commit
05f000263b
44 files changed
+2810
-295
No files matched your search
@@ -5,6 +5,7 @@ mod helpers;
|
||||
mod list_threads;
|
||||
mod live_writer;
|
||||
mod model_context;
|
||||
mod paginated_fork;
|
||||
mod read_thread;
|
||||
// This lands before the reader PRs that consume the shared lineage resolver.
|
||||
#[allow(dead_code)]
|
||||
@@ -31,6 +32,9 @@ use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio::sync::OwnedMutexGuard;
|
||||
use tokio::sync::OwnedRwLockReadGuard;
|
||||
use tokio::sync::OwnedRwLockWriteGuard;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::AppendThreadItemsParams;
|
||||
use crate::ArchiveThreadParams;
|
||||
@@ -43,6 +47,8 @@ use crate::ListItemsParams;
|
||||
use crate::ListThreadsParams;
|
||||
use crate::ListTurnsParams;
|
||||
use crate::LoadThreadHistoryParams;
|
||||
use crate::PrepareForkParams;
|
||||
use crate::PreparedFork;
|
||||
use crate::ReadThreadByRolloutPathParams;
|
||||
use crate::ReadThreadParams;
|
||||
use crate::ResumeThreadParams;
|
||||
@@ -99,19 +105,55 @@ struct LiveRecorderEntry {
|
||||
struct LiveWriterLocks {
|
||||
// Keep per-thread locks after a writer goes idle. Removing one while another caller is about
|
||||
// to acquire it could let two operations for the same thread run at once.
|
||||
by_thread: Mutex<HashMap<ThreadId, Arc<Mutex<()>>>>,
|
||||
by_thread: Mutex<HashMap<ThreadId, Arc<ThreadCoordination>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ThreadCoordination {
|
||||
// Serialize writes and capture consistent fork snapshots.
|
||||
writer: Arc<Mutex<()>>,
|
||||
// Forks hold a shared lease until their child reference is durable; deletion, archive, and
|
||||
// unarchive require exclusive access. Keeping this separate from `writer` lets the source
|
||||
// accept writes during child initialization, including MCP startup that can take 30 seconds.
|
||||
// Operations that need both locks must acquire `lifecycle` before `writer`.
|
||||
lifecycle: Arc<RwLock<()>>,
|
||||
}
|
||||
|
||||
impl LiveWriterLocks {
|
||||
async fn lock(&self, thread_id: ThreadId) -> OwnedMutexGuard<()> {
|
||||
let lock = self
|
||||
.by_thread
|
||||
async fn coordination(&self, thread_id: ThreadId) -> Arc<ThreadCoordination> {
|
||||
self.by_thread
|
||||
.lock()
|
||||
.await
|
||||
.entry(thread_id)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone();
|
||||
lock.lock_owned().await
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn lock(&self, thread_id: ThreadId) -> OwnedMutexGuard<()> {
|
||||
self.coordination(thread_id)
|
||||
.await
|
||||
.writer
|
||||
.clone()
|
||||
.lock_owned()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reserve_lifecycle(&self, thread_id: ThreadId) -> OwnedRwLockReadGuard<()> {
|
||||
self.coordination(thread_id)
|
||||
.await
|
||||
.lifecycle
|
||||
.clone()
|
||||
.read_owned()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn lock_lifecycle(&self, thread_id: ThreadId) -> OwnedRwLockWriteGuard<()> {
|
||||
self.coordination(thread_id)
|
||||
.await
|
||||
.lifecycle
|
||||
.clone()
|
||||
.write_owned()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +430,10 @@ impl ThreadStore for LocalThreadStore {
|
||||
Box::pin(async move { model_context::load_latest_model_context(self, params).await })
|
||||
}
|
||||
|
||||
fn prepare_fork(&self, params: PrepareForkParams) -> ThreadStoreFuture<'_, PreparedFork> {
|
||||
Box::pin(async move { paginated_fork::prepare(self, params).await })
|
||||
}
|
||||
|
||||
fn read_thread(&self, params: ReadThreadParams) -> ThreadStoreFuture<'_, StoredThread> {
|
||||
Box::pin(async move { read_thread::read_thread(self, params).await })
|
||||
}
|
||||
@@ -1313,6 +1359,15 @@ mod tests {
|
||||
RolloutItem::EventMsg(EventMsg::UserMessage(event)) if event.message == "Hello from user"
|
||||
)
|
||||
}));
|
||||
|
||||
let error = store
|
||||
.prepare_fork(PrepareForkParams {
|
||||
thread_id,
|
||||
boundary: crate::ForkBoundary::Latest,
|
||||
})
|
||||
.await
|
||||
.expect_err("external rollouts cannot be referenced by thread id");
|
||||
assert!(error.to_string().contains("must be in Codex home"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1575,6 +1630,7 @@ mod tests {
|
||||
selected_capability_roots: Vec::new(),
|
||||
multi_agent_version: None,
|
||||
history_mode: ThreadHistoryMode::Legacy,
|
||||
history_base: None,
|
||||
subagent_history_start_ordinal: None,
|
||||
initial_window_id: uuid::Uuid::now_v7().to_string(),
|
||||
metadata: thread_metadata(),
|
||||
|
||||
Reference in new issue
Block a user